Authentication & authorization
Every /v1/* route runs the requireTenant middleware (api/src/middleware/auth.ts) before any handler. It establishes who the caller is and which tenant they act for; the tenant is never taken from a request header or body.
Credentials accepted
requireTenant inspects the request and branches between two verification paths on the IdentityProvider port:
- API key — a
Authorization: Bearer voc_sk_…token, or anX-API-Keyheader. Routed toidentity.verifyApiKey(key). - Session — anything else: a Clerk session JWT presented as
Authorization: Bearer <jwt>. Routed toidentity.verify(c.req.raw).
const authz = c.req.header('authorization');
const bearer = authz?.startsWith('Bearer ') ? authz.slice(7).trim() : undefined;
const apiKey = bearer?.startsWith('voc_sk_') ? bearer : c.req.header('x-api-key');
const result = apiKey && identity.verifyApiKey
? await identity.verifyApiKey(apiKey)
: await identity.verify(c.req.raw);
On failure the middleware returns 401 { error: { code: 'unauthenticated', … } }. On success it reads clientAccountId, userId, email, roles, and permissions off the principal and pins them onto c.var.tenant. If the principal authenticated but has no tenant membership (e.g. a brand-new user who hasn't created a workspace), it returns 403 forbidden so the SPA can prompt workspace creation.
Sessions via Clerk
Sign-in is handled entirely by Clerk from the SPA (email code, Google, SSO — configured in the Clerk dashboard, not in this codebase); there is no first-party /auth route. The API only ever verifies the resulting credential — identity does not live in this database, which is what keeps the application's Postgres portable. The makeClerkIdentity adapter (packages/adapters/src/clerk-auth.ts) verifies the Clerk session JWT with the official @clerk/backend SDK:
verifyToken(jwt, …)validates the token against Clerk's JWKS (fetched and cached by the SDK; fully networkless when aCLERK_JWT_KEYPEM is supplied), enforcing signature and expiry. OptionalCLERK_AUTHORIZED_PARTIESconstrains the acceptedazp(SPA) origins.- The active organisation, role and user come from the verified claims:
org_id/org_role/org_slug(the adapter also accepts the v2 compactoclaim), andsub(the Clerk user id).
A Clerk Organization (org_id) maps 1:1 to a tenant.client_account via auth_org_id (now a text column holding the org_… id); on first sighting the tenant is provisioned by an idempotent upsert. RLS still keys on the numeric client_account_id, so nothing downstream changes. A principal authenticated without an active organisation (e.g. a brand-new user who hasn't created or selected a workspace) is returned with no tenant, so requireTenant returns 403 and the SPA prompts workspace creation.
Permissions and can()
A principal's effective permissions are the union of their standard role (derived from the Clerk org_role — org:admin / org:member, with the org: prefix stripped — plus the global-admin flag) and any assigned custom roles (identity.member_role, keyed on the Clerk user id). The catalogue and standard roles live in packages/core/src/permissions.ts.
Tenant mutations are gated in the route with the helpers in api/src/middleware/permissions.ts:
if (!can(c.var.tenant, 'integrations.manage')) return c.json(forbidden('integrations.manage'), 403);
can(ctx, perm)is a membership test againstctx.permissions.forbidden(perm)returns the standard{ error: { code: 'forbidden', message: 'Missing permission: …' } }body, paired with HTTP403.isSystemAdmin(ctx)is true only for the platform (cross-tenant) admin — surfaced as theoperatorrole — and gates system-wide surfaces such as feature flags.
The permission catalogue (resource.action keys):
| Category | Keys |
|---|---|
| Insight | dashboard.view, reports.view |
| Responses | responses.view, responses.edit, responses.delete, responses.export |
| Verbatim | verbatim.view, verbatim.code |
| Surveys | surveys.view, surveys.edit, surveys.distribute |
| Alerts / Leads | alerts.view, alerts.manage, leads.view, leads.manage |
| Engagement | badges.manage |
| Admin | agents.manage, users.manage, roles.manage, account.manage, compliance.manage, gdpr.manage, integrations.manage, feedback.manage |
Standard roles: system_admin (*, global), client_admin (*), manager, analyst, agent. API keys carry their permissions via scopes — see API keys.
IP allowlist
After requireTenant, the ipAllowlist middleware (api/src/middleware/ip-allowlist.ts) enforces an optional per-tenant allowlist read from account settings. An empty list (the default) is a no-op. When set, the client IP (from cf-connecting-ip, falling back to the first x-forwarded-for hop) must match an entry by exact value, a trailing-dot prefix (203.0.113.), or *; otherwise 403 forbidden. Full CIDR matching is a noted refinement.