oficina / memoria Public

Wire Home sign-in through confirmed Memoria account identities

Paul Campbell pushed by paul@keymail.dev 543474cda55b08a2c0de8e1c758a590d8ee6110b parent 4365b2b
20 files changed, +703 −55
  • cmd/memoria/main.go +5 −0
  • docs/superpowers/specs/2026-09-03-memoria-design.md +43 −2
  • go.mod +7 −0
  • go.sum +10 −0
  • internal/memoria/app.go +81 −7
  • internal/memoria/auth.go +6 −1
  • internal/memoria/config.go +10 −0
  • internal/memoria/handlers.go +31 −26
  • internal/memoria/handlers_connector.go +8 −7
  • internal/memoria/home.go +102 −0
  • internal/memoria/home_identity.go +27 −2
  • internal/memoria/home_identity_test.go +54 −6
  • internal/memoria/home_render.go +25 −0
  • internal/memoria/home_router_test.go +274 −0
  • internal/memoria/render.go +3 −1
  • internal/memoria/seal/seal.go +1 −1
  • internal/memoria/templates/home.html +2 −0
  • internal/memoria/templates/layout.html +1 −0
  • internal/memoria/templates/signin.html +1 −0
  • internal/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