Working with the Shell
The Shell owns identity — users, organizations, roles — hosts your UI, and runs the event bus. A satellite plugs into all of that without ever touching the Shell's database. Here's everything a satellite gets from the Shell, and how to use each piece.
What the Shell gives you
| From the Shell | How your satellite reaches it |
|---|---|
| The signed-in user (SSO) | The Shell broadcasts its token; your API exchanges it for a service token — no login code. |
| The user's org (the tenant) | Carried in the token as organizationId — scope every query by it. |
| Roles & permissions | Carried in the token; you enforce them locally. |
| The user directory | Never its database — subscribe to its events or call it over RPC. |
| A place in its UI | Module Federation — you expose ./ShellEntry and the Shell mounts you at /<slug> (Frontend). |
| A spot on the launchpad | veni register adds your running app to the Shell. |
Identity and the org context ship with veni enable api; local users, roles and permissions with veni enable database.
Exchange a token
Your API never trusts a Shell token directly. It exchanges it for its own service token at POST /api/auth/exchange:
POST /api/auth/exchange
Content-Type: application/json
{ "token": "<the Shell's RS256 JWT>" }// 200 OK
{ "access_token": "<your HS256 service JWT>", "expires_in": 3600, "token_type": "Bearer" }
// 401 → { "error": "...", "message": "..." }Behind that one call, auth.service.ts:
- Verifies the Shell JWT against the Shell's JWKS (RS256) — no shared secret.
- Provisions a local user — keyed by the Shell
sub, or linked to a pre-created user by email on first sign-in — and assigns default roles. - Mints an HS256 service JWT carrying local user data (see below), valid 1h.
Steps 2–3 are the DB-backed flow (after veni enable database). The SSO-only variant that ships with veni enable api skips provisioning and mints straight from the verified Shell claims — so sub stays the Shell subject, and roles / permissions are whatever the Shell token carried, not your own.
You rarely call this by hand
On the UI, the generated token-exchange.ts does it for you and api-client.ts re-exchanges once on a 401. You call /exchange directly only for server-to-server or scripted access.
What's in the token
The service token is yours, not the Shell's — its claims describe the local user:
{
"sub": "8f3c… (LOCAL user UUID, not the Shell sub)",
"shellSub": "a12b… (original Shell sub — for tracing)",
"email": "you@acme.com",
"name": "Ada Lovelace",
"organizationId": "org_123",
"roles": ["org-admin"],
"permissions": ["orders:read", "orders:write"],
"iat": 1700000000, "exp": 1700003600
}| Claim | What it is |
|---|---|
sub | Local user UUID — the FK target for created_by / updated_by. Not the Shell sub. |
shellSub | The original Shell subject, kept for tracing / admin lookups. |
email, name | Copied from the local user record. |
organizationId | The tenant — carried over from the Shell token. Scope every query by it (see Multi-tenancy). |
roles, permissions | The user's local roles and resource:action permission codes — resolved from your DB at exchange time. |
requireAuth(c) hands you this as user at the top of a handler. The sub claim above is surfaced as user.userId — use that for ownership and FKs, user.organizationId for tenant scope, user.roles / user.permissions for checks.
That's the DB-backed token. SSO-only (before veni enable database) has no local user, so sub is the Shell subject and roles / permissions are the Shell's own — there's nothing to FK against yet.
Reach the Shell's users
A satellite never reads another system's database — the Shell's user table included. To show real people (an invite picker, an "assigned to" field), you reach the directory one of two ways — and the same two channels reach any other satellite:
Asynchronous — events (keep a copy). The platform bus is Redis Streams. Subscribe to the Shell's shell:stream:users under your own consumer group and maintain a local people read-model, so the picker is instant and still works when the Shell is down. Publish your own domain events with XADD to <app>:stream:*. The scaffold ships no producer/consumer — you add it; the full pattern (consumer groups, XAUTOCLAIM, ack, backfill) is in Build a satellite → Integration.
Synchronous — Connect RPC (ask now). For one fresh answer from another satellite, call over plain HTTP POST to {baseUrl}/{package}.{Service}/{Method} with a JSON body (Protobuf-over-JSON). The scaffold's shell-auth-gateway.client.ts is a working example; the shape:
const url = `${targetBaseUrl}/orders.v1.OrdersService/GetOrder`
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' }, // + auth the target requires
body: JSON.stringify({ id }),
signal: AbortSignal.timeout(10_000), // always bound the call
})The Shell itself answers no such call. Its Connect handler serves one path — auth.v1.AuthGateway/ExchangeForService — and returns everything else to the router, so there is no ListUsers to ask. Its directory is REST: GET /api/users, gated on shell:user:list (admin / system-admin / org-admin only) and scoped to the caller's organization. Use it to seed your read model once; use the stream to keep it fresh.
To be called by others, expose your own service: add an impl under src/grpc/ and register it on the ConnectRouter (see the generated grpc/auth-service.impl.ts + grpc/connect-handler.ts), mounted at /{package}.{Service}/*. Contracts are vendored into src/grpc-contracts/gen/ so you build against a pinned copy.
Authorization
The Shell hands you the user's roles and permissions in the token — resolved from your own database at exchange time (see What's in the token). Deciding what they allow, and what you enforce it with, is your app's call.
The guards
Import them from utils/auth-guards — never re-implement them in a controller:
| Guard | Throws | Use it for |
|---|---|---|
requireAuth(c) | 401 | Any signed-in user. Returns the user; every other guard builds on it. |
requirePermission(c, code) | 401 / 403 | The normal case — a resource:action code. |
requireAnyPermission(c, codes) | 401 / 403 | Any one of several codes (OR). |
requireRole(c, role) / requireAdmin(c) | 401 / 403 | A named role, where a code would be overkill. |
requireOwnerOrAdmin(c, ownerId) | 401 / 403 | Row-level ownership — the creator, or an admin. |
They all return the user, so the common shape is one line:
@get({ configs: { path: '/', authenticate: { strategies: ['jwt'] }, // 1. verify the token
responses: { [HTTP.ResultCodes.RS_2.Ok]: { description: 'Orders' } } } })
async list(c: Context) {
const user = requirePermission(c, 'orders:read') // 2. authorize (401/403)
const data = await this.orders.list(user.organizationId)
return c.json({ data })
}To read the inputs without asserting anything — for an optional-auth route, or to feed your own policy engine — the same module exports getAuthUser(c), getRoles(c), getPermissions(c), and getLocalUserId(user).
Both lines are load-bearing — there is no default-deny
authenticate is opt-in per route: omit it and the endpoint is public. Keep it but skip the guard and the request is authenticated, yet you get no user and no audit actor. Every protected handler needs both.
Seeding your codes, and escalating to a policy engine (Casbin) when flat resource:action codes stop fitting, are both walked through in Build → Authorization. One thing to keep in mind either way:
Claims are a snapshot, not live state
roles and permissions are resolved once, at token exchange, and the service token lives an hour. Revoke a role and the stale token still carries it until it expires. Identity (userId, organizationId) is stable; the grants are not — when a revoke must bite immediately, check live state instead of claims.
Multi-tenancy
Every token carries an organizationId — the tenant the Shell put the user in. Two things make an app multi-tenant:
Scope reads and writes by the org. Pass user.organizationId into every query so one tenant never sees another's rows:
const orgId = requireAuth(c).organizationId
const data = await this.service.list(orgId, { limit, offset })Stamp who did it. Spread the auditable mixin into a table and extend AuditedRepository; the acting user (set by requireAuth) is merged into every INSERT / UPDATE — no actor passed by hand. Skip the guard, or extend the plain DefaultCRUDRepository, and the columns land NULL silently. The full chain is in Build → the repository.
Going fully isolated
The token's organizationId and the audit columns are the baseline. For hard isolation — an organization_id column on every table, a TenantRepository that stamps it, and Postgres row-level security as a backstop — follow the tenant-isolation pattern, walked through in Multi-tenancy.
Common snags
| Symptom | Fix |
|---|---|
| A route is reachable without a token | No guard on the handler — add requireAuth (or @authorize) at the top. |
created_by / updated_by land NULL | The write skipped a guard, or the repo extends DefaultCRUDRepository — extend AuditedRepository and guard the write. |
| App shows "Not signed in" on a cold load, fine after a refresh | The token-broadcast race — the remote must call requestToken() on mount. |
| A revoked role still works | Token claims are a 1-hour snapshot — for an immediate revoke, check live DB state, not user.roles. |
| One tenant sees another's rows | A query wasn't scoped by organizationId — pass it into every read and write. |
See also
- Shell → Overview — what the Shell is and how it's built.
- Build a satellite — put this to work, end to end.