App registration
How a new app gets wired into the rw3iss auth system. Every consumer — backend service, frontend SPA, mobile client, or internal tool — goes through this same flow, once.
For the broader picture (token lifecycle, multi-tenant model) see the auth-server architecture. This doc covers only the onboarding contract.
TL;DR
1. system_admin runs `ven apps create` (or POSTs to /admin/apps)2. New app sets two env vars: RW3ISS_APP_CODE + JWT_ACCESS_SECRET3. New app calls /auth/login with app_code; gets back a JWT scoped to that appThree steps total. Step 1 is one-time per app. Steps 2-3 are normal config + runtime.
Step 1 is easiest via the
venCLI — see the Apps command for the interactive form. The raw REST equivalent is documented in §2 below for reference.
1. Concepts in one paragraph each
App. A user-facing consumer of rw3iss auth — anything that initiates logins or holds access tokens. Identified by a stable code (e.g. marketplace-v2, release-manager). One row in the apps table. Owns its redirect-URL allowlist, opt-in/out of auto-grant, and the set of permission services it consumes. Required before tokens can be minted for it.
Service. A backend that owns and declares a slice of the permission catalog. Identified by a stable string (e.g. auction-api, billing). A service self-registers its permissions at boot via POST /admin/permissions/register; auth-server reconciles (upserts new, prunes removed). Most apps are 1:1 with a service of the same code. Pure-frontend apps may have no service at all and still work fine — they just don’t carry custom permissions.
user_apps membership. A row per (user, app) pair indicating the user is allowed into that app. Created either automatically on first login (auto_grant_on_signup: true) or explicitly by an admin (POST /admin/users/{userId}/apps/{appId}).
2. The registration step (one-time per app)
A system_admin creates the app row. Two paths — the CLI is the canonical one; the REST API is the underlying surface.
Path A — ven apps create (canonical)
Install once: npm install -g github:rw3iss/cli.
ven login # sign in as a system_admin userven apps create \ --code marketplace-v2 \ --name "Marketplace v2" \ --description "Public marketplace, browser SPA" \ --redirect-url https://marketplace-v2.ryanweiss.net/auth/callback \ --auth-method password --auth-method google \ --auto-grantOr run ven apps and pick + Register a new app for the interactive form (validates every field, prompts for permissions, prints the new app’s id + code on success).
Path B — direct REST
-
Get a system_admin access token. Log in via the demo at demo.auth.ryanweiss.net as a user with the
system_adminrole (today:demotest@ryanweiss.net). Copy the access token from the browser devtools (any authed request →Authorizationheader), or log in over the REST API and capture the token from the response. -
POST to
/admin/appswith the new app’s config:
POST /admin/appsAuthorization: Bearer <system-admin-token>Content-Type: application/json
{ "code": "marketplace-v2", "name": "Marketplace v2", "description": "Public marketplace, browser SPA", "allowed_redirect_urls": ["https://marketplace-v2.ryanweiss.net/auth/callback"], "service_codes": ["marketplace-v2"], // optional; defaults to [code] "auto_grant_on_signup": true, // optional; default false "registration_namespace": "default", // optional default pool; default "default" "read_namespaces": [], // optional other pools (login) "default_organization_id": "<org id>", // optional; org new users auto-join "default_role_code": "seller", // optional; org role for that membership (default org_member) "linked_app_codes": ["rw3iss-marketplace"], // optional; extra apps to also grant "status": "active"}Quick curl form:
curl -X POST https://auth.ryanweiss.net/api/v1/admin/apps \ -H "Authorization: Bearer $RW3ISS_ADMIN_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "code": "marketplace-v2", "name": "Marketplace v2", "allowed_redirect_urls": ["https://marketplace-v2.ryanweiss.net/auth/callback"], "auto_grant_on_signup": true }'Response:
{ "id": "9c1f...", "code": "marketplace-v2", "status": "active", "created_at": "..."}That’s the whole platform-side setup.
Editing after creation: PATCH /admin/apps/{appId} accepts every
field except the immutable code — including (since 2026-06) the
registration-policy fields frontend_url, allowed_email_domains,
allowed_auth_methods and default_organization_id ("" clears the
URL / org back to NULL), plus default_role_code + linked_app_codes. Mind
frontend_url’s blast radius: account emails (verify, reset, magic-link,
invitations) link to that origin.
Auto-provisioning: default role + linked apps
With auto_grant_on_signup: true, the first time a user touches the app
(register, first login, or JIT migration) the server provisions a full
entitlement set, idempotently:
- a
user_appsmembership for this app and eachlinked_app_codesentry (unknown codes are skipped with a warning, never fatal); - when
default_organization_idis set, an org membership withdefault_role_code— which must be an org-scoped role (platform rolessystem_admin/super_admin/base_userare refused →org_member).
default_role_code is validated at config time: POST/PATCH /admin/apps
rejects (400) a role that is set without a default_organization_id, or that
doesn’t exist / isn’t org-scoped — so a typo surfaces immediately instead of
silently degrading to org_member at login. linked_app_codes are not
existence-checked at config time (an app may be linked before its target
exists); unknown codes are skipped during provisioning.
Per-request override. A client may pass role_code and/or
linked_app_codes in the login or register body to override the app’s
defaults for that request (e.g. register a buyer vs. a seller through the same
app); the app config is the fallback. role_code is re-validated server-side
as an org-scoped role, so a client can never escalate to a platform role:
POST /auth/login{ "email": "...", "password": "...", "app_code": "globalsku", "role_code": "buyer", "linked_app_codes": ["rw3iss-marketplace"] }Webhooks
Apps can declare outbound webhooks fired on user.registered —
new-user creation through the app (never plain logins):
{ "webhooks": [ { "name": "Slack #signups", "url": "https://hooks.slack.com/services/T…/B…/x…", "events": ["user.registered"], "enabled": true } ]}Settable at create and PATCH. Delivery is async + best-effort (3
attempts, 5s timeout; registration never blocks on a webhook). The
JSON envelope carries the app, the new user (with pools), any org, the
complete registration body — password redacted, extra client
fields (referral codes, campaign tags, …) passed through verbatim —
and request context, plus an X-rw3iss-Event header. URLs under
hooks.slack.com automatically receive a Slack-formatted
{"text": …} summary instead of the raw envelope, so a Slack
incoming-webhook URL works as-is. The demo’s application editor shows
the exact sample payload under Webhooks → Sample payload.
What gets validated at registration
codeis unique, kebab-case, ≤100 chars.allowed_redirect_urlsis non-empty in production mode (refused by config validation).service_codesdefaults to[code]if omitted; entries are stable strings, not required to point at a service that exists yet (a service can register later).registration_namespace/read_namespacesentries are lower-case[a-z0-9_-], ≤100 chars. Omit both for the single shareddefaultpool.
User pools (optional)
By default every user lives in one global pool (default) and an app
reads + writes that pool. Apps that want segregated identity set
two fields (email is unique per pool, so the same address can be a
distinct user in two pools):
registration_namespace— the default pool (registration). New users registered through this app get it as their home pool (users.namespace). Single value; omit fordefault.read_namespaces— the other pools (login). Login matches an existing user by email across the default pool plus this set (home pool or membership tag), so an account already in one of those pools is reused, not duplicated. New registrants are also tagged into these pools (user_namespacesrows), so pool-scoped apps can address exactly that cohort.
Worked example — an app that registers into the shared pool but also recognizes users from two sibling apps:
{ "code": "claimleo", "registration_namespace": "default", "read_namespaces": ["claimleo", "wristleo"]}New emails → home pool default, tagged claimleo + wristleo;
existing claimleo- or wristleo-pool users authenticate without
re-registering; a user whose home pool isn’t in the login set simply
doesn’t exist to this app. The access token carries a namespace
claim for non-default home pools. Full semantics: the auth-server’s
docs/USER_POOLS.md.
Pools are administered at runtime (system_admin only): GET /admin/namespaces lists every pool with user counts and the apps
referencing it (pools with zero users are config-only), and
/admin/users/{id}/namespace(s) moves a user’s default pool (409 if
the email already exists in the target pool) or adds/removes tag
pools. The demo’s Applications & Services → User pools tab and the
admin user page expose all of this.
3. The consumer’s side (per environment)
The new app sets two env vars and calls the API. The shape of “calling the API” depends on the app type.
Required env
RW3ISS_AUTH_URL=https://auth.ryanweiss.netRW3ISS_APP_CODE=marketplace-v2JWT_ACCESS_SECRET=<shared with auth-server, ≥32 chars>The JWT secret is the same one the auth-server signs with; sharing it lets the consumer validate access tokens locally (HMAC signature) without a network hop per request.
Backend-only API (Node)
import { AuthClientModule } from '@rw3iss/auth-server-nest';
AuthClientModule.forRoot({ authUrl: process.env.RW3ISS_AUTH_URL, appCode: process.env.RW3ISS_APP_CODE, jwtSecret: process.env.JWT_ACCESS_SECRET,});
// In a controller:@UseGuards(AuthGuard)@Get('/me/orders')listOrders(@CurrentUser() user) { ... }The SDK validates the bearer token locally and asserts claims.app_id matches this app’s code. Any mismatch → 401.
Frontend SPA (Preact / React)
The browser SDK (@rw3iss/auth-client) is the canonical way to integrate. It handles app_code on every login, refresh-token rotation, BroadcastChannel cross-tab sync, and PKCE for SSO. See docs.auth.ryanweiss.net/auth-client/ for the full surface.
Minimal example:
import { createAuthClient } from '@rw3iss/auth-client';
const auth = createAuthClient({ apiBaseUrl: 'https://auth.ryanweiss.net/api/v1', appCode: 'marketplace-v2',});
await auth.login({ email, password });Mobile / server-to-server
Same HTTP contract as the SPA. Until language-specific SDKs exist, consumers do direct REST calls. The wire protocol is stable.
4. What’s in the issued JWT
When a user logs in with app_code: "marketplace-v2", the access token carries:
| Claim | Value |
|---|---|
app_id | the marketplace-v2 UUID |
app_code | "marketplace-v2" |
uid | user UUID |
email, first_name, last_name | user identity |
org_id, org_slug | optional org context |
roles | role codes (e.g. ["base_user"]) |
permissions | union of permissions across services in apps.service_codes, plus all core permissions |
tv | per-user token version (for logout-everywhere — see AUDIT §1.10) |
iss, aud, exp, nbf, iat, jti | standard JWT claims |
core permissions (auth-server’s own slice — users:read_self, users:update_self, etc.) are always included regardless of which app the token is scoped to. They’re the bedrock catalog every user gets.
If the app’s service_codes list services that haven’t registered any permissions yet, the permissions array is just core + whatever role-based perms the user has. That’s fine — the app simply doesn’t carry custom permissions yet.
5. Adding custom permissions (optional, later)
When the new app wants to introduce its own permissions (e.g. listings:create, bids:place):
- The app’s backend calls on boot:
POST /admin/permissions/registerAuthorization: Bearer <service-account-or-system-admin>{"service": "marketplace-v2","permissions": [{ "code": "listings:create", "name": "Create listing", "resource": "listings", "action": "create" },{ "code": "bids:place", "name": "Place bid", "resource": "bids", "action": "place" }]}
- Auth reconciles: upserts these, prunes any previously-declared marketplace-v2 permissions not in the list. Idempotent — safe to call every boot.
- A system_admin assigns the new permissions to roles via the existing role-permission API.
- Users with those roles, logging into marketplace-v2, see the permissions in their JWT.
This step is purely opt-in. Apps that don’t need custom permissions never call this.
6. Granting users access
Two modes, set on the apps row:
auto_grant_on_signup: true— every user who logs into the app gets auser_appsrow on first attempt. Use for public consumer apps where any rw3iss user can enter.auto_grant_on_signup: false(default) — users must be explicitly granted:Use for internal tools and paid-tier apps.POST /admin/users/{userId}/apps/{appId}Authorization: Bearer <system-admin>
A user can be revoked from one app without affecting their session in others:
DELETE /admin/users/{userId}/apps/{appId}This sets the user_apps.status to revoked and bumps the user’s token version, so their currently-valid access token for that app stops validating on the next request.
7. What’s required vs. optional, at a glance
| Step | Required? | Who | When |
|---|---|---|---|
| Create the app row | Yes | system_admin (POST /admin/apps) | Once per app |
Set RW3ISS_APP_CODE + JWT_ACCESS_SECRET env | Yes | the new app | Per environment |
Pass app_code on /auth/login | Yes | the new app | Every login |
Register a service catalog (permissions/register) | No | the new app’s backend | Boot, only if the app owns custom permissions |
Grant users via user_apps | No (if auto_grant_on_signup) | system_admin or auto | First login or explicit grant |
| Configure SSO providers | No | system_admin | Only if the app accepts third-party login |
8. Common pitfalls
- Forgetting
app_codeon login. Returns either a 400 (whenapp_codeis required globally) or a token without anapp_idclaim, depending on config. Downstream services that enforceclaims.app_id == self.app_idwill reject the token. - Redirect URL mismatch.
https://marketplace-v2.ryanweiss.net/auth/callbackregistered, request goes tohttps://marketplace-v2.ryanweiss.net/auth/callback/(trailing slash) — strict match, fails. Use the trailing*wildcard if you need a path prefix. - JWT secret drift. Auth-server rotates
JWT_ACCESS_SECRET, the consumer doesn’t pick up the change. Validation fails for every request. Rotations need to be coordinated; the dual-secret rotation feature on the Phase C roadmap will fix this. - Stale
service_codesafter splitting a backend. If marketplace-v2’s billing logic gets pulled into a separatebillingservice, updateapps.service_codesto["marketplace-v2", "billing"]. Otherwise marketplace-v2 users lose access to billing permissions in their JWT.
9. References
How_It_Works.md— token lifecycle, multi-tenant model, validation flowDevelopment.md— local setup, migrations, integration testsAUDIT-2026-05-11.md§1.13 (redirect allowlist), §8.3-8.7 (app scoping rationale)