App registration
How a new app gets wired into the Vendidit 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: VENDIDIT_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 Vendidit 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:Vendidit/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.vendidit.com/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 auth-demo.vendidit.com as a user with the
system_adminrole (today:demotest@vendidit.com). 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.vendidit.com/auth/callback"], "service_codes": ["marketplace-v2"], // optional; defaults to [code] "auto_grant_on_signup": true, // optional; default false "status": "active"}Quick curl form:
curl -X POST https://new-auth.vendidit.com/api/v1/admin/apps \ -H "Authorization: Bearer $VENDIDIT_ADMIN_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "code": "marketplace-v2", "name": "Marketplace v2", "allowed_redirect_urls": ["https://marketplace-v2.vendidit.com/auth/callback"], "auto_grant_on_signup": true }'Response:
{ "id": "9c1f...", "code": "marketplace-v2", "status": "active", "created_at": "..."}That’s the whole platform-side setup.
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).
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
VENDIDIT_AUTH_URL=https://auth.vendidit.comVENDIDIT_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 '@vendidit/auth-server-nest';
AuthClientModule.forRoot({ authUrl: process.env.VENDIDIT_AUTH_URL, appCode: process.env.VENDIDIT_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 (@vendidit/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.vendidit.com/auth-client/ for the full surface.
Minimal example:
import { createAuthClient } from '@vendidit/auth-client';
const auth = createAuthClient({ apiBaseUrl: 'https://new-auth.vendidit.com/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 Vendidit 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 VENDIDIT_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.vendidit.com/auth/callbackregistered, request goes tohttps://marketplace-v2.vendidit.com/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)