oficina / memoria Public

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>
Paul Campbell pushed by paul@keymail.dev 89fcf99da0a8ded278f67d2f7f87dc7239555314 parent f8a3dd7parent 6030871
7 files changed, +145 −17
  • internal/memoria/app.go +3 −0
  • internal/memoria/chrome.go +2 −2
  • internal/memoria/config.go +6 −6
  • internal/memoria/handlers.go +4 −0
  • internal/memoria/home.go +2 −4
  • internal/memoria/home_consumer_test.go +117 −0
  • internal/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)
}