import * as ApiKey from '../../ApiKey.js' import * as core_ApiKeys from '../../ApiKeys.js' import * as Organizations from '../../db/tables/organizations.js' import * as Projects from '../../db/tables/projects.js' import * as Users from '../../db/tables/users.js' import * as TestAdmin from '../../../test/Admin.js' import * as TestApp from '../../../test/App.js' import * as ApiKeys from './api-keys.js' /** Minimal valid mint payload. */ const validBody = { name: 'CI', orgId: 'org_test', scopes: ['data:read'] } as const describe('POST /api-keys', () => { test('mints a key, returns the token once, persists a hashed record', async () => { const { client, store } = await setup() const response = await client['api-keys'].$post({ json: { ...validBody, name: 'CI' } }) const body = await TestApp.json(response, ApiKeys.schema.createApiKey.Response) expect(response.status).toBe(200) // The plaintext token is returned exactly once, in the documented format. expect(body.token).toMatch(/^tempo:sk:[a-f0-9]{48}$/) // createdBy is the verified admin email, not anything the client sent. expect(body.createdBy).toBe(TestAdmin.identity.email) expect(TestAdmin.redact(body)).toMatchInlineSnapshot(` { "allowedIps": [], "createdAt": "", "createdBy": "admin@tempo.xyz", "environment": "production", "id": "", "name": "CI", "orgId": "org_test", "scopes": [ "data:read", ], "token": "", "tokenLast4": "", } `) // The token resolves through the module (proving the record persisted), // and at rest it is keyed by the token hash — never the plaintext token. const resolved = await core_ApiKeys.resolve(store, body.token) expect(resolved?.orgId).toBe('org_test') expect(await store.get(body.token)).toBeNull() expect(await store.get(ApiKey.keyFor(body.token))).not.toBeNull() }) test('defaults to the production environment', async () => { const { client, store } = await setup() const response = await client['api-keys'].$post({ json: validBody }) const body = await TestApp.json(response, ApiKeys.schema.createApiKey.Response) expect(response.status).toBe(200) expect(body.environment).toBe('production') expect((await core_ApiKeys.list(store))[0]?.environment).toBe('production') }) test('requires a human-readable name', async () => { const { client } = await setup() const response = await client['api-keys'].$post({ json: { orgId: 'org_test', scopes: ['data:read'] } as never, }) expect(response.status).toBe(400) }) test('persists a sandbox environment when provided', async () => { const { client, store } = await setup() const response = await client['api-keys'].$post({ json: { ...validBody, environment: 'sandbox' }, }) const body = await TestApp.json(response, ApiKeys.schema.createApiKey.Response) expect(response.status).toBe(200) expect(body.environment).toBe('sandbox') // Sandbox tokens carry a distinct prefix for recognizability/leak detection. expect(body.token).toMatch(/^tempo_sandbox:sk:[a-f0-9]{48}$/) const resolved = await core_ApiKeys.resolve(store, body.token) expect(resolved?.environment).toBe('sandbox') }) test('mints a key with concrete Zone read and write scopes', async () => { const { client, store } = await setup() const scopes = ['zone:421700001:read', 'zone:421700001:write'] as const const response = await client['api-keys'].$post({ json: { ...validBody, scopes }, }) const body = await TestApp.json(response, ApiKeys.schema.createApiKey.Response) expect(response.status).toBe(200) expect(body.scopes).toEqual(scopes) expect((await core_ApiKeys.resolve(store, body.token))?.scopes).toEqual(scopes) }) test('records the expiry when provided', async () => { const { client, store } = await setup() const expiresAt = '2100-01-01T00:00:00.000Z' const response = await client['api-keys'].$post({ json: { ...validBody, expiresAt } }) const body = await TestApp.json(response, ApiKeys.schema.createApiKey.Response) expect(response.status).toBe(200) expect(body.expiresAt).toBe(expiresAt) expect((await core_ApiKeys.list(store))[0]?.expiresAt).toBe(expiresAt) }) test('rejects an expiry without the minimum storage lead time', async () => { const { client } = await setup() const response = await client['api-keys'].$post({ json: { ...validBody, expiresAt: new Date(Date.now() + 60_000).toISOString() }, }) expect(response.status).toBe(400) }) test('rejects an unknown scope with 400', async () => { const { client } = await setup() const response = await client['api-keys'].$post({ // Cast past the typed client to exercise runtime validation. json: { ...validBody, scopes: ['nope:read'] } as never, }) const body = (await response.json()) as { error?: { code?: string } } expect(response.status).toMatchInlineSnapshot(`400`) expect(body.error?.code).toMatchInlineSnapshot(`"body_invalid"`) }) test('rejects non-concrete or non-canonical Zone scopes with 400', async () => { for (const scope of [ 'zone::read', 'zone:0:read', 'zone:0:write', 'zone:000421700001:read', ]) { const { client } = await setup() const response = await client['api-keys'].$post({ // Cast past the typed client to exercise runtime validation. json: { ...validBody, scopes: [scope] } as never, }) const body = (await response.json()) as { error?: { code?: string } } expect(response.status).toBe(400) expect(body.error?.code).toBe('body_invalid') } }) test('ignores a client-supplied createdBy (audit trail is server-set)', async () => { const { client } = await setup() const response = await client['api-keys'].$post({ // Cast past the typed client: the body schema omits createdBy, so an // unknown key is stripped and the server records the verified identity. json: { ...validBody, createdBy: 'spoof@example.com' } as never, }) const body = await TestApp.json(response, ApiKeys.schema.createApiKey.Response) expect(response.status).toBe(200) expect(body.createdBy).toBe(TestAdmin.identity.email) }) test('allows a one-off key without organization attribution', async () => { const { client, store } = TestAdmin.setup() const response = await client['api-keys'].$post({ json: { name: 'Trial', scopes: ['data:read'] }, }) const body = await TestApp.json(response, ApiKeys.schema.createApiKey.Response) expect(response.status).toBe(200) expect(body.orgId).toBe(body.id) expect((await core_ApiKeys.resolve(store, body.token))?.orgId).toBe(body.id) }) test('rejects an unknown organization', async () => { const { client } = TestAdmin.setup() const response = await client['api-keys'].$post({ json: validBody }) expect(response.status).toBe(404) }) test('infers organization attribution from a project', async () => { const { client, db, store } = TestAdmin.setup() const organization = await Organizations.create(db, { name: 'Project owner' }) const project = await Projects.create(db, { name: 'Project', orgId: organization.id }) const response = await client['api-keys'].$post({ json: { name: 'Project key', projectId: project.id, scopes: ['data:read'] }, }) const body = await TestApp.json(response, ApiKeys.schema.createApiKey.Response) expect(response.status).toBe(200) expect(body.orgId).toBe(organization.id) expect(body.projectId).toBe(project.id) expect((await core_ApiKeys.resolve(store, body.token))?.orgId).toBe(organization.id) }) test('rejects an unknown project', async () => { const { client } = await setup() const response = await client['api-keys'].$post({ json: { ...validBody, projectId: 'prj_missing' }, }) expect(response.status).toBe(404) }) test('rejects a project owned by another organization', async () => { const { client, db } = await setup() const other = await Organizations.create(db, { name: 'Other' }) const project = await Projects.create(db, { name: 'Other project', orgId: other.id }) const response = await client['api-keys'].$post({ json: { ...validBody, projectId: project.id }, }) expect(response.status).toBe(404) }) }) async function setup() { const context = TestAdmin.setup() await Organizations.create(context.db, { id: validBody.orgId, name: 'Test' }) return context } describe('GET /api-keys', () => { test('lists records as metadata only (never the token)', async () => { const { client, store } = TestAdmin.setup() const { token } = await core_ApiKeys.mint(store, validBody) const response = await client['api-keys'].$get({ query: {} }) const body = await TestApp.json(response, ApiKeys.schema.listApiKeys.Response) expect(response.status).toBe(200) expect(body.data).toHaveLength(1) expect(body.data[0]).toMatchObject({ createdByLabel: null, organizationName: null, projectName: null, }) // No record (and no serialized response) may carry the plaintext token. expect(body.data[0]).not.toHaveProperty('token') expect(JSON.stringify(body)).not.toContain(token) }) test('filters by orgId', async () => { const { client, store } = TestAdmin.setup() await core_ApiKeys.mint(store, { orgId: 'org_a', scopes: ['data:read'] }) await core_ApiKeys.mint(store, { orgId: 'org_b', scopes: ['data:read'] }) const response = await client['api-keys'].$get({ query: { orgId: 'org_a' } }) const body = await TestApp.json(response, ApiKeys.schema.listApiKeys.Response) expect(body.data.map((record) => record.orgId)).toEqual(['org_a']) }) test('resolves organization, project, and creator display labels', async () => { const { client, db, store } = TestAdmin.setup() const user = await Users.upsertByAddress(db, { address: `0x${'aa'.repeat(20)}` }) await Users.setEmail(db, user.id, 'alex@tempo.xyz') const organization = await Organizations.create(db, { name: 'Tempo' }) const project = await Projects.create(db, { name: 'Explorer', orgId: organization.id }) await core_ApiKeys.mint(store, { createdBy: user.id, name: 'Explorer testnet', orgId: organization.id, projectId: project.id, scopes: ['data:read'], }) const response = await client['api-keys'].$get({ query: {} }) const body = await TestApp.json(response, ApiKeys.schema.listApiKeys.Response) expect(body.data[0]).toMatchObject({ createdByLabel: 'alex@tempo.xyz', organizationName: 'Tempo', projectName: 'Explorer', }) }) }) describe('DELETE /api-keys/:id', () => { test('revokes a key, after which it no longer resolves or lists', async () => { const { client, store } = TestAdmin.setup() const { record, token } = await core_ApiKeys.mint(store, validBody) const response = await client['api-keys'][':id'].$delete({ param: { id: record.id } }) const body = await TestApp.json(response, ApiKeys.schema.revokeApiKey.Response) expect(response.status).toBe(200) expect(body).toEqual({ id: record.id }) expect(await core_ApiKeys.resolve(store, token)).toBeNull() expect(await core_ApiKeys.list(store)).toEqual([]) }) test('unknown id → 404', async () => { const { client } = TestAdmin.setup() const response = await client['api-keys'][':id'].$delete({ param: { id: 'key_missing' } }) const body = (await response.json()) as { error?: { code?: string } } expect(response.status).toMatchInlineSnapshot(`404`) expect(body.error?.code).toMatchInlineSnapshot(`"not_found"`) }) }) describe('POST /api-keys/:id/rotate', () => { test('mints a replacement with the source access and attribution', async () => { const { client, store } = await setup() const { record: source, token: sourceToken } = await core_ApiKeys.mint(store, { allowedIps: ['203.0.113.0/24'], billingActive: true, createdBy: 'customer@example.com', environment: 'sandbox', expiresAt: '2100-01-01T00:00:00.000Z', name: 'Privy Zones', orgId: 'org_privy', projectId: 'prj_privy', rateLimits: { '*': { limit: 10_000, period: 'minute' } }, scopes: ['zone:421700001:read', 'zone:421700001:write'], }) const response = await client['api-keys'][':id'].rotate.$post({ param: { id: source.id }, }) const body = await TestApp.json(response, ApiKeys.schema.rotateApiKey.Response) expect(response.status).toBe(200) expect(body).toMatchObject({ allowedIps: source.allowedIps, billingActive: source.billingActive, createdBy: TestAdmin.identity.email, environment: source.environment, expiresAt: source.expiresAt, name: source.name, orgId: source.orgId, projectId: source.projectId, rateLimits: source.rateLimits, scopes: source.scopes, }) expect(body.id).not.toBe(source.id) expect(body.token).not.toBe(sourceToken) expect(await core_ApiKeys.resolve(store, sourceToken)).not.toBeNull() expect(await core_ApiKeys.resolve(store, body.token)).toMatchObject({ id: body.id, scopes: source.scopes, }) }) test('unknown id → 404', async () => { const { client } = await setup() const response = await client['api-keys'][':id'].rotate.$post({ param: { id: 'key_missing' }, }) expect(response.status).toMatchInlineSnapshot(`404`) }) test('keeps a rotated one-off key assignable to an organization', async () => { const { client, db, store } = await setup() const organization = await Organizations.create(db, { name: 'Tempo' }) const { record: source } = await core_ApiKeys.mint(store, { name: 'One-off', scopes: ['data:read'], }) const rotateResponse = await client['api-keys'][':id'].rotate.$post({ param: { id: source.id }, }) const replacement = await TestApp.json(rotateResponse, ApiKeys.schema.rotateApiKey.Response) expect(replacement.orgId).toBe(replacement.id) const updateResponse = await client['api-keys'][':id'].$patch({ json: { orgId: organization.id }, param: { id: replacement.id }, }) expect(updateResponse.status).toBe(200) expect((await core_ApiKeys.get(store, replacement.id))?.orgId).toBe(organization.id) }) }) describe('PATCH /api-keys/:id', () => { test('preserves the allowlist when omitted', async () => { const { client, store } = TestAdmin.setup() const { record } = await core_ApiKeys.mint(store, { allowedIps: ['203.0.113.0/24'], name: 'Restricted', scopes: ['data:read'], }) const response = await client['api-keys'][':id'].$patch({ json: { name: 'Renamed' }, param: { id: record.id }, }) expect(response.status).toBe(200) expect((await core_ApiKeys.get(store, record.id))?.allowedIps).toEqual(['203.0.113.0/24']) }) test('renames and associates a one-off key without changing its token', async () => { const { client, db, store } = TestAdmin.setup() const organization = await Organizations.create(db, { name: 'Tempo' }) const { record, token } = await core_ApiKeys.mint(store, { name: 'Trial', scopes: ['data:read'], }) const response = await client['api-keys'][':id'].$patch({ json: { allowedIps: ['203.0.113.0/24'], name: 'Explorer', orgId: organization.id, projectId: 'prj_real', }, param: { id: record.id }, }) expect(response.status).toBe(200) const body = await TestApp.json(response, core_ApiKeys.schema.Record) expect(body.name).toBe('Explorer') expect(body.allowedIps).toEqual(['203.0.113.0/24']) expect(body.orgId).toBe(organization.id) expect(body.projectId).toBe('prj_real') expect((await core_ApiKeys.resolve(store, token))?.orgId).toBe(organization.id) expect(await core_ApiKeys.listByOrg(store, record.id)).toEqual([]) expect((await core_ApiKeys.listByOrg(store, organization.id)).map(({ id }) => id)).toEqual([ record.id, ]) }) test('rejects an unknown organization without changing the key', async () => { const { client, store } = TestAdmin.setup() const { record } = await core_ApiKeys.mint(store, { scopes: ['data:read'] }) const response = await client['api-keys'][':id'].$patch({ json: { orgId: 'org_missing' }, param: { id: record.id }, }) expect(response.status).toBe(404) expect((await core_ApiKeys.get(store, record.id))?.orgId).toBe(record.id) }) test('rejects changing an assigned organization', async () => { const { client, db, store } = TestAdmin.setup() const organization = await Organizations.create(db, { name: 'Tempo' }) const other = await Organizations.create(db, { name: 'Other' }) const { record, token } = await core_ApiKeys.mint(store, { orgId: organization.id, scopes: ['data:read'], }) const response = await client['api-keys'][':id'].$patch({ json: { orgId: other.id }, param: { id: record.id }, }) const body = (await response.json()) as { error?: { code?: string } } expect(response.status).toBe(409) expect(body.error?.code).toBe('organization_already_assigned') expect((await core_ApiKeys.resolve(store, token))?.orgId).toBe(organization.id) }) test('unknown id → 404', async () => { const { client } = TestAdmin.setup() const response = await client['api-keys'][':id'].$patch({ json: { projectId: 'prj_x' }, param: { id: 'key_missing' }, }) expect(response.status).toMatchInlineSnapshot(`404`) }) }) describe('POST /api-keys/backfill', () => { test('rebuilds index entries for legacy records', async () => { const { client, store } = TestAdmin.setup() const { record } = await core_ApiKeys.mint(store, validBody) // Legacy state: records exist, index entries do not. for (const prefix of ['apikey_id:', 'apikey_org:']) for (const { name } of (await store.list({ prefix })).keys) await store.delete(name) expect(await core_ApiKeys.listByOrg(store, 'org_test')).toEqual([]) const response = await client['api-keys'].backfill.$post() expect(response.status).toBe(200) expect(await TestApp.json(response, ApiKeys.schema.backfillApiKeys.Response)).toEqual({ indexed: 1, scanned: 1, }) expect((await core_ApiKeys.listByOrg(store, 'org_test')).map(({ id }) => id)).toEqual([ record.id, ]) }) })