Skip to content

feat!: admin pages served by the API, with sign-in - #33

Merged
johnf merged 10 commits into
mainfrom
spec/26-admin-pages
Oct 28, 2026
Merged

johnf merged 10 commits into
mainfrom
spec/26-admin-pages

Conversation

@johnf

@johnf johnf commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

Closes #26
Closes #29
Closes #30
Closes #31
Closes #32

Summary

The API serves its own admin pages, rendered on the server, on their own origin (ADMIN_ORIGIN) in the same process. An admin signs in through our OIDC provider and gets in only if their sub is in ADMIN_USERS. The bearer-token admin API is gone.

sequenceDiagram
    participant B as Browser
    participant A as Admin origin /admin
    participant P as Issuer /oauth (our provider)
    participant U as Upstream provider
    B->>A: GET /admin/ (no session)
    A-->>B: 303 to P /oauth/authorize (PKCE, state, nonce)
    B->>P: authorize
    P-->>B: upstream sign-in (skipped if already signed in for Oni)
    B->>U: sign in
    U-->>P: callback
    P-->>B: 303 to A /admin/callback?code
    B->>A: callback
    A->>P: token request in-process (inject), no call to our public URL
    A-->>B: session cookie (8 h, SameSite=Lax, /admin) and 303 to the page asked for
Loading
 src/
