Connect Home sign-in to existing Calendar accounts
Co-Authored-By: Codex <noreply@openai.com>
17 files changed,
+550
−50
cmd/calendar/main.go+2 −0docs/superpowers/specs/2026-09-02-calendar-design.md+16 −0go.mod+4 −1go.sum+9 −10internal/calendar/app.go+22 −2internal/calendar/auth.go+15 −19internal/calendar/config.go+2 −0internal/calendar/handlers.go+3 −2internal/calendar/home.go+97 −0internal/calendar/home_router_test.go+229 −0internal/calendar/home_security_test.go+43 −0internal/calendar/person.go+20 −0internal/calendar/render.go+13 −9internal/calendar/routes.go+44 −7internal/calendar/templates/home.html+1 −0internal/calendar/templates/layout.html+1 −0internal/calendar/viewer.go+29 −0
CI passed — run details
diff --git a/cmd/calendar/main.go b/cmd/calendar/main.go| index 78a4e27..abcafd7 100644 |
| --- a/cmd/calendar/main.go |
| +++ b/cmd/calendar/main.go |
| @@ -13,6 +13,7 @@ |
| package main |
| import ( |
| + "amadan.net/oficina/home/connect" |
| "log/slog" |
| "os" |
| "strings" |
| @@ -57,6 +58,7 @@ func main() { |
| } |
| app, err := calendar.New(calendar.Config{ |
| + Home: connect.CredentialsFromEnv("CALENDAR"), |
| DBPath: opts.DBPath, |
| Env: chooseEnv(envFlag, os.Getenv("CALENDAR_ENV")), |
| Origin: origin, |
diff --git a/docs/superpowers/specs/2026-09-02-calendar-design.md b/docs/superpowers/specs/2026-09-02-calendar-design.md| index 6ad4672..46df778 100644 |
| --- a/docs/superpowers/specs/2026-09-02-calendar-design.md |
| +++ b/docs/superpowers/specs/2026-09-02-calendar-design.md |
| @@ -291,3 +291,19 @@ own spec is `2026-09-07-booking-design.md`, reviewed eight times by |
| GPT-6 before code; the review found six defects in this engine's |
| recurrence expansion that every view already had, all fixed on the |
| same branch. |
| + |
| +### Home account connection — 2026-09-10 |
| + |
| +The approved suite sign-in extension connects a verified Home identity to an |
| +existing Calendar person after fresh local verification and explicit confirmation. |
| +The Home subject never becomes an email address or creates a person, calendar, |
| +event or reminder. Each request resolves the stored person ID and checks current |
| +admission; calendar grants still decide which events that person can read or edit. |
| +Person IDs use SQLite AUTOINCREMENT and must never be reassigned or reused. |
| + |
| +Home is optional. Configure the CALENDAR_HOME_ISSUER, CALENDAR_HOME_CONSUMER_ID, |
| +CALENDAR_HOME_KEYS_BASE64 and CALENDAR_HOME_DEK_BASE64 values together. The final |
| +value is a dedicated 32-byte connection key, separate from event encryption. |
| +Partial configuration refuses startup. Without these values, local sign-in keeps |
| +its existing routes and behavior. The shared Home connection package supplies the |
| +proof flow and account screens; Calendar keeps its own page shell and grants. |
diff --git a/go.mod b/go.mod| index 776316c..461f9c4 100644 |
| --- a/go.mod |
| +++ b/go.mod |
| @@ -3,6 +3,9 @@ module amadan.net/oficina/calendar |
| go 1.25.0 |
| require ( |
| + amadan.net/oficina/home/connect v0.0.0-20260909231415-9a780c687d4f |
| + amadan.net/oficina/home/handoff v0.0.0-20260908102137-481d2d57f489 |
| + amadan.net/rastrillo/rastrillo v0.26.1-0.20260908075707-29e4491631bd |
| github.com/carlosframework/rastrillo v0.23.0 |
| github.com/chromedp/chromedp v0.14.2 |
| github.com/go-chi/chi/v5 v5.3.2 |
| @@ -25,7 +28,7 @@ require ( |
| github.com/mattn/go-isatty v0.0.20 // indirect |
| github.com/ncruces/go-strftime v1.0.0 // indirect |
| github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect |
| - golang.org/x/crypto v0.55.0 // indirect |
| + golang.org/x/crypto v0.55.0 |
| golang.org/x/sys v0.47.0 // indirect |
| golang.org/x/text v0.41.0 // indirect |
| gorm.io/plugin/dbresolver v1.6.2 // indirect |
diff --git a/go.sum b/go.sum| index 9e6296c..a10871e 100644 |
| --- a/go.sum |
| +++ b/go.sum |
| @@ -1,3 +1,9 @@ |
| +amadan.net/oficina/home/connect v0.0.0-20260909231415-9a780c687d4f h1:DLcotgutTVa/LeYiCTmBH+MO0O8EEJ2RN97+5zFxndE= |
| +amadan.net/oficina/home/connect v0.0.0-20260909231415-9a780c687d4f/go.mod h1:jsd1+E2JaW2m1Afqh7CHjSbdxYSRk2UPAEbxeSQ8zyo= |
| +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= |
| +amadan.net/rastrillo/rastrillo v0.26.1-0.20260908075707-29e4491631bd/go.mod h1:RpyHVPD0udcSfHJroY0KZE/FUjgw6Tvfo8SJm2qeZGE= |
| github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= |
| github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= |
| github.com/carlosframework/rastrillo v0.23.0 h1:tKAAlwPt4Pg/rkqk7o6jvV12bbZ0r34oEVpuOlKfX7w= |
| @@ -48,24 +54,17 @@ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94 |
| github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= |
| golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= |
| golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= |
| -golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= |
| -golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= |
| golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= |
| -golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= |
| -golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= |
| +golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= |
| golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= |
| +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= |
| golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= |
| -golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= |
| -golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= |
| golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= |
| golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= |
| -golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug= |
| -golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4= |
| golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= |
| golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= |
| -golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= |
| -golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= |
| golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= |
| +golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= |
| gorm.io/driver/mysql v1.5.7 h1:MndhOPYOfEp2rHKgkZIhJ16eVUIRf2HmzgoPmh7FCWo= |
| gorm.io/driver/mysql v1.5.7/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkDM= |
| gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ= |
diff --git a/internal/calendar/app.go b/internal/calendar/app.go| index 44ae77c..d8a8e82 100644 |
| --- a/internal/calendar/app.go |
| +++ b/internal/calendar/app.go |
| @@ -1,6 +1,9 @@ |
| package calendar |
| import ( |
| + "amadan.net/oficina/home/connect" |
| + "amadan.net/oficina/home/connect/linkstore" |
| + "amadan.net/oficina/home/handoff" |
| "context" |
| "fmt" |
| "log/slog" |
| @@ -24,6 +27,7 @@ import ( |
| // development mail recorder both hang off this struct, and every later |
| // test depends on them. |
| type App struct { |
| + home *homeConnection |
| cfg Config |
| db *db.DB |
| mux *http.ServeMux |
| @@ -55,6 +59,10 @@ func New(cfg Config) (*App, error) { |
| return nil, err |
| } |
| cfg.Origin = origin |
| + homeSettings, err := connect.DecodeCredentials(cfg.Home, origin) |
| + if err != nil { |
| + return nil, err |
| + } |
| instanceKey, err := resolveInstanceKey(cfg) |
| if err != nil { |
| @@ -89,7 +97,11 @@ func New(cfg Config) (*App, error) { |
| // migrations plus this app's own, in apply order, while Schema |
| // stays narrow so the migration generator never sees a subsystem's |
| // tables and proposes dropping them. |
| - migrated, err := migrate.Apply(context.Background(), d, BootSchema) |
| + boot := BootSchema |
| + if homeSettings != nil { |
| + boot = migrate.Merge(BootSchema, migrate.MustFromFS(handoff.Migrations, "oficina_handoff"), migrate.MustFromFS(linkstore.Migrations, linkstore.SchemaNamespace)) |
| + } |
| + migrated, err := migrate.Apply(context.Background(), d, boot) |
| if err != nil { |
| d.Close() |
| return nil, fmt.Errorf("calendar: apply schema: %w", err) |
| @@ -116,7 +128,7 @@ func New(cfg Config) (*App, error) { |
| // reminders, booking mail. The recorder stays inside it, so tests |
| // still read what was sent (booking spec §6). |
| mailer = newBoundedSender(mailer) |
| - authPlugin, err := newAuthPlugin(origin, writer, instanceKey, mailer, list, logger) |
| + authPlugin, err := newAuthPlugin(origin, writer, instanceKey, mailer, list, logger, homeSettings != nil) |
| if err != nil { |
| d.Close() |
| return nil, fmt.Errorf("calendar: wire sign-in: %w", err) |
| @@ -133,6 +145,14 @@ func New(cfg Config) (*App, error) { |
| } |
| h := &handlers{store: store, logger: logger, origin: origin, mailer: mailer, gate: newGate()} |
| a.h = h |
| + if homeSettings != nil { |
| + a.home, err = a.newHome(homeSettings, list) |
| + if err != nil { |
| + d.Close() |
| + return nil, err |
| + } |
| + h.homeEnabled = true |
| + } |
| a.mux = a.routes(h, authPlugin, list, origin) |
| return a, nil |
| } |
diff --git a/internal/calendar/auth.go b/internal/calendar/auth.go| index a78eee1..2cfb3cf 100644 |
| --- a/internal/calendar/auth.go |
| +++ b/internal/calendar/auth.go |
| @@ -12,6 +12,7 @@ |
| package calendar |
| import ( |
| + "amadan.net/oficina/home/connect" |
| "context" |
| "database/sql" |
| "fmt" |
| @@ -261,15 +262,21 @@ func gateBegin(next http.HandlerFunc, list allowlist, signinPath string, logger |
| // different things: gateBegin stops the mail going out, Authorize stops |
| // a link that was somehow minted for a non-permitted address (mailed |
| // before the allowlist changed, say) from minting a session. |
| -func newAuthPlugin(origin string, writer *sql.DB, instanceKey string, mailer mail.Sender, list allowlist, logger *slog.Logger) (*rauth.Auth, error) { |
| +func newAuthPlugin(origin string, writer *sql.DB, instanceKey string, mailer mail.Sender, list allowlist, logger *slog.Logger, homeEnabled ...bool) (*rauth.Auth, error) { |
| + signin, signedIn := "/signin", "/" |
| + if len(homeEnabled) > 0 && homeEnabled[0] { |
| + signin, signedIn = "/signin/local", connect.LocalCompletePath |
| + } |
| return rauth.New(rauth.Config{ |
| - DB: writer, |
| - Origin: origin, |
| - InstanceKey: instanceKey, |
| - Mailer: mailer, |
| - Authorize: list.allows, |
| - Subject: "Your calendar sign-in link", |
| - Logger: logger, |
| + DB: writer, |
| + SigninPath: signin, |
| + SignedInPath: signedIn, |
| + Origin: origin, |
| + InstanceKey: instanceKey, |
| + Mailer: mailer, |
| + Authorize: list.allows, |
| + Subject: "Your calendar sign-in link", |
| + Logger: logger, |
| }) |
| } |
| @@ -283,14 +290,3 @@ func linkPath(link string) (string, error) { |
| } |
| return u.RequestURI(), nil |
| } |
| - |
| -// viewer is the signed-in identity on r, if any. |
| -// |
| -// auth.From, never sessions.UserID: under the magic-link plugin the |
| -// session Subject is a verified email address, so UserID returns |
| -// (0, false) and every owner-scoped query built on it would silently |
| -// filter to person_id = 0 — a calendar that looks empty rather than |
| -// broken, which is the more expensive failure. |
| -func viewer(r *http.Request) (rauth.Identity, bool) { |
| - return rauth.From(r) |
| -} |
diff --git a/internal/calendar/config.go b/internal/calendar/config.go| index 11e0c26..4897675 100644 |
| --- a/internal/calendar/config.go |
| +++ b/internal/calendar/config.go |
| @@ -1,6 +1,7 @@ |
| package calendar |
| import ( |
| + "amadan.net/oficina/home/connect" |
| "encoding/base64" |
| "fmt" |
| "log/slog" |
| @@ -21,6 +22,7 @@ const DefaultOrigin = "http://localhost:8080" |
| // the family's shape: a misconfigured instance refuses to start, never |
| // quietly does the wrong thing. |
| type Config struct { |
| + Home *connect.Credentials |
| // DBPath is the SQLite file this instance owns. The platform hands |
| // it in as activation argv; a developer passes -db. |
| DBPath string |
diff --git a/internal/calendar/handlers.go b/internal/calendar/handlers.go| index a26b97f..871769d 100644 |
| --- a/internal/calendar/handlers.go |
| +++ b/internal/calendar/handlers.go |
| @@ -10,8 +10,9 @@ import ( |
| // handlers holds what every handler needs. One value, built in New. |
| type handlers struct { |
| - store *Store |
| - logger *slog.Logger |
| + homeEnabled bool |
| + store *Store |
| + logger *slog.Logger |
| // origin is this instance's external origin. Handlers read it to |
| // decide Secure cookie attributes and to build the absolute URLs a |
diff --git a/internal/calendar/home.go b/internal/calendar/home.go| new file mode 100644 |
| index 0000000..898b44a |
| --- /dev/null |
| +++ b/internal/calendar/home.go |
| @@ -0,0 +1,97 @@ |
| +package calendar |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "database/sql" |
| + "errors" |
| + "net/http" |
| + |
| + "amadan.net/oficina/home/connect" |
| + "amadan.net/oficina/home/connect/linkstore" |
| + "github.com/carlosframework/rastrillo/csrf" |
| + "github.com/carlosframework/rastrillo/sessions" |
| +) |
| + |
| +type homeConnection struct { |
| + bridge *connect.Bridge |
| + routes *connect.Routes |
| + sessions *sessions.Sessions |
| + admitted allowlist |
| +} |
| + |
| +func (a *App) newHome(settings *connect.DecodedCredentials, list allowlist) (*homeConnection, error) { |
| + writer := a.db.Writer() |
| + sess, err := sessions.New(sessions.Config{DB: writer, Origin: a.cfg.Origin, Logger: a.logger}) |
| + if err != nil { |
| + return nil, err |
| + } |
| + read := func(r *http.Request) (*connect.Session, error) { |
| + s, ok := sess.From(r) |
| + if !ok { |
| + return nil, nil |
| + } |
| + return &connect.Session{Subject: s.Subject, Method: s.Method, AuthTime: s.AuthTime, At: s.At}, nil |
| + } |
| + lookup := func(ctx context.Context, q linkstore.Queryer, id int64) (string, error) { |
| + var address string |
| + if err := q.QueryRowContext(ctx, `SELECT address FROM people WHERE id=?`, id).Scan(&address); err != nil { |
| + return "", err |
| + } |
| + if !list.allows(address) { |
| + return "", sql.ErrNoRows |
| + } |
| + return address, nil |
| + } |
| + local := func(ctx context.Context, s connect.Session) (connect.Person, error) { |
| + if s.Method != "magiclink" && s.Method != "keymail" { |
| + return connect.Person{}, sql.ErrNoRows |
| + } |
| + var p connect.Person |
| + if err := writer.QueryRowContext(ctx, `SELECT id,address FROM people WHERE address=?`, normalizeAddress(s.Subject)).Scan(&p.ID, &p.Label); err != nil { |
| + return p, err |
| + } |
| + if !list.allows(p.Label) { |
| + return connect.Person{}, sql.ErrNoRows |
| + } |
| + return p, nil |
| + } |
| + page := func(w http.ResponseWriter, r *http.Request, state, label string, status int) { |
| + data := connect.Page(state, label) |
| + var buf bytes.Buffer |
| + if err := pages["home"].ExecuteTemplate(&buf, "layout", a.h.page(r, data.Title, data)); err != nil { |
| + ErrorPage(w, r, http.StatusInternalServerError, "") |
| + return |
| + } |
| + w.Header().Set("Cache-Control", "no-store") |
| + w.Header().Set("Content-Type", "text/html; charset=utf-8") |
| + w.WriteHeader(status) |
| + _, _ = buf.WriteTo(w) |
| + } |
| + b, err := connect.New(connect.Config{DB: writer, Origin: a.cfg.Origin, Issuer: settings.Issuer, ConsumerID: settings.ConsumerID, Keys: settings.Keys, DEK: settings.DEK, Namespace: "oficina/calendar", SessionCookie: sess.CookieName(), ReadSession: read, LookupLocalSession: local, LookupPerson: lookup, Render: page, MintHomeSession: func(w http.ResponseWriter, r *http.Request, s connect.Session) error { |
| + return sess.SignIn(w, r, sessions.Session{Subject: s.Subject, Method: s.Method, AuthTime: s.AuthTime}) |
| + }}) |
| + if err != nil { |
| + return nil, err |
| + } |
| + routes, err := connect.NewRoutes(b, connect.RoutesConfig{Namespace: "oficina/calendar", Origin: a.cfg.Origin, Issuer: settings.Issuer, SessionCookie: sess.CookieName(), DEK: settings.DEK, ReadSession: read, SignOut: sess.SignOut, LocalSignin: a.h.signin, Protect: csrf.Protect(a.cfg.Origin), Render: page}) |
| + if err != nil { |
| + return nil, err |
| + } |
| + return &homeConnection{bridge: b, routes: routes, sessions: sess, admitted: list}, nil |
| +} |
| + |
| +func (a *App) homePerson(r *http.Request) (Person, error) { |
| + p, err := a.home.bridge.ResolveSession(r) |
| + if err != nil { |
| + return Person{}, err |
| + } |
| + var person Person |
| + if err = a.db.G.WithContext(r.Context()).First(&person, p.ID).Error; err != nil { |
| + return Person{}, err |
| + } |
| + if person.ID != p.ID || person.Address != p.Label { |
| + return Person{}, errors.New("calendar: identity unavailable") |
| + } |
| + return person, nil |
| +} |
diff --git a/internal/calendar/home_router_test.go b/internal/calendar/home_router_test.go| new file mode 100644 |
| index 0000000..17a0e8a |
| --- /dev/null |
| +++ b/internal/calendar/home_router_test.go |
| @@ -0,0 +1,229 @@ |
| +package calendar |
| + |
| +import ( |
| + "bytes" |
| + "encoding/base64" |
| + "encoding/json" |
| + "io" |
| + "log/slog" |
| + "net/http" |
| + "net/http/httptest" |
| + "net/url" |
| + "path/filepath" |
| + "regexp" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "amadan.net/oficina/home/connect" |
| + "amadan.net/rastrillo/rastrillo/assertion" |
| + assertioncrypto "amadan.net/rastrillo/rastrillo/crypto" |
| +) |
| + |
| +const homeTestOrigin = "https://calendar.example.test" |
| +const homeTestIssuer = "https://id.example.test" |
| + |
| +type homeRouterFixture struct { |
| + app *App |
| + key *assertioncrypto.Keypair |
| + cookies map[string]*http.Cookie |
| +} |
| + |
| +func newHomeRouterFixture(t *testing.T) *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())}) |
| + a, err := New(Config{DBPath: filepath.Join(t.TempDir(), "calendar.db"), Env: "development", Origin: homeTestOrigin, Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), Home: &connect.Credentials{Issuer: homeTestIssuer, ConsumerID: "calendar-test", KeysBase64: base64.StdEncoding.EncodeToString(manifest), DEKBase64: base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{7}, 32))}}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Cleanup(func() { a.Close() }) |
| + return &homeRouterFixture{a, key, map[string]*http.Cookie{}} |
| +} |
| +func (f *homeRouterFixture) do(method, path, body string) *httptest.ResponseRecorder { |
| + r := httptest.NewRequest(method, homeTestOrigin+path, strings.NewReader(body)) |
| + for _, c := range f.cookies { |
| + r.AddCookie(c) |
| + } |
| + if method == "POST" { |
| + r.Header.Set("Content-Type", "application/x-www-form-urlencoded") |
| + r.Header.Set("Origin", homeTestOrigin) |
| + if path == "/auth/assertion" { |
| + r.Header.Set("Origin", homeTestIssuer) |
| + } |
| + } |
| + w := httptest.NewRecorder() |
| + f.app.mux.ServeHTTP(w, r) |
| + for _, c := range w.Result().Cookies() { |
| + if c.MaxAge < 0 { |
| + delete(f.cookies, c.Name) |
| + } else { |
| + f.cookies[c.Name] = c |
| + } |
| + } |
| + return w |
| +} |
| +func homeRedirect(t *testing.T, w *httptest.ResponseRecorder) string { |
| + t.Helper() |
| + if w.Code != 303 { |
| + t.Fatalf("redirect status %d: %s", w.Code, w.Body.String()) |
| + } |
| + return w.Header().Get("Location") |
| +} |
| +func (f *homeRouterFixture) local(t *testing.T) { |
| + t.Helper() |
| + homeRedirect(t, f.do("POST", "/signin", "address=alice%40example.test&force=1")) |
| + link := regexp.MustCompile(`https://[^\s<>\"]+/auth/verify\?[^\s<>\"]+`).FindString(f.app.mailRecorder.last()) |
| + u, err := url.Parse(link) |
| + if err != nil || link == "" { |
| + t.Fatal("no captured local verification link") |
| + } |
| + landing := homeRedirect(t, f.do("GET", u.RequestURI(), "")) |
| + if landing != "/auth/local/complete" { |
| + t.Fatal("wrong local landing", landing) |
| + } |
| + homeRedirect(t, f.do("GET", landing, "")) |
| +} |
| +func (f *homeRouterFixture) exchange(t *testing.T, start, subject string) *httptest.ResponseRecorder { |
| + t.Helper() |
| + target := homeRedirect(t, f.do("GET", start, "")) |
| + u, err := url.Parse(target) |
| + if err != nil || u.Query().Get("consumer") != "calendar-test" { |
| + t.Fatal("bad issuer redirect") |
| + } |
| + nonce := u.Query().Get("nonce") |
| + now := time.Now().Unix() |
| + jti, err := assertion.RandomID() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + token, err := assertion.Sign(f.key, assertion.Claims{Version: 1, Issuer: homeTestIssuer, Audience: homeTestOrigin, Subject: subject, KeyID: "key", ID: jti, Nonce: nonce, IssuedAt: now, ExpiresAt: now + 90, AuthTime: now}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + callback := f.do("POST", "/auth/assertion", url.Values{"assertion": {token}}.Encode()) |
| + finish := homeRedirect(t, callback) |
| + return f.do("GET", finish, "") |
| +} |
| + |
| +func TestHomeRouterRetainsExistingCalendarIdentity(t *testing.T) { |
| + f := newHomeRouterFixture(t) |
| + f.local(t) |
| + if w := f.do("GET", "/day/2026-09-10", ""); w.Code != 200 { |
| + t.Fatal("local calendar", w.Code, w.Body.String()) |
| + } |
| + var person Person |
| + if err := f.app.db.G.Where("address=?", "alice@example.test").First(&person).Error; err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + calendarID, err := f.app.store.DefaultCalendarID(t.Context(), person.ID) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + event, err := f.app.store.CreateEvent(t.Context(), person.ID, EventInput{CalendarID: calendarID, Title: "Existing private appointment", StartLocal: "2026-09-10T10:00", EndLocal: "2026-09-10T11:00", TZ: "Europe/Dublin"}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := f.app.store.AddReminder(t.Context(), person.ID, event.ID, 15); err != nil { |
| + t.Fatal(err) |
| + } |
| + var calendarsBefore int64 |
| + f.app.db.G.Model(&Calendar{}).Count(&calendarsBefore) |
| + cookie := f.cookies[f.app.home.sessions.CookieName()].Value |
| + if w := f.do("GET", "/account/home", ""); w.Code != 200 { |
| + t.Fatal("link page", w.Code, w.Body.String()) |
| + } |
| + start := homeRedirect(t, f.do("POST", "/account/home", "")) |
| + if got := homeRedirect(t, f.exchange(t, start, "opaque-home-alice")); got != "/account/home/confirm" { |
| + t.Fatal(got) |
| + } |
| + if f.cookies[f.app.home.sessions.CookieName()].Value != cookie { |
| + t.Fatal("proof replaced session") |
| + } |
| + homeRedirect(t, f.do("POST", "/account/home/confirm", "")) |
| + homeRedirect(t, f.do("POST", "/signout", "")) |
| + start = homeRedirect(t, f.do("GET", "/signin", "")) |
| + target := homeRedirect(t, f.exchange(t, start, "opaque-home-alice")) |
| + if target != "/" { |
| + t.Fatal("unexpected landing", target) |
| + } |
| + if w := f.do("GET", "/day/2026-09-10", ""); w.Code != 200 { |
| + t.Fatal("Home calendar", w.Code, w.Body.String()) |
| + } |
| + resolved, err := f.app.home.bridge.Resolve(t.Context(), "opaque-home-alice") |
| + if err != nil || resolved.ID != person.ID || resolved.Label != person.Address { |
| + t.Fatal("changed identity", resolved, err) |
| + } |
| + var people, calendarsAfter int64 |
| + f.app.db.G.Model(&Person{}).Count(&people) |
| + f.app.db.G.Model(&Calendar{}).Count(&calendarsAfter) |
| + if people != 1 || calendarsBefore != calendarsAfter { |
| + t.Fatal("Home created data", people, calendarsBefore, calendarsAfter) |
| + } |
| + |
| + if w := f.do("GET", "/day/2026-09-10", ""); !strings.Contains(w.Body.String(), "Existing private appointment") { |
| + t.Fatal("Home lost existing event") |
| + } |
| + reminders, err := f.app.store.RemindersFor(t.Context(), resolved.ID, event.ID) |
| + if err != nil || len(reminders) != 1 || reminders[0].PersonID != person.ID { |
| + t.Fatal("Home changed reminder owner", err) |
| + } |
| + if err := f.app.db.G.Delete(&Person{}, person.ID).Error; err != nil { |
| + t.Fatal(err) |
| + } |
| + if w := f.do("GET", "/", ""); w.Code != 404 { |
| + t.Fatal("deleted account admitted", w.Code) |
| + } |
| + replacement, err := f.app.store.EnsurePerson(t.Context(), "replacement@example.test") |
| + if err != nil || replacement.ID == person.ID { |
| + t.Fatal("account ID reused", replacement.ID, err) |
| + } |
| + if w := f.do("GET", "/", ""); w.Code != 404 { |
| + t.Fatal("Home attached replacement", w.Code) |
| + } |
| +} |
| +func TestHomeRouterUnknownSubjectCannotCreateAccount(t *testing.T) { |
| + f := newHomeRouterFixture(t) |
| + if _, err := f.app.store.EnsurePersonSetup(t.Context(), "alice@example.test"); err != nil { |
| + t.Fatal(err) |
| + } |
| + start := homeRedirect(t, f.do("GET", "/signin", "")) |
| + if w := f.exchange(t, start, "unknown-home-alice"); w.Code != 403 { |
| + t.Fatal("unknown Home admitted", w.Code) |
| + } |
| + if f.cookies[f.app.home.sessions.CookieName()] != nil { |
| + t.Fatal("unknown Home session minted") |
| + } |
| + var count int64 |
| + f.app.db.G.Model(&Person{}).Count(&count) |
| + if count != 1 { |
| + t.Fatal("Home allocated person", count) |
| + } |
| +} |
| +func TestHomeRouterRejectsCrossSiteMutationAndDuplicateSession(t *testing.T) { |
| + f := newHomeRouterFixture(t) |
| + f.local(t) |
| + req := httptest.NewRequest("POST", homeTestOrigin+"/account/home", nil) |
| + req.Header.Set("Origin", "https://stranger.example.test") |
| + for _, c := range f.cookies { |
| + req.AddCookie(c) |
| + } |
| + w := httptest.NewRecorder() |
| + f.app.mux.ServeHTTP(w, req) |
| + if w.Code != 403 { |
| + t.Fatal("cross-site mutation", w.Code) |
| + } |
| + req = httptest.NewRequest("GET", homeTestOrigin+"/account/home", nil) |
| + c := f.cookies[f.app.home.sessions.CookieName()] |
| + req.AddCookie(c) |
| + req.AddCookie(c) |
| + w = httptest.NewRecorder() |
| + f.app.mux.ServeHTTP(w, req) |
| + if w.Code < 400 { |
| + t.Fatal("ambiguous cookie admitted", w.Code) |
| + } |
| +} |
diff --git a/internal/calendar/home_security_test.go b/internal/calendar/home_security_test.go| new file mode 100644 |
| index 0000000..db21388 |
| --- /dev/null |
| +++ b/internal/calendar/home_security_test.go |
| @@ -0,0 +1,43 @@ |
| +package calendar |
| + |
| +import ( |
| + "github.com/carlosframework/rastrillo" |
| + "net/http" |
| + "net/http/httptest" |
| + "strings" |
| + "testing" |
| +) |
| + |
| +func TestHomeCSPPreservesPinnedFrameworkDirectives(t *testing.T) { |
| + mux := http.NewServeMux() |
| + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) }) |
| + baselineHandler, closeBaseline, err := rastrillo.Handler(rastrillo.Options{Mux: mux}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if closeBaseline != nil { |
| + defer closeBaseline() |
| + } |
| + rec := httptest.NewRecorder() |
| + baselineHandler.ServeHTTP(rec, httptest.NewRequest("GET", homeTestOrigin+"/", nil)) |
| + baseline := rec.Header().Get("Content-Security-Policy") |
| + want := strings.Replace(baseline, "form-action 'self'", "form-action 'self' "+homeTestIssuer, 1) |
| + if want == baseline { |
| + t.Fatal("framework form-action changed") |
| + } |
| + f := newHomeRouterFixture(t) |
| + wrapped, done, err := rastrillo.Handler(rastrillo.Options{Mux: f.app.mux}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if done != nil { |
| + defer done() |
| + } |
| + for _, path := range []string{"/signin/local", "/auth/start", "/account/home", "/signed-out"} { |
| + rec := httptest.NewRecorder() |
| + wrapped.ServeHTTP(rec, httptest.NewRequest("GET", homeTestOrigin+path, nil)) |
| + if got := rec.Header().Get("Content-Security-Policy"); got != want { |
| + t.Fatalf("policy drift at %s: %q", path, got) |
| + } |
| + } |
| +} |
diff --git a/internal/calendar/person.go b/internal/calendar/person.go| index 8e0b459..be8e2b9 100644 |
| --- a/internal/calendar/person.go |
| +++ b/internal/calendar/person.go |
| @@ -1,7 +1,9 @@ |
| package calendar |
| import ( |
| + "amadan.net/oficina/home/connect" |
| "context" |
| + "github.com/carlosframework/rastrillo/sessions" |
| "net/http" |
| ) |
| @@ -26,6 +28,20 @@ type personCtxKey struct{} |
| // empty calendar looks like a new account, not like a bug. |
| func (a *App) requirePerson(next http.Handler) http.Handler { |
| return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + if session, ok := sessions.Current(r); ok && session.Method == connect.HomeMethod { |
| + if a.home == nil { |
| + ErrorPage(w, r, http.StatusNotFound, "") |
| + return |
| + } |
| + p, err := a.homePerson(r) |
| + if err != nil { |
| + ErrorPage(w, r, http.StatusNotFound, "") |
| + return |
| + } |
| + next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), personCtxKey{}, p))) |
| + return |
| + } |
| + |
| id, ok := viewer(r) |
| if !ok { |
| // RequireSession runs first and would have refused, so |
| @@ -35,6 +51,10 @@ func (a *App) requirePerson(next http.Handler) http.Handler { |
| ErrorPage(w, r, http.StatusInternalServerError, "") |
| return |
| } |
| + if a.home != nil && !a.home.admitted.allows(id.Address) { |
| + ErrorPage(w, r, http.StatusNotFound, "") |
| + return |
| + } |
| p, err := a.store.EnsurePersonSetup(r.Context(), id.Address) |
| if err != nil { |
| // No address in the log line: the error is about this |
diff --git a/internal/calendar/render.go b/internal/calendar/render.go| index 2eab125..7955df8 100644 |
| --- a/internal/calendar/render.go |
| +++ b/internal/calendar/render.go |
| @@ -1,6 +1,7 @@ |
| package calendar |
| import ( |
| + "amadan.net/oficina/home/connect" |
| "bytes" |
| "embed" |
| "html/template" |
| @@ -31,7 +32,7 @@ var assets = rastrillo.NewAssets(appFS) |
| // is a compile-time-visible mistake rather than dead weight nobody |
| // notices. |
| var pageNames = []string{ |
| - "signin", "errors", "day", "week", "month", "agenda", |
| + "home", "signin", "errors", "day", "week", "month", "agenda", |
| "event", "event_form", "event_delete", "calendars", "share", |
| "import", "import_done", "settings", |
| "booking_pages", "booking_page_form", |
| @@ -63,6 +64,7 @@ func init() { |
| "templates/_quickadd.html", "templates/_quickpane.html", |
| "templates/_bookingpane.html", |
| "templates/"+name+".html")) |
| + pages[name] = template.Must(pages[name].ParseFS(connect.Templates(), "templates/home_link.html")) |
| } |
| } |
| @@ -114,8 +116,9 @@ type PageData struct { |
| // SignedIn and Address describe the viewer. Address is shown in the |
| // account menu and nowhere else. |
| - SignedIn bool |
| - Address string |
| + HomeEnabled bool |
| + SignedIn bool |
| + Address string |
| // QuickPane is the quick-add box's first paint. It is on the SHELL |
| // rather than on each page's own data because the box is in the |
| @@ -132,12 +135,13 @@ type PageData struct { |
| func (h *handlers) page(r *http.Request, title string, data any) PageData { |
| mode := readMode(r) |
| pd := PageData{ |
| - Title: title, |
| - ThemeAttr: themeAttr(mode), |
| - Mode: mode, |
| - Path: r.URL.Path, |
| - QuickPane: emptyPane(), |
| - Data: data, |
| + Title: title, |
| + HomeEnabled: h.homeEnabled, |
| + ThemeAttr: themeAttr(mode), |
| + Mode: mode, |
| + Path: r.URL.Path, |
| + QuickPane: emptyPane(), |
| + Data: data, |
| } |
| if id, ok := viewer(r); ok { |
| pd.SignedIn = true |
diff --git a/internal/calendar/routes.go b/internal/calendar/routes.go| index 7e128d9..3e2cb4f 100644 |
| --- a/internal/calendar/routes.go |
| +++ b/internal/calendar/routes.go |
| @@ -1,6 +1,8 @@ |
| package calendar |
| import ( |
| + "amadan.net/oficina/home/connect" |
| + "amadan.net/oficina/home/handoff" |
| "net/http" |
| "sync/atomic" |
| @@ -25,19 +27,50 @@ func (a *App) routes(h *handlers, authPlugin *rauth.Auth, list allowlist, origin |
| // App-wide, so every state-changing route added later is born |
| // covered. Origin-checking, not tokens — nothing to mint or forget |
| // in a form. |
| - r.Use(csrf.Protect(origin)) |
| + if a.home != nil { |
| + r.Use(a.home.routes.Middleware) |
| + r.Use(a.home.sessions.Middleware) |
| + } else { |
| + r.Use(csrf.Protect(origin)) |
| + } |
| // Signed-out. |
| - r.Get("/signin", h.signin) |
| + if a.home != nil { |
| + r.Get("/signin", a.home.routes.Signin) |
| + r.Get("/signin/local", h.signin) |
| + r.Get("/signed-out", a.home.routes.SignedOut) |
| + r.Get(connect.LocalCompletePath, a.home.routes.LocalComplete) |
| + r.Get(handoff.StartPath, a.home.bridge.Start) |
| + r.Post(handoff.CallbackPath, a.home.bridge.Callback) |
| + r.Get(handoff.FinishPath, a.home.bridge.Finish) |
| + r.With(a.home.sessions.Require).Get("/account/home", a.home.routes.Account) |
| + r.With(a.home.sessions.Require).Post("/account/home", a.home.routes.LinkStart) |
| + r.With(a.home.sessions.Require).Get("/account/home/confirm", a.home.bridge.ConfirmPage) |
| + r.With(a.home.sessions.Require).Post("/account/home/confirm", a.home.routes.ConfirmPost) |
| + } else { |
| + r.Get("/signin", h.signin) |
| + } |
| // 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 |
| // happened. routes runs once per App, so the closure below is the |
| // only holder and no reset can race it. |
| var refusals atomic.Int64 |
| - r.Post("/signin", gateBegin(authPlugin.Begin, list, "/signin", a.logger, &refusals)) |
| - r.Get("/auth/callback", authPlugin.Callback) |
| - r.Get("/auth/verify", authPlugin.Verify) |
| - r.Post("/signout", authPlugin.Signout) |
| + signinPath := "/signin" |
| + if a.home != nil { |
| + signinPath = "/signin/local" |
| + } |
| + begin := gateBegin(authPlugin.Begin, list, signinPath, a.logger, &refusals) |
| + if a.home != nil { |
| + r.Post("/signin", a.home.routes.LocalGuard(begin)) |
| + r.Get("/auth/callback", a.home.routes.LocalGuard(authPlugin.Callback)) |
| + r.Get("/auth/verify", a.home.routes.LocalGuard(authPlugin.Verify)) |
| + r.Post("/signout", a.home.routes.Signout) |
| + } else { |
| + r.Post("/signin", begin) |
| + r.Get("/auth/callback", authPlugin.Callback) |
| + r.Get("/auth/verify", authPlugin.Verify) |
| + r.Post("/signout", authPlugin.Signout) |
| + } |
| // The light/dark switcher, outside the session on purpose: the |
| // sign-in page is a page too, and somebody who needs a |
| @@ -64,7 +97,11 @@ func (a *App) routes(h *handlers, authPlugin *rauth.Auth, list allowlist, origin |
| r.Post("/book/{handle}/{code}", h.publicGate(h.bookPost)) |
| r.Group(func(r chi.Router) { |
| - r.Use(authPlugin.RequireSession) |
| + if a.home != nil { |
| + r.Use(a.home.sessions.Require) |
| + } else { |
| + r.Use(authPlugin.RequireSession) |
| + } |
| r.Use(a.requirePerson) |
| r.Use(h.drainOpportunistically) |
diff --git a/internal/calendar/templates/home.html b/internal/calendar/templates/home.html| new file mode 100644 |
| index 0000000..db66ed9 |
| --- /dev/null |
| +++ b/internal/calendar/templates/home.html |
| @@ -0,0 +1 @@ |
| +{{define "content"}}{{template "home-connect" .Data}}{{end}} |
diff --git a/internal/calendar/templates/layout.html b/internal/calendar/templates/layout.html| index 08b4304..b316a8e 100644 |
| --- a/internal/calendar/templates/layout.html |
| +++ b/internal/calendar/templates/layout.html |
| @@ -114,6 +114,7 @@ |
| {{if .SignedIn}} |
| <p rst-menu-group><a href="/settings">Settings</a></p> |
| +{{if .HomeEnabled}}<p rst-menu-group><a href="/account/home">Connect Home account</a></p>{{end}} |
| <p rst-menu-group><a href="/import">Import a calendar</a></p> |
| <form rst-menu-group method="post" action="/signout"> |
| <button rst-btn="ghost" type="submit">Sign out</button> |
diff --git a/internal/calendar/viewer.go b/internal/calendar/viewer.go| new file mode 100644 |
| index 0000000..4f2a15d |
| --- /dev/null |
| +++ b/internal/calendar/viewer.go |
| @@ -0,0 +1,29 @@ |
| +package calendar |
| + |
| +import ( |
| + "amadan.net/oficina/home/connect" |
| + rauth "github.com/carlosframework/rastrillo/auth" |
| + "github.com/carlosframework/rastrillo/sessions" |
| + "net/http" |
| +) |
| + |
| +// viewer is the signed-in identity on r, if any. |
| +// |
| +// auth.From, never sessions.UserID: under the magic-link plugin the |
| +// session Subject is a verified email address, so UserID returns |
| +// (0, false) and every owner-scoped query built on it would silently |
| +// filter to person_id = 0 — a calendar that looks empty rather than |
| +// broken, which is the more expensive failure. |
| +func viewer(r *http.Request) (rauth.Identity, bool) { |
| + if s, ok := sessions.Current(r); ok && s.Method == connect.HomeMethod { |
| + p, found := CurrentPerson(r) |
| + if !found { |
| + return rauth.Identity{}, false |
| + } |
| + return rauth.Identity{Address: p.Address}, true |
| + } |
| + if s, ok := sessions.Current(r); ok && (s.Method == "magiclink" || s.Method == "keymail") { |
| + return rauth.Identity{Address: normalizeAddress(s.Subject)}, s.Subject != "" |
| + } |
| + return rauth.From(r) |
| +} |