Make Calendar a ready Home consumer; land sign-out on a real page
Pins the Home-consumer tests on top of the shared chrome: the sign-out chain to the issuer, /signin returning a signed-in person to the page, and 'All apps' going to the directory root. /signin no longer shortcuts a revoked person to a 404: their session ends and a fresh sign-in starts. Without Home, signing out lands on estilo's signed-out page, not on /signin. Reviewed by GPT-6-Astra via Codex (#23); fixes checked by the coordinating session. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Squashed from feat/home-consumer into main.
7 files changed,
+237
−3
internal/calendar/chrome_calendar.go+2 −1internal/calendar/home.go+34 −0internal/calendar/home_consumer_test.go+117 −0internal/calendar/home_router_test.go+8 −0internal/calendar/routes.go+3 −2internal/calendar/signin.go+29 −0internal/calendartest/signed_out_test.go+44 −0
CI passed — run details
CI log
=== vet === go vet ./... === fmt === === test === go test ./... ? amadan.net/oficina/calendar/cmd/calendar [no test files] ok amadan.net/oficina/calendar/internal/calendar 4.602s ? amadan.net/oficina/calendar/internal/calendar/icons [no test files] ? amadan.net/oficina/calendar/internal/calendar/seal [no test files] ok amadan.net/oficina/calendar/internal/calendartest 3.281s ok amadan.net/oficina/calendar/internal/guardstest 0.046s ? amadan.net/oficina/calendar/scripts/writeestilo [no test files] === migration-check === go run github.com/carlosframework/rastrillo/cmd/rastrillo migration check rastrillo migration check: models and migrations agree
diff --git a/internal/calendar/chrome_calendar.go b/internal/calendar/chrome_calendar.go| index ef7a1b8..0216ccd 100644 |
| --- a/internal/calendar/chrome_calendar.go |
| +++ b/internal/calendar/chrome_calendar.go |
| @@ -3,6 +3,7 @@ package calendar |
| import ( |
| "html/template" |
| "net/http" |
| + "strings" |
| "amadan.net/oficina/estilo.git/chrome" |
| estilomenu "amadan.net/oficina/estilo.git/menu" |
| @@ -34,7 +35,7 @@ func calendarChrome(r *http.Request, signedIn bool, address string, mode chrome. |
| return bar |
| } |
| if directory != "" { |
| - bar.Switcher = &chrome.Switcher{Label: "Oficina apps", AllApps: estilomenu.Item{Href: directory, Label: "All apps"}} |
| + bar.Switcher = &chrome.Switcher{Label: "Oficina apps", AllApps: estilomenu.Item{Href: strings.TrimSuffix(directory, "/") + "/", Label: "All apps"}} |
| } |
| // A live session whose person could not be resolved (a removed Home |
| // person on an error page) still gets the menu, to sign out from, but |
diff --git a/internal/calendar/home.go b/internal/calendar/home.go| index b60a4c1..7921c62 100644 |
| --- a/internal/calendar/home.go |
| +++ b/internal/calendar/home.go |
| @@ -101,3 +101,37 @@ func (a *App) homePerson(r *http.Request) (Person, error) { |
| } |
| return person, nil |
| } |
| + |
| +// homeSignin is GET /signin with Home on. connect's Signin shortcuts a |
| +// signed-in person straight to where they were headed; but a session whose |
| +// person is no longer admitted (removed from the team, or a Home link to a |
| +// deleted person) is not signed in for Calendar, and the shortcut would end |
| +// on requirePerson's 404 with nowhere to go. So /signin first asks the same |
| +// question requirePerson does. Not admitted: the session ends here and |
| +// /signin is asked again without it, which starts a fresh exchange (as |
| +// sheets' homeRequire and docs' admission check do). A storage failure is |
| +// a 503, never a sign-out. |
| +func (a *App) homeSignin(w http.ResponseWriter, r *http.Request) { |
| + s, ok := a.home.sessions.From(r) |
| + if ok && (s.Method == connect.HomeMethod || s.Method == "magiclink" || s.Method == "keymail") { |
| + admitted := false |
| + if s.Method == connect.HomeMethod { |
| + _, err := a.homePerson(r) |
| + if errors.Is(err, linkstore.ErrStorage) { |
| + w.Header().Set("Cache-Control", "no-store") |
| + a.h.errorPage(w, r, http.StatusServiceUnavailable, "") |
| + return |
| + } |
| + admitted = err == nil |
| + } else { |
| + admitted = a.home.admitted.allows(normalizeAddress(s.Subject)) |
| + } |
| + if !admitted { |
| + a.home.sessions.SignOut(w, r) |
| + w.Header().Set("Cache-Control", "no-store") |
| + http.Redirect(w, r, r.URL.RequestURI(), http.StatusSeeOther) |
| + return |
| + } |
| + } |
| + a.home.routes.Signin(w, r) |
| +} |
diff --git a/internal/calendar/home_consumer_test.go b/internal/calendar/home_consumer_test.go| new file mode 100644 |
| index 0000000..ced5f50 |
| --- /dev/null |
| +++ b/internal/calendar/home_consumer_test.go |
| @@ -0,0 +1,117 @@ |
| +package calendar |
| + |
| +import ( |
| + "bytes" |
| + "encoding/base64" |
| + "encoding/json" |
| + "io" |
| + "log/slog" |
| + "net/http" |
| + "path/filepath" |
| + "regexp" |
| + "strings" |
| + "testing" |
| + |
| + "amadan.net/oficina/home/connect" |
| + assertioncrypto "amadan.net/rastrillo/rastrillo/crypto" |
| +) |
| + |
| +const homeTestDirectory = "https://directory.example.test" |
| + |
| +// newHomeConsumerFixture configures Home the way production does: from the |
| +// environment, through connect.CredentialsFromEnv, 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() |
| + key, err := assertioncrypto.Generate() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + manifest, _ := json.Marshal(map[string]string{"key": base64.StdEncoding.EncodeToString(key.SignPub())}) |
| + t.Setenv("CALENDAR_HOME_ISSUER", homeTestIssuer) |
| + t.Setenv("CALENDAR_HOME_CONSUMER_ID", "calendar-test") |
| + t.Setenv("CALENDAR_HOME_KEYS_BASE64", base64.StdEncoding.EncodeToString(manifest)) |
| + t.Setenv("CALENDAR_HOME_DEK_BASE64", base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{7}, 32))) |
| + t.Setenv("HOME_DIRECTORY_ORIGIN", directory) |
| + a, err := New(Config{DBPath: filepath.Join(t.TempDir(), "calendar.db"), Env: "development", Origin: homeTestOrigin, AllowedAddresses: "alice@example.test", Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), Home: connect.CredentialsFromEnv("CALENDAR")}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Cleanup(func() { a.Close() }) |
| + return &homeRouterFixture{a, key, map[string]*http.Cookie{}} |
| +} |
| + |
| +// Sign out in Calendar 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 Calendar 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=%2Fday%2F2026-09-10", "")); got != "/day/2026-09-10" { |
| + t.Fatalf("signed-in /signin went to %q, want /day/2026-09-10", got) |
| + } |
| +} |
| + |
| +// "All apps" is the estilo switcher's item (shared-chrome spec §6), linking |
| +// to the hub directory's root: HOME_DIRECTORY_ORIGIN (an origin, which |
| +// connect refuses with a path or a trailing slash) plus "/". No directory, |
| +// no switcher. |
| +func TestAllAppsLinksToDirectory(t *testing.T) { |
| + item := regexp.MustCompile(`<details estilo-switcher[\s\S]*?<a estilo-menu-item href="([^"]*)"><span estilo-menu-label>All apps</span></a>`) |
| + for _, configured := range []string{homeTestDirectory} { |
| + f := newHomeConsumerFixture(t, configured) |
| + f.local(t) |
| + m := item.FindStringSubmatch(f.do("GET", "/day/2026-09-10", "").Body.String()) |
| + if m == nil { |
| + t.Fatalf("HOME_DIRECTORY_ORIGIN=%s: no All apps item in the estilo switcher", configured) |
| + } |
| + if m[1] != homeTestDirectory+"/" { |
| + t.Errorf("HOME_DIRECTORY_ORIGIN=%s: All apps links to %q, want %q", configured, m[1], homeTestDirectory+"/") |
| + } |
| + } |
| + |
| + bare := newHomeConsumerFixture(t, "") |
| + bare.local(t) |
| + if body := bare.do("GET", "/day/2026-09-10", "").Body.String(); strings.Contains(body, "All apps") || strings.Contains(body, "estilo-switcher") { |
| + t.Fatal("switcher shown with no directory configured") |
| + } |
| +} |
| + |
| +// A session whose person is no longer admitted is not "already signed in": |
| +// /signin ends it and starts over, rather than shortcutting to the page |
| +// (which requirePerson answers with a terminating 404). Astra P3 on #23; |
| +// the same check docs' admitted and sheets' homeRequire make. |
| +func TestSigninDoesNotShortcutARevokedPerson(t *testing.T) { |
| + f := newHomeConsumerFixture(t, "") |
| + f.local(t) |
| + delete(f.app.home.admitted.addrs, "alice@example.test") |
| + w := f.do("GET", "/signin?return_to=%2Fday%2F2026-09-10", "") |
| + if w.Code == http.StatusSeeOther && w.Header().Get("Location") == "/day/2026-09-10" { |
| + t.Fatal("/signin shortcut a removed member to the page, which 404s") |
| + } |
| + if f.cookies[f.app.home.sessions.CookieName()] != nil { |
| + t.Fatal("/signin kept the removed member's session") |
| + } |
| + // Asked again with no session, it starts a fresh exchange. |
| + if got := homeRedirect(t, f.do("GET", "/signin?return_to=%2Fday%2F2026-09-10", "")); !strings.HasPrefix(got, "/auth/start") { |
| + t.Fatalf("second /signin went to %q, want the exchange", got) |
| + } |
| +} |
diff --git a/internal/calendar/home_router_test.go b/internal/calendar/home_router_test.go| index e1469bf..acf56b9 100644 |
| --- a/internal/calendar/home_router_test.go |
| +++ b/internal/calendar/home_router_test.go |
| @@ -219,6 +219,14 @@ func TestHomeRouterRetainsExistingCalendarIdentity(t *testing.T) { |
| if w := f.do("GET", "/", ""); w.Code != 404 { |
| t.Fatal("Home attached replacement", w.Code) |
| } |
| + // /signin does not shortcut this session to "/", which would 404 with |
| + // nowhere to go: it ends the session and starts over (Astra P3, #23). |
| + if w := f.do("GET", "/signin", ""); w.Code == http.StatusSeeOther && w.Header().Get("Location") == "/" { |
| + t.Fatal("/signin shortcut a deleted Home person to /, which 404s") |
| + } |
| + if f.cookies[f.app.home.sessions.CookieName()] != nil { |
| + t.Fatal("/signin kept a deleted Home person's session") |
| + } |
| } |
| func TestHomeRouterUnknownSubjectCannotCreateAccount(t *testing.T) { |
| f := newHomeRouterFixture(t) |
diff --git a/internal/calendar/routes.go b/internal/calendar/routes.go| index 275c52c..a5162ee 100644 |
| --- a/internal/calendar/routes.go |
| +++ b/internal/calendar/routes.go |
| @@ -43,7 +43,7 @@ func (a *App) routes(h *handlers, authPlugin *rauth.Auth, list allowlist, origin |
| // Signed-out. |
| if a.home != nil { |
| - r.Get("/signin", a.home.routes.Signin) |
| + r.Get("/signin", a.homeSignin) |
| r.Get("/signin/local", h.signin) |
| r.Get("/signed-out", a.home.routes.SignedOut) |
| r.Get(connect.LocalCompletePath, a.home.routes.LocalComplete) |
| @@ -56,6 +56,7 @@ func (a *App) routes(h *handlers, authPlugin *rauth.Auth, list allowlist, origin |
| r.With(a.home.sessions.Require).Post("/account/home/confirm", a.home.routes.ConfirmPost) |
| } else { |
| r.Get("/signin", h.signin) |
| + r.Get("/signed-out", h.signedOut) |
| } |
| // One counter for the life of the router, so the log line can say |
| // how many refusals this boot has seen rather than only that one |
| @@ -76,7 +77,7 @@ func (a *App) routes(h *handlers, authPlugin *rauth.Auth, list allowlist, origin |
| r.Post("/signin", begin) |
| r.Get("/auth/callback", authPlugin.Callback) |
| r.Get("/auth/verify", authPlugin.Verify) |
| - r.Post("/signout", authPlugin.Signout) |
| + r.Post("/signout", signOutToSignedOut(authPlugin.Signout)) |
| } |
| // The light/dark switcher, outside the session on purpose: the |
diff --git a/internal/calendar/signin.go b/internal/calendar/signin.go| index c34437c..1a2a350 100644 |
| --- a/internal/calendar/signin.go |
| +++ b/internal/calendar/signin.go |
| @@ -95,3 +95,32 @@ func signedOutDoor(r *http.Request, heading, body string, link chrome.Link) sign |
| Link: &link, |
| }} |
| } |
| + |
| +// signedOut serves GET /signed-out without Home: the same estilo door Home |
| +// mode's sign-out lands on, in Calendar's own words, so signing out is a |
| +// page that says so rather than the sign-in form again (or, before, a |
| +// 404 for anyone who reached the address). |
| +func (h *handlers) signedOut(w http.ResponseWriter, r *http.Request) { |
| + w.Header().Set("Cache-Control", "no-store") |
| + render(w, "signin", h.page(r, "Signed out", signedOutDoor(r, "Signed out", |
| + "You’re signed out of Calendar on this browser.", chrome.Link{Href: "/signin", Label: "Sign in"}))) |
| +} |
| + |
| +// signOutToSignedOut is rastrillo's standalone Sign out with one change: |
| +// where it lands. auth.Signout revokes the session and answers 303 to its |
| +// SigninPath, which every other auth outcome also uses, so the path cannot |
| +// change; this retargets that one redirect to /signed-out. |
| +func signOutToSignedOut(signout http.HandlerFunc) http.HandlerFunc { |
| + return func(w http.ResponseWriter, r *http.Request) { |
| + signout(&signedOutRedirect{ResponseWriter: w}, r) |
| + } |
| +} |
| + |
| +type signedOutRedirect struct{ http.ResponseWriter } |
| + |
| +func (s *signedOutRedirect) WriteHeader(code int) { |
| + if code == http.StatusSeeOther && s.Header().Get("Location") == "/signin" { |
| + s.Header().Set("Location", "/signed-out") |
| + } |
| + s.ResponseWriter.WriteHeader(code) |
| +} |
diff --git a/internal/calendartest/signed_out_test.go b/internal/calendartest/signed_out_test.go| new file mode 100644 |
| index 0000000..ae50df1 |
| --- /dev/null |
| +++ b/internal/calendartest/signed_out_test.go |
| @@ -0,0 +1,44 @@ |
| +package calendartest |
| + |
| +import ( |
| + "net/http" |
| + "strings" |
| + "testing" |
| +) |
| + |
| +// Standalone (no Home), signing out lands on a real page: the estilo |
| +// signed-out door at /signed-out, as Home mode's sign-out does, rather than |
| +// back on the sign-in form as if nothing happened. /signed-out used to 404 |
| +// without Home (Fable 5.1 follow-up finding 4 on #17). |
| +func TestStandaloneSignOutLandsOnTheSignedOutPage(t *testing.T) { |
| + app, session := signedIn(t, "someone@example.com") |
| + h := app.Handler() |
| + |
| + rec := postAs(t, h, session, "/signout", nil) |
| + if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/signed-out" { |
| + t.Fatalf("POST /signout: %d to %q, want 303 to /signed-out", rec.Code, rec.Header().Get("Location")) |
| + } |
| + if after := getAs(t, h, session, "/day/2026-09-24"); after.Code != http.StatusSeeOther { |
| + t.Fatalf("the session survived sign-out: GET a view answered %d", after.Code) |
| + } |
| + |
| + page := get(t, h, "/signed-out") |
| + body := page.Body.String() |
| + if page.Code != http.StatusOK || !strings.HasPrefix(page.Header().Get("Content-Type"), "text/html") { |
| + t.Fatalf("GET /signed-out: %d %s", page.Code, page.Header().Get("Content-Type")) |
| + } |
| + for _, want := range []string{ |
| + "<title>Signed out · Calendar</title>", |
| + "estilo-door", |
| + "<h1>Signed out</h1>", |
| + `<a rst-btn="primary" href="/signin">Sign in</a>`, |
| + } { |
| + if !strings.Contains(body, want) { |
| + t.Errorf("/signed-out is missing %q:\n%s", want, body) |
| + } |
| + } |
| + // The page that IS signing out shows nothing in the bar's corner. |
| + if strings.Contains(body, "estilo-bar-signin") || strings.Contains(body, "estilo-account") { |
| + t.Error("/signed-out drew a Sign in button or an account menu in the bar") |
| + } |
| +} |