Add unmounted Docs team chooser with fixed sign-in return
7 files changed,
+438
−0
.gitignore+2 −0implementation/docs-team-chooser.md+15 −0internal/home/docs_chooser.go+135 −0internal/home/docs_chooser_test.go+263 −0internal/home/templates/docs_chooser.html+11 −0internal/home/templates/docs_empty.html+6 −0internal/home/templates/docs_unavailable.html+6 −0
CI passed — run details
diff --git a/.gitignore b/.gitignore| index 1181530..82408b5 100644 |
| --- a/.gitignore |
| +++ b/.gitignore |
| @@ -16,3 +16,5 @@ |
| .claude/launch.json |
| .claude/settings.local.json |
| .superpowers/ |
| + |
| +writing/ |
diff --git a/implementation/docs-team-chooser.md b/implementation/docs-team-chooser.md| new file mode 100644 |
| index 0000000..29925b6 |
| --- /dev/null |
| +++ b/implementation/docs-team-chooser.md |
| @@ -0,0 +1,15 @@ |
| +# Docs team chooser controller |
| + |
| +This unmounted slice follows approved directory designfa64e28 and store4c0d848. Discussion26 records review. It adds only handlers and templates: no production routing, directory schema mounting, provisioning, roster producer/client or item authority. The old bare Docs alias and its resource links are untouched. Guest/former-member directory tiles remain a separate requirement; this chooser lists current team memberships only. |
| + |
| +A directory App constructs the chooser with `newDocsChooser(store)`. Credential and legacy-role Apps are refused. The store interface is directorydata.Choices; its injected app registry/destination policy remains authoritative for allowed origins. Integration mounts Entry at GET /apps/docs and Select at POST /apps/docs/team under the existing directory sessions middleware and exact-host guard. The selection handler additionally carries the real same-origin CSRF middleware itself. Do not mount these handlers on the credential origin. |
| + |
| +Entry accepts no query parameters. It reads identity exclusively from sessions.Current and requires Home's opaque person-ref shape. Signed-out navigation redirects to the shared consumer with fixed return_to=/apps/docs; a test follows the actual credential issuer, callback, inbox and finish back to that route. No email, team selection, arbitrary return URL or document path is accepted as identity/navigation state. |
| + |
| +Zero live choices renders an honest empty state. One redirects to that validated stored origin's /auth/start with local return /. Several render ordinary POST buttons carrying only team sqid. Selection re-queries current memberships and compares the requested sqid; foreign, removed and unknown teams receive the same 404. Redirecting changes no session/cookie and grants no item access. Apps must still validate roster standing and individual grants after their own authentication. Two simultaneous tabs carry independent explicit POST selections. |
| + |
| +The existing Home renderer, layout, Estilo theme assets and Rastrillo attribute components are reused. Team names stay template-escaped; no script is added. Every controller response including redirects, method/query errors, CSRF rejection and storage failure is no-store. A failed read renders fixed text without stored names or origins. Runtime routing remains unchanged. |
| + |
| +Copy pipeline: gpt-5.6-luna drafted seven strings via the writing-pipeline skill; plain-writing and humane-docs passes made the failure message specific to loading teams rather than falsely declaring Docs down. Artifacts are in ignored writing/docs-chooser. The user waived human copy review for this work. No copy approval was requested or invented. |
| + |
| +Tests use the actual directory store/schema, actual Home renderer/session middleware and real shared handoff. They cover empty/one/many destinations, membership removal after rendering, foreign/unknown teams, escaped hostile labels, no script tags, exact fixed return, query/form bounds and duplicates, hostile Origin including the sibling credential origin, email-shaped session subject refusal, no-store and no new cookie, unavailable-store privacy, and constructor credential-role refusal. Existing full Home script/markup/privacy gates apply to the new templates. Two overlay mutations (ignoring the selected sqid, and discarding /apps/docs return) fail the intended tests. No live browser/canary claim is made for unmounted handlers. |
diff --git a/internal/home/docs_chooser.go b/internal/home/docs_chooser.go| new file mode 100644 |
| index 0000000..fca4bb0 |
| --- /dev/null |
| +++ b/internal/home/docs_chooser.go |
| @@ -0,0 +1,135 @@ |
| +package home |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "io" |
| + "mime" |
| + "net/http" |
| + "net/url" |
| + |
| + "amadan.net/oficina/home/handoff" |
| + "amadan.net/oficina/home/internal/home/directorydata" |
| + "github.com/carlosframework/rastrillo/csrf" |
| + "github.com/carlosframework/rastrillo/sessions" |
| +) |
| + |
| +const docsEntryPath = "/apps/docs" |
| +const docsChoicePath = "/apps/docs/team" |
| + |
| +type docsChoices interface { |
| + Choices(context.Context, string, string) ([]directorydata.Choice, error) |
| +} |
| + |
| +type docsChooser struct { |
| + app *App |
| + store docsChoices |
| + selectHandler http.Handler |
| +} |
| + |
| +// newDocsChooser prepares handlers without mounting routes or applying schema. |
| +// The parent directory router must supply its existing sessions middleware and |
| +// exact-host guard. Selection carries its own same-origin CSRF protection. |
| +func (a *App) newDocsChooser(store docsChoices) (*docsChooser, error) { |
| + if a == nil || a.runtime == nil || a.runtime.role != "directory" || a.renderer == nil || a.origins.validate() != nil || store == nil { |
| + return nil, errors.New("invalid Docs chooser configuration") |
| + } |
| + c := &docsChooser{app: a, store: store} |
| + c.selectHandler = csrf.Protect(a.origins.Directory)(http.HandlerFunc(c.choose)) |
| + return c, nil |
| +} |
| +func (c *docsChooser) Entry(w http.ResponseWriter, r *http.Request) { |
| + w.Header().Set("Cache-Control", "no-store") |
| + if r.Method != http.MethodGet { |
| + w.Header().Set("Allow", http.MethodGet) |
| + w.WriteHeader(http.StatusMethodNotAllowed) |
| + return |
| + } |
| + if r.URL.Path != docsEntryPath || r.URL.RawQuery != "" || r.URL.ForceQuery { |
| + http.NotFound(w, r) |
| + return |
| + } |
| + subject, ok := c.subject(w, r) |
| + if !ok { |
| + return |
| + } |
| + choices, err := c.store.Choices(r.Context(), subject, "docs") |
| + if err != nil { |
| + c.unavailable(w) |
| + return |
| + } |
| + switch len(choices) { |
| + case 0: |
| + c.app.render(w, http.StatusOK, "docs_empty.html", nil) |
| + case 1: |
| + c.redirect(w, r, choices[0]) |
| + default: |
| + c.app.render(w, http.StatusOK, "docs_chooser.html", struct{ Choices []directorydata.Choice }{choices}) |
| + } |
| +} |
| +func (c *docsChooser) Select(w http.ResponseWriter, r *http.Request) { |
| + w.Header().Set("Cache-Control", "no-store") |
| + if r.Method != http.MethodPost { |
| + w.Header().Set("Allow", http.MethodPost) |
| + w.WriteHeader(http.StatusMethodNotAllowed) |
| + return |
| + } |
| + if r.URL.Path != docsChoicePath || r.URL.RawQuery != "" || r.URL.ForceQuery { |
| + http.NotFound(w, r) |
| + return |
| + } |
| + c.selectHandler.ServeHTTP(w, r) |
| +} |
| +func (c *docsChooser) choose(w http.ResponseWriter, r *http.Request) { |
| + subject, ok := c.subject(w, r) |
| + if !ok { |
| + return |
| + } |
| + media, _, err := mime.ParseMediaType(r.Header.Get("Content-Type")) |
| + if err != nil || media != "application/x-www-form-urlencoded" { |
| + http.NotFound(w, r) |
| + return |
| + } |
| + body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 1024)) |
| + if err != nil { |
| + http.NotFound(w, r) |
| + return |
| + } |
| + form, err := url.ParseQuery(string(body)) |
| + if err != nil || len(form) != 1 || len(form["team"]) != 1 || form.Get("team") == "" { |
| + http.NotFound(w, r) |
| + return |
| + } |
| + choices, err := c.store.Choices(r.Context(), subject, "docs") |
| + if err != nil { |
| + c.unavailable(w) |
| + return |
| + } |
| + for _, choice := range choices { |
| + if choice.SQID == form.Get("team") { |
| + c.redirect(w, r, choice) |
| + return |
| + } |
| + } |
| + http.NotFound(w, r) |
| +} |
| +func (c *docsChooser) subject(w http.ResponseWriter, r *http.Request) (string, bool) { |
| + session, ok := sessions.Current(r) |
| + if !ok { |
| + http.Redirect(w, r, handoff.StartPath+"?"+url.Values{"return_to": {docsEntryPath}}.Encode(), http.StatusSeeOther) |
| + return "", false |
| + } |
| + if !ValidPersonRef(session.Subject) { |
| + http.NotFound(w, r) |
| + return "", false |
| + } |
| + return session.Subject, true |
| +} |
| +func (c *docsChooser) unavailable(w http.ResponseWriter) { |
| + c.app.render(w, http.StatusServiceUnavailable, "docs_unavailable.html", nil) |
| +} |
| +func (c *docsChooser) redirect(w http.ResponseWriter, r *http.Request, choice directorydata.Choice) { |
| + // The directorydata store has already applied the configured destination |
| + // policy. Generic entry carries only the app root, never a document path. |
| + http.Redirect(w, r, choice.Origin+handoff.StartPath+"?"+url.Values{"return_to": {"/"}}.Encode(), http.StatusSeeOther) |
| +} |
diff --git a/internal/home/docs_chooser_test.go b/internal/home/docs_chooser_test.go| new file mode 100644 |
| index 0000000..eb76b4a |
| --- /dev/null |
| +++ b/internal/home/docs_chooser_test.go |
| @@ -0,0 +1,263 @@ |
| +package home |
| + |
| +import ( |
| + "context" |
| + "net/http" |
| + "net/http/httptest" |
| + "net/url" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "amadan.net/oficina/home/handoff" |
| + "amadan.net/oficina/home/internal/home/directorydata" |
| + "github.com/carlosframework/rastrillo/migrate" |
| + "github.com/carlosframework/rastrillo/sessions" |
| +) |
| + |
| +const chooserPerson = "pr_aaaaaaaaaaaaaaaaaaaaaaaaaa" |
| +const chooserOther = "pr_bbbbbbbbbbbbbbbbbbbbbbbbbb" |
| + |
| +type chooserFixture struct { |
| + p exchangePair |
| + store *directorydata.Store |
| + handler http.Handler |
| +} |
| + |
| +func newChooserFixture(t *testing.T) chooserFixture { |
| + t.Helper() |
| + p := newExchangePair(t) |
| + if _, err := migrate.Apply(context.Background(), p.directory.db, directorydata.Schema); err != nil { |
| + t.Fatal(err) |
| + } |
| + store, err := directorydata.New(p.directory.db.Writer(), p.directory.secrets.Sealer, directorydata.Config{Apps: []string{"docs"}, ValidateRoute: func(r directorydata.Route) bool { return r.Origin == "https://"+r.Label+".docs.oficina.works" }}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + c, err := p.directory.newDocsChooser(store) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + mux := http.NewServeMux() |
| + mux.HandleFunc(docsEntryPath, c.Entry) |
| + mux.HandleFunc(docsChoicePath, c.Select) |
| + return chooserFixture{p, store, p.directory.serveKnownHost(p.directory.sessions.Middleware(mux))} |
| +} |
| +func (f chooserFixture) session(t *testing.T, subject string) *http.Cookie { |
| + t.Helper() |
| + rec := httptest.NewRecorder() |
| + req := httptest.NewRequest("GET", directoryOrigin+"/", nil) |
| + if err := f.p.directory.sessions.SignIn(rec, req, sessions.Session{Subject: subject, Method: "assertion", AuthTime: time.Now()}); err != nil { |
| + t.Fatal(err) |
| + } |
| + cookie := sessionCookie(rec) |
| + if cookie == nil { |
| + t.Fatal("missing session") |
| + } |
| + return cookie |
| +} |
| +func (f chooserFixture) request(method, path, origin, body string, cookie *http.Cookie) *httptest.ResponseRecorder { |
| + r := httptest.NewRequest(method, directoryOrigin+path, strings.NewReader(body)) |
| + if cookie != nil { |
| + r.AddCookie(cookie) |
| + } |
| + if method == "POST" { |
| + r.Header.Set("Content-Type", "application/x-www-form-urlencoded") |
| + } |
| + if origin != "" { |
| + r.Header.Set("Origin", origin) |
| + } |
| + rec := httptest.NewRecorder() |
| + f.handler.ServeHTTP(rec, r) |
| + return rec |
| +} |
| +func (f chooserFixture) team(t *testing.T, sqid, label, name, person string) int64 { |
| + t.Helper() |
| + tx, err := f.p.directory.db.Writer().Begin() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer tx.Rollback() |
| + r, err := tx.Exec(`INSERT INTO teams(sqid,label,sealed_name,created_at) VALUES(?,?,zeroblob(29),1)`, sqid, label) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + id, err := r.LastInsertId() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + b, err := f.store.SealTeamName(id, name) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, q := range []struct { |
| + s string |
| + a []any |
| + }{ |
| + {`UPDATE teams SET sealed_name=? WHERE id=?`, []any{b, id}}, |
| + {`INSERT INTO memberships VALUES(?,?,?)`, []any{id, person, "member"}}, |
| + {`INSERT INTO team_instances VALUES(?,?,?,?,?)`, []any{id, "docs", "https://" + label + ".docs.oficina.works", label + "-docs", "live"}}, |
| + } { |
| + if _, err = tx.Exec(q.s, q.a...); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + if err = tx.Commit(); err != nil { |
| + t.Fatal(err) |
| + } |
| + return id |
| +} |
| +func noStore(t *testing.T, r *httptest.ResponseRecorder) { |
| + t.Helper() |
| + if r.Header().Get("Cache-Control") != "no-store" { |
| + t.Fatalf("cacheable response %d", r.Code) |
| + } |
| + if len(r.Result().Cookies()) != 0 { |
| + t.Fatal("chooser changed session/navigation cookies") |
| + } |
| +} |
| +func TestDocsChooserZeroOneManyAndRemovedChoice(t *testing.T) { |
| + f := newChooserFixture(t) |
| + cookie := f.session(t, chooserPerson) |
| + r := f.request("GET", docsEntryPath, "", "", cookie) |
| + noStore(t, r) |
| + if r.Code != 200 || !strings.Contains(r.Body.String(), "This account has no team with Docs available.") { |
| + t.Fatal(r.Code, r.Body.String()) |
| + } |
| + a := f.team(t, "A1", "alpha", "Alpha", chooserPerson) |
| + r = f.request("GET", docsEntryPath, "", "", cookie) |
| + noStore(t, r) |
| + if r.Code != 303 || r.Header().Get("Location") != "https://alpha.docs.oficina.works/auth/start?return_to=%2F" { |
| + t.Fatal(r.Code, r.Header()) |
| + } |
| + f.team(t, "B2", "beta", `<script>alert("team")</script>`, chooserPerson) |
| + f.team(t, "C3", "foreign", "Foreign secret label", chooserOther) |
| + r = f.request("GET", docsEntryPath, "", "", cookie) |
| + noStore(t, r) |
| + for _, want := range []string{`method="post" action="/apps/docs/team"`, `name="team" value="A1"`, `name="team" value="B2"`, `/static/theme.css`, `<script>`} { |
| + if !strings.Contains(r.Body.String(), want) { |
| + t.Fatal("missing", want) |
| + } |
| + } |
| + if r.Code != 200 || strings.Contains(r.Body.String(), "<script") || strings.Contains(r.Body.String(), "Foreign secret label") || strings.Contains(r.Body.String(), chooserPerson) { |
| + t.Fatal(r.Code, r.Body.String()) |
| + } |
| + r = f.request("POST", docsChoicePath, directoryOrigin, "team=B2", cookie) |
| + noStore(t, r) |
| + if r.Code != 303 || r.Header().Get("Location") != "https://beta.docs.oficina.works/auth/start?return_to=%2F" { |
| + t.Fatal(r.Code, r.Header()) |
| + } |
| + if _, err := f.p.directory.db.Writer().Exec(`DELETE FROM memberships WHERE team_id=? AND person_ref=?`, a, chooserPerson); err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, team := range []string{"A1", "C3", "Unknown"} { |
| + r = f.request("POST", docsChoicePath, directoryOrigin, "team="+team, cookie) |
| + noStore(t, r) |
| + if r.Code != 404 { |
| + t.Fatal(team, r.Code) |
| + } |
| + } |
| + r = f.request("GET", docsEntryPath, "", "", cookie) |
| + if r.Header().Get("Location") != "https://beta.docs.oficina.works/auth/start?return_to=%2F" { |
| + t.Fatal(r.Header()) |
| + } |
| +} |
| +func TestDocsChooserSignedOutReturnThroughActualHandoff(t *testing.T) { |
| + f := newChooserFixture(t) |
| + r := f.request("GET", docsEntryPath, "", "", nil) |
| + noStore(t, r) |
| + if r.Code != 303 || r.Header().Get("Location") != "/auth/start?return_to=%2Fapps%2Fdocs" { |
| + t.Fatal(r.Code, r.Header()) |
| + } |
| + start := exchangeRequest(f.p.directory, "GET", directoryOrigin+r.Header().Get("Location"), "", nil) |
| + pending := namedCookie(t, start, "__Host-oficina-assertion-pending") |
| + authorize := exchangeRequest(f.p.credential.App, "GET", start.Header().Get("Location"), "", nil) |
| + intent := namedCookie(t, authorize, assertionIntentCookie) |
| + credentialSession := signInFully(t, f.p.credential, "chooser@example.invalid") |
| + token, _ := f.p.mint(t, intent, credentialSession) |
| + callback := exchangeRequest(f.p.directory, "POST", directoryOrigin+handoff.CallbackPath, Origin, url.Values{"assertion": {token}}) |
| + inbox := namedCookie(t, callback, "__Host-oficina-assertion-inbox") |
| + finish := exchangeRequest(f.p.directory, "GET", directoryOrigin+handoff.FinishPath, "", nil, pending, inbox) |
| + if finish.Code != 303 || finish.Header().Get("Location") != docsEntryPath { |
| + t.Fatal(finish.Code, finish.Header()) |
| + } |
| + r = f.request("GET", finish.Header().Get("Location"), "", "", sessionCookie(finish)) |
| + if r.Code != 200 { |
| + t.Fatal(r.Code) |
| + } |
| +} |
| +func TestDocsChooserRefusesHostileInputAndCrossOriginSelection(t *testing.T) { |
| + f := newChooserFixture(t) |
| + cookie := f.session(t, chooserPerson) |
| + f.team(t, "A1", "alpha", "Alpha", chooserPerson) |
| + for _, path := range []string{"/apps/docs?team=A1", "/apps/docs?return_to=https://attacker.example", "/apps/docs?", "/apps/docs/team?team=A1"} { |
| + method := "GET" |
| + if strings.Contains(path, "/team") { |
| + method = "POST" |
| + } |
| + r := f.request(method, path, directoryOrigin, "team=A1", cookie) |
| + noStore(t, r) |
| + if r.Code != 404 { |
| + t.Fatal(path, r.Code) |
| + } |
| + } |
| + for _, body := range []string{"team=A1&team=A1", "team=A1&return_to=%2Fsecret", "team=", "team=%zz", "team=https%3A%2F%2Fattacker.example", strings.Repeat("x", 1025)} { |
| + r := f.request("POST", docsChoicePath, directoryOrigin, body, cookie) |
| + noStore(t, r) |
| + if r.Code != 404 { |
| + t.Fatal(body, r.Code) |
| + } |
| + } |
| + for _, origin := range []string{"https://attacker.example", Origin} { |
| + r := f.request("POST", docsChoicePath, origin, "team=A1", cookie) |
| + noStore(t, r) |
| + if r.Code != 403 { |
| + t.Fatal(origin, r.Code) |
| + } |
| + } |
| + for _, subject := range []string{"user@example.invalid", "A1", chooserOther} { |
| + other := f.session(t, subject) |
| + r := f.request("POST", docsChoicePath, directoryOrigin, "team=A1", other) |
| + noStore(t, r) |
| + if r.Code != 404 { |
| + t.Fatal(subject, r.Code) |
| + } |
| + } |
| + r := f.request("GET", docsChoicePath, "", "", cookie) |
| + noStore(t, r) |
| + if r.Code != 405 { |
| + t.Fatal(r.Code) |
| + } |
| + r = f.request("POST", docsEntryPath, directoryOrigin, "team=A1", cookie) |
| + noStore(t, r) |
| + if r.Code != 405 { |
| + t.Fatal(r.Code) |
| + } |
| +} |
| +func TestDocsChooserFailureIsPrivateAndUnmounted(t *testing.T) { |
| + f := newChooserFixture(t) |
| + cookie := f.session(t, chooserPerson) |
| + f.team(t, "A1", "alpha", "Private team name", chooserPerson) |
| + if _, err := f.p.directory.db.Writer().Exec(`UPDATE team_instances SET origin='https://unapproved.example'`); err != nil { |
| + t.Fatal(err) |
| + } |
| + r := f.request("GET", docsEntryPath, "", "", cookie) |
| + noStore(t, r) |
| + if r.Code != 503 || strings.Contains(r.Body.String(), "Private team name") || strings.Contains(r.Body.String(), "unapproved.example") { |
| + t.Fatal(r.Code, r.Body.String()) |
| + } |
| + r = exchangeRequest(f.p.directory, "GET", directoryOrigin+docsEntryPath, "", nil, cookie) |
| + if r.Code != 404 { |
| + t.Fatal("chooser mounted outside test", r.Code) |
| + } |
| +} |
| + |
| +func TestDocsChooserCannotAttachToCredentialRole(t *testing.T) { |
| + f := newChooserFixture(t) |
| + if _, err := f.p.credential.App.newDocsChooser(f.store); err == nil { |
| + t.Fatal("credential role accepted directory renderer") |
| + } |
| + if _, err := f.p.directory.newDocsChooser(nil); err == nil { |
| + t.Fatal("nil store accepted") |
| + } |
| +} |
diff --git a/internal/home/templates/docs_chooser.html b/internal/home/templates/docs_chooser.html| new file mode 100644 |
| index 0000000..5447580 |
| --- /dev/null |
| +++ b/internal/home/templates/docs_chooser.html |
| @@ -0,0 +1,11 @@ |
| +{{define "title"}}Choose a team · Oficina{{end}} |
| +{{define "content"}} |
| +<h1>Choose a team</h1> |
| +<p>Open Docs for one of your teams.</p> |
| +<form method="post" action="/apps/docs/team" rst-form> |
| +{{range .Choices}} |
| +<button type="submit" name="team" value="{{.SQID}}" rst-btn>{{.Name}}</button> |
| +{{end}} |
| +</form> |
| +<p><a href="/">Back to Home</a></p> |
| +{{end}} |
diff --git a/internal/home/templates/docs_empty.html b/internal/home/templates/docs_empty.html| new file mode 100644 |
| index 0000000..4525085 |
| --- /dev/null |
| +++ b/internal/home/templates/docs_empty.html |
| @@ -0,0 +1,6 @@ |
| +{{define "title"}}No teams available · Oficina{{end}} |
| +{{define "content"}} |
| +<h1>No teams available</h1> |
| +<p>This account has no team with Docs available.</p> |
| +<p><a href="/">Back to Home</a></p> |
| +{{end}} |
diff --git a/internal/home/templates/docs_unavailable.html b/internal/home/templates/docs_unavailable.html| new file mode 100644 |
| index 0000000..51e2bcd |
| --- /dev/null |
| +++ b/internal/home/templates/docs_unavailable.html |
| @@ -0,0 +1,6 @@ |
| +{{define "title"}}Could not load your teams · Oficina{{end}} |
| +{{define "content"}} |
| +<h1>Could not load your teams</h1> |
| +<p>Try again in a moment.</p> |
| +<p><a href="/">Back to Home</a></p> |
| +{{end}} |