Home consumer: connect 01166fc, sign-out chain, "All apps"
Calls pinned home/connect at 5b8d841 (2026-09-10), before the in-app
sign-out chain, the signed-in /signin shortcut and
Credentials.Directory. Bump to 01166fc so, in Home mode:
- POST /signout 307s on to {issuer}/signout and the id.* session ends;
- /signin for a signed-in person returns to return_to with no exchange;
- HOME_DIRECTORY_ORIGIN reaches the app, and the account corner shows
"All apps" linking to the directory. No directory, no link.
callscore.SignOut (the native apps' best-effort sign-out, which never
follows redirects) accepted only 303, so in Home mode it would report
the 307 as "Calls did not confirm the sign-out". It now accepts both.
The three tests in home_consumer_test.go configure Home from the
environment, as cmd/calls does, and TestSignOutAcceptsTheHomeChain; all
four failed first and pass now. Existing Home router, release and
native tests now expect the chain.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
10 files changed,
+158
−13
callscore/session.go+5 −2callscore/session_test.go+20 −0go.mod+1 −1go.sum+2 −2internal/calls/home_consumer_test.go+101 −0internal/calls/home_release_test.go+1 −1internal/calls/home_router_test.go+17 −5internal/calls/native_auth_test.go+1 −1internal/calls/render.go+9 −1internal/calls/templates/layout.html+1 −0
CI cancelled — superseded by a newer push — run details
diff --git a/callscore/session.go b/callscore/session.go| index 6f40203..ecf18b7 100644 |
| --- a/callscore/session.go |
| +++ b/callscore/session.go |
| @@ -144,7 +144,7 @@ func OpenLink(ctx context.Context, origin, address string, client *http.Client) |
| // SignOut revokes the held session on its instance: the same POST |
| // /signout the web's button makes, carrying the Origin csrf.Protect |
| // reads. Either sign-out route answers with a redirect once the row is |
| -// gone. |
| +// gone: 303 standalone, 307 in Home mode. |
| // |
| // Best effort, and the caller forgets its copy whatever this answers: a |
| // person who pressed Sign out is signed out of this app, and a session |
| @@ -171,7 +171,10 @@ func SignOut(ctx context.Context, origin string, client *http.Client) error { |
| return errors.New("cannot reach Calls") |
| } |
| res.Body.Close() |
| - if res.StatusCode != http.StatusSeeOther { |
| + // 303 to /signed-out standalone; in Home mode, 307 on to the issuer's |
| + // /signout for the browser's id.* session, which this client neither |
| + // holds nor follows (safeClient). Both come after the row is gone. |
| + if res.StatusCode != http.StatusSeeOther && res.StatusCode != http.StatusTemporaryRedirect { |
| return errors.New("Calls did not confirm the sign-out") |
| } |
| return nil |
diff --git a/callscore/session_test.go b/callscore/session_test.go| index fb2fe63..80b9450 100644 |
| --- a/callscore/session_test.go |
| +++ b/callscore/session_test.go |
| @@ -177,3 +177,23 @@ func TestSignOutPostsWithTheOriginAndWantsTheRedirect(t *testing.T) { |
| t.Fatal("a refused sign-out was reported as done", err) |
| } |
| } |
| + |
| +// In Home mode the server ends the Calls session and then answers 307 on |
| +// to {issuer}/signout (connect.Routes.Signout), a hop for a browser's |
| +// id.* session that the native client does not follow and has no use for. |
| +// The session is gone either way, so both redirects confirm the sign-out. |
| +func TestSignOutAcceptsTheHomeChain(t *testing.T) { |
| + var followed atomic.Bool |
| + issuer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { followed.Store(true) })) |
| + defer issuer.Close() |
| + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + http.Redirect(w, r, issuer.URL+"/signout", http.StatusTemporaryRedirect) |
| + })) |
| + defer server.Close() |
| + if err := SignOut(t.Context(), server.URL, server.Client()); err != nil { |
| + t.Fatal("a Home-mode sign-out was reported as failed:", err) |
| + } |
| + if followed.Load() { |
| + t.Fatal("the native client followed the hop to the issuer") |
| + } |
| +} |
diff --git a/go.mod b/go.mod| index 7fbf2ec..de3e184 100644 |
| --- a/go.mod |
| +++ b/go.mod |
| @@ -4,7 +4,7 @@ go 1.26.0 |
| require ( |
| amadan.net/oficina/estilo.git v0.0.0-20260929231226-cce11010ece0 |
| - amadan.net/oficina/home/connect v0.0.0-20260910000424-5b8d8410775f |
| + amadan.net/oficina/home/connect v0.0.0-20260923190447-01166fcfc0a9 |
| amadan.net/oficina/home/handoff v0.0.0-20260908102137-481d2d57f489 |
| amadan.net/rastrillo/rastrillo v0.26.1-0.20260908075707-29e4491631bd |
| github.com/go-chi/chi/v5 v5.3.2 |
diff --git a/go.sum b/go.sum| index c6810c4..04b1901 100644 |
| --- a/go.sum |
| +++ b/go.sum |
| @@ -1,7 +1,7 @@ |
| amadan.net/oficina/estilo.git v0.0.0-20260929231226-cce11010ece0 h1:Qww+xGIYJaj+6TIX4Knr71ghxeYJjtgFm6WukX6NkGg= |
| amadan.net/oficina/estilo.git v0.0.0-20260929231226-cce11010ece0/go.mod h1:4u0jNBtIdMLOsJyrScfqoEuYDJF7BBM3qM9YWbzt6aw= |
| -amadan.net/oficina/home/connect v0.0.0-20260910000424-5b8d8410775f h1:lkw3tjzHfZxe74pNfo+tGDz4mkZLg+D6R+FTkmsym6M= |
| -amadan.net/oficina/home/connect v0.0.0-20260910000424-5b8d8410775f/go.mod h1:YroavXe43Wietec+po8mtSk4a9Bir2h31Q3iAf4MWxI= |
| +amadan.net/oficina/home/connect v0.0.0-20260923190447-01166fcfc0a9 h1:K/PaOVdQqRMwifPD6ipiwLeGR5nbFSxm9FxjrDJlZcg= |
| +amadan.net/oficina/home/connect v0.0.0-20260923190447-01166fcfc0a9/go.mod h1:YroavXe43Wietec+po8mtSk4a9Bir2h31Q3iAf4MWxI= |
| amadan.net/oficina/home/handoff v0.0.0-20260908102137-481d2d57f489 h1:RPKtyry/71i06o8L+gjAaUyexbmG56kO1YPs601URAM= |
| amadan.net/oficina/home/handoff v0.0.0-20260908102137-481d2d57f489/go.mod h1:YISpzFJ+iMgrM0rUL032OSzsAQbP4/nozvXLCCOcn1k= |
| amadan.net/rastrillo/rastrillo v0.26.1-0.20260908075707-29e4491631bd h1:jlFSt95M1ontOg+VePU+kIJpChtWqDQAbnmlxqCnbgc= |
diff --git a/internal/calls/home_consumer_test.go b/internal/calls/home_consumer_test.go| new file mode 100644 |
| index 0000000..44a09ef |
| --- /dev/null |
| +++ b/internal/calls/home_consumer_test.go |
| @@ -0,0 +1,101 @@ |
| +package calls |
| + |
| +import ( |
| + "bytes" |
| + "encoding/base64" |
| + "io" |
| + "log/slog" |
| + "net/http" |
| + "net/http/httptest" |
| + "path/filepath" |
| + "strings" |
| + "testing" |
| + |
| + "amadan.net/oficina/home/connect" |
| + assertioncrypto "amadan.net/rastrillo/rastrillo/crypto" |
| + "amadan.net/rastrillo/rastrillo/db" |
| +) |
| + |
| +const homeTestDirectory = "https://directory.example.test" |
| + |
| +// newHomeConsumerFixture configures Home the way production does: from the |
| +// environment, through connect.CredentialsFromEnv (cmd/calls/main.go), so |
| +// HOME_DIRECTORY_ORIGIN (unprefixed, the name Home itself reads) is read |
| +// exactly as it is live. |
| +func newHomeConsumerFixture(t *testing.T, directory string) *homeRouterFixture { |
| + t.Helper() |
| + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) |
| + d, err := db.Open(filepath.Join(t.TempDir(), "calls.db"), logger) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Cleanup(func() { d.Close() }) |
| + key, err := assertioncrypto.Generate() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Setenv("CALLS_HOME_ISSUER", homeTestIssuer) |
| + t.Setenv("CALLS_HOME_CONSUMER_ID", "calls-test") |
| + t.Setenv("CALLS_HOME_KEYS_BASE64", homeKeysBase64(key)) |
| + t.Setenv("CALLS_HOME_DEK_BASE64", base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{7}, 32))) |
| + t.Setenv("HOME_DIRECTORY_ORIGIN", directory) |
| + return newHomeRouterFixtureWith(t, d, logger, key, connect.CredentialsFromEnv("CALLS")) |
| +} |
| + |
| +// Sign out in Calls ends the id.* session too: the app session ends and |
| +// the same POST is handed on (307) to {issuer}/signout, which returns the |
| +// browser to /signed-out. Without it the next "Sign in" passed silently |
| +// through the live id.* session (spec shared-chrome §9.5). |
| +func TestHomeSignoutChainsToIssuer(t *testing.T) { |
| + f := newHomeConsumerFixture(t, "") |
| + f.local(t) |
| + w := f.do("POST", "/signout", "") |
| + if w.Code != http.StatusTemporaryRedirect || w.Header().Get("Location") != homeTestIssuer+"/signout" { |
| + t.Fatalf("POST /signout: status %d Location %q, want 307 to %s/signout", w.Code, w.Header().Get("Location"), homeTestIssuer) |
| + } |
| + if got := w.Header().Get("Referrer-Policy"); got != "strict-origin-when-cross-origin" { |
| + t.Fatalf("Referrer-Policy %q: id.* admits the null-Origin hop by Referer", got) |
| + } |
| + if f.cookies[f.app.home.sessions.CookieName()] != nil { |
| + t.Fatal("sign-out kept the Calls session cookie") |
| + } |
| +} |
| + |
| +// A signed-in person who follows a "Sign in" link goes straight to where |
| +// they were headed, with no second exchange. |
| +func TestHomeSigninWhenSignedInReturnsToPage(t *testing.T) { |
| + f := newHomeConsumerFixture(t, "") |
| + f.local(t) |
| + if got := homeRedirect(t, f.do("GET", "/signin?return_to=%2Fsettings", "")); got != "/settings" { |
| + t.Fatalf("signed-in /signin went to %q, want /settings", got) |
| + } |
| +} |
| + |
| +// "All apps" goes to the hub directory, from HOME_DIRECTORY_ORIGIN, in the |
| +// account corner, and is absent when no directory is configured. |
| +func TestAllAppsLinksToDirectory(t *testing.T) { |
| + link := `href="` + homeTestDirectory + `/"` |
| + f := newHomeConsumerFixture(t, homeTestDirectory) |
| + f.local(t) |
| + for _, path := range []string{"/", "/settings"} { |
| + w := f.do("GET", path, "") |
| + if body := w.Body.String(); w.Code != http.StatusOK || !strings.Contains(body, link) || !strings.Contains(body, ">All apps<") { |
| + t.Fatalf("GET %s (%d): no All apps link to %s/", path, w.Code, homeTestDirectory) |
| + } |
| + } |
| + |
| + bare := newHomeConsumerFixture(t, "") |
| + bare.local(t) |
| + if body := bare.do("GET", "/", "").Body.String(); strings.Contains(body, "All apps") { |
| + t.Fatal("All apps shown with no directory configured") |
| + } |
| +} |
| + |
| +// homeSignout asserts the Home-mode sign-out response: a 307 on to the |
| +// issuer's /signout, which returns the browser to /signed-out. |
| +func homeSignout(t *testing.T, w *httptest.ResponseRecorder) { |
| + t.Helper() |
| + if w.Code != http.StatusTemporaryRedirect || w.Header().Get("Location") != homeTestIssuer+"/signout" { |
| + t.Fatalf("POST /signout: status %d Location %q, want 307 to %s/signout", w.Code, w.Header().Get("Location"), homeTestIssuer) |
| + } |
| +} |
diff --git a/internal/calls/home_release_test.go b/internal/calls/home_release_test.go| index dd9632e..2ec6357 100644 |
| --- a/internal/calls/home_release_test.go |
| +++ b/internal/calls/home_release_test.go |
| @@ -136,7 +136,7 @@ func TestHomeConnectionKeepsCurrentCallAndAgentRoutes(t *testing.T) { |
| if w.Code != 403 { |
| t.Fatalf("browser mutation lost CSRF protection: %d", w.Code) |
| } |
| - homeRedirect(t, f.do("POST", "/signout", "")) |
| + homeSignout(t, f.do("POST", "/signout", "")) |
| if w := f.do("GET", target, ""); w.Code != 303 { |
| t.Fatalf("room survives signout: %d", w.Code) |
| } |
diff --git a/internal/calls/home_router_test.go b/internal/calls/home_router_test.go| index 758ebc0..7972b06 100644 |
| --- a/internal/calls/home_router_test.go |
| +++ b/internal/calls/home_router_test.go |
| @@ -50,11 +50,23 @@ func newHomeRouterFixture(t *testing.T) *homeRouterFixture { |
| if err != nil { |
| t.Fatal(err) |
| } |
| + return newHomeRouterFixtureWith(t, d, logger, key, &connect.Credentials{Issuer: homeTestIssuer, ConsumerID: "calls-test", KeysBase64: homeKeysBase64(key), DEKBase64: base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{7}, 32))}) |
| +} |
| + |
| +// homeKeysBase64 is the public-key manifest Home publishes for key. |
| +func homeKeysBase64(key *assertioncrypto.Keypair) string { |
| sum := sha256.Sum256(key.SignPub()) |
| id := hex.EncodeToString(sum[:]) |
| raw, _ := json.Marshal(map[string]string{id: base64.StdEncoding.EncodeToString(key.SignPub())}) |
| + return base64.StdEncoding.EncodeToString(raw) |
| +} |
| + |
| +// newHomeRouterFixtureWith builds the app with Home configured by creds, |
| +// whose assertions key signs. |
| +func newHomeRouterFixtureWith(t *testing.T, d *db.DB, logger *slog.Logger, key *assertioncrypto.Keypair, creds *connect.Credentials) *homeRouterFixture { |
| + t.Helper() |
| mail := &homeTestMail{} |
| - mux, a, err := build(t.Context(), d, Config{Origin: homeTestOrigin, InstanceKey: "test-only-instance", DEK: base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{3}, 32)), AllowedAddresses: []string{"alice@example.test"}, Mailer: mail, Logger: logger, Home: &connect.Credentials{Issuer: homeTestIssuer, ConsumerID: "calls-test", KeysBase64: base64.StdEncoding.EncodeToString(raw), DEKBase64: base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{7}, 32))}}) |
| + mux, a, err := build(t.Context(), d, Config{Origin: homeTestOrigin, InstanceKey: "test-only-instance", DEK: base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{3}, 32)), AllowedAddresses: []string{"alice@example.test"}, Mailer: mail, Logger: logger, Home: creds}) |
| if err != nil { |
| t.Fatal(err) |
| } |
| @@ -144,7 +156,7 @@ func (f *homeRouterFixture) link(t *testing.T) { |
| t.Fatal("confirmation", next) |
| } |
| homeRedirect(t, f.do("POST", "/account/home/confirm", "")) |
| - homeRedirect(t, f.do("POST", "/signout", "")) |
| + homeSignout(t, f.do("POST", "/signout", "")) |
| start = homeRedirect(t, f.do("GET", "/signin", "")) |
| homeRedirect(t, f.exchange(t, start, "opaque-home-alice")) |
| } |
| @@ -159,7 +171,7 @@ func TestHomeLinksExistingMemberAndKeepsMeetings(t *testing.T) { |
| if err := f.app.db.Writer().QueryRow("SELECT created_by FROM meetings LIMIT 1").Scan(&before); err != nil { |
| t.Fatal(err) |
| } |
| - homeRedirect(t, f.do("POST", "/signout", "")) |
| + homeSignout(t, f.do("POST", "/signout", "")) |
| f.link(t) |
| w := f.do("GET", "/", "") |
| if w.Code != 200 || !strings.Contains(w.Body.String(), "Existing private meeting") { |
| @@ -211,7 +223,7 @@ func TestHomeEnabledPreservesInvitedGuestFlow(t *testing.T) { |
| if token == nil || sqid == nil { |
| t.Fatal("missing meeting links") |
| } |
| - homeRedirect(t, f.do("POST", "/signout", "")) |
| + homeSignout(t, f.do("POST", "/signout", "")) |
| path := homeRedirect(t, f.do("GET", "/j/"+token[1], "")) |
| if path != "/signin" { |
| t.Fatal(path) |
| @@ -274,7 +286,7 @@ func TestHomePublicJoinResolvesExistingMember(t *testing.T) { |
| func TestHomeRemovedMemberSigninIsRefusedWithoutServiceError(t *testing.T) { |
| f := newHomeRouterFixture(t) |
| f.link(t) |
| - homeRedirect(t, f.do("POST", "/signout", "")) |
| + homeSignout(t, f.do("POST", "/signout", "")) |
| f.app.cfg.allow = allowlist{} |
| start := homeRedirect(t, f.do("GET", "/signin", "")) |
| if w := f.exchange(t, start, "opaque-home-alice"); w.Code != 403 { |
diff --git a/internal/calls/native_auth_test.go b/internal/calls/native_auth_test.go| index 211204c..f8d61ed 100644 |
| --- a/internal/calls/native_auth_test.go |
| +++ b/internal/calls/native_auth_test.go |
| @@ -366,7 +366,7 @@ func TestNativeSessionReadsWhatTheAppReads(t *testing.T) { |
| if dest := homeRedirect(t, f.do("GET", "/j/"+token, "")); dest != "/m/"+m.Sqid+"/join" { |
| t.Fatal("invitation did not lead to its meeting", dest) |
| } |
| - homeRedirect(t, f.do("POST", "/signout", "")) |
| + homeSignout(t, f.do("POST", "/signout", "")) |
| // f.do dropped the cleared cookie; the app still holds its copy. |
| f.cookies = map[string]*http.Cookie{name: {Name: name, Value: value}} |
| if dest := homeRedirect(t, f.do("GET", "/settings", "")); !strings.HasPrefix(dest, signinPath) { |
diff --git a/internal/calls/render.go b/internal/calls/render.go| index 5a7a145..400efd3 100644 |
| --- a/internal/calls/render.go |
| +++ b/internal/calls/render.go |
| @@ -110,6 +110,10 @@ func render(w http.ResponseWriter, name string, data any) { |
| type Viewer struct { |
| Email string |
| Member bool |
| + // Directory is the hub directory origin (HOME_DIRECTORY_ORIGIN) the |
| + // account corner's "All apps" links to: set only in Home mode, and |
| + // empty means no link. |
| + Directory string |
| } |
| // page builds render's data map for a request that may be signed in: |
| @@ -126,7 +130,11 @@ type Viewer struct { |
| // a bare *http.Request cannot answer on its own. |
| func (a *app) page(r *http.Request, data map[string]any) map[string]any { |
| if p, ok := currentPerson(r); ok && !p.Anonymous { |
| - data["Viewer"] = Viewer{Email: p.Email, Member: a.isMember(p)} |
| + v := Viewer{Email: p.Email, Member: a.isMember(p)} |
| + if a.home != nil { |
| + v.Directory = a.cfg.home.Directory |
| + } |
| + data["Viewer"] = v |
| } |
| return data |
| } |
diff --git a/internal/calls/templates/layout.html b/internal/calls/templates/layout.html| index b25a823..d912c4f 100644 |
| --- a/internal/calls/templates/layout.html |
| +++ b/internal/calls/templates/layout.html |
| @@ -43,6 +43,7 @@ |
| <details rst-dropdown name="rst-menus"><summary>Help</summary><div rst-dropdown-menu><a href="/help/open-source" referrerpolicy="no-referrer" rel="noreferrer">Open source</a></div></details> |
| <div rst-shell-account> |
| <span>{{.Viewer.Email}}</span> |
| +{{if .Viewer.Directory}}<a href="{{.Viewer.Directory}}/">All apps</a>{{end}} |
| {{if .Viewer.Member}}<a href="/settings">Settings</a>{{end}} |
| <form method="post" action="/signout"><button type="submit" rst-btn="ghost">Sign out</button></form> |
| </div> |