> ## Documentation Index
> Fetch the complete documentation index at: https://docs.soundchecklive.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Delegation tokens

> Short-lived, gig-scoped bearer tokens that let CheckAI call the api-events REST API as one org member, for one gig.

A delegation token lets CheckAI act in a Slack gig channel **as a known org member** without holding that member's Clerk session. The token is signed by api-events, lives at most ten minutes and only works on routes that touch its one gig. Every request still runs the handler's normal role check for that member, so a token never grants more than the member already has.

## Token format

A compact JWT signed with HMAC-SHA256 (`api-events/src/auth/delegationToken.ts`).

| Part | Value |
| - | - |
| header | exactly `{ "alg": "HS256", "typ": "JWT", "kid": "deleg-v1" }` |
| `iss` | `soundcheck-delegation` |
| `aud` | `soundcheck-api` |
| `sub` | the member's user id (`User.id`, the Clerk subject) |
| `org` | the organization id |
| `evt` | the gig (`Event.id`) |
| `pur` | the purpose; only `slack-channel` today |
| `jti` | a random id, recorded in the audit log |
| `iat`, `exp` | whole seconds; `exp - iat` is at most 600 |

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:

* `sub` is an **active** `OrgMember` of `org`;
* the organization is not scheduled for deletion;
* `evt` belongs to `org` and is not scheduled for deletion.

Any miss answers 401, so removing the member or deleting the gig revokes outstanding tokens at once. A database failure answers 503. The auth data carries `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`).

| Route | Delegated caller may |
| - | - |
| `/mcp`, `/public/mcp`, `/webhooks` | nothing |
| an endpoint missing from the purpose's allowlist (`DELEGATION_ALLOWED_ENDPOINTS` in `delegationScope.ts`) | nothing |
| no `authz` declaration (still in `scripts/authz/baseline.json`) | nothing |
| `public`, `service-oidc`, `provider-signature` (webhooks, verified by the provider's signature), `org-member-or-event-invitee`, `org-role`, `platform-admin`, `recipient`, `signed-in`, `self` | nothing (`signed-in` reads such as `GET /api/v1/events` reach beyond the gig) |
| a method the route does not declare | nothing |
| any path param after `/events/`, named `eventId` / `event_id`, or used as the org source | only the token's gig; any other value is denied first |
| `org-member`, `org-permission`, `event-participant` with the org from an event param | every declared method on the token's gig |
| org from an org path param, and the path also names the token's gig | every declared method, when the org param is the token's org |
| org from an org path param, with a `projectId` / `project_id` param | every declared method, when the org param is the token's org and the token's gig belongs to that project |
| org from an org path param, no gig or project param | `GET` and `HEAD` in the token's org |
| org from a query, lead, setlist, song, availability-request or body param | nothing |

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:

* `replaceRunOfShow` keeps 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.
* `eventsById` rejects a change to `venue_id`, `group_id` or `tour_id`, and any `place_id` or `place_details`, because a new venue's access code and notes would come back on the next read.
* `createEventChecklist` rejects template mode, which copies another template's contents.

Accepted: a delegated caller can assign an active org member to one of the gig's tasks, or add one as a passenger or hotel guest on the project's travel. These write only to the gig or its project, and the member is already visible to the poster. Hotels come from the global hotel catalog.

Path params are compared as raw path segments, so an encoded id never matches the gig. To make a new route reachable, declare its authz to match what the handler enforces and regenerate the table:

```bash theme={null}
node scripts/authz/generate-route-authz.mjs
cd api-events && npm run check:route-authz && npm run test:route-authz
```

`test:route-authz` fails when the committed table drifts from the declarations.

## Audit

A delegated request is recorded with `actor_type = 'CHECKAI'`, `source = 'CHECKAI'` and `actor_user_id` = the member (see [Audit actor model](https://docs.soundchecklive.io/engineering/backend/audit-actor-model.md)). 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 secret `DelegationTokenSecret`, 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:

1. Generate a new value: `openssl rand -base64 48`.
2. Add it as a new version: `gcloud secrets versions add <env>-DelegationTokenSecret --data-file=-`, or paste it in the service console.
3. Roll the Cloud Run revision so api-events reads the new version.

There is one active key. Tokens signed with the old key fail with 401 from the moment the new revision serves traffic. A token lives at most ten minutes, so the caller only has to mint a fresh one and retry.

Nothing calls `mintDelegationToken` yet. The CheckAI Slack flow that mints and sends these tokens is follow-up work.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.