ADR-0038: portalr in the template¶
Status: Accepted Date: 2026-10-16 Deciders: Alex Nodeland
Builds the template's half of portalr's phase 7 and lattice RFC-0002's "The template": an application generated with grantr can mount portalr's router and serve its two UIs.
Context¶
RFC-0002 has the template gain a portal question, on when access is grantr, which portalr needs to say who may read what. The application mounts portalr_router beside the libraries', with the readers for the tools the stack runs, and installs portalr from its released wheel. Three things were open.
- portalr's UIs are in its wheel, not in its source. Vite builds them into
src/portalr/static/, which git ignores. The template installs every library from lattice at the commit it was rendered from, because none is on PyPI, so an application that does the same for portalr has a router and no UIs. Every library, portalr among them, has been on PyPI since 2026-10-17; the template still installs them from lattice, as ADR-0018 decided, until that is revisited. - The Supabase project is the application's to name. The UIs sign in to it from the browser, with its publishable key, which
config.jsonpublishes. The key is a setting that no default can supply. - The audit trail has to reach Loki. portalr's access records and grantr's audit records are logged at INFO, under
portalr.accessandgrantr.audit, and the application's logging would leave them at the root's WARNING. The template'sLOG_CONFIGsets both loggers to INFO for every generated application, with or without the portal (portalr ADR-0035); this decision adds nothing to it.
Decision¶
- A
portalquestion, asked only withaccess=grantr, on by default (the user's decision, over a recommendation to start off). Its help text says it needs a Supabase project and adds a Bun build to the image, and the guide says thatcopier updateof an existing application now asks it.use_portalis the derived answer the files read, so that an application without grantr is never given one. Without it, the application is as it was. portal.pyis a part, like the libraries', withmigrate,lifespanandmount, built over the application'sAccess: the router'sresolve_actoris the one grantr's admin API uses, and itsGrantsis the application's own, with portalr's catalog (portalr:read_telemetry,read_spend,read_deployment) among the roles' actions.access.mdgains those three rows, whichscripts/access-matrixwrites and a test compares.- It is mounted only where the Supabase project is named (
PORTAL_SUPABASE_URL,PORTAL_SUPABASE_KEY): without them the router is not mounted, an INFO line says so, and/leaves it out. The default application and its tests run as before. portalr'sSiterefuses a secret key, so the browser can't be given one. - Each tool is read where its setting names it, and left out where it doesn't. Tempo, Loki and Prometheus (through prom-label-proxy) by
PORTAL_*_URL; Langfuse byLANGFUSE_BASE_URL, the project's id and its keys; LiteLLM by the gateway's address andPORTAL_LITELLM_KEY, a key of its own that reads spend and nothing else, never the master key (the user's decision): stackr'smake portal-keymakes it, a view-only admin's key limited to/team/info,/team/list,/team/daily/activityand/tag/daily/activity, which on the pinned LiteLLM (v1.103.0) opens those four routes and is refused every other (checked against the proxy, and in the smoke test); a test ties the list to the LiteLLM paths portalr's readers call, so a reader that gains a route fails it. A leaked key reads every tenant's spend and teams, and--rotatereplaces a key whose value is lost; Grafana for operators' links.stackr.envgives the app profile the stack's addresses for them. Invitations go through Supabase Auth's admin API whenPORTAL_AUTH_URLandAUTH_SERVICE_ROLE_KEYare set, and fail with that reason when they aren't. - People are named by email, with the same credentials as invitations.
portal.people()passespeople=SupabasePeople(...)to the router when the application hasPORTAL_AUTH_URL(Supabase's gateway) andAUTH_SERVICE_ROLE_KEY, the two thatinvitations()needs, and passes none otherwise: there is then no people route, and the UIs show short ids (portalr ADR-0038). The secret key stays server-side as everywhere here: it is read from the environment (.env, mode 0600), sent to the gateway in the request's headers over the stack's network, and never in an argument, a log, an error, or what the browser is given (config.jsoncarries the publishable key and whether people are named, and a test checks the secret is not in it); the emails are kept in memory for minutes bySupabasePeople, and logged nowhere. On a single host the overlay setsPORTAL_AUTH_URLempty, so people are not named there, as invitations are not. On the PostgreSQL adapter without the overlay,stackr.envstill defaultsPORTAL_AUTH_URLto local Supabase's gateway (supabase_kong_stackr-supabase), which does not exist there: people and invitations are then enabled but point at a host that cannot be reached, which is harmless (a failed read is remembered for five seconds, no one is named, and an invitation fails saying so); setSTACK_PORTAL_AUTH_URLto a gateway to name them. The smoke test runs admin's Members against a generated application on local Supabase and checks both people appear by email (portalr ADR-0040). - The image builds the UIs. A stage of
oven/bun, pinned by tag and digest at the version lattice's.prototoolshas (check-templateholds the two together), fetches lattice at the commituv.lockpins portalr at, installs the two UIs' workspaces with the lockfile (with a cache) and buildsappandadmininto/ui, andPORTAL_UI_DIR=/uipoints the router at them. The commit is not written a second time:scripts/build-portal-ui.shreads it fromuv.lock, so the UIs are always the installed router's own. The script is that stage's, andmake portal-uiruns it on a machine with git and Bun, into.portal-ui. This is a stopgap with a date: when portalr is on PyPI, the application installs its wheel, and the stage and the script go. - It is checked as everything in the template is.
check-templaterenders fifteen variants, one of them with grantr and without the portal. CI's Template job runs the generated application's ownmake checkfor the same, so the portal's tests (its config and both UIs served, a read asked of grantr and refused across tenants, the tools and invitations each configured and not, and the settings) run at 100% coverage. The smoke test signs a user in to an application whose image has built the UIs, and checks that it serves both, that the owner reads the tenant's spend, and that a tenant the user isn't in is refused. - A single host does not ship an inert portal. The generated overlay sets
PORTAL_REQUIRED, so the application stops at startup, saying why, unless the Supabase project is named and at an https address; and it trusts the edge'sX-Forwarded-*headers (FORWARDED_ALLOW_IPS: "*"), which means any service on thestackrnetwork, since the application shares it with all of them: they are stackr's own, and only the edge is reachable from outside. Invitations are off there and say why: GoTrue alone has no gateway behind its private address. - The portal signs in at the application's own origin. supabase-js sends preflighted JSON
POSTs with anapikeyheader to<url>/auth/v1, and the stack's GoTrue answers a preflight with 204 and noAccess-Controlheaders (it has noGOTRUE_CORS_*), so a portal atapp.<domain>signing in atauth.<domain>would fail in the browser. The choice, over a CORS header at the edge and a path underauth.<domain>, is the same origin: the overlay setsPORTAL_SUPABASE_URLtohttps://app.<STACKR_DOMAIN>, and the edge serves GoTrue's calls under/auth/v1on that host, which also keeps the content security policy'sconnect-src 'self'true.auth.<domain>is unchanged, for other clients and for the links GoTrue emails. - Only the calls the sign-in makes reach GoTrue there, by an allow-list.
POST /auth/v1/token(the password sign-in, and a session's refresh),GET /auth/v1/userandPOST /auth/v1/logout: the calls portalr's UIs make (signInWithPassword,getSession's refresh,signOut, and the user), checked against supabase-js and GoTrue v2.197.0. The paths are matched exactly, on the normalised path, and anything else under/auth/v1is answered 404, with a second rule that answers 404 to any path with anadminsegment outside portalr's own/portalr/(whose admin UI is one). A first version stripped the prefix and refused/admin/*inside it, which forwarded/auth/v1/../admin/users,/auth/v1/%2e%2e/admin/users,/auth/v1/admin%00/users,/auth/v1/admin./users,/adminand/auth/v1/admin. An allow-list does not depend on every spelling of a refusal being foreseen.tests/test_stackr_edge.pyruns the shipped Caddyfile in Caddy in front of stand-in backends and sends forty-one paths by GET and POST, becausecaddy validateproves nothing about routing; the smoke test sends a dozen and signs the smoke user in through the edge.
Options considered¶
| Option | For | Against |
|---|---|---|
| Build the UIs in the image (chosen) | Works from a repository install, as every other library does | Needs git, Bun and the network at build time |
| Install portalr from a release wheel | What RFC-0002 says; no build in the image | No wheel exists until the first release, and a template at a commit would name a release |
| Commit the built UIs | No build | Generated files in the repository, which portalr ADR-0033's notices rely on being built |
| Fail without a Supabase project | Nothing silently off | The default application and every test would need one |
Consequences¶
- Easier:
copier copygives an application the app and admin UIs, the reads and the audit trail, on the same grantr. - Harder: the image's build is longer, and fetches lattice from GitHub, as
uv syncalready does. - Not done: a single host's overlay does not yet name the tools' addresses, and, with no gateway behind it, names no people (
people=above). - When portalr is released, the stage and
scripts/build-portal-ui.shgo.