Skip to content

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.json publishes. 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.access and grantr.audit, and the application's logging would leave them at the root's WARNING. The template's LOG_CONFIG sets both loggers to INFO for every generated application, with or without the portal (portalr ADR-0035); this decision adds nothing to it.

Decision

  • A portal question, asked only with access=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 that copier update of an existing application now asks it. use_portal is 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.py is a part, like the libraries', with migrate, lifespan and mount, built over the application's Access: the router's resolve_actor is the one grantr's admin API uses, and its Grants is the application's own, with portalr's catalog (portalr:read_telemetry, read_spend, read_deployment) among the roles' actions. access.md gains those three rows, which scripts/access-matrix writes 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's Site refuses 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 by LANGFUSE_BASE_URL, the project's id and its keys; LiteLLM by the gateway's address and PORTAL_LITELLM_KEY, a key of its own that reads spend and nothing else, never the master key (the user's decision): stackr's make portal-key makes it, a view-only admin's key limited to /team/info, /team/list, /team/daily/activity and /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 --rotate replaces a key whose value is lost; Grafana for operators' links. stackr.env gives the app profile the stack's addresses for them. Invitations go through Supabase Auth's admin API when PORTAL_AUTH_URL and AUTH_SERVICE_ROLE_KEY are set, and fail with that reason when they aren't.
  • People are named by email, with the same credentials as invitations. portal.people() passes people=SupabasePeople(...) to the router when the application has PORTAL_AUTH_URL (Supabase's gateway) and AUTH_SERVICE_ROLE_KEY, the two that invitations() 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.json carries 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 by SupabasePeople, and logged nowhere. On a single host the overlay sets PORTAL_AUTH_URL empty, so people are not named there, as invitations are not. On the PostgreSQL adapter without the overlay, stackr.env still defaults PORTAL_AUTH_URL to 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); set STACK_PORTAL_AUTH_URL to 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 .prototools has (check-template holds the two together), fetches lattice at the commit uv.lock pins portalr at, installs the two UIs' workspaces with the lockfile (with a cache) and builds app and admin into /ui, and PORTAL_UI_DIR=/ui points the router at them. The commit is not written a second time: scripts/build-portal-ui.sh reads it from uv.lock, so the UIs are always the installed router's own. The script is that stage's, and make portal-ui runs 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-template renders fifteen variants, one of them with grantr and without the portal. CI's Template job runs the generated application's own make check for 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's X-Forwarded-* headers (FORWARDED_ALLOW_IPS: "*"), which means any service on the stackr network, 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 an apikey header to <url>/auth/v1, and the stack's GoTrue answers a preflight with 204 and no Access-Control headers (it has no GOTRUE_CORS_*), so a portal at app.<domain> signing in at auth.<domain> would fail in the browser. The choice, over a CORS header at the edge and a path under auth.<domain>, is the same origin: the overlay sets PORTAL_SUPABASE_URL to https://app.<STACKR_DOMAIN>, and the edge serves GoTrue's calls under /auth/v1 on that host, which also keeps the content security policy's connect-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/user and POST /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/v1 is answered 404, with a second rule that answers 404 to any path with an admin segment 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, /admin and /auth/v1/admin. An allow-list does not depend on every spelling of a refusal being foreseen. tests/test_stackr_edge.py runs the shipped Caddyfile in Caddy in front of stand-in backends and sends forty-one paths by GET and POST, because caddy validate proves 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 copy gives 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 sync already 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.sh go.