03 / Develop · Working with the Shell

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 ShellHow 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 & permissionsCarried in the token; you enforce them locally.
The user directoryNever its database — subscribe to its events or call it over RPC.
A place in its UIModule Federation — you expose ./ShellEntry and the Shell mounts you at /<slug> (Frontend).
A spot on the launchpadveni 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:

http
POST /api/auth/exchange
Content-Type: application/json

{ "token": "<the Shell's RS256 JWT>" }
json
// 200 OK
{ "access_token": "<your HS256 service JWT>", "expires_in": 3600, "token_type": "Bearer" }
// 401 → { "error": "...", "message": "..." }

Behind that one call, auth.service.ts:

  1. Verifies the Shell JWT against the Shell's JWKS (RS256) — no shared secret.
  2. 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.
  3. 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:

json
{
  "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
}
ClaimWhat it is
subLocal user UUID — the FK target for created_by / updated_by. Not the Shell sub.
shellSubThe original Shell subject, kept for tracing / admin lookups.
email, nameCopied from the local user record.
organizationIdThe tenant — carried over from the Shell token. Scope every query by it (see Multi-tenancy).
roles, permissionsThe 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:

ts
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:

GuardThrowsUse it for
requireAuth(c)401Any signed-in user. Returns the user; every other guard builds on it.
requirePermission(c, code)401 / 403The normal case — a resource:action code.
requireAnyPermission(c, codes)401 / 403Any one of several codes (OR).
requireRole(c, role) / requireAdmin(c)401 / 403A named role, where a code would be overkill.
requireOwnerOrAdmin(c, ownerId)401 / 403Row-level ownership — the creator, or an admin.

They all return the user, so the common shape is one line:

ts
@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:

ts
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 ​

SymptomFix
A route is reachable without a tokenNo guard on the handler — add requireAuth (or @authorize) at the top.
created_by / updated_by land NULLThe 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 refreshThe token-broadcast race — the remote must call requestToken() on mount.
A revoked role still worksToken claims are a 1-hour snapshot — for an immediate revoke, check live DB state, not user.roles.
One tenant sees another's rowsA query wasn't scoped by organizationId — pass it into every read and write.

See also ​