The create-team form, gated on what can actually be provisioned
A signed-in person can now ask for a team: a name, an address, and which apps it should have. The request reserves the label and queues one row per app. It creates nothing — the team is real only once a drain has made it so, and the confirmation page says that rather than implying otherwise. The registry carries a provisionable flag per app, every entry false today, under a comment that provisioning stays disabled "until the audited operator path is available". The form offers exactly that set and filters the POST against it, so a request for an app no drain may create cannot be queued — such a request could only expire, holding a label the whole time. With nothing provisionable the form says so and renders no submit button, which is the honest state today and what the tests assert. Flipping those flags is the go-live switch and belongs with the drain, not with the form in front of it. The sealed payload carries the owner's pseudonym, the label and the app list — no address. §3.2 discloses the queue as new custody when it holds addresses; this one does not, so enqueueing adds none, and the welcome mail that does need an address stays the credential origin's job. Tests cover the session gate, the provisionable filter, that a refused request reserves nothing, and that a cross-origin post cannot reach it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
5 files changed,
+275
−0
internal/home/directory.go+5 −0internal/home/team_new.go+135 −0internal/home/team_new_test.go+101 −0internal/home/templates/team_new.html+26 −0internal/home/templates/team_queued.html+8 −0
diff --git a/internal/home/directory.go b/internal/home/directory.go| index fefa080..affda02 100644 |
| --- a/internal/home/directory.go |
| +++ b/internal/home/directory.go |
| @@ -105,6 +105,11 @@ func (a *App) directoryRouter() http.Handler { |
| } |
| a.render(w, 200, "directory.html", map[string]any{"Apps": directoryCatalog(*a.runtime)}) |
| }) |
| + // Creating a team needs a session, and only a session: the home is |
| + // deciding about a resource it owns (a label), never about anything |
| + // inside an instance. |
| + r.With(a.sessions.Require).Get("/teams/new", a.handleTeamNew) |
| + r.With(a.sessions.Require).Post("/teams", a.handleTeamCreate) |
| r.Post("/signout", func(w http.ResponseWriter, r *http.Request) { |
| a.sessions.SignOut(w, r) |
| http.Redirect(w, r, "/signed-out", http.StatusSeeOther) |
diff --git a/internal/home/team_new.go b/internal/home/team_new.go| new file mode 100644 |
| index 0000000..ce36cad |
| --- /dev/null |
| +++ b/internal/home/team_new.go |
| @@ -0,0 +1,135 @@ |
| +package home |
| + |
| +import ( |
| + "encoding/json" |
| + "errors" |
| + "net/http" |
| + "strings" |
| + "time" |
| + |
| + "amadan.net/oficina/home/internal/home/directorydata" |
| + "github.com/carlosframework/rastrillo/sessions" |
| +) |
| + |
| +// How long a queued request holds its label before the sweep releases it, |
| +// and how many a person may make. ttl must be at least the window — a |
| +// request that expired inside the window would erase the history the limit |
| +// counts — and CreateTeam refuses the configuration if it is not. |
| +const ( |
| + teamRequestTTL = 24 * time.Hour |
| + teamRequestMax = 3 |
| + teamRateWindow = 24 * time.Hour |
| + requestSealPurpose = "oficina/provisioning/request/v1" |
| +) |
| + |
| +// requestPayload is what the drain is handed. It names the owner and what |
| +// they asked for, and nothing else: no address, so enqueueing adds no |
| +// custody the directory did not already have. §3.2 discloses the queue as |
| +// new custody when it carries addresses; this one does not carry one, and |
| +// the welcome mail that needs one is the credential origin's to send. |
| +type requestPayload struct { |
| + Owner string `json:"owner"` |
| + Label string `json:"label"` |
| + Apps []string `json:"apps"` |
| +} |
| + |
| +// ProvisionableApp is the registry's provisionable set, as a template can |
| +// see it. directoryApp's fields are unexported on purpose, and the registry |
| +// is the one place that decides what may be provisioned. |
| +type ProvisionableApp struct{ Slug, Name string } |
| + |
| +// provisionableApps is the gate this whole surface hangs on. The registry |
| +// carries `provisionable` per app and every entry is false today, under a |
| +// comment that says provisioning stays disabled "until the audited operator |
| +// path is available". This form offers exactly that set, so it cannot queue |
| +// a request for an app no drain is allowed to create — a request that could |
| +// only ever expire, holding a label the whole time. |
| +// |
| +// Flipping those flags is the deliberate go-live switch, and it belongs with |
| +// the drain, not here. |
| +func provisionableApps() []ProvisionableApp { |
| + var out []ProvisionableApp |
| + for _, app := range directoryApps() { |
| + if app.provisionable { |
| + out = append(out, ProvisionableApp{Slug: app.slug, Name: app.name}) |
| + } |
| + } |
| + return out |
| +} |
| + |
| +func (a *App) handleTeamNew(w http.ResponseWriter, r *http.Request) { |
| + w.Header().Set("Cache-Control", "no-store") |
| + a.render(w, http.StatusOK, "team_new.html", map[string]any{"Apps": provisionableApps()}) |
| +} |
| + |
| +// handleTeamCreate reserves a label and queues the provisioning requests. |
| +// It creates nothing: the team is real only once a drain has made it so, |
| +// and the page says exactly that rather than implying a team now exists. |
| +func (a *App) handleTeamCreate(w http.ResponseWriter, r *http.Request) { |
| + w.Header().Set("Cache-Control", "no-store") |
| + session, ok := sessions.Current(r) |
| + if !ok { |
| + http.Redirect(w, r, "/", http.StatusSeeOther) |
| + return |
| + } |
| + if err := r.ParseForm(); err != nil { |
| + a.renderTeamError(w, "That form could not be read. Try again.") |
| + return |
| + } |
| + label := strings.ToLower(strings.TrimSpace(r.PostFormValue("label"))) |
| + name := strings.TrimSpace(r.PostFormValue("name")) |
| + // Only the provisionable set, whatever the form posted. A request for an |
| + // app outside it could never be drained, so accepting one would hold a |
| + // label until it expired and nothing else. |
| + allowed := make(map[string]bool, len(provisionableApps())) |
| + for _, app := range provisionableApps() { |
| + allowed[app.Slug] = true |
| + } |
| + var apps []string |
| + for _, app := range r.PostForm["app"] { |
| + if allowed[app] { |
| + apps = append(apps, app) |
| + } |
| + } |
| + if len(apps) == 0 { |
| + a.renderTeamError(w, "Choose at least one app for this team.") |
| + return |
| + } |
| + |
| + payload, err := json.Marshal(requestPayload{Owner: session.Subject, Label: label, Apps: apps}) |
| + if err != nil { |
| + a.renderTeamError(w, "That request could not be prepared. Try again.") |
| + return |
| + } |
| + sealed, err := a.secrets.Sealer.Seal(requestSealPurpose, payload) |
| + if err != nil { |
| + a.logger.Error("seal provisioning request", "err", err) |
| + a.renderTeamError(w, "That request could not be prepared. Try again.") |
| + return |
| + } |
| + |
| + team, err := a.directory.store.CreateTeam(r.Context(), session.Subject, label, name, apps, sealed, |
| + time.Now(), teamRequestTTL, teamRequestMax, teamRateWindow) |
| + switch { |
| + case errors.Is(err, directorydata.ErrLabelTaken): |
| + // Says the name is unavailable and never who holds it. |
| + a.renderTeamError(w, "That address is already taken. Try another name.") |
| + return |
| + case errors.Is(err, directorydata.ErrRateLimited): |
| + a.renderTeamError(w, "That is a lot of teams at once. Try again tomorrow.") |
| + return |
| + case errors.Is(err, directorydata.ErrInvalid): |
| + a.renderTeamError(w, "A team needs a name, and an address of letters, digits and hyphens.") |
| + return |
| + case err != nil: |
| + a.logger.Error("create team", "err", err) |
| + a.renderTeamError(w, "That could not be saved. Try again.") |
| + return |
| + } |
| + a.render(w, http.StatusOK, "team_queued.html", map[string]any{"Label": team.Label, "Name": team.Name, "Apps": apps}) |
| +} |
| + |
| +func (a *App) renderTeamError(w http.ResponseWriter, message string) { |
| + w.Header().Set("Cache-Control", "no-store") |
| + a.render(w, http.StatusBadRequest, "team_new.html", map[string]any{"Apps": provisionableApps(), "Error": message}) |
| +} |
diff --git a/internal/home/team_new_test.go b/internal/home/team_new_test.go| new file mode 100644 |
| index 0000000..ff1a715 |
| --- /dev/null |
| +++ b/internal/home/team_new_test.go |
| @@ -0,0 +1,101 @@ |
| +package home |
| + |
| +import ( |
| + "net/url" |
| + "strings" |
| + "testing" |
| +) |
| + |
| +// The registry gates this whole surface: every app carries provisionable |
| +// false today, under a comment saying provisioning stays disabled until the |
| +// audited operator path exists. So the form must say so rather than offering |
| +// a team it cannot deliver, and it must not be reachable without a session. |
| +func TestTeamFormOffersOnlyWhatCanActuallyBeProvisioned(t *testing.T) { |
| + f := newMountedChooser(t) |
| + |
| + anonymous := f.request("GET", "/teams/new", "", "", nil) |
| + if anonymous.Code == 200 { |
| + t.Fatal("the team form was served without a session") |
| + } |
| + |
| + cookie := f.session(t, chooserPerson) |
| + page := f.request("GET", "/teams/new", "", "", cookie) |
| + if page.Code != 200 { |
| + t.Fatal(page.Code) |
| + } |
| + noStore(t, page) |
| + body := page.Body.String() |
| + |
| + provisionable := provisionableApps() |
| + if len(provisionable) == 0 { |
| + // Today's state. The form must be honest about it, and must not |
| + // render a submit button for a request nothing could drain. |
| + if !strings.Contains(body, "No app can be set up automatically yet") { |
| + t.Fatal("form did not say provisioning is unavailable", body) |
| + } |
| + if strings.Contains(body, `type="submit"`) { |
| + t.Fatal("form offered a submit button with nothing provisionable") |
| + } |
| + return |
| + } |
| + for _, app := range provisionable { |
| + if !strings.Contains(body, `value="`+app.Slug+`"`) { |
| + t.Fatalf("provisionable app %q missing from the form", app.Slug) |
| + } |
| + } |
| +} |
| + |
| +// A POST naming an app outside the provisionable set must not queue it. Such |
| +// a request could never be drained, so accepting it would reserve a label |
| +// and hold it until expiry for nothing. |
| +func TestTeamCreateRefusesAppsOutsideTheProvisionableSet(t *testing.T) { |
| + f := newMountedChooser(t) |
| + cookie := f.session(t, chooserPerson) |
| + form := url.Values{"name": {"Acme"}, "label": {"acme"}, "app": {"docs", "sheets", "nosuchapp"}} |
| + r := f.request("POST", "/teams", directoryOrigin, form.Encode(), cookie) |
| + |
| + if len(provisionableApps()) == 0 { |
| + if r.Code != 400 || !strings.Contains(r.Body.String(), "Choose at least one app") { |
| + t.Fatalf("with nothing provisionable a create must be refused: %d %s", r.Code, r.Body.String()) |
| + } |
| + // And nothing may have been reserved by a refused request. |
| + var teams int |
| + if err := f.p.directory.db.Writer().QueryRow(`SELECT count(*) FROM teams`).Scan(&teams); err != nil { |
| + t.Fatal(err) |
| + } |
| + if teams != 0 { |
| + t.Fatalf("a refused create reserved %d labels", teams) |
| + } |
| + return |
| + } |
| + if r.Code != 200 { |
| + t.Fatalf("create: %d %s", r.Code, r.Body.String()) |
| + } |
| + var queued int |
| + if err := f.p.directory.db.Writer().QueryRow( |
| + `SELECT count(*) FROM provisioning_queue WHERE app = 'nosuchapp'`).Scan(&queued); err != nil { |
| + t.Fatal(err) |
| + } |
| + if queued != 0 { |
| + t.Fatal("an app outside the provisionable set was queued") |
| + } |
| +} |
| + |
| +// The form is a mutation on the directory origin, so it is CSRF-protected |
| +// like every other one. A cross-origin POST must not reserve a label. |
| +func TestTeamCreateRefusesACrossOriginPost(t *testing.T) { |
| + f := newMountedChooser(t) |
| + cookie := f.session(t, chooserPerson) |
| + form := url.Values{"name": {"Acme"}, "label": {"acme"}, "app": {"docs"}} |
| + r := f.request("POST", "/teams", "https://attacker.example", form.Encode(), cookie) |
| + if r.Code == 200 { |
| + t.Fatal("a cross-origin post reached team creation") |
| + } |
| + var teams int |
| + if err := f.p.directory.db.Writer().QueryRow(`SELECT count(*) FROM teams`).Scan(&teams); err != nil { |
| + t.Fatal(err) |
| + } |
| + if teams != 0 { |
| + t.Fatalf("a cross-origin post reserved %d labels", teams) |
| + } |
| +} |
diff --git a/internal/home/templates/team_new.html b/internal/home/templates/team_new.html| new file mode 100644 |
| index 0000000..1c9989a |
| --- /dev/null |
| +++ b/internal/home/templates/team_new.html |
| @@ -0,0 +1,26 @@ |
| +{{define "title"}}Create a team · Oficina{{end}} |
| +{{define "content"}} |
| +<h1 estilo-brand><img estilo-brand-icon src="/static/brand/icon.svg" alt="" width="28" height="28">Create a team</h1> |
| +<p>A team is where your work lives. Its address is the name you choose, and every app in the team shares it.</p> |
| +{{if .Error}}<p rst-alert="negative">{{.Error}}</p>{{end}} |
| +<form method="post" action="/teams" rst-form> |
| +<label for="name">Team name</label> |
| +<input id="name" name="name" type="text" required maxlength="64" autocomplete="organization"> |
| +<label for="label">Address</label> |
| +<input id="label" name="label" type="text" required maxlength="63" pattern="[a-z0-9][a-z0-9-]*[a-z0-9]" inputmode="latin"> |
| +<p rst-hint>Letters, digits and hyphens. Your team will live at this name under each app, for example <code>yourteam.docs.oficina.works</code>.</p> |
| +{{if .Apps}} |
| +<fieldset> |
| +<legend>Apps for this team</legend> |
| +{{range .Apps}} |
| +<label><input type="checkbox" name="app" value="{{.Slug}}" checked> {{.Name}}</label> |
| +{{end}} |
| +</fieldset> |
| +<button type="submit" rst-btn="primary">Request this team</button> |
| +{{else}} |
| +<p rst-alert="neutral">No app can be set up automatically yet, so a team cannot be created here today.</p> |
| +{{end}} |
| +</form> |
| +<p><a href="/">Back</a></p> |
| +{{end}} |
| +{{define "help"}}{{template "home/help" .}}{{end}} |
diff --git a/internal/home/templates/team_queued.html b/internal/home/templates/team_queued.html| new file mode 100644 |
| index 0000000..2dd926c |
| --- /dev/null |
| +++ b/internal/home/templates/team_queued.html |
| @@ -0,0 +1,8 @@ |
| +{{define "title"}}Team requested · Oficina{{end}} |
| +{{define "content"}} |
| +<h1 estilo-brand><img estilo-brand-icon src="/static/brand/icon.svg" alt="" width="28" height="28">{{.Name}} is on its way</h1> |
| +<p><strong>{{.Label}}</strong> is reserved for you across every app in the suite. Nobody else can take it.</p> |
| +<p>Your apps are being set up now. They appear on your home page as they come online — you do not need to stay on this page, and we will not ask you to do anything else.</p> |
| +<p><a href="/">Back to your apps</a></p> |
| +{{end}} |
| +{{define "help"}}{{template "home/help" .}}{{end}} |