Skip to content

Getting started

This page gets portalr's two UIs, app and admin, running inside an application you already have: install the wheel, mount the router, give the UIs your Supabase project, and sign in. portalr is a Python router your application mounts beside the libraries'; the UIs are static files it carries and serves (ADR-0001).

What is built

Every area of both UIs described in these guides is built and tested. stackr's template asks a portal question and mounts portalr for you (stackr ADR-0038); this page is for an application that mounts it itself, and says what the template does for you. The template passes people= and invite= when it has the gateway's address and its secret key, and stackr's smoke test runs a few scenarios against a generated application (ADR-0040). Not built yet: magic links and other sign-in providers (a password signs in).

What you need

portalr reads and writes through the packages you already run, so it needs their surfaces:

Needs For
grantr Who may see and do what. Every read and command is checked by it, and the UIs ask it what to show
artifactr's router The app's workspaces, threads, artifacts and proposals
reflexr's router The app's rules and runs, and admin's Runs
Supabase Auth Signing in, inviting people, and naming them by email
Any of Tempo, Loki, Prometheus, Langfuse, LiteLLM and Grafana Admin's traces, logs and health, feedback, spend, and the operators' links. Each is optional: a tool you don't run is "not configured", and only its own area says so

stackr's stack runs all of them, and its template generates an application that mounts the libraries, and portalr beside them when its portal question is answered yes, which is the default with grantr.

Install

portalr needs Python 3.12 or newer. Its wheel carries the two built UIs, which Vite builds into it, so install the wheel, not the source. It is on PyPI, as are the two packages it requires, grantr[fastapi] and lattice-core-ai.

uv add portalr

Each package's GitHub release has the same wheel and sdist attached, with their provenance attested (ADR-0023).

For a change on main before its release, build the wheel from a checkout of lattice, with Bun and moon installed (proto use installs them):

git clone https://github.com/alexnodeland/lattice
cd lattice
bun install --frozen-lockfile
moon run portalr:build        # both UIs, then the wheel: packages/portalr/dist/portalr-<version>-py3-none-any.whl

Then, in your application's project, with LATTICE set to the checkout's path:

uv add "lattice-core-ai @ $LATTICE/packages/lattice-core" \
       "grantr[fastapi] @ $LATTICE/packages/grantr" \
       $LATTICE/packages/portalr/dist/portalr-*.whl

lattice-core-ai comes from the checkout too, so that it is the one the checkout's grantr and portalr were written against; PyPI's can be older.

The built wheel holds portalr/static/app/ and portalr/static/admin/; moon run portalr:build and that install were run to write this page.

A git install has no UIs

pip install "portalr @ git+https://github.com/alexnodeland/lattice#subdirectory=packages/portalr" installs the Python, but the UIs are not in git: they are built into the wheel. The router would have nothing to serve at /portalr/app/ and /portalr/admin/.

Mount the router

portalr_router takes what it needs from your application and returns a FastAPI router to mount at /portalr:

Argument Is
resolve_actor Authenticates a request, as grantr's router does: it returns a Caller, or raises Unauthorized
grants grantr's Grants, with portalr's catalog, portalr.actions.ACTIONS, among its catalogs
readers The tools your stack runs, each optional
invite Supabase Auth's admin API, as a function from an email to a user id. Without it, there is no invitation route and no way to create a tenant from admin
site Your Supabase project's URL and publishable key, and where you mounted each router
people Optional. Names people by email: a function from user ids to the emails it can find, such as portalr.people.SupabasePeople(url=, secret_key=) over Supabase Auth's admin API. Without it, there is no people route, and the UIs show people by a short id
from fastapi import FastAPI
from starlette.requests import HTTPConnection

from grantr import Grants, InMemoryStorage, User
from grantr.fastapi import Caller, Unauthorized
from portalr.actions import ACTIONS
from portalr.fastapi import Site, portalr_router
from portalr.readers import Readers

grants = Grants(InMemoryStorage(), catalogs=[ACTIONS])


async def resolve_actor(connection: HTTPConnection) -> Caller:
    """Who the request is from. A stand-in: see below for a real one."""
    if user := connection.headers.get("x-user"):
        return Caller(User(id=user))
    raise Unauthorized("sign in")


async def invite(email: str) -> str:
    """A stand-in for Supabase Auth's admin API: the user's id is the email's local part."""
    return email.partition("@")[0]


async def people(ids):
    """A stand-in for ``SupabasePeople``: everyone's email is their id at example.com."""
    return {each: f"{each}@example.com" for each in ids}


app = FastAPI()
app.include_router(
    portalr_router(
        resolve_actor=resolve_actor,
        grants=grants,
        readers=Readers(),
        invite=invite,
        site=Site(supabase_url="https://project.supabase.co", supabase_key="sb_publishable_x"),
        people=people,
    ),
    prefix="/portalr",
)

resolve_actor here trusts an x-user header, which is fine for a test and for nothing else. A real one verifies the access token Supabase Auth issued, from the Authorization: Bearer header, or from the access_token query on a WebSocket, which a browser opens without headers. Supabase now signs tokens with an asymmetric key (ES256 by default) that it publishes at a JWKS URL, so you verify against that, as stackr's template does in its auth.py (its AUTH_JWKS_URL setting):

import asyncio
import os

import jwt