+├── admin/
+│   ├── plugin.ts        # routes, session, ADMIN_USERS guard, Origin check, CSP, host constraint
+│   ├── sign-in.ts       # built-in client's sign-in, reusing the upstream PKCE flow
+│   ├── client.ts        # built-in confidential client, secret from OIDC_COOKIE_KEYS (HKDF)
+│   ├── crates.ts        # crate tree with each index's real state
+│   ├── actions.ts       # index / reindex / delete, one crate or all, guarded and logged
+│   ├── views/*.eta      # layout and pages: Eta via @fastify/view, escaping by default
+│   ├── admin.css        # Tailwind source, scans views/ → public/admin.css (gitignored, `pnpm build`)
+│   └── public/admin.js  # filter box, delete confirm()
-└── routes/admin.ts      # /api/admin, TOKEN_ADMIN, @fastify/bearer-auth

Changes from the spec as written, from review and a design change agreed during the work (the issues are updated to match):

  • Own origin: the admin pages answer only on ADMIN_ORIGIN, and only /admin answers there, because arocapi serves crate files inline on the API origin (Sandbox file responses so crate files can't run scripts on the API origin crate-works/arocapi#133). This adds the setting ADMIN_ORIGIN. In development and test it defaults to http://admin.localhost:8080; in production, admin is off unless it is set. The reverse proxy must pass the original Host through.
  • Sign-out: ends on /admin/signed-out, not /admin/, so it doesn't sign straight back in.
  • Reindex all: a new action, beside Index all.
  • Delete scope: deleting a crate also deletes the crates under it, matching indexing. "Under" means the id plus /, not a bare string prefix.
  • More refusals: deleting while a covered crate is queued or indexing; indexing while any covered crate is being deleted, queued or indexing; an unknown crate.
  • Audit log: each action is logged with the admin's sub.
  • Rendering: Eta templates via @fastify/view, not a hand-rolled html helper. Templates aren't type-checked; the data passed to them is typed.
  • Queued up front: createIndex marks every affected crate queued before it starts, so the page's self-refresh starts at once. It also fixes indexing by a string id, which never loaded the object, and getState now returns a copy, so a page read mid-index isn't changed under it.

Evidence

  • Before: admin was a shared TOKEN_ADMIN compiled into ldaca-admin, and index state was kept in the browser's local storage.
    After: pnpm test: 19 files, 161 tests pass; tsc, biome and knip are clean. The admin cases all go through HTTP with real Postgres and OpenSearch and the fake upstream:
sign-in   no session → sign in → back to page asked for · POST with no session → home
          already signed in at our provider → straight in · non-admin → 403 with sub
          empty ADMIN_USERS → nobody · removed from ADMIN_USERS → stopped
          session > 8 h → sign in · tampered/foreign cookie → no session · wrong state → refused
          sign-out → provider end-session → signed-out page
security  missing/foreign/issuer Origin → refused, nothing changed · only on admin origin
          CSP same-origin scripts/styles, no framing · crate name with HTML escaped
crates    tree of collections and objects · real state per index · refresh only while in progress
actions   index · reindex · index all · reindex all · delete one · delete all · flash once
          refused during delete or another index run, with reason · id prefix doesn't over-match · audit log
off       not found, with a startup warning, when sign-in or ADMIN_ORIGIN is unset

Not covered: the filter box and confirm() run only in a browser, and nothing in the project runs browser JS. The tests check the script is served, is allowed by the CSP and is wired up.

Merge Danger

Door: two-way

Removing /api/admin and TOKEN_ADMIN is breaking (feat!), but nothing in production calls them. The CLI scripts call the indexer directly.

Blast Radius: deploy

  • New deploy steps: turning admin on in production needs ADMIN_ORIGIN, a proxy hostname that passes Host through, ADMIN_USERS, and pnpm build for the stylesheet. Without them, admin stays off and startup warns.
  • OIDC_COOKIE_KEYS parsing: it now drops blank entries and needs at least one key.

Follow-up: #35, shared entities lost when one crate's index is deleted (predates this branch; Delete makes it easy to hit).

Unfixed review findings:

  • Page loads read the whole repository: each load of the page, including the 5-second self-refresh while work runs, reads and parses every crate's metadata and counts the crates that aren't indexed. A cached listing belongs in the repository, not the admin pages.
  • The crate tree nests by id path prefix, as ldaca-admin did, not by memberOf. Check this against real LDaCA ids.

Server-rendered admin pages at /admin/, registered when sign-in is on.
Admins sign in through our own provider as a built-in confidential client,
with the token request made in-process. ADMIN_USERS lists the admins; others
get a 403 page showing their sub. Sessions last 8 hours, POSTs must carry our
Origin, and responses carry a strict CSP.
johnf added 9 commits October 28, 2026 11:17
@tailwindcss/cli builds src/admin/public/admin.css (gitignored) from the
admin templates: pnpm build builds it and pnpm dev watches it.
@fastify/static serves it under /admin/. Tests build it in global setup
when it is missing.
…dmin API

Admins can index, reindex and delete one crate's indexes, and index or
delete all of them, from the crate tree. Each action posts a form and
redirects back with a one-time flash message. Delete actions ask for
confirmation.

BREAKING CHANGE: the admin API under /api/admin is gone, along with the
TOKEN_ADMIN setting. Use the admin pages at /admin/ instead.
Crate files are served inline on the API's origin, so script in a crate
could act as an admin. The admin pages now answer only on ADMIN_ORIGIN
(default http://admin.localhost:8080 outside production; unset in
production turns admin off), and nothing else is served there.
- An action on a crate covers it and the crates whose id follows it with
  a slash, for index and delete alike; an unknown crate is refused.
- Every covered crate is queued before indexing starts, so the page
  keeps refreshing until the work is done.
- Deleting is refused while a covered crate is queued or indexing, and
  indexing while one is being deleted.
- Add Reindex all, log every action with the admin's sub, and return
  sign-in after a POST to the home page.
- Sign-out ends on a signed-out page rather than signing straight back in.
- The filter survives the page's refresh.
getState returned the live state cache entry. The crates page reads
every crate's state before rendering any, so a crate whose structural
index finished in between showed the cleared entry: "Structural: not
indexed" beside "Search: indexing", without the count that would have
shown it indexed. Return a copy instead.
@johnf
johnf force-pushed the spec/26-admin-pages branch from 851be32 to c54b03a Compare October 28, 2026 00:18
@johnf
johnf merged commit e2db8c2 into main Oct 28, 2026
4 checks passed
@johnf
johnf deleted the spec/26-admin-pages branch October 28, 2026 00:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant