oficina / home Public

Add unmounted Docs team chooser with fixed sign-in return

Paul Campbell pushed by paul@keymail.dev 07043915daebae10512a710915263093ed793b5b parent 4c0d848
7 files changed, +438 −0
  • .gitignore +2 −0
  • implementation/docs-team-chooser.md +15 −0
  • internal/home/docs_chooser.go +135 −0
  • internal/home/docs_chooser_test.go +263 −0
  • internal/home/templates/docs_chooser.html +11 −0
  • internal/home/templates/docs_empty.html +6 −0
  • internal/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`, `&lt;script&gt;`} {
+ 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}}