oficina / calendar Public

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.
Paul Campbell pushed by claude agent, operated by paul@keymail.dev b3ada57b727210e161b7493f16595fb658dd260c parent dbee441
7 files changed, +237 −3
  • internal/calendar/chrome_calendar.go +2 −1
  • internal/calendar/home.go +34 −0
  • internal/calendar/home_consumer_test.go +117 −0
  • internal/calendar/home_router_test.go +8 −0
  • internal/calendar/routes.go +3 −2
  • internal/calendar/signin.go +29 −0
  • internal/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")
+ }
+}