oficina / memoria Public

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>
Paul Campbell pushed by paul@keymail.dev e504bca88d0557edff912387b72f0d9be26ab3e2 parent 97b8a23
11 files changed, +158 −35
  • docs/superpowers/specs/2026-09-03-memoria-design.md +5 −1
  • go.mod +1 −1
  • go.sum +2 −2
  • internal/memoria/config.go +5 −0
  • internal/memoria/credits.go +1 −1
  • internal/memoria/handlers.go +26 −17
  • internal/memoria/handlers_connector.go +10 −7
  • internal/memoria/home.go +1 −0
  • internal/memoria/home_consumer_test.go +92 −0
  • internal/memoria/home_router_test.go +14 −6
  • internal/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>