Home consumer: connect 01166fc, sign-out chain, "All apps"
Memoria pinned home/connect at 5b8d841 (2026-09-10), before the in-app
sign-out chain, the signed-in /signin shortcut and
Credentials.Directory, and built its Credentials by hand without the
directory. Bump to 01166fc and read HOME_DIRECTORY_ORIGIN (unprefixed,
only alongside the four Home values, as CredentialsFromEnv does), so in
Home mode:
- POST /signout 307s on to {issuer}/signout and the id.* session ends;
- /signin for a signed-in person returns to return_to with no exchange;
- the header shows "All apps" linking to the directory. No directory,
no link.
The three tests in home_consumer_test.go load config through
LoadConfig from the environment, as production does; all three failed
first on the old pin and pass now. The spec's "stops at /signed-out"
sentence is amended.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
11 files changed,
+158
−35
docs/superpowers/specs/2026-09-03-memoria-design.md+5 −1go.mod+1 −1go.sum+2 −2internal/memoria/config.go+5 −0internal/memoria/credits.go+1 −1internal/memoria/handlers.go+26 −17internal/memoria/handlers_connector.go+10 −7internal/memoria/home.go+1 −0internal/memoria/home_consumer_test.go+92 −0internal/memoria/home_router_test.go+14 −6internal/memoria/templates/layout.html+1 −0
CI passed — run details
CI log
=== vet === go vet ./... go: downloading amadan.net/oficina/home/connect v0.0.0-20260923190447-01166fcfc0a9 === fmt === === test === go test ./... ? amadan.net/oficina/memoria/cmd/memoria [no test files] ok amadan.net/oficina/memoria/internal/memoria 10.249s ok amadan.net/oficina/memoria/internal/memoria/chunk 0.014s ok amadan.net/oficina/memoria/internal/memoria/fact 2.426s ? amadan.net/oficina/memoria/internal/memoria/icons [no test files] ok amadan.net/oficina/memoria/internal/memoria/index 0.145s ok amadan.net/oficina/memoria/internal/memoria/notion 0.076s ok amadan.net/oficina/memoria/internal/memoria/provider 1.286s ok amadan.net/oficina/memoria/internal/memoria/query 0.014s ok amadan.net/oficina/memoria/internal/memoria/seal 0.023s ok amadan.net/oficina/memoria/internal/memoria/sibling 3.112s ok amadan.net/oficina/memoria/internal/memoriatest 5.775s ? amadan.net/oficina/memoria/scripts/writeestilo [no test files] ? amadan.net/oficina/memoria/tools/bench [no test files] ? amadan.net/oficina/memoria/tools/drive [no test files] ? amadan.net/oficina/memoria/tools/seed [no test files] ? amadan.net/oficina/memoria/tools/vendorassets [no test files] === migration-check === go run github.com/carlosframework/rastrillo/cmd/rastrillo migration check rastrillo migration check: models and migrations agree
diff --git a/docs/superpowers/specs/2026-09-03-memoria-design.md b/docs/superpowers/specs/2026-09-03-memoria-design.md| index 6ef0493..2998732 100644 |
| --- a/docs/superpowers/specs/2026-09-03-memoria-design.md |
| +++ b/docs/superpowers/specs/2026-09-03-memoria-design.md |
| @@ -1427,7 +1427,11 @@ The additional surface is `GET /signin/local`, `GET /signed-out`, |
| 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`. |
| +Sign-out revokes the app session, then hands the same POST on (307) to |
| +`{issuer}/signout`, which ends the Home session too and returns the browser to |
| +`/signed-out` (connect 01166fc; amended 2026-09-30, feat/home-consumer). The |
| +optional unprefixed `HOME_DIRECTORY_ORIGIN`, read only with the four Home |
| +values, adds an "All apps" link to the hub directory in the header. |
| **Admission is the home's roster — suite §5.3, ruling 4.** memoria holds no |
diff --git a/go.mod b/go.mod| index 3ec360d..0dfacdb 100644 |
| --- a/go.mod |
| +++ b/go.mod |
| @@ -6,7 +6,7 @@ toolchain go1.26.5 |
| require ( |
| amadan.net/oficina/estilo.git v0.0.0-20260929231226-cce11010ece0 |
| - amadan.net/oficina/home/connect v0.0.0-20260910000424-5b8d8410775f |
| + amadan.net/oficina/home/connect v0.0.0-20260923190447-01166fcfc0a9 |
| amadan.net/oficina/home/handoff v0.0.0-20260908102137-481d2d57f489 |
| amadan.net/oficina/suite v0.0.0-20260905215529-60b01a8a0d6d |
| amadan.net/rastrillo/native/server v0.0.0-20260923173546-2a5d07b2c8d3 |
diff --git a/go.sum b/go.sum| index a4586d6..945bb9f 100644 |
| --- a/go.sum |
| +++ b/go.sum |
| @@ -1,7 +1,7 @@ |
| amadan.net/oficina/estilo.git v0.0.0-20260929231226-cce11010ece0 h1:Qww+xGIYJaj+6TIX4Knr71ghxeYJjtgFm6WukX6NkGg= |
| amadan.net/oficina/estilo.git v0.0.0-20260929231226-cce11010ece0/go.mod h1:4u0jNBtIdMLOsJyrScfqoEuYDJF7BBM3qM9YWbzt6aw= |
| -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/connect v0.0.0-20260923190447-01166fcfc0a9 h1:K/PaOVdQqRMwifPD6ipiwLeGR5nbFSxm9FxjrDJlZcg= |
| +amadan.net/oficina/home/connect v0.0.0-20260923190447-01166fcfc0a9/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= |
diff --git a/internal/memoria/config.go b/internal/memoria/config.go| index 7950722..b027eca 100644 |
| --- a/internal/memoria/config.go |
| +++ b/internal/memoria/config.go |
| @@ -133,6 +133,11 @@ func LoadConfig(getenv func(string) string) (Config, error) { |
| } |
| 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 != "" { |
| + // The hub directory "All apps" links to: unprefixed, the name and |
| + // value Home itself reads, as connect.CredentialsFromEnv does. It |
| + // is optional and enables nothing, so it does not count toward |
| + // "Home is configured"; DecodeCredentials validates it. |
| + home.Directory = getenv("HOME_DIRECTORY_ORIGIN") |
| c.Home, err = connect.DecodeCredentials(&home, c.Origin) |
| if err != nil { |
| return c, err |
diff --git a/internal/memoria/credits.go b/internal/memoria/credits.go| index 2c899af..05775ef 100644 |
| --- a/internal/memoria/credits.go |
| +++ b/internal/memoria/credits.go |
| @@ -28,7 +28,7 @@ func (a *app) credits(w http.ResponseWriter, r *http.Request) { |
| w.Header().Set("Content-Type", "text/html; charset=utf-8") |
| w.Header().Set("Cache-Control", "no-store") |
| w.Header().Set("Referrer-Policy", "no-referrer") |
| - render(w, "credits", map[string]any{"Signed": true, "HomeEnabled": a.showHomeConnection(r), "ModelOn": a.modelOn(), "Data": credits.PageData{Title: "Open source", MissingNoticeLabel: "No notice file is included in this release.", Components: embeddedCredits.Components}}) |
| + render(w, "credits", map[string]any{"Signed": true, "HomeEnabled": a.showHomeConnection(r), "Directory": a.directory, "ModelOn": a.modelOn(), "Data": credits.PageData{Title: "Open source", MissingNoticeLabel: "No notice file is included in this release.", Components: embeddedCredits.Components}}) |
| } |
| func serveCreditsCSS(w http.ResponseWriter, r *http.Request) { |
diff --git a/internal/memoria/handlers.go b/internal/memoria/handlers.go| index 164b9d8..c20fd49 100644 |
| --- a/internal/memoria/handlers.go |
| +++ b/internal/memoria/handlers.go |
| @@ -87,6 +87,9 @@ type app struct { |
| store *Store |
| auth *auth.Auth |
| home *homeIntegration |
| + // directory is the hub directory origin "All apps" links to; set |
| + // only in Home mode, and empty means no link. |
| + directory string |
| syncer *Syncer |
| // owners is the admission list, and there is exactly one of it. |
| @@ -138,6 +141,9 @@ func (a *app) UseAuth(x *auth.Auth) { a.auth = x } |
| type page struct { |
| Signed bool |
| HomeEnabled bool |
| + // Directory is the hub directory origin "All apps" links to, or |
| + // empty (layout.html). |
| + Directory string |
| // ModelOn switches the footer's egress sentence (layout.html). Set on |
| // EVERY page, because the footer is on every page — the first build |
| // updated the README and left every page promising "no document ever |
| @@ -559,7 +565,7 @@ func (a *app) chunkCount() int { |
| func (a *app) index(w http.ResponseWriter, r *http.Request) { |
| q := strings.TrimSpace(r.URL.Query().Get("q")) |
| p := page{ |
| - Signed: true, HomeEnabled: a.showHomeConnection(r), ModelOn: a.modelOn(), |
| + Signed: true, HomeEnabled: a.showHomeConnection(r), Directory: a.directory, ModelOn: a.modelOn(), |
| Chunks: a.chunkCount(), |
| Query: q, |
| Searched: q != "", |
| @@ -585,7 +591,7 @@ func (a *app) index(w http.ResponseWriter, r *http.Request) { |
| func (a *app) addContent(w http.ResponseWriter, r *http.Request) { |
| drop := newDropZone() |
| p := page{ |
| - Signed: true, HomeEnabled: a.showHomeConnection(r), ModelOn: a.modelOn(), |
| + Signed: true, HomeEnabled: a.showHomeConnection(r), Directory: a.directory, ModelOn: a.modelOn(), |
| Chunks: a.chunkCount(), |
| Drop: drop, |
| } |
| @@ -613,7 +619,7 @@ func (a *app) addContent(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, HomeEnabled: a.showHomeConnection(r), ModelOn: a.modelOn(), Query: q, Searched: q != ""} |
| + p := page{Signed: true, HomeEnabled: a.showHomeConnection(r), Directory: a.directory, ModelOn: a.modelOn(), 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 |
| @@ -1039,19 +1045,22 @@ type passage struct { |
| type documentPage struct { |
| Signed bool |
| HomeEnabled bool |
| - ModelOn 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 |
| + // Directory is the hub directory origin "All apps" links to, or |
| + // empty (layout.html). |
| + Directory string |
| + ModelOn 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) { |
| @@ -1088,7 +1097,7 @@ func (a *app) showDocument(w http.ResponseWriter, r *http.Request) { |
| } |
| _ = body |
| render(w, "document", documentPage{ |
| - Signed: true, HomeEnabled: a.showHomeConnection(r), ModelOn: a.modelOn(), |
| + Signed: true, HomeEnabled: a.showHomeConnection(r), Directory: a.directory, ModelOn: a.modelOn(), |
| Chunks: a.chunkCount(), |
| Title: title, |
| Source: source, |
diff --git a/internal/memoria/handlers_connector.go b/internal/memoria/handlers_connector.go| index 679991a..49c0a12 100644 |
| --- a/internal/memoria/handlers_connector.go |
| +++ b/internal/memoria/handlers_connector.go |
| @@ -14,12 +14,15 @@ import ( |
| type connectorPage struct { |
| Signed bool |
| HomeEnabled bool |
| - ModelOn bool |
| - Chunks int |
| - Flash string |
| - FlashKind string |
| - Configured bool |
| - Verified string |
| + // Directory is the hub directory origin "All apps" links to, or |
| + // empty (layout.html). |
| + Directory string |
| + ModelOn 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 |
| @@ -101,7 +104,7 @@ func failMessage(code string) string { |
| } |
| func (a *app) connectorBase(r *http.Request) connectorPage { |
| - return connectorPage{Signed: true, HomeEnabled: a.showHomeConnection(r), ModelOn: a.modelOn(), Chunks: a.chunkCount()} |
| + return connectorPage{Signed: true, HomeEnabled: a.showHomeConnection(r), Directory: a.directory, ModelOn: a.modelOn(), 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| index 4006405..538a1c5 100644 |
| --- a/internal/memoria/home.go |
| +++ b/internal/memoria/home.go |
| @@ -64,6 +64,7 @@ func (a *app) WireHome(mux *http.ServeMux, cfg Config) error { |
| return errHomeIdentity |
| } |
| a.home = &homeIntegration{bridge: b, routes: routes, sessions: sess} |
| + a.directory = h.Directory |
| return nil |
| } |
diff --git a/internal/memoria/home_consumer_test.go b/internal/memoria/home_consumer_test.go| new file mode 100644 |
| index 0000000..5b35997 |
| --- /dev/null |
| +++ b/internal/memoria/home_consumer_test.go |
| @@ -0,0 +1,92 @@ |
| +package memoria |
| + |
| +import ( |
| + "bytes" |
| + "encoding/base64" |
| + "encoding/json" |
| + "net/http" |
| + "strings" |
| + "testing" |
| + |
| + assertioncrypto "amadan.net/rastrillo/rastrillo/crypto" |
| +) |
| + |
| +const homeTestDirectory = "https://directory.example.test" |
| + |
| +// newHomeConsumerFixture loads Home the way production does, through |
| +// LoadConfig from the environment, so HOME_DIRECTORY_ORIGIN (unprefixed, |
| +// the name Home itself reads) is read exactly as it is live. |
| +func newHomeConsumerFixture(t *testing.T, directory string) *homeRouterFixture { |
| + t.Helper() |
| + key, err := assertioncrypto.Generate() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + manifest, _ := json.Marshal(map[string]string{"key": base64.StdEncoding.EncodeToString(key.SignPub())}) |
| + 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, |
| + "MEMORIA_HOME_ISSUER": homeTestIssuer, |
| + "MEMORIA_HOME_CONSUMER_ID": "memoria-test", |
| + "MEMORIA_HOME_KEYS_BASE64": base64.StdEncoding.EncodeToString(manifest), |
| + "MEMORIA_HOME_DEK_BASE64": base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{7}, 32)), |
| + "HOME_DIRECTORY_ORIGIN": directory, |
| + } |
| + cfg, err := LoadConfig(func(k string) string { return env[k] }) |
| + if err != nil || cfg.Home == nil { |
| + t.Fatalf("LoadConfig: %v", err) |
| + } |
| + return newHomeRouterFixtureWith(t, key, cfg) |
| +} |
| + |
| +// Sign out in Memoria ends the id.* session too: the app session ends and |
| +// the same POST is handed on (307) to {issuer}/signout, which returns the |
| +// browser to /signed-out. Without it the next "Sign in" passed silently |
| +// through the live id.* session (spec shared-chrome §9.5). |
| +func TestHomeSignoutChainsToIssuer(t *testing.T) { |
| + f := newHomeConsumerFixture(t, "") |
| + f.local(t) |
| + w := f.do("POST", "/signout", "") |
| + if w.Code != http.StatusTemporaryRedirect || w.Header().Get("Location") != homeTestIssuer+"/signout" { |
| + t.Fatalf("POST /signout: status %d Location %q, want 307 to %s/signout", w.Code, w.Header().Get("Location"), homeTestIssuer) |
| + } |
| + if got := w.Header().Get("Referrer-Policy"); got != "strict-origin-when-cross-origin" { |
| + t.Fatalf("Referrer-Policy %q: id.* admits the null-Origin hop by Referer", got) |
| + } |
| + if f.cookies[f.app.home.sessions.CookieName()] != nil { |
| + t.Fatal("sign-out kept the Memoria session cookie") |
| + } |
| +} |
| + |
| +// A signed-in person who follows a "Sign in" link goes straight to where |
| +// they were headed, with no second exchange. |
| +func TestHomeSigninWhenSignedInReturnsToPage(t *testing.T) { |
| + f := newHomeConsumerFixture(t, "") |
| + f.local(t) |
| + if got := homeRedirect(t, f.do("GET", "/signin?return_to=%2Fconnectors", "")); got != "/connectors" { |
| + t.Fatalf("signed-in /signin went to %q, want /connectors", got) |
| + } |
| +} |
| + |
| +// "All apps" goes to the hub directory, from HOME_DIRECTORY_ORIGIN, on |
| +// every signed-in page, and is absent when no directory is configured. |
| +func TestAllAppsLinksToDirectory(t *testing.T) { |
| + link := `href="` + homeTestDirectory + `/"` |
| + f := newHomeConsumerFixture(t, homeTestDirectory) |
| + f.local(t) |
| + for _, path := range []string{"/", "/connectors", "/help/open-source"} { |
| + body := f.do("GET", path, "").Body.String() |
| + if !strings.Contains(body, link) || !strings.Contains(body, ">All apps<") { |
| + t.Fatalf("GET %s: no All apps link to %s/", path, homeTestDirectory) |
| + } |
| + } |
| + |
| + bare := newHomeConsumerFixture(t, "") |
| + bare.local(t) |
| + if body := bare.do("GET", "/", "").Body.String(); strings.Contains(body, "All apps") { |
| + t.Fatal("All apps shown with no directory configured") |
| + } |
| +} |
diff --git a/internal/memoria/home_router_test.go b/internal/memoria/home_router_test.go| index 6ade219..98e19fd 100644 |
| --- a/internal/memoria/home_router_test.go |
| +++ b/internal/memoria/home_router_test.go |
| @@ -39,6 +39,17 @@ type homeRouterFixture struct { |
| } |
| func newHomeRouterFixture(t *testing.T) *homeRouterFixture { |
| + t.Helper() |
| + key, err := assertioncrypto.Generate() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return newHomeRouterFixtureWith(t, key, Config{Origin: homeTestOrigin, Home: &connect.DecodedCredentials{Issuer: homeTestIssuer, ConsumerID: "memoria-test", Keys: map[string][]byte{"key": key.SignPub()}, DEK: bytes.Repeat([]byte{7}, 32)}}) |
| +} |
| + |
| +// newHomeRouterFixtureWith wires Home from cfg, signed by key under the |
| +// key ID "key". |
| +func newHomeRouterFixtureWith(t *testing.T, key *assertioncrypto.Keypair, cfg Config) *homeRouterFixture { |
| t.Helper() |
| logger := slog.New(slog.NewTextHandler(io.Discard, nil)) |
| d, err := db.Open(filepath.Join(t.TempDir(), "memoria.db"), logger) |
| @@ -62,11 +73,6 @@ func newHomeRouterFixture(t *testing.T) *homeRouterFixture { |
| 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) |
| } |
| @@ -169,7 +175,9 @@ func TestHomeRouterLinkKeepsContentAndSiblingAddress(t *testing.T) { |
| t.Fatal("confirmation", w.Code, w.Body.String()) |
| } |
| homeRedirect(t, f.do("POST", "/account/home/confirm", "")) |
| - homeRedirect(t, f.do("POST", "/signout", "")) |
| + if w := f.do("POST", "/signout", ""); w.Code != http.StatusTemporaryRedirect || w.Header().Get("Location") != homeTestIssuer+"/signout" { |
| + t.Fatal("sign-out did not chain to the issuer", w.Code, w.Header().Get("Location")) |
| + } |
| if f.cookies[f.app.home.sessions.CookieName()] != nil { |
| t.Fatal("signout retained app cookie") |
| } |
diff --git a/internal/memoria/templates/layout.html b/internal/memoria/templates/layout.html| index 6d3b6d3..ec3bfee 100644 |
| --- a/internal/memoria/templates/layout.html |
| +++ b/internal/memoria/templates/layout.html |
| @@ -37,6 +37,7 @@ |
| <a href="/" rst-brand estilo-brand><img estilo-brand-icon src="{{asset "static/brand/icon.svg"}}" alt="" width="28" height="28">Memoria</a> |
| {{if .Signed}}<nav rst-inline> |
| <details rst-dropdown name="rst-menus"><summary>Help</summary><div rst-dropdown-menu><a href="/help/open-source" referrerpolicy="no-referrer" rel="noreferrer">Open source</a></div></details> |
| + {{if .Directory}}<a href="{{.Directory}}/" rst-btn="quiet compact">All apps</a>{{end}} |
| {{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> |