import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts' import * as ApiKeys from '../../../ApiKeys.js' import * as Auth from '../../../internal/Auth.js' import * as OpenApi from '../../../internal/OpenApi.js' import * as Scope from '../../../Scope.js' import * as TestApp from '../../../../test/App.js' import * as Memberships from '../../../db/tables/memberships.js' import * as Keys from './api-keys.js' import * as Me from './me.js' import * as Orgs from './orgs.js' import * as Projects from './projects.js' /** Origin pinned for SIWE domain binding; Hono test requests use this host. */ const origin = 'http://localhost' /** Super admin secret configured on the test app. */ const secret = 'tempo:sk:c2c2c2c2c2c2c2c2c2c2c2c2c2c2c2c2c2c2c2c2c2c2c2c2' /** RequestInit for a JSON mutation carrying the session cookie. */ function json(method: 'PATCH' | 'POST', cookie: string, body: unknown) { return { body: JSON.stringify(body), headers: { 'content-type': 'application/json', cookie }, method, } } /** RequestInit for a JSON mutation authenticated as the super admin. */ function superAdminJson(method: 'PATCH' | 'POST', body: unknown) { return { body: JSON.stringify(body), headers: { 'content-type': 'application/json', 'tempo-api-key': secret }, method, } } // Resolve-cache disabled so revocation assertions observe the store directly. type State = ReturnType function createApp( options: { db?: ReturnType kv?: { store: State } scopes?: readonly Scope.Entry[] } = {}, ) { return TestApp.create({ auth: { cache: false, superAdmin: { secret } }, db: options.db ?? TestApp.database(), ...(options.kv ? { kv: options.kv } : {}), ...(options.scopes ? { scopes: options.scopes } : {}), session: { wallet: { origin } }, }) } function failRecordDeletes(store: State) { const delete_ = store.delete.bind(store) store.delete = async (key) => { if (key.startsWith('apikey:')) throw new Error('KV delete failed.') await delete_(key) } } /** Signs in a fresh scripted account; returns its session cookie. */ async function session(app: TestApp.signIn.App) { const account = privateKeyToAccount(generatePrivateKey()) const { cookie } = await TestApp.signIn(app, account) return cookie! } /** Creates an org and a project owned by the session. */ async function fixture(app: ReturnType, cookie: string) { const org = await TestApp.json( await app.request('/v1/orgs', json('POST', cookie, { name: 'Acme' })), Orgs.schema.Organization, ) const project = await TestApp.json( await app.request(`/v1/orgs/${org.id}/projects`, json('POST', cookie, { name: 'Checkout' })), Projects.schema.Project, ) return { org, project } } test('publishes generator-ready OpenAPI contracts', async () => { const app = createApp({ kv: { store: TestApp.kvStore() } }) const spec = await (await app.request('/openapi.json')).json() const collection = spec.paths['/v1/orgs/{orgId}/projects/{projectId}/api-keys'] const detail = spec.paths['/v1/orgs/{orgId}/projects/{projectId}/api-keys/{keyId}'] const organization = spec.paths['/v1/orgs/{orgId}/api-keys'] const rotation = spec.paths['/v1/orgs/{orgId}/projects/{projectId}/api-keys/{keyId}/rotate'] expect({ components: [ 'ApiKey', 'ApiKeyList', 'CreateApiKeyRequest', 'CreateApiKeyResponse', 'RevokeApiKeyResponse', 'RotateApiKeyResponse', 'UpdateApiKeyRequest', ].filter((name) => spec.components.schemas[name]), create: { errors: { 400: collection.post.responses[400].content['application/json'].schema, 403: collection.post.responses[403].content['application/json'].schema, 404: collection.post.responses[404].content['application/json'].schema, }, operationId: collection.post.operationId, request: collection.post.requestBody.content['application/json'].schema, response: collection.post.responses[200].content['application/json'].schema, }, listOrganization: { errors: { 400: organization.get.responses[400].content['application/json'].schema, 403: organization.get.responses[403].content['application/json'].schema, 404: organization.get.responses[404].content['application/json'].schema, }, operationId: organization.get.operationId, response: organization.get.responses[200].content['application/json'].schema, }, listProject: { errors: { 400: collection.get.responses[400].content['application/json'].schema, 403: collection.get.responses[403].content['application/json'].schema, 404: collection.get.responses[404].content['application/json'].schema, }, operationId: collection.get.operationId, response: collection.get.responses[200].content['application/json'].schema, }, revoke: { errors: { 400: detail.delete.responses[400].content['application/json'].schema, 403: detail.delete.responses[403].content['application/json'].schema, 404: detail.delete.responses[404].content['application/json'].schema, }, operationId: detail.delete.operationId, response: detail.delete.responses[200].content['application/json'].schema, }, rotate: { errors: { 400: rotation.post.responses[400].content['application/json'].schema, 403: rotation.post.responses[403].content['application/json'].schema, 404: rotation.post.responses[404].content['application/json'].schema, }, operationId: rotation.post.operationId, response: rotation.post.responses[200].content['application/json'].schema, }, timestamps: { createdAt: spec.components.schemas.ApiKey.properties.createdAt.format, expiresAt: spec.components.schemas.ApiKey.properties.expiresAt.format, lastUsedAt: spec.components.schemas.ApiKey.properties.lastUsedAt.format, }, update: { errors: { 400: detail.patch.responses[400].content['application/json'].schema, 403: detail.patch.responses[403].content['application/json'].schema, 404: detail.patch.responses[404].content['application/json'].schema, }, operationId: detail.patch.operationId, request: detail.patch.requestBody.content['application/json'].schema, response: detail.patch.responses[200].content['application/json'].schema, }, }).toMatchInlineSnapshot(` { "components": [ "ApiKey", "ApiKeyList", "CreateApiKeyRequest", "CreateApiKeyResponse", "RevokeApiKeyResponse", "RotateApiKeyResponse", "UpdateApiKeyRequest", ], "create": { "errors": { "400": { "$ref": "#/components/schemas/ApiKeyMalformedOrBodyInvalidOrParamInvalidError", }, "403": { "$ref": "#/components/schemas/ApiKeyForbiddenOrApiKeyIpForbiddenOrForbiddenOrScopeNotIssuableError", }, "404": { "$ref": "#/components/schemas/ApiKeysNotEnabledOrOrganizationNotFoundOrProjectNotFoundError", }, }, "operationId": "createApiKey", "request": { "$ref": "#/components/schemas/CreateApiKeyRequest", }, "response": { "$ref": "#/components/schemas/CreateApiKeyResponse", }, }, "listOrganization": { "errors": { "400": { "$ref": "#/components/schemas/ApiKeyMalformedOrParamInvalidOrQueryInvalidError", }, "403": { "$ref": "#/components/schemas/ForbiddenError", }, "404": { "$ref": "#/components/schemas/ApiKeysNotEnabledOrOrganizationNotFoundError", }, }, "operationId": "listOrgApiKeys", "response": { "$ref": "#/components/schemas/ApiKeyList", }, }, "listProject": { "errors": { "400": { "$ref": "#/components/schemas/ApiKeyMalformedOrParamInvalidOrQueryInvalidError", }, "403": { "$ref": "#/components/schemas/ForbiddenError", }, "404": { "$ref": "#/components/schemas/ApiKeysNotEnabledOrOrganizationNotFoundOrProjectNotFoundError", }, }, "operationId": "listApiKeys", "response": { "$ref": "#/components/schemas/ApiKeyList", }, }, "revoke": { "errors": { "400": { "$ref": "#/components/schemas/ApiKeyMalformedOrParamInvalidError", }, "403": { "$ref": "#/components/schemas/ForbiddenError", }, "404": { "$ref": "#/components/schemas/ApiKeyNotFoundOrApiKeysNotEnabledOrOrganizationNotFoundOrProjectNotFoundError", }, }, "operationId": "revokeApiKey", "response": { "$ref": "#/components/schemas/RevokeApiKeyResponse", }, }, "rotate": { "errors": { "400": { "$ref": "#/components/schemas/ApiKeyMalformedOrParamInvalidError", }, "403": { "$ref": "#/components/schemas/ApiKeyForbiddenOrApiKeyIpForbiddenOrForbiddenOrScopeNotIssuableError", }, "404": { "$ref": "#/components/schemas/ApiKeyNotFoundOrApiKeysNotEnabledOrOrganizationNotFoundOrProjectNotFoundError", }, }, "operationId": "rotateApiKey", "response": { "$ref": "#/components/schemas/RotateApiKeyResponse", }, }, "timestamps": { "createdAt": "date-time", "expiresAt": "date-time", "lastUsedAt": "date-time", }, "update": { "errors": { "400": { "$ref": "#/components/schemas/ApiKeyMalformedOrBodyInvalidOrParamInvalidError", }, "403": { "$ref": "#/components/schemas/ForbiddenError", }, "404": { "$ref": "#/components/schemas/ApiKeyNotFoundOrApiKeysNotEnabledOrOrganizationNotFoundOrProjectNotFoundError", }, }, "operationId": "updateApiKey", "request": { "$ref": "#/components/schemas/UpdateApiKeyRequest", }, "response": { "$ref": "#/components/schemas/ApiKey", }, }, } `) }) describe('keys', () => { test('requires an owner to issue management write keys', async () => { const db = TestApp.database() const app = createApp({ db }) const owner = await session(app) const { org, project } = await fixture(app, owner) const admin = await session(app) const user = await TestApp.json( await app.request('/v1/me', { headers: { cookie: admin } }), Me.schema.User, ) await Memberships.create(db, { orgId: org.id, role: 'admin', userId: user.id, }) const base = `/v1/orgs/${org.id}/projects/${project.id}/api-keys` const read = await app.request(base, json('POST', admin, { scopes: ['management:read'] })) expect(read.status).toBe(200) const denied = await app.request(base, json('POST', admin, { scopes: ['management:write'] })) expect(denied.status).toBe(403) expect(await denied.json()).toMatchObject({ error: { code: 'scope_not_issuable' } }) const write = await app.request(base, json('POST', owner, { scopes: ['management:write'] })) expect(write.status).toBe(200) const created = await TestApp.json(write, Keys.schema.createApiKey.Response) const rotateDenied = await app.request(`${base}/${created.id}/rotate`, { headers: { cookie: admin }, method: 'POST', }) expect(rotateDenied.status).toBe(403) expect(await rotateDenied.json()).toMatchObject({ error: { code: 'scope_not_issuable' } }) const rotateAllowed = await app.request(`${base}/${created.id}/rotate`, { headers: { cookie: owner }, method: 'POST', }) expect(rotateAllowed.status).toBe(200) }) test('requires an owner to rotate wildcard keys and allows the super admin', async () => { const db = TestApp.database() const store = TestApp.kvStore({ keys: [] }) const app = createApp({ db, kv: { store } }) const owner = await session(app) const { org, project } = await fixture(app, owner) const admin = await session(app) const user = await TestApp.json( await app.request('/v1/me', { headers: { cookie: admin } }), Me.schema.User, ) await Memberships.create(db, { orgId: org.id, role: 'admin', userId: user.id }) const base = `/v1/orgs/${org.id}/projects/${project.id}/api-keys` const { record: source } = await ApiKeys.mint(store, { environment: 'production', orgId: org.id, projectId: project.id, scopes: [Scope.wildcard], }) const denied = await app.request(`${base}/${source.id}/rotate`, { headers: { cookie: admin }, method: 'POST', }) expect(denied.status).toBe(403) expect(await denied.json()).toMatchObject({ error: { code: 'scope_not_issuable' } }) const allowed = await app.request(`${base}/${source.id}/rotate`, { headers: { 'tempo-api-key': secret }, method: 'POST', }) expect(allowed.status).toBe(200) }) test('zone scopes are never self-serve', async () => { const app = createApp() const owner = await session(app) const { org, project } = await fixture(app, owner) const base = `/v1/orgs/${org.id}/projects/${project.id}/api-keys` // Even the org owner cannot self-mint zone access: membership is // controlled zone-side, so zone scopes are super-admin-issued. for (const scope of ['zone:421700001:read', 'zone:421700001:write']) { const denied = await app.request(base, json('POST', owner, { scopes: [scope] })) expect(denied.status).toBe(403) expect(await denied.json()).toMatchObject({ error: { code: 'scope_not_issuable' } }) } }) test('rotates a super-admin-issued zone key without delegating scope issuance', async () => { const store = TestApp.kvStore({ keys: [] }) const app = createApp({ kv: { store } }) const owner = await session(app) const { org, project } = await fixture(app, owner) const base = `/v1/orgs/${org.id}/projects/${project.id}/api-keys` const source = await TestApp.json( await app.request( base, superAdminJson('POST', { allowedIps: ['203.0.113.0/24'], environment: 'production', name: 'Privy', scopes: ['zone:421700001:read', 'zone:421700001:write'], }), ), Keys.schema.createApiKey.Response, ) const response = await app.request(`${base}/${source.id}/rotate`, { headers: { cookie: owner }, method: 'POST', }) expect(response.status).toBe(200) const replacement = await TestApp.json(response, Keys.schema.rotateApiKey.Response) expect(replacement).toMatchObject({ allowedIps: ['203.0.113.0/24'], environment: 'production', name: 'Privy', orgId: org.id, projectId: project.id, scopes: ['zone:421700001:read', 'zone:421700001:write'], }) expect(replacement.createdBy).not.toBe('super_admin') expect(replacement.id).not.toBe(source.id) expect(replacement.token).not.toBe(source.token) expect(await ApiKeys.resolve(store, source.token)).not.toBeNull() expect(await ApiKeys.resolve(store, replacement.token)).not.toBeNull() }) test('rotates an organization-level super-admin-issued zone key', async () => { const store = TestApp.kvStore({ keys: [] }) const app = createApp({ kv: { store } }) const owner = await session(app) const { org, project } = await fixture(app, owner) const { record: projectSource } = await ApiKeys.mint(store, { orgId: org.id, projectId: project.id, scopes: ['data:read'], }) const projectResponse = await app.request( `/v1/orgs/${org.id}/api-keys/${projectSource.id}/rotate`, { headers: { cookie: owner }, method: 'POST' }, ) expect(projectResponse.status).toBe(404) const projectRevoke = await app.request(`/v1/orgs/${org.id}/api-keys/${projectSource.id}`, { headers: { cookie: owner }, method: 'DELETE', }) expect(projectRevoke.status).toBe(404) const { record: source } = await ApiKeys.mint(store, { createdBy: 'super_admin', environment: 'production', name: 'Privy', orgId: org.id, scopes: ['zone:421700001:read', 'zone:421700001:write'], }) const response = await app.request(`/v1/orgs/${org.id}/api-keys/${source.id}/rotate`, { headers: { cookie: owner }, method: 'POST', }) expect(response.status).toBe(200) const replacement = await TestApp.json(response, Keys.schema.rotateApiKey.Response) expect(replacement).toMatchObject({ environment: 'production', name: 'Privy', orgId: org.id, scopes: ['zone:421700001:read', 'zone:421700001:write'], }) expect(replacement.projectId).toBeUndefined() expect(replacement.createdBy).not.toBe('super_admin') expect(replacement.id).not.toBe(source.id) const revoked = await app.request(`/v1/orgs/${org.id}/api-keys/${replacement.id}`, { headers: { cookie: owner }, method: 'DELETE', }) expect(revoked.status).toBe(200) expect(await TestApp.json(revoked, Keys.schema.revokeApiKey.Response)).toEqual({ id: replacement.id, }) expect(await ApiKeys.resolve(store, replacement.token)).toBeNull() }) test('refreshes sandbox billing state during rotation', async () => { const db = TestApp.database() const store = TestApp.kvStore({ keys: [] }) const app = createApp({ db, kv: { store } }) const owner = await session(app) const { org, project } = await fixture(app, owner) const { record: source } = await ApiKeys.mint(store, { billingActive: true, environment: 'sandbox', orgId: org.id, projectId: project.id, scopes: ['data:read'], }) const response = await app.request( `/v1/orgs/${org.id}/projects/${project.id}/api-keys/${source.id}/rotate`, { headers: { cookie: owner }, method: 'POST' }, ) expect(response.status).toBe(200) const replacement = await TestApp.json(response, Keys.schema.rotateApiKey.Response) expect((await ApiKeys.get(store, replacement.id))?.billingActive).toBe(false) }) test('does not rotate a key from another project', async () => { const app = createApp() const owner = await session(app) const { org, project } = await fixture(app, owner) const other = await TestApp.json( await app.request(`/v1/orgs/${org.id}/projects`, json('POST', owner, { name: 'Other' })), Projects.schema.Project, ) const source = await TestApp.json( await app.request( `/v1/orgs/${org.id}/projects/${project.id}/api-keys`, superAdminJson('POST', { scopes: ['zone:421700001:read'] }), ), Keys.schema.createApiKey.Response, ) const response = await app.request( `/v1/orgs/${org.id}/projects/${other.id}/api-keys/${source.id}/rotate`, { headers: { cookie: owner }, method: 'POST' }, ) expect(response.status).toBe(404) expect(await response.json()).toMatchObject({ error: { code: 'api_key_not_found' } }) }) test('restricts project management keys without delegating issuance', async () => { const app = createApp() const cookie = await session(app) const { org, project } = await fixture(app, cookie) const other = await TestApp.json( await app.request(`/v1/orgs/${org.id}/projects`, json('POST', cookie, { name: 'Payments' })), Projects.schema.Project, ) const base = `/v1/orgs/${org.id}/projects/${project.id}/api-keys` const siblingBase = `/v1/orgs/${org.id}/projects/${other.id}/api-keys` const manager = await TestApp.json( await app.request( base, superAdminJson('POST', { scopes: ['management:read', 'management:write'], }), ), Keys.schema.createApiKey.Response, ) const target = await TestApp.json( await app.request(siblingBase, superAdminJson('POST', { scopes: ['data:read'] })), Keys.schema.createApiKey.Response, ) const own = await app.request(base, { headers: { 'tempo-api-key': manager.token }, }) expect(own.status).toMatchInlineSnapshot(`200`) const listed = await app.request(siblingBase, { headers: { 'tempo-api-key': manager.token }, }) const { requestId: listedRequestId, ...listedBody } = (await listed.json()) as { requestId?: string } expect(listed.status).toMatchInlineSnapshot(`404`) expect(listedRequestId).toBeDefined() expect(listedBody).toMatchInlineSnapshot(` { "error": { "code": "project_not_found", "message": "Project not found", }, } `) const delegated = await app.request(base, { body: JSON.stringify({ scopes: ['management:read'] }), headers: { 'content-type': 'application/json', 'tempo-api-key': manager.token }, method: 'POST', }) expect(delegated.status).toBe(403) const revoked = await app.request(`${siblingBase}/${target.id}`, { headers: { 'tempo-api-key': manager.token }, method: 'DELETE', }) const { requestId: revokedRequestId, ...revokedBody } = (await revoked.json()) as { requestId?: string } expect(revoked.status).toMatchInlineSnapshot(`404`) expect(revokedRequestId).toBeDefined() expect(revokedBody).toMatchInlineSnapshot(` { "error": { "code": "project_not_found", "message": "Project not found", }, } `) }) test('mints, lists, authenticates, and revokes a project key', async () => { const app = createApp() const cookie = await session(app) const { org, project } = await fixture(app, cookie) const base = `/v1/orgs/${org.id}/projects/${project.id}/api-keys` // Scoped keys mint through the super admin: no scope is self-issuable. const minted = await app.request( base, superAdminJson('POST', { environment: 'sandbox', name: 'CI', scopes: ['data:read'] }), ) expect(minted.status).toBe(200) const created = await TestApp.json(minted, Keys.schema.createApiKey.Response) expect(created.id).toMatch(/^key_/) expect(created.token).toMatch(/^tempo_sandbox:sk:[a-f0-9]{48}$/) expect(created.orgId).toBe(org.id) expect(created.projectId).toBe(project.id) expect(created.createdBy).toBe('super_admin') // The minted key authenticates data API requests. `no-store` forces an // origin pass — an edge-cache hit would skip auth and mask the assertion. const data = await app.request('/v1/tokens', { headers: { 'cache-control': 'no-store', 'tempo-api-key': created.token }, }) expect(data.status).toBe(200) // Listing exposes metadata only — never the token. The key is sandbox, so // the list is scoped to that environment (production is the default). const listed = await app.request(`${base}?environment=sandbox`, { headers: { cookie } }) expect(listed.status).toBe(200) const list = await TestApp.json(listed, Keys.schema.listApiKeys.Response) expect(list.data.map(({ id }) => id)).toEqual([created.id]) expect(JSON.stringify(list)).not.toContain(created.token) expect(list.data[0]!.tokenLast4).toBe(created.token.slice(-4)) const revoked = await app.request(`${base}/${created.id}`, { headers: { cookie }, method: 'DELETE', }) expect(revoked.status).toBe(200) expect(await TestApp.json(revoked, Keys.schema.revokeApiKey.Response)).toEqual({ id: created.id, }) // With the resolve cache disabled, revocation is immediate. const after = await app.request('/v1/tokens', { headers: { 'cache-control': 'no-store', 'tempo-api-key': created.token }, }) expect(after.status).toBe(401) const empty = await TestApp.json( await app.request(base, { headers: { cookie } }), Keys.schema.listApiKeys.Response, ) expect(empty).toEqual({ data: [] }) }) test('creates, reads, updates, enforces, and clears an IP allowlist', async () => { const state = TestApp.kvStore() const app = TestApp.create({ // Avoid waiting for the accepted KV and isolate-cache propagation delay. auth: { cache: false, superAdmin: { secret } }, kv: { store: state }, session: { wallet: { origin } }, }).get( '/custom/allowlist-test', Auth.policy({ apiKey: { scopes: ['data:read'] } }), OpenApi.describeRoute({ operationId: 'getAllowlistTest', responses: { 200: { description: 'Allowlist test response.' } }, summary: 'Test allowlist', }), (c) => c.json({ ok: true }, 200), ) const cookie = await session(app) const { org, project } = await fixture(app, cookie) const base = `/v1/orgs/${org.id}/projects/${project.id}/api-keys` const created = await TestApp.json( await app.request( base, superAdminJson('POST', { allowedIps: ['203.0.113.0/24'], scopes: ['data:read'], }), ), Keys.schema.createApiKey.Response, ) expect(created.allowedIps).toEqual(['203.0.113.0/24']) const authenticate = (address?: string) => app.request('/custom/allowlist-test', { headers: { ...(address ? { 'cf-connecting-ip': address } : {}), 'tempo-api-key': created.token, }, }) const listed = await TestApp.json( await app.request(base, { headers: { cookie } }), Keys.schema.listApiKeys.Response, ) expect(listed.data[0]?.allowedIps).toEqual(['203.0.113.0/24']) expect((await authenticate('203.0.113.7')).status).toBe(200) const denied = await authenticate('198.51.100.7') expect(denied.status).toBe(403) expect(await denied.json()).toMatchObject({ error: { code: 'api_key_ip_forbidden' } }) const updated = await TestApp.json( await app.request( `${base}/${created.id}`, json('PATCH', cookie, { allowedIps: ['198.51.100.7'] }), ), Keys.schema.Key, ) expect(updated.allowedIps).toEqual(['198.51.100.7']) expect((await authenticate('203.0.113.7')).status).toBe(403) expect((await authenticate('198.51.100.7')).status).toBe(200) const cleared = await TestApp.json( await app.request(`${base}/${created.id}`, json('PATCH', cookie, { allowedIps: [] })), Keys.schema.Key, ) expect(cleared.allowedIps).toEqual([]) expect((await authenticate()).status).toBe(200) }) test('rejects malformed IP allowlists on create and update', async () => { const app = createApp() const cookie = await session(app) const { org, project } = await fixture(app, cookie) const base = `/v1/orgs/${org.id}/projects/${project.id}/api-keys` const invalidCreate = await app.request( base, json('POST', cookie, { allowedIps: ['203.0.113.0/33'], scopes: ['data:read'] }), ) expect(invalidCreate.status).toBe(400) const created = await TestApp.json( await app.request(base, json('POST', cookie, { scopes: ['data:read'] })), Keys.schema.createApiKey.Response, ) const invalidUpdate = await app.request( `${base}/${created.id}`, json('PATCH', cookie, { allowedIps: ['*'] }), ) expect(invalidUpdate.status).toBe(400) }) test('rejects the wildcard scope at mint', async () => { const app = createApp() const cookie = await session(app) const { org, project } = await fixture(app, cookie) const response = await app.request( `/v1/orgs/${org.id}/projects/${project.id}/api-keys`, json('POST', cookie, { scopes: ['*'] }), ) expect(response.status).toBe(400) }) test('mints zero-scope keys for sessions', async () => { const app = createApp() const cookie = await session(app) const { org, project } = await fixture(app, cookie) const minted = await app.request( `/v1/orgs/${org.id}/projects/${project.id}/api-keys`, json('POST', cookie, { name: 'CI' }), ) expect(minted.status).toBe(200) const created = await TestApp.json(minted, Keys.schema.createApiKey.Response) expect(created.scopes).toEqual([]) expect(created.createdBy).toMatch(/^usr_/) // The key authenticates but grants nothing: scoped routes stay forbidden. const data = await app.request('/v1/tokens', { headers: { 'cache-control': 'no-store', 'tempo-api-key': created.token }, }) expect(data.status).toBe(403) const { requestId, ...body } = (await data.json()) as { requestId?: string } expect(requestId).toBeDefined() expect(body).toMatchInlineSnapshot(` { "error": { "code": "api_key_forbidden", "message": "API key missing required scope", }, } `) }) test('lets sessions mint catalog scopes on production keys', async () => { const app = createApp() const cookie = await session(app) const { org, project } = await fixture(app, cookie) const created = await TestApp.json( await app.request( `/v1/orgs/${org.id}/projects/${project.id}/api-keys`, json('POST', cookie, { scopes: ['data:read'] }), ), Keys.schema.createApiKey.Response, ) expect(created.environment).toBe('production') expect(created.scopes).toEqual(['data:read']) // Session-minted, not granted through the super admin. expect(created.createdBy).not.toBe('super_admin') }) test('mints and authenticates host scopes', async () => { const app = createApp({ scopes: [ { description: 'Read project accounts.', scope: 'accounts:read', selfServe: true, }, ], }).get( '/custom/accounts', Auth.policy({ apiKey: { scopes: ['accounts:read'] } }), OpenApi.describeRoute({ operationId: 'getCustomAccounts', responses: { 200: { description: 'Custom accounts response.' } }, summary: 'Get custom accounts', }), (c) => c.json({ ok: true }, 200), ) const cookie = await session(app) const { org, project } = await fixture(app, cookie) const created = await TestApp.json( await app.request( `/v1/orgs/${org.id}/projects/${project.id}/api-keys`, json('POST', cookie, { scopes: ['accounts:read'] }), ), Keys.schema.createApiKey.Response, ) const response = await app.request('/custom/accounts', { headers: { 'tempo-api-key': created.token }, }) expect(created.scopes).toEqual(['accounts:read']) expect(response.status).toBe(200) expect(await response.json()).toEqual({ ok: true }) }) test('rejects unregistered host scopes', async () => { const app = createApp() const cookie = await session(app) const { org, project } = await fixture(app, cookie) const response = await app.request( `/v1/orgs/${org.id}/projects/${project.id}/api-keys`, superAdminJson('POST', { scopes: ['accounts:read'] }), ) expect(response.status).toBe(400) expect(await response.json()).toMatchObject({ error: { code: 'body_invalid' } }) }) test('rejects session mints of non-self-serve scopes (webhooks)', async () => { const app = createApp() const cookie = await session(app) const { org, project } = await fixture(app, cookie) const response = await app.request( `/v1/orgs/${org.id}/projects/${project.id}/api-keys`, json('POST', cookie, { scopes: ['webhooks:write'] }), ) expect(response.status).toBe(403) const body = (await response.json()) as { error: { code: string } } expect(body.error.code).toBe('scope_not_issuable') }) test('lets sessions mint catalog scopes on sandbox keys', async () => { const app = createApp() const cookie = await session(app) const { org, project } = await fixture(app, cookie) const created = await TestApp.json( await app.request( `/v1/orgs/${org.id}/projects/${project.id}/api-keys`, json('POST', cookie, { environment: 'sandbox', scopes: ['data:read'] }), ), Keys.schema.createApiKey.Response, ) expect(created.environment).toBe('sandbox') expect(created.scopes).toEqual(['data:read']) // Session-minted, not granted through the super admin. expect(created.createdBy).not.toBe('super_admin') }) test('binds keys to their project on every verb', async () => { const app = createApp() const owner = await session(app) const { org, project } = await fixture(app, owner) const other = await TestApp.json( await app.request(`/v1/orgs/${org.id}/projects`, json('POST', owner, { name: 'Payments' })), Projects.schema.Project, ) const minted = await TestApp.json( await app.request( `/v1/orgs/${org.id}/projects/${project.id}/api-keys`, superAdminJson('POST', { scopes: ['data:read'] }), ), Keys.schema.createApiKey.Response, ) // Same org, sibling project: the key id must not resolve through it. const crossList = await app.request(`/v1/orgs/${org.id}/projects/${other.id}/api-keys`, { headers: { cookie: owner }, }) expect((await TestApp.json(crossList, Keys.schema.listApiKeys.Response)).data).toEqual([]) const crossRevoke = await app.request( `/v1/orgs/${org.id}/projects/${other.id}/api-keys/${minted.id}`, { headers: { cookie: owner }, method: 'DELETE' }, ) expect(crossRevoke.status).toBe(404) const crossUpdate = await app.request( `/v1/orgs/${org.id}/projects/${other.id}/api-keys/${minted.id}`, json('PATCH', owner, { allowedIps: ['203.0.113.0/24'] }), ) expect(crossUpdate.status).toBe(404) // Foreign session: the whole surface 404s at the org-ownership link. const stranger = await session(app) const foreignMint = await app.request( `/v1/orgs/${org.id}/projects/${project.id}/api-keys`, json('POST', stranger, { scopes: ['data:read'] }), ) expect(foreignMint.status).toBe(404) const foreignRevoke = await app.request( `/v1/orgs/${org.id}/projects/${project.id}/api-keys/${minted.id}`, { headers: { cookie: stranger }, method: 'DELETE' }, ) expect(foreignRevoke.status).toBe(404) // The key survives every attempt, still listed for the owner. const listed = await app.request(`/v1/orgs/${org.id}/projects/${project.id}/api-keys`, { headers: { cookie: owner }, }) const list = await TestApp.json(listed, Keys.schema.listApiKeys.Response) expect(list.data.map(({ id }) => id)).toEqual([minted.id]) }) test('lists keys org-wide across projects', async () => { const store = TestApp.kvStore({ keys: [] }) const app = createApp({ kv: { store } }) const owner = await session(app) const { org, project } = await fixture(app, owner) const other = await TestApp.json( await app.request(`/v1/orgs/${org.id}/projects`, json('POST', owner, { name: 'Payments' })), Projects.schema.Project, ) const first = await TestApp.json( await app.request( `/v1/orgs/${org.id}/projects/${project.id}/api-keys`, superAdminJson('POST', { name: 'Checkout CI', scopes: ['data:read'] }), ), Keys.schema.createApiKey.Response, ) const second = await TestApp.json( await app.request( `/v1/orgs/${org.id}/projects/${other.id}/api-keys`, superAdminJson('POST', { name: 'Payments CI', scopes: ['data:read'] }), ), Keys.schema.createApiKey.Response, ) // Org-attributed key without a project (admin-surface mints land like this). const orgWide = await ApiKeys.mint(store, { orgId: org.id, scopes: ['data:read'] }) const listed = await app.request(`/v1/orgs/${org.id}/api-keys`, { headers: { cookie: owner }, }) const list = await TestApp.json(listed, Keys.schema.listOrgApiKeys.Response) expect(list.data.map(({ id }) => id).sort()).toEqual( [first.id, second.id, orgWide.record.id].sort(), ) expect(list.data.find(({ id }) => id === first.id)?.projectId).toBe(project.id) expect(list.data.find(({ id }) => id === second.id)?.projectId).toBe(other.id) expect(list.data.find(({ id }) => id === orgWide.record.id)?.projectId).toBeUndefined() // Non-members see the org as absent. const stranger = await session(app) const foreign = await app.request(`/v1/orgs/${org.id}/api-keys`, { headers: { cookie: stranger }, }) expect(foreign.status).toBe(404) }) test('filters keys by environment (defaulting to production)', async () => { const app = createApp() const cookie = await session(app) const { org, project } = await fixture(app, cookie) const projectBase = `/v1/orgs/${org.id}/projects/${project.id}/api-keys` const orgBase = `/v1/orgs/${org.id}/api-keys` const production = await TestApp.json( await app.request( projectBase, superAdminJson('POST', { name: 'Prod', scopes: ['data:read'] }), ), Keys.schema.createApiKey.Response, ) const sandbox = await TestApp.json( await app.request( projectBase, superAdminJson('POST', { environment: 'sandbox', name: 'Sandbox', scopes: ['data:read'] }), ), Keys.schema.createApiKey.Response, ) const ids = async (path: string) => ( await TestApp.json( await app.request(path, { headers: { cookie } }), Keys.schema.listApiKeys.Response, ) ).data .map(({ id }) => id) .sort() // Each filter returns only its own environment. expect(await ids(`${projectBase}?environment=production`)).toEqual([production.id]) expect(await ids(`${projectBase}?environment=sandbox`)).toEqual([sandbox.id]) // Absent param defaults to production. expect(await ids(projectBase)).toEqual([production.id]) // Same behavior on the org-wide list route. const orgIds = async (path: string) => ( await TestApp.json( await app.request(path, { headers: { cookie } }), Keys.schema.listOrgApiKeys.Response, ) ).data .map(({ id }) => id) .sort() expect(await orgIds(`${orgBase}?environment=sandbox`)).toEqual([sandbox.id]) expect(await orgIds(orgBase)).toEqual([production.id]) // An unknown environment value is rejected. const invalid = await app.request(`${projectBase}?environment=staging`, { headers: { cookie } }) expect(invalid.status).toBe(400) }) test('revokes keys when their project or organization is deleted', async () => { const app = createApp() const cookie = await session(app) const { org, project } = await fixture(app, cookie) const minted = await TestApp.json( await app.request( `/v1/orgs/${org.id}/projects/${project.id}/api-keys`, superAdminJson('POST', { scopes: ['data:read'] }), ), Keys.schema.createApiKey.Response, ) const deletedProject = await app.request(`/v1/orgs/${org.id}/projects/${project.id}`, { headers: { cookie }, method: 'DELETE', }) expect(deletedProject.status).toBe(200) const afterProject = await app.request('/v1/tokens', { headers: { 'cache-control': 'no-store', 'tempo-api-key': minted.token }, }) expect(afterProject.status).toBe(401) // Org deletion revokes keys of every remaining project. const other = await TestApp.json( await app.request(`/v1/orgs/${org.id}/projects`, json('POST', cookie, { name: 'Payments' })), Projects.schema.Project, ) const survivor = await TestApp.json( await app.request( `/v1/orgs/${org.id}/projects/${other.id}/api-keys`, superAdminJson('POST', { scopes: ['data:read'] }), ), Keys.schema.createApiKey.Response, ) const deletedOrg = await app.request(`/v1/orgs/${org.id}`, { headers: { cookie }, method: 'DELETE', }) expect(deletedOrg.status).toBe(200) const afterOrg = await app.request('/v1/tokens', { headers: { 'cache-control': 'no-store', 'tempo-api-key': survivor.token }, }) expect(afterOrg.status).toBe(401) }) test('keeps the project when key revocation fails during project deletion', async () => { const store = TestApp.kvStore({ keys: [] }) const app = createApp({ kv: { store } }) const cookie = await session(app) const { org, project } = await fixture(app, cookie) await TestApp.json( await app.request( `/v1/orgs/${org.id}/projects/${project.id}/api-keys`, superAdminJson('POST', { scopes: ['data:read'] }), ), Keys.schema.createApiKey.Response, ) failRecordDeletes(store) const deletedProject = await app.request(`/v1/orgs/${org.id}/projects/${project.id}`, { headers: { cookie }, method: 'DELETE', }) expect(deletedProject.status).toBe(502) const projectStill = await app.request(`/v1/orgs/${org.id}/projects/${project.id}`, { headers: { cookie }, }) expect(projectStill.status).toBe(200) }) test('keeps the organization when key revocation fails during organization deletion', async () => { const store = TestApp.kvStore({ keys: [] }) const app = createApp({ kv: { store } }) const cookie = await session(app) const { org, project } = await fixture(app, cookie) await TestApp.json( await app.request( `/v1/orgs/${org.id}/projects/${project.id}/api-keys`, superAdminJson('POST', { scopes: ['data:read'] }), ), Keys.schema.createApiKey.Response, ) failRecordDeletes(store) const deletedOrg = await app.request(`/v1/orgs/${org.id}`, { headers: { cookie }, method: 'DELETE', }) expect(deletedOrg.status).toBe(502) const orgStill = await app.request(`/v1/orgs/${org.id}`, { headers: { cookie } }) expect(orgStill.status).toBe(200) }) test('mints as the super admin with super admin attribution', async () => { const app = createApp() const cookie = await session(app) const { org, project } = await fixture(app, cookie) const minted = await app.request(`/v1/orgs/${org.id}/projects/${project.id}/api-keys`, { body: JSON.stringify({ scopes: ['data:read'] }), headers: { 'content-type': 'application/json', 'tempo-api-key': secret }, method: 'POST', }) expect(minted.status).toBe(200) const created = await TestApp.json(minted, Keys.schema.createApiKey.Response) expect(created.createdBy).toBe('super_admin') expect(created.environment).toBe('production') }) test('rejects API keys on the keys surface', async () => { const app = createApp() const cookie = await session(app) const { org, project } = await fixture(app, cookie) const response = await app.request(`/v1/orgs/${org.id}/projects/${project.id}/api-keys`, { headers: { 'tempo-api-key': TestApp.key.token }, }) expect(response.status).toBe(403) }) }) describe('early access', () => { test('does not require an early-access grant', async () => { const app = createApp() const cookie = await session(app) const { org, project } = await fixture(app, cookie) const minted = await app.request( `/v1/orgs/${org.id}/projects/${project.id}/api-keys`, json('POST', cookie, { scopes: ['data:read'] }), ) expect(minted.status).toBe(200) }) })