def token_resolver(keys: jwt.PyJWKClient, audience: str = "authenticated"):
    """A ``resolve_actor`` that verifies Supabase's access tokens against its published keys."""

    async def resolve_actor(connection: HTTPConnection) -> Caller:
        scheme, _, token = connection.headers.get("authorization", "").partition(" ")
        if scheme.lower() != "bearer" and connection.scope["type"] == "websocket":
            token, scheme = connection.query_params.get("access_token", ""), "bearer"
        if scheme.lower() != "bearer" or not token:
            raise Unauthorized("sign in")
        try:
            # Fetching a key may call out to Supabase, so it runs in a thread.
            key = await asyncio.to_thread(keys.get_signing_key_from_jwt, token)
            claims = jwt.decode(
                token,
                key,
                algorithms=["ES256"],
                audience=audience,
                options={"require": ["exp", "sub", "aud"]},
            )
        except jwt.PyJWTError as error:
            raise Unauthorized("the access token is not valid") from error
        return Caller(User(id=claims["sub"]))

    return resolve_actor


# In your application: the key set, fetched once and cached.
keys = jwt.PyJWKClient(
    os.environ.get("AUTH_JWKS_URL", "https://project.supabase.co/auth/v1/.well-known/jwks.json"),
    cache_keys=True,
    lifespan=300,
)
resolve_actor = token_resolver(keys)

A test of this guide signs a token with a generated key and checks that this function accepts it, and refuses an expired one, one for another audience, and one signed by another key.

A project on the legacy JWT secret

A Supabase project that still signs with the legacy shared secret issues HS256 tokens: verify those with algorithms=["HS256"] and the secret (SUPABASE_JWT_SECRET, in the project's settings) in place of key. The template accepts either or both. Prefer moving the project to asymmetric keys.

A person's grantr id is their Supabase user's sub, an opaque UUID, and grantr holds no names. With people, the UIs show people by email, which they ask of GET /portalr/v1/people for the people the caller may see (ADR-0038; what portalr keeps from view says who sees whose). Without it, or for an id it can't name, they show the first eight characters of a long id, such as 3f2a91c4….

Site refuses a secret key: config.json is public, and a secret (sb_secret_… or a service_role JWT) bypasses row security. The invite and people functions are the only places the secret key lives, in your application.

What the router serves

Route Answers
/portalr/app/ and /portalr/admin/ The built UIs: hashed assets cached for good, the index for every client-side route, with a strict Content-Security-Policy
GET /portalr/config.json What the UIs need to know about your deployment, below
/portalr/v1/… portalr's own reads, its two commands, and, with people, the emails of people: the admin guide and the operators' runbook say what each is for

The UIs call the other routers directly, on the same origin and with the same token: a chat goes to artifactr's WebSocket, a rule to reflexr's REST, a grant to grantr's API. Nothing proxies them.

config.json

The UIs read this first. It is public, so it holds no secret:

Key Is
supabase.url Your Supabase project's URL, or a path on your own origin that leads to it
supabase.key Its publishable key
surfaces Where you mounted each router: artifactr (default /artifactr/v1), reflexr (default /reflexr/v1), grantr (default "": its paths carry /grantr/v1 themselves), portalr (default /portalr/v1) and relayr (default null: the application has no tracker; give the origin of relayr's tracker reads, "" on this one, and runs link to what they filed). Pass Surfaces(...) in Site if you mounted them elsewhere
readers Which tools are configured, by name: tempo, loki, prometheus, langfuse, litellm and grafana, each true or false
people Whether the router names people by email: true where people was passed
editors Each native file's app's hosted editor, by its integration, such as files:wordcraft: pass editors={...} in Site where you serve one on an origin of its own (ADR-0049). Empty by default
from httpx import ASGITransport, AsyncClient

async with AsyncClient(transport=ASGITransport(app=app), base_url="http://app") as client:
    config = (await client.get("/portalr/config.json")).json()
    assert sorted(config) == ["editors", "people", "readers", "supabase", "surfaces"]
    assert config["readers"]["tempo"] is False
    assert config["people"] is True

Name portalr's actions to grantr

portalr's catalog has three actions, which grantr's roles give out:

Action Held by For
portalr:read_telemetry A tenant's owner and admin Feedback, traces, logs, health
portalr:read_spend A tenant's owner and admin Spend and usage
portalr:read_deployment operator The tenant list, creating a tenant, and the tools' links

Pass catalogs=[ACTIONS, ...] to Grants, as above, along with artifactr's and reflexr's. A viewer or an editor of a workspace is not shown admin: it opens for a tenant's owners and admins, and for operators.

from grantr.core import TENANT

await grants.first_operator("root")
await grants.create_tenant("acme", owner="alice", by=User(id="root"))

assert await grants.check("acme", User(id="alice"), "portalr:read_telemetry", TENANT)
assert not await grants.check("acme", User(id="alice"), "portalr:read_deployment", TENANT)

Sign in

Open /portalr/app/ for the people's UI, or /portalr/admin/ for the admin UI. Both sign in through Supabase Auth with an email and a password, keep the session in the browser's localStorage, refresh the token, and send it with every request. Signing out ends the session in that browser alone. A request the server refuses as unsigned ends the session too, and the UI goes back to the sign-in page.

  • The app lists the tenants someone belongs to: none is "no access", one goes straight to it. Then it lists the workspaces they can open.
  • Admin opens on the tenant someone last used, or their only one; operators open on "All tenants"; someone with no tenant to administer is told there is no access.

Next: the app, the admin UI, and, for operators, the runbook. Read what portalr keeps from view before you give anyone admin.