oficina / memoria Public

Merge branch 'feat/home-sso' into main

claude agent, operated by paul@keymail.dev pushed by paul@keymail.dev 270e55053203297492395133cc023c01d333fd81 parent b9e5de9parent 0ee92ed
25 files changed, +1060 −47
  • Makefile +2 −0
  • cmd/memoria/main.go +5 −0
  • docs/superpowers/specs/2026-09-03-memoria-design.md +43 −2
  • go.mod +8 −0
  • go.sum +8 −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 +110 −0
  • internal/memoria/home_config_test.go +48 −0
  • internal/memoria/home_identity.go +118 −0
  • internal/memoria/home_identity_test.go +168 −0
  • internal/memoria/home_migrations.go +19 −0
  • internal/memoria/home_migrations/migrations/0001_local_identities.sql +5 −0
  • internal/memoria/home_render.go +25 −0
  • internal/memoria/home_router_test.go +301 −0
  • internal/memoria/home_security_test.go +44 −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/Makefile b/Makefile
index 45b0ecf..36d2a8a 100644
--- a/Makefile
+++ b/Makefile
@@ -1,3 +1,5 @@
+export GOPRIVATE := $(GOPRIVATE),amadan.net/oficina/estilo.git
+
APP := memoria
# What `carlos ship -target` defaults to, and therefore what a release
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..85c1a2d 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-20260910000424-5b8d8410775f
+ 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
@@ -12,6 +14,12 @@ require (
)
require (
+ amadan.net/rastrillo/rastrillo v0.26.1-0.20260908075707-29e4491631bd
+ github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2 // indirect
+)
+
+require (
+ amadan.net/oficina/estilo.git v0.0.0-20260909234655-dec3908b237c // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
diff --git a/go.sum b/go.sum
index 9979444..e42d89e 100644
--- a/go.sum
+++ b/go.sum
@@ -1,5 +1,13 @@
+amadan.net/oficina/estilo.git v0.0.0-20260909234655-dec3908b237c h1:LgE7WLm6zMG/qZScoAHb4X2gguzorctB4XGjPYavXts=
+amadan.net/oficina/estilo.git v0.0.0-20260909234655-dec3908b237c/go.mod h1:0R45/vCUAT+kpS7S6K/ZxBdE8f7YxFN4HT3HyDJ3nPY=
+amadan.net/oficina/home/connect v0.0.0-20260910000424-5b8d8410775f h1:lkw3tjzHfZxe74pNfo+tGDz4mkZLg+D6R+FTkmsym6M=
+amadan.net/oficina/home/connect v0.0.0-20260910000424-5b8d8410775f/go.mod h1:YroavXe43Wietec+po8mtSk4a9Bir2h31Q3iAf4MWxI=
+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=
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..21a8c94 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.showHomeConnection(r),
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.showHomeConnection(r), 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.showHomeConnection(r),
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..e5e4475 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.showHomeConnection(r), 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..4006405
--- /dev/null
+++ b/internal/memoria/home.go
@@ -0,0 +1,110 @@
+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.sessions.Middleware(http.HandlerFunc(a.home.routes.Signin)).ServeHTTP(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)
+}
+
+func (a *app) showHomeConnection(r *http.Request) bool {
+ if a.home == nil {
+ return false
+ }
+ session, ok := sessions.Current(r)
+ return ok && (session.Method == "magiclink" || session.Method == "keymail")
+}
diff --git a/internal/memoria/home_config_test.go b/internal/memoria/home_config_test.go
new file mode 100644
index 0000000..c30e285
--- /dev/null
+++ b/internal/memoria/home_config_test.go
@@ -0,0 +1,48 @@
+package memoria
+
+import (
+ assertioncrypto "amadan.net/rastrillo/rastrillo/crypto"
+ "bytes"
+ "crypto/sha256"
+ "encoding/base64"
+ "encoding/hex"
+ "encoding/json"
+ "testing"
+)
+
+func TestHomeConfigRequiresCompleteExplicitCredentials(t *testing.T) {
+ env := map[string]string{"MEMORIA_DEK": base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{2}, 32)), "MEMORIA_INSTANCE_KEY": "test-only-instance", "MEMORIA_OWNERS": "alice@example.test", "MEMORIA_ALLOW_LOGGED_MAIL": "1", "MEMORIA_ORIGIN": homeTestOrigin}
+ get := func(k string) string { return env[k] }
+ cfg, err := LoadConfig(get)
+ if err != nil || cfg.Home != nil {
+ t.Fatalf("standalone: %v", err)
+ }
+ key, err := assertioncrypto.Generate()
+ if err != nil {
+ t.Fatal(err)
+ }
+ sum := sha256.Sum256(key.SignPub())
+ id := hex.EncodeToString(sum[:])
+ raw, err := json.Marshal(map[string]string{id: base64.StdEncoding.EncodeToString(key.SignPub())})
+ if err != nil {
+ t.Fatal(err)
+ }
+ fields := map[string]string{"MEMORIA_HOME_ISSUER": homeTestIssuer, "MEMORIA_HOME_CONSUMER_ID": "memoria-test", "MEMORIA_HOME_KEYS_BASE64": base64.StdEncoding.EncodeToString(raw), "MEMORIA_HOME_DEK_BASE64": base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{7}, 32))}
+ for k, v := range fields {
+ env[k] = v
+ }
+ cfg, err = LoadConfig(get)
+ if err != nil || cfg.Home == nil {
+ t.Fatalf("complete: %v", err)
+ }
+ if cfg.Home.Issuer != homeTestIssuer || !bytes.Equal(cfg.Home.Keys[id], key.SignPub()) {
+ t.Fatal("changed credentials")
+ }
+ for k, v := range fields {
+ delete(env, k)
+ if _, err = LoadConfig(get); err == nil {
+ t.Fatalf("accepted without %s", k)
+ }
+ env[k] = v
+ }
+}
diff --git a/internal/memoria/home_identity.go b/internal/memoria/home_identity.go
new file mode 100644
index 0000000..48fa111
--- /dev/null
+++ b/internal/memoria/home_identity.go
@@ -0,0 +1,118 @@
+package memoria
+
+import (
+ "context"
+ "database/sql"
+ "errors"
+ "fmt"
+
+ "github.com/carlosframework/rastrillo/sessions"
+)
+
+var errHomeIdentity = errors.New("memoria: Home identity unavailable")
+
+type homeIdentityQueryer interface {
+ QueryRowContext(context.Context, string, ...any) *sql.Row
+}
+
+const homeAddressFingerprintPurpose = "home.local-address.lookup.v1"
+
+func homeAddressPurpose(id int64) string {
+ return fmt.Sprintf("home.local-address.v1:%d", id)
+}
+
+// ensureHomeLocalIdentity allocates only after independent local verification. The
+// row is an identity binding, not a membership or content ownership record.
+func (a *app) ensureHomeLocalIdentity(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
+ }
+ tx, err := a.db.Writer().BeginTx(ctx, nil)
+ if err != nil {
+ return 0, "", errHomeIdentity
+ }
+ defer tx.Rollback()
+ var id int64
+ err = tx.QueryRowContext(ctx, "SELECT id FROM memoria_home_local_identities WHERE address_lookup=?", fingerprint).Scan(&id)
+ if errors.Is(err, sql.ErrNoRows) {
+ result, insertErr := tx.ExecContext(ctx, "INSERT INTO memoria_home_local_identities(address_lookup,address_sealed) VALUES(?,?)", fingerprint, []byte{})
+ if insertErr != nil {
+ return 0, "", errHomeIdentity
+ }
+ id, err = result.LastInsertId()
+ if err != nil || id <= 0 {
+ return 0, "", errHomeIdentity
+ }
+ sealed, sealErr := a.store.sealer.Seal(homeAddressPurpose(id), []byte(address))
+ if sealErr != nil {
+ return 0, "", errHomeIdentity
+ }
+ if _, err = tx.ExecContext(ctx, "UPDATE memoria_home_local_identities SET address_sealed=? WHERE id=?", sealed, id); err != nil {
+ return 0, "", errHomeIdentity
+ }
+ } else if err != nil {
+ return 0, "", errHomeIdentity
+ }
+ label, err := a.lookupHomeLocalIdentity(ctx, tx, id)
+ if err != nil || label != address || tx.Commit() != nil {
+ return 0, "", errHomeIdentity
+ }
+ return id, label, nil
+}
+
+func (a *app) lookupHomeLocalIdentity(ctx context.Context, q homeIdentityQueryer, id int64) (string, error) {
+ if id <= 0 {
+ return "", sql.ErrNoRows
+ }
+ var fingerprint string
+ var sealed []byte
+ if err := q.QueryRowContext(ctx, "SELECT address_lookup,address_sealed FROM memoria_home_local_identities WHERE id=?", id).Scan(&fingerprint, &sealed); err != nil {
+ return "", err
+ }
+ plain, err := a.store.sealer.Open(homeAddressPurpose(id), sealed)
+ if err != nil {
+ return "", errHomeIdentity
+ }
+ address := string(plain)
+ lookup, err := a.store.sealer.Fingerprint(homeAddressFingerprintPurpose, plain)
+ if err != nil || lookup != fingerprint || address != normaliseAddress(address) {
+ return "", errHomeIdentity
+ }
+ if !a.Admit(address) {
+ return "", sql.ErrNoRows
+ }
+ 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
new file mode 100644
index 0000000..cac7675
--- /dev/null
+++ b/internal/memoria/home_identity_test.go
@@ -0,0 +1,168 @@
+package memoria
+
+import (
+ "bytes"
+ "database/sql"
+ "errors"
+ "testing"
+
+ "github.com/carlosframework/rastrillo/migrate"
+ "github.com/carlosframework/rastrillo/sessions"
+)
+
+func homeIdentityFixture(t *testing.T) *app {
+ t.Helper()
+ store := storeForTest(t)
+ if _, err := migrate.Apply(t.Context(), store.db, homeIdentitySchema); err != nil {
+ t.Fatal(err)
+ }
+ return &app{db: store.db, store: store, owners: []string{"alice@example.test", "bob@example.test"}}
+}
+
+func TestHomeLocalIdentityRequiresIndependentAdmittedLocalSession(t *testing.T) {
+ a := homeIdentityFixture(t)
+ for _, session := range []sessions.Session{
+ {Subject: "alice@example.test", Method: "home-assertion"},
+ {Subject: "alice@example.test", Method: ""},
+ {Subject: "stranger@example.test", Method: "magiclink"},
+ {Subject: "", Method: "keymail"},
+ } {
+ if _, _, err := a.ensureHomeLocalIdentity(t.Context(), session); err == nil {
+ t.Fatal("unverified or unadmitted identity allocated a binding")
+ }
+ }
+ var count int
+ 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.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.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)
+ }
+ var sealed []byte
+ var lookup string
+ if err := a.db.Writer().QueryRow("SELECT address_lookup,address_sealed FROM memoria_home_local_identities WHERE id=?", first).Scan(&lookup, &sealed); err != nil {
+ t.Fatal(err)
+ }
+ if bytes.Contains(sealed, []byte(label)) || bytes.Contains([]byte(lookup), []byte(label)) {
+ t.Fatal("binding persisted a readable address")
+ }
+ a.owners = []string{"bob@example.test"}
+ if _, err := a.lookupHomeLocalIdentity(t.Context(), a.db.Writer(), first); err == nil {
+ t.Fatal("removed owner retained Home admission")
+ }
+}
+
+func TestHomeLocalIdentityCiphertextIsBoundToRowAndLookup(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 || second == first {
+ t.Fatal(second, err)
+ }
+ if _, err := a.db.Writer().Exec("UPDATE memoria_home_local_identities SET address_sealed=(SELECT address_sealed FROM memoria_home_local_identities WHERE id=?) WHERE id=?", second, first); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := a.lookupHomeLocalIdentity(t.Context(), a.db.Writer(), first); err == nil {
+ t.Fatal("moved ciphertext changed the account identity")
+ }
+ 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")
+ }
+}
+
+func TestHomeLocalIdentityIDIsNotReusedAfterDeletion(t *testing.T) {
+ a := homeIdentityFixture(t)
+ s := sessions.Session{Subject: "alice@example.test", Method: "magiclink"}
+ first, _, err := a.ensureHomeLocalIdentity(t.Context(), s)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err = a.db.Writer().Exec("DELETE FROM memoria_home_local_identities WHERE id=?", first); err != nil {
+ t.Fatal(err)
+ }
+ if _, err = a.lookupHomeLocalIdentity(t.Context(), a.db.Writer(), first); err == nil {
+ t.Fatal("deleted identity still resolved")
+ }
+ next, _, err := a.ensureHomeLocalIdentity(t.Context(), s)
+ if err != nil || next <= first {
+ t.Fatal("deleted identity ID reused", first, next, err)
+ }
+}
+
+func TestHomeLookupDistinguishesMissingIdentityFromStorageFailure(t *testing.T) {
+ a := homeIdentityFixture(t)
+ _, err := a.lookupHomeLocalIdentity(t.Context(), a.db.Writer(), 999)
+ if !errors.Is(err, sql.ErrNoRows) {
+ t.Fatalf("missing identity: %v", err)
+ }
+ id, _, err := a.ensureHomeLocalIdentity(t.Context(), sessions.Session{Subject: "alice@example.test", Method: "magiclink"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ a.SetOwners([]string{"bob@example.test"})
+ if _, err = a.lookupHomeLocalIdentity(t.Context(), a.db.Writer(), id); !errors.Is(err, sql.ErrNoRows) {
+ t.Fatalf("removed admission: %v", err)
+ }
+ if _, err = a.db.Writer().Exec("ALTER TABLE memoria_home_local_identities RENAME TO unavailable_home_identities"); err != nil {
+ t.Fatal(err)
+ }
+ if _, err = a.lookupHomeLocalIdentity(t.Context(), a.db.Writer(), id); err == nil || errors.Is(err, sql.ErrNoRows) {
+ t.Fatalf("storage failure became missing: %v", err)
+ }
+}
diff --git a/internal/memoria/home_migrations.go b/internal/memoria/home_migrations.go
new file mode 100644
index 0000000..75d6b11
--- /dev/null
+++ b/internal/memoria/home_migrations.go
@@ -0,0 +1,19 @@
+package memoria
+
+import (
+ "embed"
+ "io/fs"
+
+ "github.com/carlosframework/rastrillo/migrate"
+)
+
+//go:embed home_migrations/migrations/*.sql
+var homeMigrationFS embed.FS
+
+var homeIdentitySchema = func() *migrate.Set {
+ sub, err := fs.Sub(homeMigrationFS, "home_migrations")
+ if err != nil {
+ panic(err)
+ }
+ return migrate.MustFromFS(sub, "memoria_home_identity")
+}()
diff --git a/internal/memoria/home_migrations/migrations/0001_local_identities.sql b/internal/memoria/home_migrations/migrations/0001_local_identities.sql
new file mode 100644
index 0000000..65a0106
--- /dev/null
+++ b/internal/memoria/home_migrations/migrations/0001_local_identities.sql
@@ -0,0 +1,5 @@
+CREATE TABLE memoria_home_local_identities (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ address_lookup TEXT NOT NULL UNIQUE,
+ address_sealed BLOB NOT NULL
+);
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..c9bb99c
--- /dev/null
+++ b/internal/memoria/home_router_test.go
@@ -0,0 +1,301 @@
+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)
+ }
+}
+func TestHomeSigninClearsOnlyDefinitivelyStaleSession(t *testing.T) {
+ f := newHomeRouterFixture(t)
+ f.local(t)
+ cookieName := f.app.home.sessions.CookieName()
+ saved := f.cookies[cookieName].Value
+ if _, err := f.app.db.Writer().Exec("ALTER TABLE sessions RENAME TO unavailable_sessions"); err != nil {
+ t.Fatal(err)
+ }
+ homeRedirect(t, f.do("GET", "/signin", ""))
+ if f.cookies[cookieName] == nil || f.cookies[cookieName].Value != saved {
+ t.Fatal("temporary storage failure cleared valid cookie")
+ }
+ if _, err := f.app.db.Writer().Exec("ALTER TABLE unavailable_sessions RENAME TO sessions"); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := f.app.db.Writer().Exec("DELETE FROM sessions"); err != nil {
+ t.Fatal(err)
+ }
+ start := homeRedirect(t, f.do("GET", "/signin", ""))
+ if f.cookies[cookieName] != nil {
+ t.Fatal("stale cookie not cleared")
+ }
+ target := homeRedirect(t, f.do("GET", start, ""))
+ if !strings.HasPrefix(target, homeTestIssuer+"/") {
+ t.Fatal("stale cookie prevented Home sign-in", target)
+ }
+}
diff --git a/internal/memoria/home_security_test.go b/internal/memoria/home_security_test.go
new file mode 100644
index 0000000..fe8ea15
--- /dev/null
+++ b/internal/memoria/home_security_test.go
@@ -0,0 +1,44 @@
+package memoria
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "github.com/carlosframework/rastrillo"
+)
+
+func TestHomeCSPPreservesPinnedFrameworkDirectives(t *testing.T) {
+ mux := http.NewServeMux()
+ mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) })
+ baselineHandler, closeBaseline, err := rastrillo.Handler(rastrillo.Options{Mux: mux})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if closeBaseline != nil {
+ defer closeBaseline()
+ }
+ rec := httptest.NewRecorder()
+ baselineHandler.ServeHTTP(rec, httptest.NewRequest("GET", homeTestOrigin+"/", nil))
+ baseline := rec.Header().Get("Content-Security-Policy")
+ want := strings.Replace(baseline, "form-action 'self'", "form-action 'self' "+homeTestIssuer, 1)
+ if want == baseline {
+ t.Fatal("framework form-action changed")
+ }
+ f := newHomeRouterFixture(t)
+ wrapped, done, err := rastrillo.Handler(rastrillo.Options{Mux: f.handler.(*http.ServeMux)})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if done != nil {
+ defer done()
+ }
+ for _, path := range []string{"/signin/local", "/auth/start", "/account/home", "/signed-out"} {
+ rec := httptest.NewRecorder()
+ wrapped.ServeHTTP(rec, httptest.NewRequest("GET", homeTestOrigin+path, nil))
+ if got := rec.Header().Get("Content-Security-Policy"); got != want {
+ t.Fatalf("policy drift at %s: %q", path, got)
+ }
+ }
+}
diff --git a/internal/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..c850a01 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="ghost">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