oficina / meet Public

Make Calls a ready Home consumer

connect 01166fc (sign-out chain, signed-in /signin shortcut, Directory),
'All apps' in the account corner, a signed-out page request keeps its
return_to in Home mode, and callscore.SignOut accepts the 307 sign-out
hop. Prepares the Oficina sign-in switch (polish-audit/SSO-SWITCH.md).
Reviewed by GPT-6-Astra via Codex (#22): approve with nits.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Squashed from feat/home-consumer into main.
claude agent, operated by paul@keymail.dev bf4e24d08329d5fd61ebf8a69ff649cdc6dec375 parent 84ed5dc
11 files changed, +195 −15
  • callscore/session.go +5 −2
  • callscore/session_test.go +20 −0
  • go.mod +1 −1
  • go.sum +2 −2
  • internal/calls/guest.go +20 −2
  • internal/calls/home_consumer_test.go +118 −0
  • internal/calls/home_release_test.go +1 −1
  • internal/calls/home_router_test.go +17 −5
  • internal/calls/native_auth_test.go +1 −1
  • internal/calls/render.go +9 −1
  • internal/calls/templates/layout.html +1 −0

CI passed — run details

CI log
=== vet ===
go vet ./...
=== fmt ===
=== test ===
go test ./...
ok  	amadan.net/oficina/calls/callscore	6.831s
?   	amadan.net/oficina/calls/cmd/calls	[no test files]
ok  	amadan.net/oficina/calls/internal/calls	1.514s
ok  	amadan.net/oficina/calls/internal/calls/apns	(cached)
ok  	amadan.net/oficina/calls/internal/calls/hub	(cached)
?   	amadan.net/oficina/calls/internal/calls/icons	[no test files]
ok  	amadan.net/oficina/calls/internal/calls/relay	(cached)
ok  	amadan.net/oficina/calls/internal/calls/seal	(cached)
ok  	amadan.net/oficina/calls/internal/callstest	8.528s
?   	amadan.net/oficina/calls/protocol	[no test files]
?   	amadan.net/oficina/calls/scripts/writeestilo	[no test files]
=== migration-check ===
go run amadan.net/rastrillo/rastrillo/cmd/rastrillo migration check
rastrillo migration check: models and migrations agree
=== race ===
CGO_ENABLED=1 go test -race ./internal/...
ok  	amadan.net/oficina/calls/internal/calls	9.661s
ok  	amadan.net/oficina/calls/internal/calls/apns	(cached)
ok  	amadan.net/oficina/calls/internal/calls/hub	(cached)
?   	amadan.net/oficina/calls/internal/calls/icons	[no test files]
ok  	amadan.net/oficina/calls/internal/calls/relay	(cached)
ok  	amadan.net/oficina/calls/internal/calls/seal	(cached)
ok  	amadan.net/oficina/calls/internal/callstest	24.390s
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/guest.go b/internal/calls/guest.go
index 3fc42b6..0fd355b 100644
--- a/internal/calls/guest.go
+++ b/internal/calls/guest.go
@@ -2,6 +2,7 @@ package calls
import (
"net/http"
+ "net/url"
"strconv"
"strings"
"unicode/utf8"
@@ -13,7 +14,7 @@ import (
// Guest sessions use the ordinary revocable session store, with a distinct
// identity method. They can never resolve to a verified address or team member.
func (a *app) requireParticipant(next http.Handler) http.Handler {
- return a.auth.RequireSession(a.requirePerson(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ return a.homeReturnTo(a.auth.RequireSession(a.requirePerson(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
p, _ := currentPerson(r)
path := r.URL.Path
if p.Anonymous && !strings.HasPrefix(path, "/m/") && path != "/native/authorize" && path != "/native/complete" {
@@ -21,7 +22,24 @@ func (a *app) requireParticipant(next http.Handler) http.Handler {
return
}
next.ServeHTTP(w, r)
- })))
+ }))))
+}
+
+// homeReturnTo: in Home mode a signed-out page request carries the page to
+// sign-in, so Home sign-in comes back to it rather than to the root;
+// connect's Signin keeps a safe return_to through the exchange. The local
+// plugin's own refusal drops the path. Same cookie, same table: wireHome
+// checked the names agree. Standalone is unchanged.
+func (a *app) homeReturnTo(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if a.home != nil && (r.Method == http.MethodGet || r.Method == http.MethodHead) {
+ if _, ok := a.home.sessions.From(r); !ok {
+ http.Redirect(w, r, signinPath+"?return_to="+url.QueryEscape(r.URL.RequestURI()), http.StatusSeeOther)
+ return
+ }
+ }
+ next.ServeHTTP(w, r)
+ })
}
func (a *app) guestPage(w http.ResponseWriter, r *http.Request, m Meeting, token, name, message string) {
nativeHeaders(w)
diff --git a/internal/calls/home_consumer_test.go b/internal/calls/home_consumer_test.go
new file mode 100644
index 0000000..02575a9
--- /dev/null
+++ b/internal/calls/home_consumer_test.go
@@ -0,0 +1,118 @@
+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)
+ }
+}
+
+// A signed-out visit to a page in Home mode goes to sign-in carrying the
+// page, and Home sign-in comes back to that exact page, not the root.
+func TestHomeSigninReturnsToTheExactPage(t *testing.T) {
+ f := newHomeConsumerFixture(t, "")
+ f.link(t)
+ homeSignout(t, f.do("POST", "/signout", ""))
+
+ signin := homeRedirect(t, f.do("GET", "/settings", ""))
+ if signin != "/signin?return_to=%2Fsettings" {
+ t.Fatalf("signed-out /settings went to %q, want /signin?return_to=%%2Fsettings", signin)
+ }
+ start := homeRedirect(t, f.do("GET", signin, ""))
+ if got := homeRedirect(t, f.exchange(t, start, "opaque-home-alice")); got != "/settings" {
+ t.Fatalf("Home sign-in landed on %q, want /settings", got)
+ }
+}
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>