template 0.8.0: anon, user, admin — three tiers decided in Forgejo, carried by the edge; the door, hardened #21

Merged
plat merged 2 commits from roles-catalyst into main 2026-08-25 19:23:11 +00:00
Owner

Why: gretchy.store went public and every question — who may sign in, who runs the Studio, what a stranger can see — became a hand-rolled iteration (a login list in env, a door pointed at a bridge that does not exist in platform mode). This makes the answers preconfigured for every app born from now on, without boxing an app in.

Three tiers, decided at the auth layer (src/contracts/viewer.ts, wiki/Roles-and-Access.md):

  • anon — no identity; reaches the app only when it is declared public: true in the org ground (share cards) or in local dev.
  • user — any verified Forgejo identity.
  • admin — a Forgejo owner (X-Plat-Perm: admin: org owners on every org repo, repo admins on theirs). PLAT_ADMIN_TEAMS=<team>,<team> grants the same standing to an org team via X-Plat-Teams — group associations, managed in Forgejo, no login list in app code. manage (write+) stays as the contributor capability.

Guards: requireUser / requireAdmin / requireManage / requireTeam("name") at preValidation. /api/me carries role; /api/admin is the example. SPA: isAdmin(), roleOf(), the tier in the badge.

The door (src/security.ts, first in the lifecycle): every /api, /openapi and flag-registry request answers an anonymous caller 401 with one sentence — before the body parser, validator, content-type gate or 404 handler can say more. PUBLIC_READS is the explicit, reviewed list of anonymous reads (empty by default). src/anon-surface.test.ts builds the real app and walks every route as a stranger; anything open and not written down fails the build. Headers + CSP (hash-pinned inline scripts; CSP_EXTRA_SOURCES for a tile/media host), same-origin JSON-only mutations (403 audited / 415), anonymous rate limit (ANON_RATE_LIMIT).

Sign-in on a public app: the edge's /_plat/login / /_plat/logout on this host (plat-auth 0.45.2) from the door in both modes; sign-out in platform mode too. Closes #20.

Structure: src/server.ts is the entry; src/app.ts builds the app so tests can inject. contract-emit's main guard matched contract-emit.test.js under node --test and booted a server from the test — exact-name guard; its fetch presents an edge identity (docs are signed-in only). CI smoke asserts the door semantics.

57/57 tests, lint + typecheck clean, contract regenerated. Smoke on the built server: anon 401 on everything private (incl. unknown /api paths), reader 200/403/404, owner admin, team-member admin with PLAT_ADMIN_TEAMS, cross-site POST 403, form POST 415.

