Merge feat/home-consumer (memoria #20) into the shared chrome
Resolved toward the chrome: the bar's switcher is the one "All apps" (from a.directory, which #20 sets in WireHome); credits, document and connector pages use the page struct and Frame; the signed-out return_to redirect in requireSession covers both modes, so #20's Home-only copy of it (unreachable after the merge) is dropped; config reads HOME_DIRECTORY_ORIGIN once, #20's way. #20's tests pass unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
7 files changed,
+145
−17
internal/memoria/app.go+3 −0internal/memoria/chrome.go+2 −2internal/memoria/config.go+6 −6internal/memoria/handlers.go+4 −0internal/memoria/home.go+2 −4internal/memoria/home_consumer_test.go+117 −0internal/memoria/home_router_test.go+11 −5
diff --git a/internal/memoria/app.go b/internal/memoria/app.go| index f1232df..c848a57 100644 |
| --- a/internal/memoria/app.go |
| +++ b/internal/memoria/app.go |
| @@ -253,6 +253,9 @@ func (a *app) requireSession(next http.Handler) http.Handler { |
| // for (spec §9.4): rastrillo's own refusal sends a bare /signin, so |
| // a document link opened signed out landed on the index. Anything |
| // else keeps rastrillo's answer below (a POST still gets its 403). |
| + // Both modes: with Home, connect's Signin keeps a safe return_to |
| + // through the exchange (feat/home-consumer added the Home half; |
| + // a.sess reads the same cookie and table as Home's sessions). |
| if r.Method == http.MethodGet || r.Method == http.MethodHead { |
| if _, ok := a.sess.From(r); !ok { |
| w.Header().Set("Cache-Control", "no-store") |
diff --git a/internal/memoria/chrome.go b/internal/memoria/chrome.go| index 739d814..adfb1cd 100644 |
| --- a/internal/memoria/chrome.go |
| +++ b/internal/memoria/chrome.go |
| @@ -256,12 +256,12 @@ func (a *app) offersHomeLink(v viewer) bool { |
| // a tile today still costs an assertion exchange through the directory |
| // session. |
| func (a *app) switcher() *chrome.Switcher { |
| - if a.home == nil || a.home.directory == "" { |
| + if a.home == nil || a.directory == "" { |
| return nil |
| } |
| return &chrome.Switcher{ |
| Label: "Oficina apps", |
| CurrentLabel: "(this app)", |
| - AllApps: estilomenu.Item{Href: a.home.directory + "/", Label: "All apps"}, |
| + AllApps: estilomenu.Item{Href: a.directory + "/", Label: "All apps"}, |
| } |
| } |
diff --git a/internal/memoria/config.go b/internal/memoria/config.go| index 6405155..b027eca 100644 |
| --- a/internal/memoria/config.go |
| +++ b/internal/memoria/config.go |
| @@ -131,13 +131,13 @@ func LoadConfig(getenv func(string) string) (Config, error) { |
| if c.Origin = getenv("MEMORIA_ORIGIN"); c.Origin == "" { |
| c.Origin = "http://localhost:8080" |
| } |
| - // Directory is the hub directory origin, where the app switcher's |
| - // "All apps" goes (shared chrome §6.2). HOME_DIRECTORY_ORIGIN, the name |
| - // Home itself reads — unprefixed, like connect.CredentialsFromEnv. It is |
| - // validated by DecodeCredentials (a bare origin, not the issuer), and it |
| - // means nothing without Home: set alone, it is ignored with the rest. |
| - 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"), Directory: getenv("HOME_DIRECTORY_ORIGIN")} |
| + 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/handlers.go b/internal/memoria/handlers.go| index ee9d6f8..8804b57 100644 |
| --- a/internal/memoria/handlers.go |
| +++ b/internal/memoria/handlers.go |
| @@ -100,6 +100,10 @@ type app struct { |
| origin string |
| // clock is time.Now unless a test sets it (the return cookie's expiry). |
| clock func() time.Time |
| + // directory is the hub directory origin (HOME_DIRECTORY_ORIGIN) the |
| + // switcher's "All apps" goes to; set only in Home mode, and empty means |
| + // no switcher. |
| + directory string |
| syncer *Syncer |
| // owners is the admission list, and there is exactly one of it. |
diff --git a/internal/memoria/home.go b/internal/memoria/home.go| index 9dbebb1..78e76fd 100644 |
| --- a/internal/memoria/home.go |
| +++ b/internal/memoria/home.go |
| @@ -17,9 +17,6 @@ type homeIntegration struct { |
| bridge *connect.Bridge |
| routes *connect.Routes |
| sessions *sessions.Sessions |
| - // directory is the hub directory origin (HOME_DIRECTORY_ORIGIN), where |
| - // the switcher's "All apps" goes. Empty: no switcher. |
| - directory string |
| } |
| // WireHome keeps the local account's address as the subject used for existing |
| @@ -66,7 +63,8 @@ func (a *app) WireHome(mux *http.ServeMux, cfg Config) error { |
| if err != nil { |
| return errHomeIdentity |
| } |
| - a.home = &homeIntegration{bridge: b, routes: routes, sessions: sess, directory: h.Directory} |
| + 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..5c41319 |
| --- /dev/null |
| +++ b/internal/memoria/home_consumer_test.go |
| @@ -0,0 +1,117 @@ |
| +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") |
| + } |
| +} |
| + |
| +// A signed-out visit to a page in Home mode goes to sign-in carrying the |
| +// page, and Home sign-in comes back to that exact page, not the root. |
| +func TestHomeSigninReturnsToTheExactPage(t *testing.T) { |
| + f := newHomeConsumerFixture(t, "") |
| + f.local(t) |
| + if w := f.do("GET", "/account/home", ""); w.Code != http.StatusOK { |
| + t.Fatal("link page", w.Code) |
| + } |
| + start := homeRedirect(t, f.do("POST", "/account/home", "")) |
| + if homeRedirect(t, f.exchange(t, start, "opaque-home-alice")) != "/account/home/confirm" { |
| + t.Fatal("wrong proof landing") |
| + } |
| + homeRedirect(t, f.do("POST", "/account/home/confirm", "")) |
| + f.do("POST", "/signout", "") |
| + |
| + signin := homeRedirect(t, f.do("GET", "/connectors", "")) |
| + if signin != "/signin?return_to=%2Fconnectors" { |
| + t.Fatalf("signed-out /connectors went to %q, want /signin?return_to=%%2Fconnectors", signin) |
| + } |
| + start = homeRedirect(t, f.do("GET", signin, "")) |
| + if got := homeRedirect(t, f.exchange(t, start, "opaque-home-alice")); got != "/connectors" { |
| + t.Fatalf("Home sign-in landed on %q, want /connectors", got) |
| + } |
| +} |
diff --git a/internal/memoria/home_router_test.go b/internal/memoria/home_router_test.go| index 6c1cd3d..8c9e16b 100644 |
| --- a/internal/memoria/home_router_test.go |
| +++ b/internal/memoria/home_router_test.go |
| @@ -46,6 +46,17 @@ func newHomeRouterFixture(t *testing.T) *homeRouterFixture { |
| // newHomeRouterFixtureDir is the fixture with a hub directory origin |
| // (HOME_DIRECTORY_ORIGIN), which is what puts the switcher in the bar. |
| func newHomeRouterFixtureDir(t *testing.T, directory string) *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), Directory: directory}}) |
| +} |
| + |
| +// 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) |
| @@ -69,11 +80,6 @@ func newHomeRouterFixtureDir(t *testing.T, directory string) *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), Directory: directory}} |
| if err = a.WireHome(mux, cfg); err != nil { |
| t.Fatal(err) |
| } |