Skip to main content

API-key minting (local e2e)

Scope: getting a raw opl_sdk_… / opl_api_… key against the local backend so SDK live-e2e suites can run. Does not cover SDK-side test commands — those live in each SDK repo's docs/test.md.

Recipe verified as recorded (legacy TESTS-NOTES: node 2026-09-20, android 2026-09-23, first-run green). Re-verify on next use.

1. Unlock api_access on the demo org​

/portal/api-keys returns {locked:true} when the org's plan lacks api_access (SOLO and below). The seeded Demo Org is starter → locked. Fast unlock (the seed resets it):

psql "$DATABASE_URL" -tAc "UPDATE \"Organization\" SET plan='growth' WHERE \"clerkOrgId\"='org_3GX2Hsem68kleDYAPyaqPfmMqrQ';"

2. Mint an org-active token (Backend API directly)​

The Clerk CLI POST /sessions 404s — use the Backend API:

  1. Demo user id from the DB (the column is clerkUserId — "clerkId" does not exist): SELECT "clerkUserId" FROM "User" WHERE email='[email protected]';
  2. POST /v1/sessions, then POST /v1/sessions/:id/tokens with organization_id=org_3GX2Hsem68kleDYAPyaqPfmMqrQ.
  3. Token lives ~60s — mint and use immediately.

3. Regenerate the key — raw key ONLY in the response​

POST /portal/api-keys/SERVER/regenerate (or /CLIENT/regenerate) with that (token) — capture the raw keys for the checks below:

SRV=$(curl -s -X POST localhost:3000/portal/api-keys/SERVER/regenerate \
-H "Authorization: Bearer $TOK" | node -pe 'JSON.parse(require("fs").readFileSync(0)).rawKey')
SDK=$(curl -s -X POST localhost:3000/portal/api-keys/CLIENT/regenerate \
-H "Authorization: Bearer $TOK" | node -pe 'JSON.parse(require("fs").readFileSync(0)).rawKey')

The raw key appears only in the response's .rawKey field; GET /portal/api-keys shows masked displayKey forever after.

4. Prove the keys (tier enforcement)​

curl -s localhost:3000/portal/api-keys -H "Authorization: Bearer $TOK"
# → {sdkKey:{prefix:"opl_sdk_",displayKey:…},serverKey:{…},locked:false}
# (plan without api_access → {sdkKey:null,serverKey:null,locked:true})

curl -s -o /dev/null -w '%{http_code}\n' -X POST localhost:3000/sdk/session \
-H "Authorization: Bearer $SDK" -H 'Content-Type: application/json' \
-d '{"deviceId":"rt-1"}' # → 200 (CLIENT key on CLIENT surface)
curl -s -o /dev/null -w '%{http_code}\n' localhost:3000/links \
-H "Authorization: Bearer $SRV" # → 200 (SERVER key on link CRUD)
curl -s -o /dev/null -w '%{http_code}\n' localhost:3000/links \
-H "Authorization: Bearer $SDK" # → 403 (valid key, wrong tier)
curl -s -o /dev/null -w '%{http_code}\n' -X POST localhost:3000/portal/api-keys/NOPE/regenerate \
-H "Authorization: Bearer $TOK" # → 400 (unknown tier; consumes a regen point)

Design detail for these endpoints (grace window, rotation, D-1 retrievability): Authentication architecture.

Gotchas​

  • POST /links returns the raw row + url but NO domain object; only list/get carry domain; PATCH returns a bare row.
  • The /links 429 preset (100 req/s) fires no rate-limit headers — to trip it, burst in parallel (~140 parallel PATCHes yields ~12×429); a sequential loop never trips it.