**Why:** gretchy.store went public and every question — who may sign in, who runs the Studio, what a stranger can see — became a hand-rolled iteration (a login list in env, a door pointed at a bridge that does not exist in platform mode). This makes the answers preconfigured for every app born from now on, without boxing an app in. **Three tiers, decided at the auth layer** (`src/contracts/viewer.ts`, `wiki/Roles-and-Access.md`): - **anon** — no identity; reaches the app only when it is declared `public: true` in the org ground (share cards) or in local dev. - **user** — any verified Forgejo identity. - **admin** — a Forgejo **owner** (`X-Plat-Perm: admin`: org owners on every org repo, repo admins on theirs). `PLAT_ADMIN_TEAMS=<team>,<team>` grants the same standing to an org team via `X-Plat-Teams` — group associations, managed in Forgejo, no login list in app code. `manage` (write+) stays as the contributor capability. **Guards:** `requireUser` / `requireAdmin` / `requireManage` / `requireTeam("name")` at `preValidation`. `/api/me` carries `role`; `/api/admin` is the example. SPA: `isAdmin()`, `roleOf()`, the tier in the badge. **The door** (`src/security.ts`, first in the lifecycle): every `/api`, `/openapi` and flag-registry request answers an anonymous caller **401** with one sentence — before the body parser, validator, content-type gate or 404 handler can say more. `PUBLIC_READS` is the explicit, reviewed list of anonymous reads (empty by default). `src/anon-surface.test.ts` builds the real app and walks every route as a stranger; anything open and not written down fails the build. Headers + CSP (hash-pinned inline scripts; `CSP_EXTRA_SOURCES` for a tile/media host), same-origin JSON-only mutations (403 audited / 415), anonymous rate limit (`ANON_RATE_LIMIT`). **Sign-in on a public app:** the edge's `/_plat/login` / `/_plat/logout` on this host (plat-auth 0.45.2) from the door in both modes; sign-out in platform mode too. Closes #20. **Structure:** `src/server.ts` is the entry; `src/app.ts` builds the app so tests can inject. `contract-emit`'s main guard matched `contract-emit.test.js` under `node --test` and booted a server from the test — exact-name guard; its fetch presents an edge identity (docs are signed-in only). CI smoke asserts the door semantics. 57/57 tests, lint + typecheck clean, contract regenerated. Smoke on the built server: anon 401 on everything private (incl. unknown `/api` paths), reader 200/403/404, owner admin, team-member admin with `PLAT_ADMIN_TEAMS`, cross-site POST 403, form POST 415.
template 0.8.0: anon, user, admin — three tiers decided in Forgejo, carried by the edge; the door, hardened
Some checks failed
check / check (push) Failing after 35s
guard-ppt / no-ppt (push) Successful in 0s
guard-ppt / no-ppt (pull_request) Successful in 1s
check / check (pull_request) Failing after 35s
preview / preview (pull_request) Successful in 26s
78995ff876
Who is asking is answered once, at the auth layer: anon (no identity —
reaches the app only when it is declared public in the org ground, or in
local dev), user (any verified Forgejo identity), admin (a Forgejo OWNER:
the edge collapses owner/admin to X-Plat-Perm admin — org owners on every
org repo, a repo's own admins on that repo). PLAT_ADMIN_TEAMS grants the
same standing to an org team via X-Plat-Teams, so 'the shopkeepers' or
'ward staff' is a team someone maintains in Forgejo and no app ever grows
a list of logins. manage (write+) stays as the contributor capability.

Guards: requireUser / requireAdmin / requireManage / requireTeam(...), at
preValidation. /api/me carries role; /api/admin is the example admin
route; the SPA has isAdmin(), roleOf(), and shows the tier.

The door (src/security.ts), first in the request lifecycle: every
/api, /openapi and flag-registry request answers an anonymous caller 401
with one sentence — before the body parser, the validator, the content
type gate or the 404 handler can say more; PUBLIC_READS is the explicit,
reviewed list of anonymous reads (empty by default); src/anon-surface
walks every registered route as a stranger and fails on anything open
that is not written down. Headers/CSP with hash-pinned inline scripts on
every response (CSP_EXTRA_SOURCES for a tile or media host), same-origin
JSON-only mutations (403, audited; 415), an anonymous rate limit
(ANON_RATE_LIMIT).

Sign-in on a public app: the edge's own entry points on this host,
/_plat/login and /_plat/logout (plat-auth 0.45.2), from the door in both
modes; sign-out works in platform mode too. src/server.ts is now the
entry only; src/app.ts builds the app so tests can inject.

contract-emit: its main guard matched contract-emit.test.js under
node --test and booted a server from the test; exact-name guard now, and
the spec fetch presents an edge identity (the docs are signed-in only).
CI smoke asserts the door semantics (anon 401 on /openapi.json, /api/
admin and unknown /api paths; a reader gets 200/403/404).

57/57 tests, lint and typecheck clean, contract regenerated.
Author
Owner

🔴 CI run check failed on 78995ff876 — queuing an agent to look (added agent-work).

🔴 CI run `check` failed on `78995ff876` — queuing an agent to look (added `agent-work`).
Author
Owner
🔎 Preview is live for QA: https://pr-21-app-template-plat.open-platform.sh
ci: the smoke speaks as the edge for the signed-in half
All checks were successful
guard-ppt / no-ppt (push) Successful in 0s
guard-ppt / no-ppt (pull_request) Successful in 0s
check / check (push) Successful in 35s
check / check (pull_request) Successful in 36s
preview / preview (pull_request) Successful in 4s
f695d84c2d
The app-mode boot (better-auth, Postgres sidecar) keeps the anonymous
assertions — the door answers 401 there too. A second, header-trust boot
presents X-Plat-* as the edge would and asserts the tiers (reader
200/403/404, owner admin, cross-site 403) and fetches /openapi.json for
the contract gate, which is signed-in only now.
plat merged commit 24d3f51d9d into main 2026-08-25 19:23:11 +00:00
plat deleted branch roles-catalyst 2026-08-25 19:23:12 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
open-platform/app-template!21
No description provided.