oficina / calendar Public

Connect Home sign-in to existing Calendar accounts

Co-Authored-By: Codex <noreply@openai.com>
Paul Campbell pushed by paul@keymail.dev 462ea56d46b009ed0826ab7194401dfbfa937310 parent d961b65
17 files changed, +550 −50
  • cmd/calendar/main.go +2 −0
  • docs/superpowers/specs/2026-09-02-calendar-design.md +16 −0
  • go.mod +4 −1
  • go.sum +9 −10
  • internal/calendar/app.go +22 −2
  • internal/calendar/auth.go +15 −19
  • internal/calendar/config.go +2 −0
  • internal/calendar/handlers.go +3 −2
  • internal/calendar/home.go +97 −0
  • internal/calendar/home_router_test.go +229 −0
  • internal/calendar/home_security_test.go +43 −0
  • internal/calendar/person.go +20 −0
  • internal/calendar/render.go +13 −9
  • internal/calendar/routes.go +44 −7
  • internal/calendar/templates/home.html +1 −0
  • internal/calendar/templates/layout.html +1 −0
  • internal/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)
+}