Skip to content

Add portal playground mode: anonymous browser-pinned sessions - #38

Merged
16francej merged 2 commits into
mainfrom
playground-mode
Jul 30, 2026
Merged

Add portal playground mode: anonymous browser-pinned sessions#38
16francej merged 2 commits into
mainfrom
playground-mode

Conversation

@16francej

@16francej 16francej commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

What

PORTAL_PLAYGROUND=1 turns a deployment into a public try-it instance with one real admin. An unauthenticated browser navigation (a GET that accepts HTML) mints an anonymous playground-<random> principal, seals it into the ordinary portal_session cookie, and continues. Each visitor's sessions, files, memory, keychain view, and sandbox are pinned to their browser through the same personal:<principal> scoping that isolates real teammates — no new isolation machinery.

What playground mode deliberately does not change:

  • /auth/login still runs the full OIDC flow; production still demands the usual OIDC config. That is how the one admin signs in, and admin identity remains the core's ADMIN_GRANTS, derived per request.
  • /admin refuses anonymous sessions outright (HTML card or JSON 403).
  • Non-HTML requests without a session still get 401 — the SPA's API calls ride the cookie from the first page load, and bare curl never mints.
  • Sign-out just clears the cookie; the next visit starts a fresh identity.

Abuse containment

Minting is rate-limited per client IP (clientIpOf, Fly-header aware) through the core's Postgres-backed single-use claim store — the same /v1/auth/broker/claim slot pattern the sign-in broker uses — so restarts, blue-green deploys, and multiple portal instances share one budget. If the core can't record the claim, the portal fails closed with a 429 card. Tunables: PORTAL_PLAYGROUND_MINTS_PER_IP (default 30) per PORTAL_PLAYGROUND_MINT_WINDOW_S (default 3600).

A cleared cookie is a fresh principal, so the per-principal core brakes are soft; the README directs operators to the real ones — ORG_BUDGET_USD_PER_WINDOW, BUDGET_USD_PER_WINDOW, RATE_LIMIT_PER_WINDOW, and pinning a single model via the admin base-model / webui-models resources — and to run a playground as its own deployment. Known gap, stated in the README: nothing garbage-collects an abandoned visitor's scope yet.

Moved

plugins/auth/src/claims.tsplugins/chassis/src/claims.ts (the claim-store client + withinRateLimit slot math), now that both the auth broker and the portal consume it. Chassis is the sanctioned home for plugin↔core plumbing; the log prefix became a parameter.

New surface

The only new rendered page is the 429 "playground is busy" card, built from the existing portal card system. Rendered from the real playgroundBusyHtml() output (the live path needs an exhausted IP budget, so this is a direct render — same bytes):

The playground busy 429 card

Testing

  • New plugins/portal/test/playground.test.ts boots the real portal server against a stub core + upstream and covers: mint-on-first-visit with cookie pinning (no re-mint on return), 401 for sessionless API calls, /admin refusal for anonymous sessions, /auth/login still reaching the IdP, per-IP budget exhaustion → 429 with no cookie, and fail-closed when the claim store refuses.
  • Portal: 101/101 tests, typecheck, lint. Auth: 52/52, typecheck (import-path move only).

View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

PORTAL_PLAYGROUND=1 lets one deployment serve as a public try-it
instance. An unauthenticated browser navigation mints an anonymous
playground-<random> principal into the ordinary portal_session cookie,
so every visitor gets their own scoped sessions, files, memory, and
sandbox through the same personal-scope isolation real teammates use.
Non-HTML requests without a session still get 401, so only real page
loads mint. /auth/login keeps the full OIDC flow for the one admin,
/admin refuses anonymous sessions outright, and signing out simply
starts a fresh identity on the next visit.

Minting is rate-limited per client IP through the core's durable
single-use claim store, failing closed to a 429 page when the claim
cannot be recorded, so restarts and blue-green deploys cannot reset the
budget. The claim-store helper moves from plugins/auth to the shared
chassis package now that both the auth broker and the portal consume it.
Refuse to boot when playground is combined with a domain-wide cookie,
an apps domain, or deployment proxying, since those surfaces never see
the anon flag and would take an anonymous session at face value. Refuse
out-of-range mint knobs instead of silently serving 429 to everyone:
the core grants at most 64 claim slots per request and a 24-hour claim
horizon, so values outside those bounds brick minting. Bucket IPv6
minting per /64 so a routed prefix cannot rotate through fresh budgets,
and warn at boot when the socket address would make every visitor share
one bucket behind a reverse proxy. Refuse anonymous sessions the
connect and secret-drop flows so real OAuth tokens and dropped secrets
cannot be attached to a throwaway principal that a cleared cookie
orphans.
@16francej

Copy link
Copy Markdown
Contributor Author

Pushed a hardening commit after two independent adversarial reviews (fresh-context review agent + Codex challenge):

  • Boot refusalsPORTAL_PLAYGROUND now refuses to start with PORTAL_COOKIE_DOMAIN, PORTAL_APPS_DOMAIN, or PORTAL_DEPLOYMENTS_ENABLED (those surfaces never see the anon flag and would honor an anonymous cookie), and refuses mint knobs outside the core's real bounds (limit 1–64 claim slots, window 60–86400s) instead of silently 429ing every visitor. An explicit-but-invalid value now fails boot instead of silently becoming the default.
  • IPv6 minting is bucketed per /64, so a routed prefix can't rotate through fresh budgets; boot warns when the socket address would make everyone behind a reverse proxy share one bucket (PORTAL_XFF_TRUSTED_HOPS).
  • Anonymous sessions are refused /connect/* and /drop/*, so real OAuth tokens and dropped secrets can't be attached to a throwaway principal that a cleared cookie orphans.
  • New tests: anon surviving sliding renewal, connect/drop refusal, the /64 bucketing, and the boot refusal matrix. Portal 105/105.

Documented residual (by design, now stated plainly in the README): the anon flag never crosses the portal identity boundary — inside the playground org a visitor is an ordinary internal principal, so a playground must be its own deployment with nothing sensitive at org: scope. Also still open, as before: no GC for abandoned visitor scopes.

@16francej
16francej merged commit 35c47a3 into main Jul 30, 2026
15 checks passed
@16francej
16francej deleted the playground-mode branch July 30, 2026 23:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant