oficina / memoria Public

Sign-in: continue to Keymail instead of redirecting a form POST there

Signing in with a Keymail address could not leave the sign-in page on
memoria.oficina.works (reported by Paul, 2026-09-29, with Safari's
console error). Begin answers such an address with 303 to the Keymail
server's /oauth/authorize, and the CSP's form-action 'self' is enforced
across a form submission's whole redirect chain, so the browser refused
it and the button span forever.

memoria now wraps Begin in home/connect's OAuthContinuation — the helper
extracted from Docs and already used by Sheets — inside the existing
admission guard. It replaces exactly that 303, after validating it as an
authorize URL for this origin and callback with PKCE S256, with a
no-store, no-referrer continuation page whose meta refresh and link are
navigations rather than form submissions. Everything else, including the
magic-link redirect and gate 33's refusal, passes through untouched. The
CSP is not widened.

Also: the model layer now reads its TensorX credential from
MEMORIA_TENSORX_KEY, the name it was already given on the platform, so
switching it on is MEMORIA_AI=on and nothing else.

Tested through rastrillo's real Begin and the real mounted route, with
only the network faked; restoring the old wiring fails with the
production symptom. Reviewed by Codex GPT-6-Astra (dispatched
codex/gpt-6-astra; self-reported "GPT-6 (Codex)"): APPROVE, no
must-fix — including a WebKit and Chromium run of the rendered page
under the exact CSP, where both refused the old redirect and followed the
continuation. Two test-strength polish items are a follow-up.

Docs, Calendar and Calls do not wire OAuthContinuation either and very
likely have the same bug; not touched here.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Squashed from fix/signin-keymail into main.
Paul Campbell pushed by claude agent, operated by paul@keymail.dev d495612f7d5ff50ff80dd02f976e443842e5a28e parent 7d3fa70
8 files changed, +288 −8
  • MODEL-FALLBACK-PLAN.md +1 −1
  • internal/memoria/app.go +3 −2
  • internal/memoria/auth.go +43 −0
  • internal/memoria/config.go +7 −2
  • internal/memoria/model_config_test.go +2 −2
  • internal/memoria/render.go +2 −1
  • internal/memoria/templates/signin_oauth.html +10 −0
  • internal/memoriatest/signin_keymail_test.go +220 −0

CI passed — run details

CI log
=== vet ===
go vet ./...
=== fmt ===
=== test ===
go test ./...
?   	amadan.net/oficina/memoria/cmd/memoria	[no test files]
ok  	amadan.net/oficina/memoria/internal/memoria	3.217s
ok  	amadan.net/oficina/memoria/internal/memoria/chunk	(cached)
ok  	amadan.net/oficina/memoria/internal/memoria/fact	(cached)
?   	amadan.net/oficina/memoria/internal/memoria/icons	[no test files]
ok  	amadan.net/oficina/memoria/internal/memoria/index	(cached)
ok  	amadan.net/oficina/memoria/internal/memoria/notion	(cached)
ok  	amadan.net/oficina/memoria/internal/memoria/provider	(cached)
ok  	amadan.net/oficina/memoria/internal/memoria/query	(cached)
ok  	amadan.net/oficina/memoria/internal/memoria/seal	(cached)
ok  	amadan.net/oficina/memoria/internal/memoria/sibling	(cached)
ok  	amadan.net/oficina/memoria/internal/memoriatest	1.567s
?   	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/MODEL-FALLBACK-PLAN.md b/MODEL-FALLBACK-PLAN.md
index 77c964b..7287816 100644
--- a/MODEL-FALLBACK-PLAN.md
+++ b/MODEL-FALLBACK-PLAN.md
@@ -97,7 +97,7 @@ The only code in memoria that dials a model.
- `MEMORIA_AI` — `on` or unset. Decision 4: opt-in per team, the owner's
choice. Anything other than `on` or empty is a startup error, so a typo
does not silently mean off.
-- `MEMORIA_TENSORX_API_KEY` — required when `MEMORIA_AI=on`, **refused at
+- `MEMORIA_TENSORX_KEY` — required when `MEMORIA_AI=on`, **refused at
startup** if AI is on and it is absent (loud), and ignored when AI is
off. A key present with AI off does not turn AI on.
diff --git a/internal/memoria/app.go b/internal/memoria/app.go
index 30cad65..150b625 100644
--- a/internal/memoria/app.go
+++ b/internal/memoria/app.go
@@ -176,8 +176,9 @@ func (a *app) MountAuth(mux *http.ServeMux, origin string) {
r := chi.NewRouter()
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))
+ // address's domain before Authorize runs. beginGuard says why. And
+ // continued, not redirected: signinBegin says why.
+ r.Post("/auth/begin", a.signinBegin(origin, a.auth.Begin))
if a.home == nil {
r.Get("/auth/callback", a.auth.Callback)
r.Get("/auth/verify", a.auth.Verify)
diff --git a/internal/memoria/auth.go b/internal/memoria/auth.go
index a9a6b41..d5de3e1 100644
--- a/internal/memoria/auth.go
+++ b/internal/memoria/auth.go
@@ -7,6 +7,7 @@ import (
"net/http"
"strings"
+ "amadan.net/oficina/home/connect"
"github.com/carlosframework/rastrillo/auth"
"github.com/carlosframework/rastrillo/mail"
)
@@ -211,3 +212,45 @@ func NewAuth(sqlDB *sql.DB, env AuthEnv, logger *slog.Logger, authorize func(str
}
return a, nil
}
+
+// signinBegin is POST /auth/begin's whole handler chain, in one place with
+// its own tests rather than composed inline in MountAuth.
+//
+// **The guard is what keeps a non-admitted address from being dialled**
+// (gate 33), and it runs before Begin either way. It is placed outermost
+// by preference, so a refused address never reaches the continuation code
+// at all — but the two orders are behaviourally identical, and a review of
+// the mutation that swaps them found nothing to tell them apart: the
+// continuation rewrites only an EXTERNAL 303, and the guard only ever
+// answers with a same-origin one. The order is a preference, not a guard.
+//
+// **Inside it, Begin is wrapped in Home's OAuthContinuation.** For a
+// Keymail address Begin answers 303 to the Keymail server's
+// /oauth/authorize — and CSP's form-action 'self' is enforced across a
+// form submission's WHOLE redirect chain, so the browser refused it and
+// sign-in with a Keymail address could not leave the sign-in page (seen
+// in Safari, 2026-09-29; the rastrillo docs describe the same refusal).
+// OAuthContinuation replaces exactly that 303 — after checking it is a
+// well-formed authorize URL for THIS origin and callback, with PKCE S256 —
+// with a no-store, no-referrer continuation page whose navigation is not a
+// form submission. Every other response, including the magic-link path's
+// same-origin redirect, passes through untouched. The CSP is not widened:
+// that would have to name every Keymail server, and the framework notes
+// that a federated address on an unlisted host would still be refused.
+func (a *app) signinBegin(origin string, begin http.HandlerFunc) http.HandlerFunc {
+ return a.beginGuard(origin, connect.OAuthContinuation(origin, begin, a.renderOAuth))
+}
+
+// renderOAuth renders the continuation page inside memoria's layout, footer
+// and AI state included — it is a page like any other.
+func (a *app) renderOAuth(w http.ResponseWriter, r *http.Request, destination string) {
+ render(w, "signin_oauth", oauthPage{
+ page: page{Chunks: a.chunkCount(), ModelOn: a.modelOn()},
+ Data: connect.OAuthPage(destination),
+ })
+}
+
+type oauthPage struct {
+ page
+ Data connect.OAuthData
+}
diff --git a/internal/memoria/config.go b/internal/memoria/config.go
index 9d174fd..7950722 100644
--- a/internal/memoria/config.go
+++ b/internal/memoria/config.go
@@ -111,8 +111,13 @@ func LoadConfig(getenv func(string) string) (Config, error) {
case "":
case "on":
c.AI = true
- if c.TensorXKey = getenv("MEMORIA_TENSORX_API_KEY"); c.TensorXKey == "" {
- return c, fmt.Errorf("%w: MEMORIA_TENSORX_API_KEY (MEMORIA_AI=on names a provider but gives no credential for it)", ErrMissing)
+ // MEMORIA_TENSORX_KEY, because that is the name the credential was
+ // already given on the platform (set by hand for the 2026-09-05
+ // live-key checks). This code first read MEMORIA_TENSORX_API_KEY,
+ // which would have meant a second copy of the key under a new name —
+ // and a platform secret cannot be read back to make one.
+ if c.TensorXKey = getenv("MEMORIA_TENSORX_KEY"); c.TensorXKey == "" {
+ return c, fmt.Errorf("%w: MEMORIA_TENSORX_KEY (MEMORIA_AI=on names a provider but gives no credential for it)", ErrMissing)
}
default:
return c, fmt.Errorf("memoria: MEMORIA_AI must be \"on\" or unset; refusing to guess what %q means", ai)
diff --git a/internal/memoria/model_config_test.go b/internal/memoria/model_config_test.go
index c169509..1139e84 100644
--- a/internal/memoria/model_config_test.go
+++ b/internal/memoria/model_config_test.go
@@ -34,7 +34,7 @@ func TestTheModelSwitch(t *testing.T) {
// Presence of a credential is not consent; the switch is.
{"a key with AI unset is still off", "", "k", false, ""},
{"on with a key is on", "on", "k", true, ""},
- {"on without a key is refused", "on", "", false, "MEMORIA_TENSORX_API_KEY"},
+ {"on without a key is refused", "on", "", false, "MEMORIA_TENSORX_KEY"},
// A typo must not be read as either answer.
{"'true' is refused, not on", "true", "k", false, "MEMORIA_AI"},
{"'On' is refused, not on", "On", "k", false, "MEMORIA_AI"},
@@ -42,7 +42,7 @@ func TestTheModelSwitch(t *testing.T) {
} {
t.Run(c.name, func(t *testing.T) {
env := baseEnv()
- env["MEMORIA_AI"], env["MEMORIA_TENSORX_API_KEY"] = c.ai, c.key
+ env["MEMORIA_AI"], env["MEMORIA_TENSORX_KEY"] = c.ai, c.key
cfg, err := LoadConfig(func(k string) string { return env[k] })
if c.wantErr != "" {
if err == nil || !strings.Contains(err.Error(), c.wantErr) {
diff --git a/internal/memoria/render.go b/internal/memoria/render.go
index fb5a43b..7b9d4f1 100644
--- a/internal/memoria/render.go
+++ b/internal/memoria/render.go
@@ -52,13 +52,14 @@ func funcs() template.FuncMap {
}
func init() {
- for _, name := range []string{"index", "document", "signin", "connectors", "notion", "home", "credits"} {
+ for _, name := range []string{"index", "document", "signin", "signin_oauth", "connectors", "notion", "home", "credits"} {
pages[name] = template.Must(template.New("layout").
Funcs(funcs()).
ParseFS(appFS, "templates/layout.html", "templates/results.html", "templates/"+name+".html"))
}
pages["credits"] = template.Must(pages["credits"].ParseFS(credits.Templates(), "templates/credits.html"))
pages["home"] = template.Must(pages["home"].ParseFS(connect.Templates(), "templates/home_link.html"))
+ pages["signin_oauth"] = template.Must(pages["signin_oauth"].ParseFS(connect.Templates(), "templates/oauth.html"))
fragments = template.Must(template.New("fragments").
Funcs(funcs()).
ParseFS(appFS, "templates/results.html"))
diff --git a/internal/memoria/templates/signin_oauth.html b/internal/memoria/templates/signin_oauth.html
new file mode 100644
index 0000000..7823ba5
--- /dev/null
+++ b/internal/memoria/templates/signin_oauth.html
@@ -0,0 +1,10 @@
+{{/* The Keymail continuation page (home/connect OAuthContinuation). The
+ sign-in POST ends here, with a 200, instead of a 303 to Keymail: CSP's
+ form-action 'self' applies to a form submission's WHOLE redirect chain,
+ so the 303 was refused and sign-in with a Keymail address could not
+ leave the page. This page's meta refresh — and its link, for anyone
+ without it — is a navigation, not a form submission, so form-action
+ does not apply. The markup is Home's shared template (Estilo
+ homeconnect), rendered inside memoria's own layout as Sheets does. */}}
+{{define "head"}}{{template "home-oauth-head" .Data}}{{end}}
+{{define "content"}}{{template "home-oauth" .Data}}{{end}}
diff --git a/internal/memoriatest/signin_keymail_test.go b/internal/memoriatest/signin_keymail_test.go
new file mode 100644
index 0000000..658f96a
--- /dev/null
+++ b/internal/memoriatest/signin_keymail_test.go
@@ -0,0 +1,220 @@
+package memoriatest
+
+import (
+ "context"
+ "encoding/binary"
+ "io"
+ "log/slog"
+ "net"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "path/filepath"
+ "strings"
+ stdsync "sync"
+ "testing"
+
+ "github.com/carlosframework/rastrillo/db"
+
+ "amadan.net/oficina/memoria/internal/memoria"
+)
+
+// fakeKeymail answers the two probes signin's classifier makes of a
+// Keymail server — /.well-known/keymail and /api/federation/lookup — for
+// one host, and 404s everything else. It stands in for http.DefaultTransport,
+// which the classifier's client falls back to.
+type fakeKeymail struct {
+ host string
+ mu stdsync.Mutex
+ hits []string
+}
+
+func (f *fakeKeymail) RoundTrip(r *http.Request) (*http.Response, error) {
+ f.mu.Lock()
+ f.hits = append(f.hits, r.URL.Host+r.URL.Path)
+ f.mu.Unlock()
+ body, code := `{}`, http.StatusNotFound
+ if r.URL.Host == f.host {
+ switch r.URL.Path {
+ case "/.well-known/keymail":
+ body, code = `{"version":"1"}`, http.StatusOK
+ case "/api/federation/lookup":
+ code = http.StatusOK
+ }
+ }
+ return &http.Response{StatusCode: code, Body: io.NopCloser(strings.NewReader(body)),
+ Header: http.Header{"Content-Type": {"application/json"}}, Request: r}, nil
+}
+
+// nxdomainResolver answers every DNS query NXDOMAIN — "this domain
+// publishes no _keymail delegation" — which signin's classifier reads as
+// "the domain itself is the Keymail candidate". Go's resolver speaks
+// length-prefixed DNS over any conn that is not a PacketConn, which a
+// net.Pipe is not.
+func nxdomainResolver() *net.Resolver {
+ return &net.Resolver{PreferGo: true, Dial: func(context.Context, string, string) (net.Conn, error) {
+ client, server := net.Pipe()
+ go func() {
+ defer server.Close()
+ for {
+ var n [2]byte
+ if _, err := io.ReadFull(server, n[:]); err != nil {
+ return
+ }
+ q := make([]byte, binary.BigEndian.Uint16(n[:]))
+ if _, err := io.ReadFull(server, q); err != nil || len(q) < 12 {
+ return
+ }
+ // Header plus the question, and nothing after it.
+ i := 12
+ for i < len(q) && q[i] != 0 {
+ i += int(q[i]) + 1
+ }
+ i += 5 // the root label, QTYPE, QCLASS
+ if i > len(q) {
+ return
+ }
+ resp := append([]byte(nil), q[:i]...)
+ rd := binary.BigEndian.Uint16(q[2:4]) & 0x0100
+ binary.BigEndian.PutUint16(resp[2:4], 0x8000|0x0400|rd|0x0080|3) // QR AA RD RA, NXDOMAIN
+ binary.BigEndian.PutUint16(resp[4:6], 1)
+ for _, off := range []int{6, 8, 10} {
+ binary.BigEndian.PutUint16(resp[off:off+2], 0)
+ }
+ binary.BigEndian.PutUint16(n[:], uint16(len(resp)))
+ if _, err := server.Write(append(n[:], resp...)); err != nil {
+ return
+ }
+ }
+ }()
+ return client, nil
+ }}
+}
+
+// keymailSigninApp is the real app with the real sign-in wired, whose
+// owner's domain resolves — through the fakes above — to a Keymail server.
+func keymailSigninApp(t *testing.T) (http.Handler, *fakeKeymail) {
+ t.Helper()
+ logger := slog.New(slog.NewTextHandler(io.Discard, nil))
+ d, err := db.Open(filepath.Join(t.TempDir(), "app.db"), logger)
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { d.Close() })
+ mux, a, err := memoria.App(d, testOrigin, testDEK(t), logger)
+ if err != nil {
+ t.Fatal(err)
+ }
+ au, err := memoria.NewAuth(d.Writer(), memoria.AuthEnv{
+ Origin: testOrigin, InstanceKey: "signin-keymail-instance-key",
+ Owners: []string{dialTestOwner}, Mailer: &stubMailer{},
+ }, logger, a.Admit)
+ if err != nil {
+ t.Fatal(err)
+ }
+ a.UseAuth(au)
+ a.SetOwners([]string{dialTestOwner})
+ a.MountAuth(mux, testOrigin)
+
+ km := &fakeKeymail{host: "example.test"}
+ prevT, prevR := http.DefaultTransport, net.DefaultResolver
+ http.DefaultTransport, net.DefaultResolver = km, nxdomainResolver()
+ t.Cleanup(func() { http.DefaultTransport, net.DefaultResolver = prevT, prevR })
+ return mux, km
+}
+
+// **Signing in with a Keymail address must be able to leave the page.**
+//
+// Begin answers a Keymail address with 303 to the server's /oauth/authorize,
+// and memoria's CSP says form-action 'self' — which browsers enforce across
+// a form submission's WHOLE redirect chain. Safari refused the redirect and
+// the sign-in button span forever (2026-09-29, memoria.oficina.works). The
+// POST now ends in a 200 continuation page whose navigation is not a form
+// submission.
+//
+// Through rastrillo's REAL Begin and the REAL mounted route: the classifier
+// is only faked at the network, so what is asserted is what production
+// sends.
+func TestAKeymailSigninCanLeaveThePage(t *testing.T) {
+ h, km := keymailSigninApp(t)
+ rr := beginSignin(t, h, dialTestOwner)
+
+ // Presence: the classifier really took the Keymail path. Without this,
+ // a 200 below could be the magic-link page and prove nothing.
+ if !contains(km.hits, "example.test/api/federation/lookup") {
+ t.Fatalf("the classifier never reached the Keymail probes (hits %v); this proves nothing", km.hits)
+ }
+
+ if rr.Code == http.StatusSeeOther {
+ t.Fatalf("POST /auth/begin still redirects (Location %q): form-action 'self' refuses this in the browser", rr.Header().Get("Location"))
+ }
+ if rr.Code != http.StatusOK {
+ t.Fatalf("status %d, want 200", rr.Code)
+ }
+ body := rr.Body.String()
+
+ // The continuation navigates to the authorize URL Begin chose, for THIS
+ // origin and callback.
+ i := strings.Index(body, `http-equiv="refresh"`)
+ if i < 0 {
+ t.Fatalf("no meta refresh in the continuation page:\n%s", body)
+ }
+ for _, want := range []string{
+ "https://example.test/oauth/authorize",
+ "client_id=" + url.QueryEscape(testOrigin),
+ "redirect_uri=" + url.QueryEscape(testOrigin+"/auth/callback"),
+ "code_challenge_method=S256",
+ } {
+ if !strings.Contains(strings.ReplaceAll(body, "&amp;", "&"), want) {
+ t.Errorf("continuation page lacks %q", want)
+ }
+ }
+ if got := rr.Header().Get("Cache-Control"); got != "no-store" {
+ t.Errorf("Cache-Control = %q, want no-store: the page carries a one-time PKCE state", got)
+ }
+ if got := rr.Header().Get("Referrer-Policy"); got != "no-referrer" {
+ t.Errorf("Referrer-Policy = %q, want no-referrer", got)
+ }
+ // Begin's pending cookie must survive the wrapping, or the callback
+ // cannot match the flow it started.
+ if !strings.Contains(strings.Join(rr.Header().Values("Set-Cookie"), "\n"), "rastrillo_pending") {
+ t.Errorf("the pending cookie did not survive: %q", rr.Header().Values("Set-Cookie"))
+ }
+ // Rendered as a memoria page, footer and all.
+ if !strings.Contains(body, "<footer") {
+ t.Error("the continuation page is not inside memoria's layout")
+ }
+}
+
+// And the paths that must NOT change: a non-owner still gets the guard's
+// same-origin redirect with no dial (gate 33's ground), and an address with
+// no Keymail still gets the magic link's same-origin redirect, untouched.
+func TestSigninPathsThatDoNotLeaveAreUnchanged(t *testing.T) {
+ h, km := keymailSigninApp(t)
+
+ rr := beginSignin(t, h, "stranger@elsewhere.test")
+ if rr.Code != http.StatusSeeOther || rr.Header().Get("Location") != "/signin?sent=1" {
+ t.Errorf("non-owner: %d %q, want 303 /signin?sent=1", rr.Code, rr.Header().Get("Location"))
+ }
+ if len(km.hits) != 0 {
+ t.Errorf("a non-owner's sign-in reached the network: %v", km.hits)
+ }
+
+ // An owner whose domain has no Keymail (the fake 404s other hosts).
+ km.host = "nowhere.invalid"
+ rr = beginSignin(t, h, dialTestOwner)
+ if rr.Code != http.StatusSeeOther || !strings.HasPrefix(rr.Header().Get("Location"), "/") {
+ t.Errorf("magic-link path: %d %q, want a same-origin 303", rr.Code, rr.Header().Get("Location"))
+ }
+}
+
+func contains(xs []string, want string) bool {
+ for _, x := range xs {
+ if x == want {
+ return true
+ }
+ }
+ return false
+}
+
+var _ = httptest.NewRecorder