Wire Home sign-in through confirmed Memoria account identities
20 files changed,
+703
−55
cmd/memoria/main.go+5 −0docs/superpowers/specs/2026-09-03-memoria-design.md+43 −2go.mod+7 −0go.sum+10 −0internal/memoria/app.go+81 −7internal/memoria/auth.go+6 −1internal/memoria/config.go+10 −0internal/memoria/handlers.go+31 −26internal/memoria/handlers_connector.go+8 −7internal/memoria/home.go+102 −0internal/memoria/home_identity.go+27 −2internal/memoria/home_identity_test.go+54 −6internal/memoria/home_render.go+25 −0internal/memoria/home_router_test.go+274 −0internal/memoria/render.go+3 −1internal/memoria/seal/seal.go+1 −1internal/memoria/templates/home.html+2 −0internal/memoria/templates/layout.html+1 −0internal/memoria/templates/signin.html+1 −0internal/memoriatest/routes_test.go+12 −2
CI passed — run details
diff --git a/cmd/memoria/main.go b/cmd/memoria/main.go| index 01d4fe7..879a932 100644 |
| --- a/cmd/memoria/main.go |
| +++ b/cmd/memoria/main.go |
| @@ -78,12 +78,17 @@ func main() { |
| InstanceKey: cfg.InstanceKey, |
| Owners: cfg.Owners, |
| Mailer: mail.FromEnv("CARLOS", logger), |
| + HomeEnabled: cfg.Home != nil, |
| }, logger, app.Admit) |
| if err != nil { |
| logger.Error("wire sign-in", "err", err) |
| os.Exit(1) |
| } |
| app.UseAuth(a) |
| + if err := app.WireHome(mux, cfg); err != nil { |
| + logger.Error("Home sign-in unavailable") |
| + os.Exit(1) |
| + } |
| app.MountAuth(mux, cfg.Origin) |
| // The apps of record memoria may ask about passages (§4.1). |
diff --git a/docs/superpowers/specs/2026-09-03-memoria-design.md b/docs/superpowers/specs/2026-09-03-memoria-design.md| index 5d686a0..441aec6 100644 |
| --- a/docs/superpowers/specs/2026-09-03-memoria-design.md |
| +++ b/docs/superpowers/specs/2026-09-03-memoria-design.md |
| @@ -1364,6 +1364,47 @@ it. |
| ### 4.3 Admission comes from the roster |
| +#### Optional Home sign-in for an existing local account (2026-09-10) |
| + |
| +Home authentication can be enabled while admission still uses the existing |
| +`MEMORIA_OWNERS` list. This is an authentication adapter for lone shape, not |
| +roster activation. An opaque Home subject gains no membership or content access |
| +until the person independently proves their existing local account and confirms |
| +its connection to Home. Shared `home/connect` handles fresh proofs, browser |
| +binding and assertion replay protection. |
| + |
| +Memoria has no numeric people table. The adapter therefore allocates a stable |
| +local identity ID only at the explicit linking entry, after a fresh verified |
| +magic-link or Keymail session. Its address is encrypted with a distinct, |
| +row-bound purpose; a keyed fingerprint provides the lookup. Ordinary assertion |
| +handling and account lookups never allocate identities. These bindings are |
| +per-person records in addition to the existing encrypted breaker tallies. They |
| +contain no content, membership or grants. Current admission is rechecked at each |
| +lookup and protected request; removing an address from configured owners also |
| +refuses existing Home sessions. |
| + |
| +Home sessions carry the opaque Home subject and a distinct method. The adapter |
| +resolves that subject through the confirmed binding to the original verified |
| +local address. Existing owner checks and S2 `SubjectAddress` questions use that |
| +address; nothing rewrites documents, provenance, source coordinates or custody. |
| +Disabling Home refuses remaining Home sessions instead of interpreting their |
| +subjects as email addresses. |
| + |
| +The optional configuration is `MEMORIA_HOME_ISSUER`, |
| +`MEMORIA_HOME_CONSUMER_ID`, `MEMORIA_HOME_KEYS_BASE64` and |
| +`MEMORIA_HOME_DEK_BASE64`. Partial configuration refuses startup. The last value |
| +is a dedicated exchange key, separate from the content DEK. |
| + |
| +The additional surface is `GET /signin/local`, `GET /signed-out`, |
| +`GET /auth/start`, `POST /auth/assertion`, `GET /auth/finish`, |
| +`GET /auth/local/complete`, and GET/POST pairs at `/account/home` and |
| +`/account/home/confirm`. Only the assertion callback accepts an exact configured |
| +cross-origin issuer; all other mutations retain CSRF protection. The normal |
| +`/signin` starts Home handoff when enabled. Local email verification remains |
| +available for first-time connection, including explicit `force=1` fallback. |
| +Sign-out revokes the app session and stops at `/signed-out`. |
| + |
| + |
| **Admission is the home's roster — suite §5.3, ruling 4.** memoria holds no |
| members table of its own and asks no sibling. In **suite shape** it pulls |
| the roster (suite call 8, consumer token, roster epoch in every response) |
| @@ -2232,7 +2273,7 @@ a provider — and per §3.2, nothing leaves the instance in this mode. |
| and `NOT_VISIBLE` — sealed under their own HKDF purpose, **retained 8 |
| days and no longer**, which is the window plus the baseline plus one. |
| That is a per-person, per-sibling record of how much each person was |
| - allowed to see, it is the only per-person record memoria keeps, and |
| + allowed to see, it is a per-person record, alongside optional Home identity bindings, and |
| **§3.2's leak list carries it** rather than leaving it to be discovered. |
| Two integers a day is the smallest thing that answers the question; a |
| per-refusal log would answer it too and is not built, because the |
| @@ -2271,7 +2312,7 @@ a provider — and per §3.2, nothing leaves the instance in this mode. |
| wrong twice over. §13 already carried the correct fix, and this section |
| was amended without opening it, which is the failure AGENTS.md calls |
| characteristic, committed while repairing another one. And the third |
| - integer enlarged the only per-person record memoria keeps — a "how often |
| + integer enlarged the per-person breaker record memoria keeps — a "how often |
| did they query" count — without §3.2's leak list being opened either. |
| Both reverted; two integers, and the clause moved instead.* |
diff --git a/go.mod b/go.mod| index a9a9c73..cb3aff8 100644 |
| --- a/go.mod |
| +++ b/go.mod |
| @@ -3,6 +3,8 @@ module amadan.net/oficina/memoria |
| go 1.26 |
| require ( |
| + amadan.net/oficina/home/connect v0.0.0-20260909231415-9a780c687d4f |
| + amadan.net/oficina/home/handoff v0.0.0-20260908102137-481d2d57f489 |
| amadan.net/oficina/suite v0.0.0-20260905215529-60b01a8a0d6d |
| github.com/carlosframework/rastrillo v0.23.0 |
| github.com/go-chi/chi/v5 v5.3.2 |
| @@ -11,6 +13,11 @@ require ( |
| modernc.org/sqlite v1.55.0 |
| ) |
| +require ( |
| + amadan.net/rastrillo/rastrillo v0.26.1-0.20260908075707-29e4491631bd |
| + github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2 // indirect |
| +) |
| + |
| require ( |
| github.com/dustin/go-humanize v1.0.1 // indirect |
| github.com/google/uuid v1.6.0 // indirect |
diff --git a/go.sum b/go.sum| index 9979444..852ec6c 100644 |
| --- a/go.sum |
| +++ b/go.sum |
| @@ -1,5 +1,11 @@ |
| +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/oficina/suite v0.0.0-20260905215529-60b01a8a0d6d h1:x6Bq9+QzpFYAyV+zRzgyHwD/RmZ4Hy4biwtxK7CdaSs= |
| amadan.net/oficina/suite v0.0.0-20260905215529-60b01a8a0d6d/go.mod h1:Xgw0N+DBiDzkhQHm/nIhgumfaiMElDg/KIFD1OxTm5w= |
| +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,11 +54,15 @@ 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.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= |
| golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= |
| +golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= |
| +golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= |
| 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.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= |
| golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= |
| +golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= |
| +golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= |
| 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.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= |
diff --git a/internal/memoria/app.go b/internal/memoria/app.go| index 7fcfc3a..70f3061 100644 |
| --- a/internal/memoria/app.go |
| +++ b/internal/memoria/app.go |
| @@ -8,10 +8,11 @@ import ( |
| "github.com/go-chi/chi/v5" |
| + "amadan.net/oficina/home/connect" |
| "github.com/carlosframework/rastrillo/auth" |
| - "github.com/carlosframework/rastrillo/csrf" |
| "github.com/carlosframework/rastrillo/db" |
| "github.com/carlosframework/rastrillo/migrate" |
| + "github.com/carlosframework/rastrillo/sessions" |
| "amadan.net/oficina/memoria/internal/memoria/index" |
| "amadan.net/oficina/memoria/internal/memoria/seal" |
| @@ -71,10 +72,52 @@ func App(d *db.DB, origin string, sealer *seal.Sealer, logger *slog.Logger) (*ht |
| a := &app{db: d, logger: logger, store: store, syncer: NewSyncer(store, logger)} |
| r := chi.NewRouter() |
| - r.Use(csrf.Protect(origin)) |
| + r.Use(a.homeMiddleware(origin)) |
| // Public: the sign-in page and auth's own callbacks. |
| r.Get("/signin", a.signin) |
| + r.Get("/signin/local", func(w http.ResponseWriter, r *http.Request) { |
| + if a.home == nil { |
| + http.NotFound(w, r) |
| + return |
| + } |
| + a.localSignin(w, r) |
| + }) |
| + r.Get("/signed-out", func(w http.ResponseWriter, r *http.Request) { |
| + if a.home == nil { |
| + http.NotFound(w, r) |
| + return |
| + } |
| + a.home.routes.SignedOut(w, r) |
| + }) |
| + r.With(a.requireSession).Get("/account/home", func(w http.ResponseWriter, r *http.Request) { |
| + if a.home == nil { |
| + http.NotFound(w, r) |
| + return |
| + } |
| + a.homeAccount(w, r) |
| + }) |
| + r.With(a.requireSession).Post("/account/home", func(w http.ResponseWriter, r *http.Request) { |
| + if a.home == nil { |
| + http.NotFound(w, r) |
| + return |
| + } |
| + a.home.routes.LinkStart(w, r) |
| + }) |
| + r.With(a.requireSession).Get("/account/home/confirm", func(w http.ResponseWriter, r *http.Request) { |
| + if a.home == nil { |
| + http.NotFound(w, r) |
| + return |
| + } |
| + a.home.bridge.ConfirmPage(w, r) |
| + }) |
| + r.With(a.requireSession).Post("/account/home/confirm", func(w http.ResponseWriter, r *http.Request) { |
| + if a.home == nil { |
| + http.NotFound(w, r) |
| + return |
| + } |
| + a.home.routes.ConfirmPost(w, r) |
| + }) |
| // Everything else requires a session. memoria holds the team's work |
| // in one place, so there is no anonymous surface at all — not even a |
| @@ -111,13 +154,23 @@ func App(d *db.DB, origin string, sealer *seal.Sealer, logger *slog.Logger) (*ht |
| // admission gate and App builds the router; the caller wires both. |
| func (a *app) MountAuth(mux *http.ServeMux, origin string) { |
| r := chi.NewRouter() |
| - r.Use(csrf.Protect(origin)) |
| + r.Use(a.homeMiddleware(origin)) |
| // Guarded, not bare: the classifier inside Begin dials the submitted |
| // address's domain before Authorize runs. beginGuard says why. |
| r.Post("/auth/begin", a.beginGuard(origin, a.auth.Begin)) |
| - r.Get("/auth/callback", a.auth.Callback) |
| - r.Get("/auth/verify", a.auth.Verify) |
| - r.Post("/signout", a.auth.Signout) |
| + if a.home == nil { |
| + r.Get("/auth/callback", a.auth.Callback) |
| + r.Get("/auth/verify", a.auth.Verify) |
| + r.Post("/signout", a.auth.Signout) |
| + } else { |
| + r.Get("/auth/callback", a.home.routes.LocalGuard(a.auth.Callback)) |
| + r.Get("/auth/verify", a.home.routes.LocalGuard(a.auth.Verify)) |
| + r.Post("/signout", a.home.routes.Signout) |
| + r.Get("/auth/start", a.home.bridge.Start) |
| + r.Post("/auth/assertion", a.home.bridge.Callback) |
| + r.Get("/auth/finish", a.home.bridge.Finish) |
| + r.Get("/auth/local/complete", a.home.routes.LocalComplete) |
| + } |
| mux.Handle("/auth/", r) |
| mux.Handle("POST /signout", r) |
| } |
| @@ -146,7 +199,15 @@ func (a *app) requireSession(next http.Handler) http.Handler { |
| http.Error(w, "sign-in is not configured", http.StatusInternalServerError) |
| return |
| } |
| - a.auth.RequireSession(next).ServeHTTP(w, r) |
| + guarded := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + address, ok := a.identity(r) |
| + if !ok || !a.Admit(address) { |
| + http.NotFound(w, r) |
| + return |
| + } |
| + next.ServeHTTP(w, r) |
| + }) |
| + a.auth.RequireSession(guarded).ServeHTTP(w, r) |
| }) |
| } |
| @@ -184,6 +245,19 @@ func (a *app) identity(r *http.Request) (string, bool) { |
| if a.testIdentity != "" { |
| return a.testIdentity, true |
| } |
| + if s, ok := sessions.Current(r); ok && s.Method == connect.HomeMethod { |
| + if a.home == nil { |
| + return "", false |
| + } |
| + person, err := a.home.bridge.Resolve(r.Context(), s.Subject) |
| + if err != nil { |
| + return "", false |
| + } |
| + return person.Label, a.Admit(person.Label) |
| + } |
| + if s, ok := sessions.Current(r); ok && s.Method != "magiclink" && s.Method != "keymail" { |
| + return "", false |
| + } |
| id, ok := auth.From(r) |
| if !ok { |
| return "", false |
diff --git a/internal/memoria/auth.go b/internal/memoria/auth.go| index 0b87f84..a9a6b41 100644 |
| --- a/internal/memoria/auth.go |
| +++ b/internal/memoria/auth.go |
| @@ -133,6 +133,7 @@ type AuthEnv struct { |
| InstanceKey string |
| Mailer mail.Sender |
| Owners []string |
| + HomeEnabled bool |
| } |
| // ParseOwners splits a comma-separated allowlist, lowercasing and |
| @@ -190,6 +191,10 @@ func NewAuth(sqlDB *sql.DB, env AuthEnv, logger *slog.Logger, authorize func(str |
| // a bug either way and this is the version that says so. |
| return nil, errors.New("sign-in: refusing to wire auth with no admission gate") |
| } |
| + landing := "/" |
| + if env.HomeEnabled { |
| + landing = "/auth/local/complete" |
| + } |
| a, err := auth.New(auth.Config{ |
| DB: sqlDB, |
| Origin: env.Origin, |
| @@ -197,7 +202,7 @@ func NewAuth(sqlDB *sql.DB, env AuthEnv, logger *slog.Logger, authorize func(str |
| Mailer: env.Mailer, |
| Authorize: authorize, |
| SigninPath: signinPath, |
| - SignedInPath: "/", |
| + SignedInPath: landing, |
| Subject: "Your memoria sign-in link", |
| Logger: logger, |
| }) |
diff --git a/internal/memoria/config.go b/internal/memoria/config.go| index 91e7e25..35773f9 100644 |
| --- a/internal/memoria/config.go |
| +++ b/internal/memoria/config.go |
| @@ -5,6 +5,8 @@ import ( |
| "errors" |
| "fmt" |
| + "amadan.net/oficina/home/connect" |
| + |
| "amadan.net/oficina/memoria/internal/memoria/seal" |
| ) |
| @@ -22,6 +24,7 @@ type Config struct { |
| InstanceKey string |
| Owners []string |
| Sealer *seal.Sealer |
| + Home *connect.DecodedCredentials |
| // AllowLoggedMail permits booting with no SMTP, which makes |
| // rastrillo substitute mail.Logged — and mail.Logged writes the |
| // magic link, a live credential, into the process log at Warn. |
| @@ -94,6 +97,13 @@ func LoadConfig(getenv func(string) string) (Config, error) { |
| if c.Origin = getenv("MEMORIA_ORIGIN"); c.Origin == "" { |
| c.Origin = "http://localhost:8080" |
| } |
| + home := connect.Credentials{Issuer: getenv("MEMORIA_HOME_ISSUER"), ConsumerID: getenv("MEMORIA_HOME_CONSUMER_ID"), KeysBase64: getenv("MEMORIA_HOME_KEYS_BASE64"), DEKBase64: getenv("MEMORIA_HOME_DEK_BASE64")} |
| + if home.Issuer != "" || home.ConsumerID != "" || home.KeysBase64 != "" || home.DEKBase64 != "" { |
| + c.Home, err = connect.DecodeCredentials(&home, c.Origin) |
| + if err != nil { |
| + return c, err |
| + } |
| + } |
| return c, nil |
| } |
diff --git a/internal/memoria/handlers.go b/internal/memoria/handlers.go| index 9e70f4e..7794194 100644 |
| --- a/internal/memoria/handlers.go |
| +++ b/internal/memoria/handlers.go |
| @@ -82,6 +82,7 @@ type app struct { |
| logger *slog.Logger |
| store *Store |
| auth *auth.Auth |
| + home *homeIntegration |
| syncer *Syncer |
| // owners is the admission list, and there is exactly one of it. |
| @@ -131,15 +132,17 @@ func (a *app) UseAuth(x *auth.Auth) { a.auth = x } |
| // because the layout states them on every screen: a corpus count is how |
| // a person tells "nothing matched" from "nothing indexed". |
| type page struct { |
| - Signed bool |
| - Chunks int |
| - Query string |
| - Searched bool |
| - Flash string |
| - FlashKind string |
| - Results []Result |
| - Documents []Summary |
| - Drop dropZone |
| + Signed bool |
| + HomeEnabled bool |
| + Chunks int |
| + Query string |
| + Searched bool |
| + Flash string |
| + FlashKind string |
| + ForceEmail bool |
| + Results []Result |
| + Documents []Summary |
| + Drop dropZone |
| // Notices is §6.5's loud withholding: what this page could not show |
| // and which app it came from. **Never a count of what the reader may |
| @@ -540,7 +543,7 @@ func (a *app) index(w http.ResponseWriter, r *http.Request) { |
| q := strings.TrimSpace(r.URL.Query().Get("q")) |
| drop := newDropZone() |
| p := page{ |
| - Signed: true, |
| + Signed: true, HomeEnabled: a.home != nil, |
| Chunks: a.chunkCount(), |
| Query: q, |
| Searched: q != "", |
| @@ -583,7 +586,7 @@ func (a *app) index(w http.ResponseWriter, r *http.Request) { |
| // "/" and still gets these results, inside a page. |
| func (a *app) searchResults(w http.ResponseWriter, r *http.Request) { |
| q := strings.TrimSpace(r.URL.Query().Get("q")) |
| - p := page{Signed: true, Query: q, Searched: q != ""} |
| + p := page{Signed: true, HomeEnabled: a.home != nil, Query: q, Searched: q != ""} |
| a.runSearch(r, &p) |
| // The passage count is only spoken by the no-match copy, and this |
| // route runs once per keystroke. Counting only when the count will |
| @@ -1003,19 +1006,20 @@ type passage struct { |
| } |
| type documentPage struct { |
| - Signed bool |
| - Chunks int |
| - Title string |
| - Source string |
| - Bytes int |
| - Revision int |
| - Passages []passage |
| - Documents []Summary |
| - Query string |
| - Searched bool |
| - Results []Result |
| - Flash string |
| - FlashKind string |
| + Signed bool |
| + HomeEnabled bool |
| + Chunks int |
| + Title string |
| + Source string |
| + Bytes int |
| + Revision int |
| + Passages []passage |
| + Documents []Summary |
| + Query string |
| + Searched bool |
| + Results []Result |
| + Flash string |
| + FlashKind string |
| } |
| func (a *app) showDocument(w http.ResponseWriter, r *http.Request) { |
| @@ -1052,7 +1056,7 @@ func (a *app) showDocument(w http.ResponseWriter, r *http.Request) { |
| } |
| _ = body |
| render(w, "document", documentPage{ |
| - Signed: true, |
| + Signed: true, HomeEnabled: a.home != nil, |
| Chunks: a.chunkCount(), |
| Title: title, |
| Source: source, |
| @@ -1087,8 +1091,9 @@ func (a *app) deleteDocument(w http.ResponseWriter, r *http.Request) { |
| http.Redirect(w, r, "/", http.StatusSeeOther) |
| } |
| -func (a *app) signin(w http.ResponseWriter, r *http.Request) { |
| +func (a *app) localSignin(w http.ResponseWriter, r *http.Request) { |
| p := page{Chunks: a.chunkCount()} |
| + p.ForceEmail = a.home != nil && r.URL.Query().Get("force") == "1" |
| switch r.URL.Query().Get("outcome") { |
| case "sent": |
| p.Flash, p.FlashKind = "Check your email — the link signs you in.", "good" |
diff --git a/internal/memoria/handlers_connector.go b/internal/memoria/handlers_connector.go| index 3f2df86..f3d034e 100644 |
| --- a/internal/memoria/handlers_connector.go |
| +++ b/internal/memoria/handlers_connector.go |
| @@ -12,12 +12,13 @@ import ( |
| // connectorPage is what the connector templates render. |
| type connectorPage struct { |
| - Signed bool |
| - Chunks int |
| - Flash string |
| - FlashKind string |
| - Configured bool |
| - Verified string |
| + Signed bool |
| + HomeEnabled bool |
| + Chunks int |
| + Flash string |
| + FlashKind string |
| + Configured bool |
| + Verified string |
| // Run is the most recent run, if any. |
| Run *runView |
| // Pages is how many pages the ledger holds, and Imported how many |
| @@ -99,7 +100,7 @@ func failMessage(code string) string { |
| } |
| func (a *app) connectorBase(r *http.Request) connectorPage { |
| - return connectorPage{Signed: true, Chunks: a.chunkCount()} |
| + return connectorPage{Signed: true, HomeEnabled: a.home != nil, Chunks: a.chunkCount()} |
| } |
| func (a *app) connectors(w http.ResponseWriter, r *http.Request) { |
diff --git a/internal/memoria/home.go b/internal/memoria/home.go| new file mode 100644 |
| index 0000000..0b95d87 |
| --- /dev/null |
| +++ b/internal/memoria/home.go |
| @@ -0,0 +1,102 @@ |
| +package memoria |
| + |
| +import ( |
| + "context" |
| + "net/http" |
| + "time" |
| + |
| + "amadan.net/oficina/home/connect" |
| + "amadan.net/oficina/home/connect/linkstore" |
| + "amadan.net/oficina/home/handoff" |
| + "github.com/carlosframework/rastrillo/csrf" |
| + "github.com/carlosframework/rastrillo/migrate" |
| + "github.com/carlosframework/rastrillo/sessions" |
| +) |
| + |
| +type homeIntegration struct { |
| + bridge *connect.Bridge |
| + routes *connect.Routes |
| + sessions *sessions.Sessions |
| +} |
| + |
| +// WireHome keeps the local account's address as the subject used for existing |
| +// owner and sibling authorization. Home subjects never stand in for addresses. |
| +func (a *app) WireHome(mux *http.ServeMux, cfg Config) error { |
| + if cfg.Home == nil { |
| + return nil |
| + } |
| + if a.home != nil || a.auth == nil || mux == nil { |
| + return errHomeIdentity |
| + } |
| + h := cfg.Home |
| + schema := migrate.Merge(homeIdentitySchema, migrate.MustFromFS(handoff.Migrations, "oficina_handoff"), migrate.MustFromFS(linkstore.Migrations, linkstore.SchemaNamespace)) |
| + if _, err := migrate.Apply(context.Background(), a.db, schema); err != nil { |
| + return errHomeIdentity |
| + } |
| + sess, err := sessions.New(sessions.Config{DB: a.db.Writer(), Origin: cfg.Origin, SigninPath: "/signin", Logger: a.logger}) |
| + if err != nil || sess.CookieName() != a.auth.SessionCookie() { |
| + return errHomeIdentity |
| + } |
| + 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 |
| + } |
| + b, err := connect.New(connect.Config{DB: a.db.Writer(), Origin: cfg.Origin, Issuer: h.Issuer, ConsumerID: h.ConsumerID, Namespace: "oficina/memoria", SessionCookie: sess.CookieName(), Keys: h.Keys, DEK: h.DEK, ReadSession: read, |
| + 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}) |
| + }, |
| + LookupLocalSession: func(ctx context.Context, s connect.Session) (connect.Person, error) { |
| + id, label, err := a.localHomeIdentity(ctx, sessions.Session{Subject: s.Subject, Method: s.Method, AuthTime: s.AuthTime, At: s.At}) |
| + return connect.Person{ID: id, Label: label}, err |
| + }, |
| + LookupPerson: func(ctx context.Context, q linkstore.Queryer, id int64) (string, error) { |
| + return a.lookupHomeLocalIdentity(ctx, q, id) |
| + }, Render: a.renderHome, |
| + }) |
| + if err != nil { |
| + return errHomeIdentity |
| + } |
| + routes, err := connect.NewRoutes(b, connect.RoutesConfig{Namespace: "oficina/memoria", Origin: cfg.Origin, Issuer: h.Issuer, SessionCookie: sess.CookieName(), DEK: h.DEK, ReadSession: read, SignOut: sess.SignOut, LocalSignin: a.localSignin, Protect: csrf.Protect(cfg.Origin), Render: a.renderHome}) |
| + if err != nil { |
| + return errHomeIdentity |
| + } |
| + a.home = &homeIntegration{bridge: b, routes: routes, sessions: sess} |
| + return nil |
| +} |
| + |
| +func (a *app) homeMiddleware(origin string) func(http.Handler) http.Handler { |
| + return func(next http.Handler) http.Handler { |
| + local := csrf.Protect(origin)(next) |
| + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + if a.home != nil { |
| + a.home.routes.Middleware(next).ServeHTTP(w, r) |
| + return |
| + } |
| + local.ServeHTTP(w, r) |
| + }) |
| + } |
| +} |
| + |
| +func (a *app) signin(w http.ResponseWriter, r *http.Request) { |
| + if a.home != nil { |
| + a.home.routes.Signin(w, r) |
| + return |
| + } |
| + a.localSignin(w, r) |
| +} |
| + |
| +func (a *app) homeAccount(w http.ResponseWriter, r *http.Request) { |
| + s, ok := a.home.sessions.From(r) |
| + if ok { |
| + if _, fresh := connect.FreshLocal(connect.Session{Subject: s.Subject, Method: s.Method, AuthTime: s.AuthTime, At: s.At}, time.Now()); fresh { |
| + if _, _, err := a.ensureHomeLocalIdentity(r.Context(), s); err != nil { |
| + a.renderHome(w, r, "failed", "", http.StatusForbidden) |
| + return |
| + } |
| + } |
| + } |
| + a.home.routes.Account(w, r) |
| +} |
diff --git a/internal/memoria/home_identity.go b/internal/memoria/home_identity.go| index f205d49..a37b090 100644 |
| --- a/internal/memoria/home_identity.go |
| +++ b/internal/memoria/home_identity.go |
| @@ -21,9 +21,9 @@ func homeAddressPurpose(id int64) string { |
| return fmt.Sprintf("home.local-address.v1:%d", id) |
| } |
| -// localHomeIdentity allocates only after independent local verification. The |
| +// ensureHomeLocalIdentity allocates only after independent local verification. The |
| // row is an identity binding, not a membership or content ownership record. |
| -func (a *app) localHomeIdentity(ctx context.Context, session sessions.Session) (int64, string, error) { |
| +func (a *app) ensureHomeLocalIdentity(ctx context.Context, session sessions.Session) (int64, string, error) { |
| if session.Method != "magiclink" && session.Method != "keymail" { |
| return 0, "", errHomeIdentity |
| } |
| @@ -88,3 +88,28 @@ func (a *app) lookupHomeLocalIdentity(ctx context.Context, q homeIdentityQueryer |
| } |
| return address, nil |
| } |
| + |
| +// localHomeIdentity is read-only: resolving a browser session during a Home |
| +// exchange cannot allocate an identity or change the account being linked. |
| +func (a *app) localHomeIdentity(ctx context.Context, session sessions.Session) (int64, string, error) { |
| + if session.Method != "magiclink" && session.Method != "keymail" { |
| + return 0, "", errHomeIdentity |
| + } |
| + address := normaliseAddress(session.Subject) |
| + if address == "" || !a.Admit(address) { |
| + return 0, "", errHomeIdentity |
| + } |
| + fingerprint, err := a.store.sealer.Fingerprint(homeAddressFingerprintPurpose, []byte(address)) |
| + if err != nil { |
| + return 0, "", errHomeIdentity |
| + } |
| + var id int64 |
| + if err = a.db.Writer().QueryRowContext(ctx, "SELECT id FROM memoria_home_local_identities WHERE address_lookup=?", fingerprint).Scan(&id); err != nil { |
| + return 0, "", errHomeIdentity |
| + } |
| + label, err := a.lookupHomeLocalIdentity(ctx, a.db.Writer(), id) |
| + if err != nil || label != address { |
| + return 0, "", errHomeIdentity |
| + } |
| + return id, label, nil |
| +} |
diff --git a/internal/memoria/home_identity_test.go b/internal/memoria/home_identity_test.go| index 6731eb4..cb48791 100644 |
| --- a/internal/memoria/home_identity_test.go |
| +++ b/internal/memoria/home_identity_test.go |
| @@ -25,7 +25,7 @@ func TestHomeLocalIdentityRequiresIndependentAdmittedLocalSession(t *testing.T) |
| {Subject: "stranger@example.test", Method: "magiclink"}, |
| {Subject: "", Method: "keymail"}, |
| } { |
| - if _, _, err := a.localHomeIdentity(t.Context(), session); err == nil { |
| + if _, _, err := a.ensureHomeLocalIdentity(t.Context(), session); err == nil { |
| t.Fatal("unverified or unadmitted identity allocated a binding") |
| } |
| } |
| @@ -33,11 +33,11 @@ func TestHomeLocalIdentityRequiresIndependentAdmittedLocalSession(t *testing.T) |
| if err := a.db.Writer().QueryRow("SELECT COUNT(*) FROM memoria_home_local_identities").Scan(&count); err != nil || count != 0 { |
| t.Fatal("refusal created a binding", count, err) |
| } |
| - first, label, err := a.localHomeIdentity(t.Context(), sessions.Session{Subject: "Alice@Example.Test", Method: "magiclink"}) |
| + first, label, err := a.ensureHomeLocalIdentity(t.Context(), sessions.Session{Subject: "Alice@Example.Test", Method: "magiclink"}) |
| if err != nil || first <= 0 || label != "alice@example.test" { |
| t.Fatal(first, label, err) |
| } |
| - again, _, err := a.localHomeIdentity(t.Context(), sessions.Session{Subject: "alice@example.test", Method: "keymail"}) |
| + again, _, err := a.ensureHomeLocalIdentity(t.Context(), sessions.Session{Subject: "alice@example.test", Method: "keymail"}) |
| if err != nil || again != first { |
| t.Fatal("local account binding changed with method", again, err) |
| } |
| @@ -57,11 +57,11 @@ func TestHomeLocalIdentityRequiresIndependentAdmittedLocalSession(t *testing.T) |
| func TestHomeLocalIdentityCiphertextIsBoundToRowAndLookup(t *testing.T) { |
| a := homeIdentityFixture(t) |
| - first, _, err := a.localHomeIdentity(t.Context(), sessions.Session{Subject: "alice@example.test", Method: "magiclink"}) |
| + first, _, err := a.ensureHomeLocalIdentity(t.Context(), sessions.Session{Subject: "alice@example.test", Method: "magiclink"}) |
| if err != nil { |
| t.Fatal(err) |
| } |
| - second, _, err := a.localHomeIdentity(t.Context(), sessions.Session{Subject: "bob@example.test", Method: "magiclink"}) |
| + second, _, err := a.ensureHomeLocalIdentity(t.Context(), sessions.Session{Subject: "bob@example.test", Method: "magiclink"}) |
| if err != nil || second == first { |
| t.Fatal(second, err) |
| } |
| @@ -71,7 +71,55 @@ func TestHomeLocalIdentityCiphertextIsBoundToRowAndLookup(t *testing.T) { |
| if _, err := a.lookupHomeLocalIdentity(t.Context(), a.db.Writer(), first); err == nil { |
| t.Fatal("moved ciphertext changed the account identity") |
| } |
| - if _, _, err := a.localHomeIdentity(t.Context(), sessions.Session{Subject: "alice@example.test", Method: "magiclink"}); err == nil { |
| + if _, _, err := a.ensureHomeLocalIdentity(t.Context(), sessions.Session{Subject: "alice@example.test", Method: "magiclink"}); err == nil { |
| t.Fatal("local sign-in repaired a corrupt binding silently") |
| } |
| } |
| + |
| +func TestHomeLocalSessionLookupNeverAllocates(t *testing.T) { |
| + a := homeIdentityFixture(t) |
| + session := sessions.Session{Subject: "alice@example.test", Method: "magiclink"} |
| + if _, _, err := a.localHomeIdentity(t.Context(), session); err == nil { |
| + t.Fatal("unbound local identity resolved") |
| + } |
| + var n int |
| + if err := a.db.Writer().QueryRow("SELECT COUNT(*) FROM memoria_home_local_identities").Scan(&n); err != nil || n != 0 { |
| + t.Fatal("lookup allocated identity", n, err) |
| + } |
| + id, _, err := a.ensureHomeLocalIdentity(t.Context(), session) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + got, label, err := a.localHomeIdentity(t.Context(), session) |
| + if err != nil || got != id || label != session.Subject { |
| + t.Fatal("bound identity failed", got, label, err) |
| + } |
| +} |
| + |
| +func TestHomeLocalIdentityRejectsWholeRowReassignment(t *testing.T) { |
| + a := homeIdentityFixture(t) |
| + first, _, err := a.ensureHomeLocalIdentity(t.Context(), sessions.Session{Subject: "alice@example.test", Method: "magiclink"}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + second, _, err := a.ensureHomeLocalIdentity(t.Context(), sessions.Session{Subject: "bob@example.test", Method: "magiclink"}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + tx, err := a.db.Writer().Begin() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer tx.Rollback() |
| + for _, pair := range [][2]int64{{first, -1}, {second, first}, {-1, second}} { |
| + if _, err = tx.Exec("UPDATE memoria_home_local_identities SET id=? WHERE id=?", pair[1], pair[0]); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + if err = tx.Commit(); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err = a.lookupHomeLocalIdentity(t.Context(), a.db.Writer(), first); err == nil { |
| + t.Fatal("moving ciphertext and its lookup together reassigned a linked identity") |
| + } |
| +} |
diff --git a/internal/memoria/home_render.go b/internal/memoria/home_render.go| new file mode 100644 |
| index 0000000..a822c7c |
| --- /dev/null |
| +++ b/internal/memoria/home_render.go |
| @@ -0,0 +1,25 @@ |
| +package memoria |
| + |
| +import ( |
| + "amadan.net/oficina/home/connect" |
| + "bytes" |
| + "net/http" |
| +) |
| + |
| +type homePage struct { |
| + page |
| + Home connect.PageData |
| +} |
| + |
| +func (a *app) renderHome(w http.ResponseWriter, r *http.Request, state, label string, status int) { |
| + p := homePage{page: page{Chunks: a.chunkCount()}, Home: connect.Page(state, label)} |
| + var body bytes.Buffer |
| + if err := pages["home"].ExecuteTemplate(&body, "layout", p); err != nil { |
| + http.Error(w, "something went wrong", http.StatusInternalServerError) |
| + return |
| + } |
| + w.Header().Set("Content-Type", "text/html; charset=utf-8") |
| + w.Header().Set("Cache-Control", "no-store") |
| + w.WriteHeader(status) |
| + _, _ = w.Write(body.Bytes()) |
| +} |
diff --git a/internal/memoria/home_router_test.go b/internal/memoria/home_router_test.go| new file mode 100644 |
| index 0000000..8330d2b |
| --- /dev/null |
| +++ b/internal/memoria/home_router_test.go |
| @@ -0,0 +1,274 @@ |
| +package memoria |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "io" |
| + "log/slog" |
| + "net/http" |
| + "net/http/httptest" |
| + "net/url" |
| + "path/filepath" |
| + "regexp" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "amadan.net/oficina/home/connect" |
| + "amadan.net/oficina/memoria/internal/memoria/seal" |
| + "amadan.net/oficina/suite" |
| + "amadan.net/rastrillo/rastrillo/assertion" |
| + assertioncrypto "amadan.net/rastrillo/rastrillo/crypto" |
| + "github.com/carlosframework/rastrillo/db" |
| +) |
| + |
| +const homeTestOrigin = "https://memoria.example.test" |
| +const homeTestIssuer = "https://id.example.test" |
| + |
| +type homeTestMail struct{ body string } |
| + |
| +func (m *homeTestMail) Send(_ context.Context, _, _, body string) error { m.body = body; return nil } |
| + |
| +type homeRouterFixture struct { |
| + app *app |
| + handler http.Handler |
| + key *assertioncrypto.Keypair |
| + mail *homeTestMail |
| + cookies map[string]*http.Cookie |
| +} |
| + |
| +func newHomeRouterFixture(t *testing.T) *homeRouterFixture { |
| + t.Helper() |
| + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) |
| + d, err := db.Open(filepath.Join(t.TempDir(), "memoria.db"), logger) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Cleanup(func() { d.Close() }) |
| + cipher, err := seal.New(bytes.Repeat([]byte{6}, 32)) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + mux, a, err := App(d, homeTestOrigin, cipher, logger) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Cleanup(func() { a.store.ix.Close() }) |
| + a.SetOwners([]string{"alice@example.test"}) |
| + mail := &homeTestMail{} |
| + auth, err := NewAuth(d.Writer(), AuthEnv{Origin: homeTestOrigin, InstanceKey: "isolated-test-instance-key", Mailer: mail, HomeEnabled: true}, logger, a.Admit) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + a.UseAuth(auth) |
| + key, err := assertioncrypto.Generate() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + cfg := Config{Origin: homeTestOrigin, Home: &connect.DecodedCredentials{Issuer: homeTestIssuer, ConsumerID: "memoria-test", Keys: map[string][]byte{"key": key.SignPub()}, DEK: bytes.Repeat([]byte{7}, 32)}} |
| + if err = a.WireHome(mux, cfg); err != nil { |
| + t.Fatal(err) |
| + } |
| + a.MountAuth(mux, homeTestOrigin) |
| + return &homeRouterFixture{a, mux, key, mail, 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.handler.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", "/auth/begin", "address=alice%40example.test&force=1")) |
| + link := regexp.MustCompile(`https://[^\s<>\"]+/auth/verify\?[^\s<>\"]+`).FindString(f.mail.body) |
| + 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") != "memoria-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 TestHomeRouterLinkKeepsContentAndSiblingAddress(t *testing.T) { |
| + f := newHomeRouterFixture(t) |
| + custody := &fixedCustody{apps: docsRoster(), give: suite.VISIBLE} |
| + f.app.store.custody = custody |
| + for _, spec := range []IngestSpec{{Source: "Upload", Key: "own", Title: "Own nebula", Body: "nebula local content"}, {Source: "Docs", Key: "sibling", Title: "Shared nebula", Body: "nebula sibling content", ItemID: "doc-one", SiblingRevision: 3}} { |
| + if _, err := f.app.store.Ingest(t.Context(), spec); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + f.local(t) |
| + localCookie := f.cookies[f.app.home.sessions.CookieName()].Value |
| + before := f.do("GET", "/?q=nebula", "") |
| + if before.Code != 200 || !strings.Contains(before.Body.String(), "Shared nebula") { |
| + t.Fatal("local baseline failed", before.Code) |
| + } |
| + 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", "")) |
| + finish := f.exchange(t, start, "opaque-home-alice") |
| + if homeRedirect(t, finish) != "/account/home/confirm" { |
| + t.Fatal("wrong proof landing") |
| + } |
| + if f.cookies[f.app.home.sessions.CookieName()].Value != localCookie { |
| + t.Fatal("proof replaced local session") |
| + } |
| + if w := f.do("GET", "/account/home/confirm", ""); w.Code != 200 || !strings.Contains(w.Body.String(), "alice@example.test") { |
| + t.Fatal("confirmation", w.Code, w.Body.String()) |
| + } |
| + homeRedirect(t, f.do("POST", "/account/home/confirm", "")) |
| + homeRedirect(t, f.do("POST", "/signout", "")) |
| + if f.cookies[f.app.home.sessions.CookieName()] != nil { |
| + t.Fatal("signout retained app cookie") |
| + } |
| + start = homeRedirect(t, f.do("GET", "/signin?return_to=%2F%3Fq%3Dnebula", "")) |
| + target := homeRedirect(t, f.exchange(t, start, "opaque-home-alice")) |
| + after := f.do("GET", target, "") |
| + if after.Code != 200 || !strings.Contains(after.Body.String(), "Shared nebula") || !strings.Contains(after.Body.String(), "Own nebula") { |
| + t.Fatal("Home lost content", after.Code, after.Body.String()) |
| + } |
| + if custody.asker.Kind != suite.SubjectAddress || custody.asker.Value != "alice@example.test" { |
| + t.Fatal("Home changed S2 asker", custody.asker) |
| + } |
| + var docs, chunks int |
| + f.app.db.Writer().QueryRow("SELECT COUNT(*) FROM documents").Scan(&docs) |
| + f.app.db.Writer().QueryRow("SELECT COUNT(*) FROM chunks").Scan(&chunks) |
| + if docs != 2 || chunks != 2 { |
| + t.Fatal("link changed content", docs, chunks) |
| + } |
| + custody.give = suite.NOT_VISIBLE |
| + denied := f.do("GET", "/?q=nebula", "") |
| + if denied.Code != 200 || strings.Contains(denied.Body.String(), "nebula sibling content") || !strings.Contains(denied.Body.String(), "Own nebula") { |
| + t.Fatal("Home bypassed sibling refusal", denied.Code) |
| + } |
| + enabled := f.app.home |
| + f.app.home = nil |
| + if w := f.do("GET", "/", ""); w.Code != 404 { |
| + t.Fatal("Home session survived disabled integration", w.Code) |
| + } |
| + f.app.home = enabled |
| + f.app.SetOwners([]string{"bob@example.test"}) |
| + if w := f.do("GET", "/", ""); w.Code != 404 { |
| + t.Fatal("removed owner still admitted", w.Code) |
| + } |
| +} |
| +func TestHomeRouterUnknownSubjectCannotAllocateOrRead(t *testing.T) { |
| + f := newHomeRouterFixture(t) |
| + start := homeRedirect(t, f.do("GET", "/signin", "")) |
| + w := f.exchange(t, start, "opaque-unlinked-alice") |
| + if w.Code != 403 || !strings.Contains(w.Body.String(), "Home account not connected") { |
| + t.Fatal("unknown subject", w.Code, w.Body.String()) |
| + } |
| + var n int |
| + if err := f.app.db.Writer().QueryRow("SELECT COUNT(*) FROM memoria_home_local_identities").Scan(&n); err != nil || n != 0 { |
| + t.Fatal("Home allocated identity", n, err) |
| + } |
| + if f.cookies[f.app.home.sessions.CookieName()] != nil { |
| + t.Fatal("Home minted session before linking") |
| + } |
| +} |
| + |
| +func TestHomeRouterMutationsKeepCSRFAndCookieGuards(t *testing.T) { |
| + f := newHomeRouterFixture(t) |
| + f.local(t) |
| + for _, path := range []string{"/account/home", "/account/home/confirm", "/auth/begin", "/signout"} { |
| + req := httptest.NewRequest("POST", homeTestOrigin+path, strings.NewReader("address=alice%40example.test&force=1")) |
| + req.Header.Set("Origin", "https://attacker.example") |
| + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") |
| + for _, c := range f.cookies { |
| + req.AddCookie(c) |
| + } |
| + rec := httptest.NewRecorder() |
| + f.handler.ServeHTTP(rec, req) |
| + if rec.Code != 403 { |
| + t.Fatal("cross-origin mutation", path, rec.Code) |
| + } |
| + } |
| + req := httptest.NewRequest("POST", homeTestOrigin+"/auth/assertion", strings.NewReader("assertion=invalid")) |
| + req.Header.Set("Origin", "https://attacker.example") |
| + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") |
| + rec := httptest.NewRecorder() |
| + f.handler.ServeHTTP(rec, req) |
| + if rec.Code != 400 { |
| + t.Fatal("wrong issuer accepted", rec.Code) |
| + } |
| + req = httptest.NewRequest("GET", homeTestOrigin+"/account/home", nil) |
| + c := f.cookies[f.app.home.sessions.CookieName()] |
| + req.AddCookie(c) |
| + req.AddCookie(c) |
| + rec = httptest.NewRecorder() |
| + f.handler.ServeHTTP(rec, req) |
| + if rec.Code != 400 { |
| + t.Fatal("duplicate app cookie accepted", rec.Code) |
| + } |
| + var n int |
| + if err := f.app.db.Writer().QueryRow("SELECT COUNT(*) FROM memoria_home_local_identities").Scan(&n); err != nil || n != 0 { |
| + t.Fatal("refused route allocated identity", n, err) |
| + } |
| +} |
| + |
| +func TestHomeRouterMissingKeymailFreshnessCannotAllocateLink(t *testing.T) { |
| + f := newHomeRouterFixture(t) |
| + f.local(t) |
| + if _, err := f.app.db.Writer().Exec("UPDATE sessions SET method='keymail',auth_time=''"); err != nil { |
| + t.Fatal(err) |
| + } |
| + w := f.do("GET", "/account/home", "") |
| + if homeRedirect(t, w) != "/signin/local" { |
| + t.Fatal("missing Keymail freshness did not request local proof") |
| + } |
| + var n int |
| + if err := f.app.db.Writer().QueryRow("SELECT COUNT(*) FROM memoria_home_local_identities").Scan(&n); err != nil || n != 0 { |
| + t.Fatal("missing freshness allocated identity", n, err) |
| + } |
| +} |
diff --git a/internal/memoria/render.go b/internal/memoria/render.go| index d5f636d..9ad4f80 100644 |
| --- a/internal/memoria/render.go |
| +++ b/internal/memoria/render.go |
| @@ -7,6 +7,7 @@ import ( |
| "net/http" |
| "strings" |
| + "amadan.net/oficina/home/connect" |
| "github.com/carlosframework/rastrillo" |
| "amadan.net/oficina/memoria/internal/memoria/icons" |
| @@ -50,11 +51,12 @@ func funcs() template.FuncMap { |
| } |
| func init() { |
| - for _, name := range []string{"index", "document", "signin", "connectors", "notion"} { |
| + for _, name := range []string{"index", "document", "signin", "connectors", "notion", "home"} { |
| pages[name] = template.Must(template.New("layout"). |
| Funcs(funcs()). |
| ParseFS(appFS, "templates/layout.html", "templates/results.html", "templates/"+name+".html")) |
| } |
| + pages["home"] = template.Must(pages["home"].ParseFS(connect.Templates(), "templates/home_link.html")) |
| fragments = template.Must(template.New("fragments"). |
| Funcs(funcs()). |
| ParseFS(appFS, "templates/results.html")) |
diff --git a/internal/memoria/seal/seal.go b/internal/memoria/seal/seal.go| index fe58040..caa723a 100644 |
| --- a/internal/memoria/seal/seal.go |
| +++ b/internal/memoria/seal/seal.go |
| @@ -82,7 +82,7 @@ const ( |
| // UTC day), how many passages that person was allowed to see and how |
| // many they were refused. |
| // |
| - // **This is the only per-person record memoria keeps**, and §3.2's |
| + // **This is the per-person passage-access record**, and §3.2's |
| // leak list carries it rather than leaving it to be found. Two |
| // integers a day is the smallest thing that answers "is this sibling |
| // refusing people it used to allow"; a per-refusal log would answer it |
diff --git a/internal/memoria/templates/home.html b/internal/memoria/templates/home.html| new file mode 100644 |
| index 0000000..241ad7a |
| --- /dev/null |
| +++ b/internal/memoria/templates/home.html |
| @@ -0,0 +1,2 @@ |
| +{{define "title"}}{{.Home.Title}} · Memoria{{end}} |
| +{{define "content"}}{{template "home-connect" .Home}}{{end}} |
diff --git a/internal/memoria/templates/layout.html b/internal/memoria/templates/layout.html| index a8f3b4d..ada8afb 100644 |
| --- a/internal/memoria/templates/layout.html |
| +++ b/internal/memoria/templates/layout.html |
| @@ -29,6 +29,7 @@ |
| <header rst-page-header> |
| <a href="/" rst-brand>memoria</a> |
| {{if .Signed}}<nav rst-inline> |
| + {{if .HomeEnabled}}<a href="/account/home" rst-btn="quiet compact">Connect Home account</a>{{end}} |
| <a href="/connectors" rst-btn="quiet compact">Sources</a> |
| <form method="post" action="/signout" rst-inline><button rst-btn="quiet compact">Sign out</button></form> |
| </nav>{{end}} |
diff --git a/internal/memoria/templates/signin.html b/internal/memoria/templates/signin.html| index 6e66141..7822e4f 100644 |
| --- a/internal/memoria/templates/signin.html |
| +++ b/internal/memoria/templates/signin.html |
| @@ -5,6 +5,7 @@ |
| we'll send you a link.</p> |
| {{if .Flash}}<div rst-callout rst-tone="{{.FlashKind | tone}}"><p rst-callout-body>{{.Flash}}</p></div>{{end}} |
| <form method="post" action="/auth/begin" rst-stack> |
| + {{if .ForceEmail}}<input type="hidden" name="force" value="1">{{end}} |
| <label for="address">Email address</label> |
| <input type="email" id="address" name="address" required autocomplete="email" autofocus> |
| <div rst-form-bar><button rst-btn="primary">Send me a link</button></div> |
diff --git a/internal/memoriatest/routes_test.go b/internal/memoriatest/routes_test.go| index a19b87d..2dc467c 100644 |
| --- a/internal/memoriatest/routes_test.go |
| +++ b/internal/memoriatest/routes_test.go |
| @@ -60,8 +60,18 @@ func TestNoUndeclaredRoutes(t *testing.T) { |
| // wrong, which §8.4 records. Adding to this list is a decision, which |
| // is the point of it being a list. |
| declaredChi := map[string]bool{ |
| - "GET /signin": true, |
| - "GET /": true, |
| + "GET /signin": true, |
| + "GET /signin/local": true, |
| + "GET /signed-out": true, |
| + "GET /account/home": true, |
| + "POST /account/home": true, |
| + "GET /account/home/confirm": true, |
| + "POST /account/home/confirm": true, |
| + "GET /auth/start": true, |
| + "POST /auth/assertion": true, |
| + "GET /auth/finish": true, |
| + "GET /auth/local/complete": true, |
| + "GET /": true, |
| // GET /search renders the results region alone, from the same |
| // template GET /?q= renders it inside a page, so live search can |
| // replace that region on a keystroke. Same session gate, no passage |