Token format
A compact JWT signed with HMAC-SHA256 (api-events/src/auth/delegationToken.ts).
Verification rejects anything else: a different header or extra header keys (
alg: none, HS512, jku), extra or missing claims, a purpose outside the allowlist, an iat more than 60 seconds in the future, an expired token, a lifetime over 600 seconds, non-canonical base64url and a bad signature (compared in constant time). mintDelegationToken caps the requested TTL at 600 seconds.
Authentication
clerkAuthHandler checks the header kid first. A deleg-v1 token never reaches Clerk. After the signature and claims pass, hasActiveDelegationGrant (api-events/src/db/delegationGrant.ts) confirms in one query that:
subis an activeOrgMemberoforg;- the organization is not scheduled for deletion;
evtbelongs toorgand is not scheduled for deletion.
authMethod: 'delegation', orgID: org and delegation: { eventId, purpose, jti }. It never carries platformAdmin or an org role. Clerk-only helpers (resolveClerkUserId, resolveCallerOrgId, requireClerkUserId, the service-console switches) refuse it, and provisionMiddleware skips it.
Route enforcement
delegationScopeMiddleware runs on every endpoint and acts only when authMethod is delegation. It looks up the endpoint in api-events/src/systems/shared/routeAuthz.generated.ts, which scripts/authz/generate-route-authz.mjs builds from each route’s openapiRoute(..., { authz }) declaration. It denies by default with a Go-envelope 403 (code: FORBIDDEN).
The allowlist comes first, and it is the main boundary. A route under the gig can still name another object in its body:
save-as-template on gig A accepts template B and overwrites it. So a token reaches only the endpoints listed for its purpose. For slack-channel (SLACK_CHANNEL_ENDPOINTS) these are reads of the gig, edits to the gig’s details, run of show, checklist, contacts, show notes and links, and the travel of the gig’s project. Deletes, cancels, duplicates, templates, invitations and settlement are not on it. Before adding an endpoint, check that its handler confirms every id in the body or query belongs to the gig.
Three allowlisted handlers also refuse a delegated caller (isDelegatedRequest(), read from the audit context) where the body could reach another record:
replaceRunOfShowkeeps documents already attached to the gig’s run of show but rejects new ones, because attaching any org document returns a signed URL for it.eventsByIdrejects a change tovenue_id,group_idortour_id, and anyplace_idorplace_details, because a new venue’s access code and notes would come back on the next read.createEventChecklistrejects template mode, which copies another template’s contents.
test:route-authz fails when the committed table drifts from the declarations.
Audit
A delegated request is recorded withactor_type = 'CHECKAI', source = 'CHECKAI' and actor_user_id = the member (see Audit actor model). The request context carries delegation: { via: 'slack', jti, event_id }. Explicit recordAudit rows merge it into metadata.delegation, and the app.audit_ctx setting carries it for the trigger floor. No schema change was needed.
Secret and rotation
The signing key is the Secret Manager secretDelegationTokenSecret, mounted as DELEGATION_TOKEN_SECRET (enable_delegation_token_secret, on in dev, qa and prod). A value that is unset, PLACEHOLDER or shorter than 32 characters is treated as missing: minting throws and every token is rejected.
To rotate:
- Generate a new value:
openssl rand -base64 48. - Add it as a new version:
gcloud secrets versions add <env>-DelegationTokenSecret --data-file=-, or paste it in the service console. - Roll the Cloud Run revision so api-events reads the new version.
mintDelegationToken yet. The CheckAI Slack flow that mints and sends these tokens is follow-up work.