From 9d0fdf66fbe3789a33bfc98609c93e3a954dac99 Mon Sep 17 00:00:00 2001 From: Florian Breuer Date: Tue, 5 May 2026 20:50:26 +0200 Subject: [PATCH 1/3] feat(api-key): add scopes (read/write/update/delete) and token-type restriction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scopes are selected per key on creation and editable afterwards. Default scope per HTTP method (GET=read, POST=write, PATCH/PUT=update, DELETE=delete) with per-route override via decorator config. Session-token requests bypass scope checks; only api_key tokens are enforced. Token-type restriction added so security-critical routes can opt out of api-key access entirely. Hidden routes (schema.hide=true) are now session- only by default rule, and api-key management routes are explicitly session- only — no key can mint, list, update, or revoke other keys. Frontend: checkbox group on create + edit dialogs; scope badges in list view (single "Full access" badge when all four scopes are granted, including legacy keys with empty scopes); i18n in all 9 locales. Tests: middleware unit tests, default-method-mapping unit test, integration tests for scope rejection, token-type rejection, and update flow on real Clerk-issued keys, plus scope-coverage matrix that introspects every registered route and asserts correct scope and token-type configuration. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../scope-coverage-matrix.test.ts.snap | 69 +++++ .../__tests__/scope-coverage-matrix.test.ts | 134 +++++++++ apps/backend/src/core/decorators/route.ts | 26 ++ .../src/core/domain/schema/UserSchema.ts | 1 + apps/backend/src/core/error/http/index.ts | 4 + .../error/http/insufficient-scope.error.ts | 16 + .../http/token-type-not-allowed.error.ts | 15 + .../enforce-scope.middleware.test.ts | 125 ++++++++ .../enforce-token-type.middleware.test.ts | 36 +++ .../add-user-to-request.middleware.ts | 13 +- .../middleware/enforce-scope.middleware.ts | 27 ++ .../enforce-token-type.middleware.ts | 23 ++ .../resolve-scope-for-method.test.ts | 29 ++ apps/backend/src/libs/fastify/helpers.ts | 73 ++++- .../analytics-integration.controller.ts | 1 + .../api-key/http/__tests__/api-key.test.ts | 275 +++++++++++++++++- .../modules/api-key/http/__tests__/utils.ts | 30 +- .../http/controller/api-key.controller.ts | 65 ++++- .../useCase/create-api-key.use-case.ts | 3 + .../api-key/useCase/list-api-keys.use-case.ts | 6 +- .../useCase/update-api-key.use-case.ts | 62 ++++ .../api-key/util/filter-known-scopes.ts | 11 + .../controller/custom-domain.controller.ts | 3 + .../http/__tests__/render-qr-code.test.ts | 2 +- .../http/controller/qr-code.controller.ts | 1 + .../dashboard/api-keys/ApiKeyList.tsx | 1 + .../dashboard/api-keys/ApiKeyListItem.tsx | 59 +++- .../api-keys/ApiKeyListItemActions.tsx | 3 + .../dashboard/api-keys/CreateApiKeyDialog.tsx | 86 +++++- .../dashboard/api-keys/EditApiKeyDialog.tsx | 155 ++++++++++ apps/frontend/src/dictionaries/de.json | 22 +- apps/frontend/src/dictionaries/en.json | 22 +- apps/frontend/src/dictionaries/es.json | 22 +- apps/frontend/src/dictionaries/fr.json | 22 +- apps/frontend/src/dictionaries/it.json | 22 +- apps/frontend/src/dictionaries/nl.json | 22 +- apps/frontend/src/dictionaries/pl.json | 22 +- apps/frontend/src/dictionaries/pt.json | 22 +- apps/frontend/src/dictionaries/ru.json | 22 +- apps/frontend/src/lib/api/api-key.ts | 23 ++ .../src/dtos/api-key/ApiKeyResponseDto.ts | 2 + .../shared/src/dtos/api-key/ApiKeyScope.ts | 7 + .../src/dtos/api-key/CreateApiKeyDto.ts | 2 + .../src/dtos/api-key/UpdateApiKeyDto.ts | 19 ++ packages/shared/src/index.ts | 2 + 45 files changed, 1557 insertions(+), 50 deletions(-) create mode 100644 apps/backend/src/__tests__/__snapshots__/scope-coverage-matrix.test.ts.snap create mode 100644 apps/backend/src/__tests__/scope-coverage-matrix.test.ts create mode 100644 apps/backend/src/core/error/http/insufficient-scope.error.ts create mode 100644 apps/backend/src/core/error/http/token-type-not-allowed.error.ts create mode 100644 apps/backend/src/core/http/middleware/__tests__/enforce-scope.middleware.test.ts create mode 100644 apps/backend/src/core/http/middleware/__tests__/enforce-token-type.middleware.test.ts create mode 100644 apps/backend/src/core/http/middleware/enforce-scope.middleware.ts create mode 100644 apps/backend/src/core/http/middleware/enforce-token-type.middleware.ts create mode 100644 apps/backend/src/libs/fastify/__tests__/resolve-scope-for-method.test.ts create mode 100644 apps/backend/src/modules/api-key/useCase/update-api-key.use-case.ts create mode 100644 apps/backend/src/modules/api-key/util/filter-known-scopes.ts create mode 100644 apps/frontend/src/components/dashboard/api-keys/EditApiKeyDialog.tsx create mode 100644 packages/shared/src/dtos/api-key/ApiKeyScope.ts create mode 100644 packages/shared/src/dtos/api-key/UpdateApiKeyDto.ts diff --git a/apps/backend/src/__tests__/__snapshots__/scope-coverage-matrix.test.ts.snap b/apps/backend/src/__tests__/__snapshots__/scope-coverage-matrix.test.ts.snap new file mode 100644 index 00000000..09908728 --- /dev/null +++ b/apps/backend/src/__tests__/__snapshots__/scope-coverage-matrix.test.ts.snap @@ -0,0 +1,69 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`scope-coverage-matrix snapshot of route → method → scope mapping (catches accidental drift) 1`] = ` +[ + "AnalyticsIntegrationController DELETE /:id → delete [session_token]", + "AnalyticsIntegrationController GET → read [session_token]", + "AnalyticsIntegrationController PATCH /:id → update [session_token]", + "AnalyticsIntegrationController POST → write [session_token]", + "AnalyticsIntegrationController POST /:id/test → read (override) [session_token]", + "ApiKeyController DELETE /:id → delete [session_token]", + "ApiKeyController GET → read [session_token]", + "ApiKeyController PATCH /:id → update [session_token]", + "ApiKeyController POST → write [session_token]", + "BillingController GET /subscription → read [session_token]", + "BillingController POST /checkout-session → write [session_token]", + "BillingController POST /portal-session → write [session_token]", + "ConfigTemplateController DELETE /:id → delete", + "ConfigTemplateController GET → read", + "ConfigTemplateController GET /:id → read", + "ConfigTemplateController GET /predefined → read", + "ConfigTemplateController PATCH /:id → update", + "ConfigTemplateController POST → write", + "ConfigTemplateController POST /:id/duplicate → write", + "CustomDomainController DELETE /:id → delete [session_token]", + "CustomDomainController GET → read [session_token]", + "CustomDomainController GET /:id → read [session_token]", + "CustomDomainController GET /:id/setup-instructions → read [session_token]", + "CustomDomainController GET /default → read [session_token]", + "CustomDomainController GET /resolve → read", + "CustomDomainController POST → write [session_token]", + "CustomDomainController POST /:id/set-default → update (override) [session_token]", + "CustomDomainController POST /:id/verify → update (override) [session_token]", + "CustomDomainController POST /clear-default → update (override) [session_token]", + "QrCodeController DELETE /:id → delete", + "QrCodeController GET → read", + "QrCodeController GET /:id → read", + "QrCodeController GET /:id/download → read", + "QrCodeController GET /screenshot → read [session_token]", + "QrCodeController PATCH /:id → update", + "QrCodeController POST → write", + "QrCodeController POST /:id/duplicate → write", + "QrCodeController POST /bulk-import → write", + "QrCodeController POST /render → read (override) [session_token]", + "QrCodeShareController DELETE /:id/share → delete", + "QrCodeShareController GET /:id/share → read", + "QrCodeShareController PATCH /:id/share → update", + "QrCodeShareController POST /:id/share → write", + "ShortUrlController DELETE /:shortCode → delete", + "ShortUrlController GET → read", + "ShortUrlController GET /:shortCode → read [session_token]", + "ShortUrlController GET /:shortCode/analytics → read", + "ShortUrlController GET /:shortCode/detail → read", + "ShortUrlController GET /:shortCode/get-views → read", + "ShortUrlController GET /reserved → read", + "ShortUrlController PATCH /:shortCode → update", + "ShortUrlController PATCH /:shortCode/toggle-active-state → update", + "ShortUrlController POST → write", + "ShortUrlController POST /:shortCode/duplicate → write", + "ShortUrlController POST /:shortCode/record-scan → write [session_token]", + "TagController DELETE /:id → delete", + "TagController GET → read", + "TagController PATCH /:id → update", + "TagController POST → write", + "TagController PUT /qr-code/:id → update", + "TagController PUT /short-url/:id → update", + "UserSurveyController GET /status → read [session_token]", + "UserSurveyController POST → write [session_token]", +] +`; diff --git a/apps/backend/src/__tests__/scope-coverage-matrix.test.ts b/apps/backend/src/__tests__/scope-coverage-matrix.test.ts new file mode 100644 index 00000000..7b2f55c2 --- /dev/null +++ b/apps/backend/src/__tests__/scope-coverage-matrix.test.ts @@ -0,0 +1,134 @@ +import 'reflect-metadata'; +import { ROUTE_METADATA_KEY, type RouteMetadata } from '@/core/decorators/route'; +import { resolveScopeForMethod } from '@/libs/fastify/helpers'; + +// Controllers that participate in API-key auth (not webhooks, not health) +import { ApiKeyController } from '@/modules/api-key/http/controller/api-key.controller'; +import { AnalyticsIntegrationController } from '@/modules/analytics-integration/http/controller/analytics-integration.controller'; +import { QrCodeController } from '@/modules/qr-code/http/controller/qr-code.controller'; +import { QrCodeShareController } from '@/modules/qr-code/http/controller/qr-code-share.controller'; +import { CustomDomainController } from '@/modules/custom-domain/http/controller/custom-domain.controller'; +import { ConfigTemplateController } from '@/modules/config-template/http/controller/config-template.controller'; +import { TagController } from '@/modules/tag/http/controller/tag.controller'; +import { ShortUrlController } from '@/modules/url-shortener/http/controller/short-url.controller'; +import { BillingController } from '@/modules/billing/http/controller/billing.controller'; +import { UserSurveyController } from '@/modules/user-survey/http/controller/user-survey.controller'; + +const CONTROLLERS = [ + ApiKeyController, + AnalyticsIntegrationController, + QrCodeController, + QrCodeShareController, + CustomDomainController, + ConfigTemplateController, + TagController, + ShortUrlController, + BillingController, + UserSurveyController, +]; + +type RouteRow = { + controller: string; + method: string; + path: string; + scope: string | null; + authHandlerSetting: 'default' | 'custom' | 'disabled'; + allowedTokenTypes: string[] | undefined; + effectiveTokenTypes: string[] | null; + overrideExplicit: boolean; + hidden: boolean; +}; + +function gatherRoutes(): RouteRow[] { + const rows: RouteRow[] = []; + for (const Controller of CONTROLLERS) { + const meta = Reflect.getMetadata(ROUTE_METADATA_KEY, Controller) as RouteMetadata[] | undefined; + if (!meta) continue; + for (const r of meta) { + const explicit = r.options.config?.scope; + const resolved = explicit ?? resolveScopeForMethod(r.method); + let authSetting: RouteRow['authHandlerSetting'] = 'default'; + if (r.options.authHandler === false) authSetting = 'disabled'; + else if (typeof r.options.authHandler !== 'undefined') authSetting = 'custom'; + const explicitAllowed = r.options.config?.allowedTokenTypes; + const hidden = (r.options.schema as { hide?: boolean } | undefined)?.hide === true; + const effectiveAllowed = + authSetting === 'disabled' + ? null + : (explicitAllowed ?? (hidden ? ['session_token'] : null)); + rows.push({ + controller: Controller.name, + method: r.method.toUpperCase(), + path: r.path, + scope: resolved ?? null, + authHandlerSetting: authSetting, + allowedTokenTypes: explicitAllowed, + effectiveTokenTypes: effectiveAllowed, + overrideExplicit: explicit !== undefined, + hidden, + }); + } + } + return rows; +} + +describe('scope-coverage-matrix', () => { + const rows = gatherRoutes(); + + it('discovers at least one route per controller (regression guard)', () => { + const byController = new Map(); + rows.forEach((r) => byController.set(r.controller, (byController.get(r.controller) ?? 0) + 1)); + for (const Controller of CONTROLLERS) { + expect(byController.get(Controller.name) ?? 0).toBeGreaterThan(0); + } + }); + + it('every authenticated route resolves to a non-null scope', () => { + const violations = rows.filter((r) => r.authHandlerSetting !== 'disabled' && r.scope === null); + expect(violations).toEqual([]); + }); + + it('every route uses a known HTTP method', () => { + const allowed = new Set(['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS']); + const violations = rows.filter((r) => !allowed.has(r.method)); + expect(violations).toEqual([]); + }); + + it('every resolved scope is one of read/write/update/delete (or null)', () => { + const allowed = new Set(['read', 'write', 'update', 'delete', null]); + const violations = rows.filter((r) => !allowed.has(r.scope)); + expect(violations).toEqual([]); + }); + + it('api-key management routes are session-only', () => { + const apiKeyRoutes = rows.filter((r) => r.controller === 'ApiKeyController'); + // Session-token-only restriction is non-negotiable here: an API key must + // never be able to mint, list, update, or revoke other API keys. + const violations = apiKeyRoutes.filter( + (r) => !r.allowedTokenTypes?.includes('session_token') || r.allowedTokenTypes.length !== 1, + ); + expect(violations).toEqual([]); + }); + + it('every hidden route is effectively session-only (default rule)', () => { + const violations = rows.filter( + (r) => + r.hidden && + r.authHandlerSetting !== 'disabled' && + (!r.effectiveTokenTypes || + !r.effectiveTokenTypes.includes('session_token') || + r.effectiveTokenTypes.includes('api_key')), + ); + expect(violations).toEqual([]); + }); + + it('snapshot of route → method → scope mapping (catches accidental drift)', () => { + const snapshot = rows + .map((r) => { + const tokenTag = r.effectiveTokenTypes ? ` [${r.effectiveTokenTypes.join('|')}]` : ''; + return `${r.controller} ${r.method} ${r.path} → ${r.scope}${r.overrideExplicit ? ' (override)' : ''}${tokenTag}`; + }) + .sort(); + expect(snapshot).toMatchSnapshot(); + }); +}); diff --git a/apps/backend/src/core/decorators/route.ts b/apps/backend/src/core/decorators/route.ts index ef0eb543..93081686 100644 --- a/apps/backend/src/core/decorators/route.ts +++ b/apps/backend/src/core/decorators/route.ts @@ -1,6 +1,8 @@ import { type HTTPMethods, type RouteShorthandOptions } from 'fastify'; import { type ZodSchema } from 'zod'; +import { type ApiKeyScope } from '@shared/schemas'; import { type RateLimitPolicy } from '../rate-limit/rate-limit.policy'; +import { type TTokenType } from '../domain/schema/UserSchema'; export type HandlerName = string; export type Constructable = new (...args: unknown[]) => T; @@ -18,6 +20,30 @@ interface CustomRouteOptions { responseSchema?: Record; config?: { rateLimitPolicy?: RateLimitPolicy; + /** + * API-key scope required to call this route. + * + * When omitted, defaults are derived from the HTTP method: + * GET, HEAD → 'read' + * POST → 'write' + * PUT, PATCH → 'update' + * DELETE → 'delete' + * + * Override per route when the default doesn't match the operation's + * semantic intent (e.g. a POST that's actually a read like "verify domain"). + * + * Session-token requests are unaffected — scopes only apply to API keys. + */ + scope?: ApiKeyScope; + /** + * Restricts which authentication token types may call this route. + * + * When omitted, any token type the auth middleware accepts is allowed. + * Set to `['session_token']` to lock a route to web-UI users — useful for + * security-critical endpoints (e.g. API-key management) that must NOT be + * callable using an API key, regardless of its scopes. + */ + allowedTokenTypes?: TTokenType[]; }; } diff --git a/apps/backend/src/core/domain/schema/UserSchema.ts b/apps/backend/src/core/domain/schema/UserSchema.ts index cfde51ba..219d9896 100644 --- a/apps/backend/src/core/domain/schema/UserSchema.ts +++ b/apps/backend/src/core/domain/schema/UserSchema.ts @@ -8,5 +8,6 @@ export const UserSchema = z.object({ id: z.string(), tokenType: TokenTypeSchema, plan: z.enum(PlanName), + scopes: z.array(z.string()).optional(), }); export type TUser = z.infer; diff --git a/apps/backend/src/core/error/http/index.ts b/apps/backend/src/core/error/http/index.ts index 08a635a8..01a234be 100644 --- a/apps/backend/src/core/error/http/index.ts +++ b/apps/backend/src/core/error/http/index.ts @@ -4,7 +4,9 @@ import { CustomApiError } from './custom-api.error'; import { NotFoundError } from './not-found.error'; import { UnauthorizedError } from './unauthorized.error'; import { ForbiddenError } from './forbidden.error'; +import { InsufficientScopeError } from './insufficient-scope.error'; import { ServiceUnavailableError } from './service-unavailable.error'; +import { TokenTypeNotAllowedError } from './token-type-not-allowed.error'; export { CustomApiError, @@ -13,5 +15,7 @@ export { NotFoundError, UnauthorizedError, ForbiddenError, + InsufficientScopeError, ServiceUnavailableError, + TokenTypeNotAllowedError, }; diff --git a/apps/backend/src/core/error/http/insufficient-scope.error.ts b/apps/backend/src/core/error/http/insufficient-scope.error.ts new file mode 100644 index 00000000..c1978ed1 --- /dev/null +++ b/apps/backend/src/core/error/http/insufficient-scope.error.ts @@ -0,0 +1,16 @@ +import { type ApiKeyScope } from '@shared/schemas'; +import { ForbiddenError } from './forbidden.error'; + +export class InsufficientScopeError extends ForbiddenError { + public readonly errorCode = 'INSUFFICIENT_SCOPE' as const; + public readonly requiredScope: ApiKeyScope; + public readonly grantedScopes: string[]; + + constructor(requiredScope: ApiKeyScope, grantedScopes: string[]) { + super( + `Insufficient API key scope. This endpoint requires "${requiredScope}", but the key only has [${grantedScopes.join(', ') || 'none'}].`, + ); + this.requiredScope = requiredScope; + this.grantedScopes = grantedScopes; + } +} diff --git a/apps/backend/src/core/error/http/token-type-not-allowed.error.ts b/apps/backend/src/core/error/http/token-type-not-allowed.error.ts new file mode 100644 index 00000000..3e27ce3f --- /dev/null +++ b/apps/backend/src/core/error/http/token-type-not-allowed.error.ts @@ -0,0 +1,15 @@ +import { ForbiddenError } from './forbidden.error'; + +export class TokenTypeNotAllowedError extends ForbiddenError { + public readonly errorCode = 'TOKEN_TYPE_NOT_ALLOWED' as const; + public readonly providedTokenType: string; + public readonly allowedTokenTypes: string[]; + + constructor(provided: string, allowed: string[]) { + super( + `Authentication type "${provided}" is not allowed for this endpoint. Allowed: [${allowed.join(', ')}].`, + ); + this.providedTokenType = provided; + this.allowedTokenTypes = allowed; + } +} diff --git a/apps/backend/src/core/http/middleware/__tests__/enforce-scope.middleware.test.ts b/apps/backend/src/core/http/middleware/__tests__/enforce-scope.middleware.test.ts new file mode 100644 index 00000000..cc9f8eff --- /dev/null +++ b/apps/backend/src/core/http/middleware/__tests__/enforce-scope.middleware.test.ts @@ -0,0 +1,125 @@ +import { type FastifyRequest } from 'fastify'; +import { type ApiKeyScope } from '@shared/schemas'; +import { enforceScope } from '../enforce-scope.middleware'; +import { InsufficientScopeError } from '@/core/error/http/insufficient-scope.error'; + +const fakeRequest = ( + user: { tokenType: string; scopes?: string[]; id?: string; plan?: string } | undefined, +) => ({ user }) as unknown as FastifyRequest; + +describe('enforceScope', () => { + const cases: Array<{ + name: string; + required: ApiKeyScope; + user: { tokenType: string; scopes?: string[] } | undefined; + expectThrow: boolean; + }> = [ + { + name: 'no user (route opted out of auth)', + required: 'read', + user: undefined, + expectThrow: false, + }, + { + name: 'session token bypass', + required: 'delete', + user: { tokenType: 'session_token' }, + expectThrow: false, + }, + { + name: 'm2m token bypass', + required: 'delete', + user: { tokenType: 'm2m_token' }, + expectThrow: false, + }, + { + name: 'oauth token bypass', + required: 'delete', + user: { tokenType: 'oauth_token' }, + expectThrow: false, + }, + { + name: 'api_key with empty scopes (legacy grandfather)', + required: 'delete', + user: { tokenType: 'api_key', scopes: [] }, + expectThrow: false, + }, + { + name: 'api_key with no scopes property (legacy grandfather)', + required: 'delete', + user: { tokenType: 'api_key' }, + expectThrow: false, + }, + { + name: 'api_key with matching scope (read)', + required: 'read', + user: { tokenType: 'api_key', scopes: ['read'] }, + expectThrow: false, + }, + { + name: 'api_key with matching scope (write)', + required: 'write', + user: { tokenType: 'api_key', scopes: ['read', 'write'] }, + expectThrow: false, + }, + { + name: 'api_key with matching scope (update)', + required: 'update', + user: { tokenType: 'api_key', scopes: ['update'] }, + expectThrow: false, + }, + { + name: 'api_key with matching scope (delete)', + required: 'delete', + user: { tokenType: 'api_key', scopes: ['delete'] }, + expectThrow: false, + }, + { + name: 'api_key with all four scopes', + required: 'delete', + user: { tokenType: 'api_key', scopes: ['read', 'write', 'update', 'delete'] }, + expectThrow: false, + }, + { + name: 'api_key with non-matching scope (read-only key, write required)', + required: 'write', + user: { tokenType: 'api_key', scopes: ['read'] }, + expectThrow: true, + }, + { + name: 'api_key with read+write but delete required', + required: 'delete', + user: { tokenType: 'api_key', scopes: ['read', 'write'] }, + expectThrow: true, + }, + { + name: 'api_key with unknown scope value (forward-compat)', + required: 'read', + user: { tokenType: 'api_key', scopes: ['something_we_dont_know'] }, + expectThrow: true, + }, + ]; + + cases.forEach(({ name, required, user, expectThrow }) => { + it(name, async () => { + const handler = enforceScope(required); + const req = fakeRequest(user); + if (expectThrow) { + await expect(handler(req)).rejects.toBeInstanceOf(InsufficientScopeError); + } else { + await expect(handler(req)).resolves.toBeUndefined(); + } + }); + }); + + it('attaches requiredScope and grantedScopes on the thrown error', async () => { + const handler = enforceScope('delete'); + const req = fakeRequest({ tokenType: 'api_key', scopes: ['read'] }); + await expect(handler(req)).rejects.toMatchObject({ + errorCode: 'INSUFFICIENT_SCOPE', + requiredScope: 'delete', + grantedScopes: ['read'], + statusCode: 403, + }); + }); +}); diff --git a/apps/backend/src/core/http/middleware/__tests__/enforce-token-type.middleware.test.ts b/apps/backend/src/core/http/middleware/__tests__/enforce-token-type.middleware.test.ts new file mode 100644 index 00000000..e34f9aca --- /dev/null +++ b/apps/backend/src/core/http/middleware/__tests__/enforce-token-type.middleware.test.ts @@ -0,0 +1,36 @@ +import { type FastifyRequest } from 'fastify'; +import { enforceTokenType } from '../enforce-token-type.middleware'; +import { TokenTypeNotAllowedError } from '@/core/error/http/token-type-not-allowed.error'; + +const fakeRequest = (user: { tokenType: string } | undefined) => + ({ user }) as unknown as FastifyRequest; + +describe('enforceTokenType', () => { + it('no-op when there is no user (auth-skipped route)', async () => { + const handler = enforceTokenType(['session_token']); + await expect(handler(fakeRequest(undefined))).resolves.toBeUndefined(); + }); + + it('passes when token type is in the allowed list', async () => { + const handler = enforceTokenType(['session_token', 'api_key']); + await expect(handler(fakeRequest({ tokenType: 'session_token' }))).resolves.toBeUndefined(); + await expect(handler(fakeRequest({ tokenType: 'api_key' }))).resolves.toBeUndefined(); + }); + + it('throws when token type is not in the allowed list', async () => { + const handler = enforceTokenType(['session_token']); + await expect(handler(fakeRequest({ tokenType: 'api_key' }))).rejects.toBeInstanceOf( + TokenTypeNotAllowedError, + ); + }); + + it('attaches providedTokenType and allowedTokenTypes on the thrown error', async () => { + const handler = enforceTokenType(['session_token']); + await expect(handler(fakeRequest({ tokenType: 'api_key' }))).rejects.toMatchObject({ + errorCode: 'TOKEN_TYPE_NOT_ALLOWED', + providedTokenType: 'api_key', + allowedTokenTypes: ['session_token'], + statusCode: 403, + }); + }); +}); diff --git a/apps/backend/src/core/http/middleware/add-user-to-request.middleware.ts b/apps/backend/src/core/http/middleware/add-user-to-request.middleware.ts index fb244eba..65640f1e 100644 --- a/apps/backend/src/core/http/middleware/add-user-to-request.middleware.ts +++ b/apps/backend/src/core/http/middleware/add-user-to-request.middleware.ts @@ -37,13 +37,20 @@ async function resolveUserPlan(userId: string): Promise { } export async function addUserToRequestMiddleware(request: FastifyRequest, _reply: unknown) { - const { userId, tokenType } = getAuth(request, { + const auth = getAuth(request, { acceptsToken: ['session_token', 'api_key'], - }) as { userId: string | null; tokenType: TTokenType }; + }) as { + userId: string | null; + tokenType: TTokenType; + scopes?: string[]; + }; + + const { userId, tokenType } = auth; if (userId) { const plan = await resolveUserPlan(userId); - request.user = { id: userId, tokenType, plan }; + const scopes = tokenType === 'api_key' ? (auth.scopes ?? []) : undefined; + request.user = { id: userId, tokenType, plan, scopes }; void trackActiveSession(container.resolve(KeyCache).getClient(), userId); } else { request.user = undefined; diff --git a/apps/backend/src/core/http/middleware/enforce-scope.middleware.ts b/apps/backend/src/core/http/middleware/enforce-scope.middleware.ts new file mode 100644 index 00000000..6894a25a --- /dev/null +++ b/apps/backend/src/core/http/middleware/enforce-scope.middleware.ts @@ -0,0 +1,27 @@ +import { type FastifyRequest } from 'fastify'; +import { type ApiKeyScope } from '@shared/schemas'; +import { InsufficientScopeError } from '@/core/error/http/insufficient-scope.error'; + +/** + * Returns a Fastify preHandler that enforces an API-key scope. + * + * Behaviour: + * - No `request.user` (route opted out of auth) → no-op. + * - Token type is not `api_key` (session, m2m, oauth) → no-op. Scopes only apply to API keys. + * - API key has empty scopes (legacy keys created before this feature) → no-op (grandfathered). + * - API key has scopes but doesn't include the required one → throws `InsufficientScopeError` (403). + */ +export const enforceScope = + (required: ApiKeyScope) => + async (request: FastifyRequest): Promise => { + const user = request.user; + if (!user) return; + if (user.tokenType !== 'api_key') return; + + const scopes = user.scopes ?? []; + if (scopes.length === 0) return; + + if (!scopes.includes(required)) { + throw new InsufficientScopeError(required, scopes); + } + }; diff --git a/apps/backend/src/core/http/middleware/enforce-token-type.middleware.ts b/apps/backend/src/core/http/middleware/enforce-token-type.middleware.ts new file mode 100644 index 00000000..6c149641 --- /dev/null +++ b/apps/backend/src/core/http/middleware/enforce-token-type.middleware.ts @@ -0,0 +1,23 @@ +import { type FastifyRequest } from 'fastify'; +import { type TTokenType } from '@/core/domain/schema/UserSchema'; +import { TokenTypeNotAllowedError } from '@/core/error/http/token-type-not-allowed.error'; + +/** + * Returns a Fastify preHandler that restricts which authentication token types + * may call a route. Used to lock security-critical endpoints (API-key + * management) to web-UI users only — an API-key holder cannot mint, list, + * update, or revoke other keys. + * + * Behaviour: + * - No `request.user` (route opted out of auth) → no-op. + * - Token type is in the allowed list → no-op. + * - Token type is NOT in the allowed list → throws TokenTypeNotAllowedError (403). + */ +export const enforceTokenType = + (allowed: TTokenType[]) => + async (request: FastifyRequest): Promise => { + const user = request.user; + if (!user) return; + if (allowed.includes(user.tokenType)) return; + throw new TokenTypeNotAllowedError(user.tokenType, allowed); + }; diff --git a/apps/backend/src/libs/fastify/__tests__/resolve-scope-for-method.test.ts b/apps/backend/src/libs/fastify/__tests__/resolve-scope-for-method.test.ts new file mode 100644 index 00000000..c3de6c30 --- /dev/null +++ b/apps/backend/src/libs/fastify/__tests__/resolve-scope-for-method.test.ts @@ -0,0 +1,29 @@ +import { resolveScopeForMethod } from '../helpers'; + +describe('resolveScopeForMethod', () => { + it.each([ + ['GET', 'read'], + ['HEAD', 'read'], + ['POST', 'write'], + ['PUT', 'update'], + ['PATCH', 'update'], + ['DELETE', 'delete'], + ])('%s → %s', (method, expected) => { + expect(resolveScopeForMethod(method)).toBe(expected); + }); + + it('returns null for OPTIONS (CORS preflights have no auth)', () => { + expect(resolveScopeForMethod('OPTIONS')).toBeNull(); + }); + + it('is case-insensitive', () => { + expect(resolveScopeForMethod('get')).toBe('read'); + expect(resolveScopeForMethod('post')).toBe('write'); + expect(resolveScopeForMethod('Patch')).toBe('update'); + }); + + it('returns null for unknown methods (forward-compat, no scope check applied)', () => { + expect(resolveScopeForMethod('TRACE')).toBeNull(); + expect(resolveScopeForMethod('CONNECT')).toBeNull(); + }); +}); diff --git a/apps/backend/src/libs/fastify/helpers.ts b/apps/backend/src/libs/fastify/helpers.ts index 98bd650d..064bbbf5 100644 --- a/apps/backend/src/libs/fastify/helpers.ts +++ b/apps/backend/src/libs/fastify/helpers.ts @@ -9,7 +9,13 @@ import { } from 'fastify'; import { env } from '@/core/config/env'; import { deepMerge, mergeZodErrorObjects } from '@/utils/general'; -import { BadRequestError, CustomApiError, UnauthorizedError } from '@/core/error/http'; +import { + BadRequestError, + CustomApiError, + InsufficientScopeError, + TokenTypeNotAllowedError, + UnauthorizedError, +} from '@/core/error/http'; import { container, type InjectionToken } from 'tsyringe'; import { Logger } from '@/core/logging'; import { ErrorReporter } from '@/core/error'; @@ -19,6 +25,9 @@ import { ROUTE_METADATA_KEY, type RouteMetadata } from '@/core/decorators/route' import { type IHttpResponse } from '@/core/interface/response.interface'; import type AbstractController from '@/core/http/controller/abstract.controller'; import { defaultApiAuthMiddleware } from '@/core/http/middleware/default-api-auth.middleware'; +import { enforceScope } from '@/core/http/middleware/enforce-scope.middleware'; +import { enforceTokenType } from '@/core/http/middleware/enforce-token-type.middleware'; +import { type ApiKeyScope } from '@shared/schemas'; import z, { type ZodType } from 'zod'; import qs from 'qs'; import { UnhandledServerError } from '@/core/error/http/unhandled-server.error'; @@ -56,6 +65,18 @@ export const fastifyErrorHandler = ( responsePayload.fieldErrors = mergedErrors; } + if (error instanceof InsufficientScopeError) { + responsePayload.errorCode = error.errorCode; + responsePayload.requiredScope = error.requiredScope; + responsePayload.grantedScopes = error.grantedScopes; + } + + if (error instanceof TokenTypeNotAllowedError) { + responsePayload.errorCode = error.errorCode; + responsePayload.providedTokenType = error.providedTokenType; + responsePayload.allowedTokenTypes = error.allowedTokenTypes; + } + logger.error('CustomApiError', { request: createRequestLogObject(_request), error: { @@ -143,6 +164,34 @@ function parseJsonFields(body: Record, fieldsToParse: string[] = [' return parsedBody; } +/** + * Maps an HTTP method to its default API-key scope. + * + * - GET / HEAD → 'read' + * - POST → 'write' + * - PUT / PATCH → 'update' + * - DELETE → 'delete' + * - OPTIONS → null (CORS preflights — no auth, no scope check) + * + * Exported so the scope-coverage matrix test can assert the same mapping. + */ +export function resolveScopeForMethod(method: string): ApiKeyScope | null { + switch (method.toUpperCase()) { + case 'GET': + case 'HEAD': + return 'read'; + case 'POST': + return 'write'; + case 'PUT': + case 'PATCH': + return 'update'; + case 'DELETE': + return 'delete'; + default: + return null; + } +} + export function registerRoutes( fastify: FastifyInstance, ControllerClass: unknown, @@ -241,6 +290,28 @@ export function registerRoutes( // no-op: skip authentication for this route } + // Token-type and scope enforcement run after auth so they see the populated + // request.user. Skipped when the route opts out of auth (webhooks, health) — + // there's no user to check against. + if (routeMeta.options.authHandler !== false) { + // Default rule: hidden routes (schema.hide === true) are session-only. + // They are not part of the public API contract, so an API key has no + // legitimate reason to call them. Override via `config.allowedTokenTypes` + // when a hidden route deliberately needs API-key access. + const explicitAllowed = routeMeta.options.config?.allowedTokenTypes; + const isHidden = (routeMeta.options.schema as { hide?: boolean } | undefined)?.hide === true; + const effectiveAllowed = explicitAllowed ?? (isHidden ? ['session_token' as const] : null); + if (effectiveAllowed) { + routeOptions.preHandler.push(enforceTokenType(effectiveAllowed)); + } + + const requiredScope = + routeMeta.options.config?.scope ?? resolveScopeForMethod(routeMeta.method); + if (requiredScope) { + routeOptions.preHandler.push(enforceScope(requiredScope)); + } + } + fastify.route(routeOptions); }); } diff --git a/apps/backend/src/modules/analytics-integration/http/controller/analytics-integration.controller.ts b/apps/backend/src/modules/analytics-integration/http/controller/analytics-integration.controller.ts index d3bb44ee..6aac626b 100644 --- a/apps/backend/src/modules/analytics-integration/http/controller/analytics-integration.controller.ts +++ b/apps/backend/src/modules/analytics-integration/http/controller/analytics-integration.controller.ts @@ -158,6 +158,7 @@ export class AnalyticsIntegrationController extends AbstractController { description: 'Validates the stored credentials against the analytics provider.', operationId: 'analytics-integration/test', }, + config: { scope: 'read' }, }) async test( request: IHttpRequest, diff --git a/apps/backend/src/modules/api-key/http/__tests__/api-key.test.ts b/apps/backend/src/modules/api-key/http/__tests__/api-key.test.ts index 9fe5a94c..afa80612 100644 --- a/apps/backend/src/modules/api-key/http/__tests__/api-key.test.ts +++ b/apps/backend/src/modules/api-key/http/__tests__/api-key.test.ts @@ -8,12 +8,14 @@ import { eq } from 'drizzle-orm'; import { randomUUID } from 'crypto'; import { KeyCache } from '@/core/cache'; import { + ALL_API_KEY_SCOPES, API_KEY_API_PATH, QR_CODE_API_PATH, createApiKeyRequest, getTestContext, listApiKeysRequest, revokeApiKeyRequest, + updateApiKeyRequest, } from './utils'; describe('api-key endpoints', () => { @@ -99,7 +101,7 @@ describe('api-key endpoints', () => { const response = await testServer.inject({ method: 'POST', url: API_KEY_API_PATH, - payload: { name: 'No auth' }, + payload: { name: 'No auth', scopes: ALL_API_KEY_SCOPES }, }); expect(response.statusCode).toBe(401); }); @@ -149,7 +151,17 @@ describe('api-key endpoints', () => { method: 'POST', url: API_KEY_API_PATH, headers: { Authorization: `Bearer ${accessTokenPro}` }, - payload: { name: '' }, + payload: { name: '', scopes: ALL_API_KEY_SCOPES }, + }); + expect(response.statusCode).toBe(400); + }); + + it('rejects creation without scopes (min 1 enforced)', async () => { + const response = await testServer.inject({ + method: 'POST', + url: API_KEY_API_PATH, + headers: { Authorization: `Bearer ${accessTokenPro}` }, + payload: { name: 'Empty-scopes' }, }); expect(response.statusCode).toBe(400); }); @@ -385,4 +397,263 @@ describe('api-key endpoints', () => { expect(createResponse.statusCode).toBe(403); // …but cannot create API keys }); }); + + describe('Scope enforcement', () => { + it('rejects a write call when the key only has read', async () => { + const createResponse = await createApiKeyRequest( + testServer, + { name: 'Read-only key', scopes: ['read'] }, + accessTokenPro, + ); + const { id, secret } = JSON.parse(createResponse.payload) as { + id: string; + secret: string; + }; + createdApiKeyIds.push(id); + + // POST /tag is a documented authenticated write endpoint with a simple body — + // good fixture for asserting the scope check fires (returns 403, not a 400 + // from validation or 401 from auth). + const tagCreate = await testServer.inject({ + method: 'POST', + url: '/api/v1/tag', + headers: { Authorization: `Bearer ${secret}`, 'Content-Type': 'application/json' }, + payload: { name: 'Test tag', color: '#ff0000' }, + }); + expect(tagCreate.statusCode).toBe(403); + const body = JSON.parse(tagCreate.payload); + expect(body.errorCode).toBe('INSUFFICIENT_SCOPE'); + expect(body.requiredScope).toBe('write'); + expect(body.grantedScopes).toEqual(['read']); + }); + + it('allows a read call when the key has read', async () => { + const createResponse = await createApiKeyRequest( + testServer, + { name: 'Read-only listing', scopes: ['read'] }, + accessTokenPro, + ); + const { id, secret } = JSON.parse(createResponse.payload) as { + id: string; + secret: string; + }; + createdApiKeyIds.push(id); + + const listResponse = await testServer.inject({ + method: 'GET', + url: `${QR_CODE_API_PATH}?page=1&limit=1`, + headers: { Authorization: `Bearer ${secret}` }, + }); + expect(listResponse.statusCode).toBe(200); + }); + + it('rejects a delete call when the key has read+write+update but not delete', async () => { + const createResponse = await createApiKeyRequest( + testServer, + { name: 'No-delete key', scopes: ['read', 'write', 'update'] }, + accessTokenPro, + ); + const { id, secret } = JSON.parse(createResponse.payload) as { + id: string; + secret: string; + }; + createdApiKeyIds.push(id); + + const deleteResponse = await testServer.inject({ + method: 'DELETE', + url: `${QR_CODE_API_PATH}/some-id-that-doesnt-matter`, + headers: { Authorization: `Bearer ${secret}` }, + }); + expect(deleteResponse.statusCode).toBe(403); + const body = JSON.parse(deleteResponse.payload); + expect(body.errorCode).toBe('INSUFFICIENT_SCOPE'); + expect(body.requiredScope).toBe('delete'); + }); + + it('hidden routes reject api_key auth with TOKEN_TYPE_NOT_ALLOWED', async () => { + const createResponse = await createApiKeyRequest( + testServer, + { name: 'Hidden-route probe', scopes: ALL_API_KEY_SCOPES }, + accessTokenPro, + ); + const { id, secret } = JSON.parse(createResponse.payload) as { + id: string; + secret: string; + }; + createdApiKeyIds.push(id); + + // Hidden routes (e.g. POST /custom-domain/clear-default) are session-only + // by default — even a fully-scoped API key is rejected. + const response = await testServer.inject({ + method: 'POST', + url: '/api/v1/custom-domain/clear-default', + headers: { Authorization: `Bearer ${secret}` }, + }); + expect(response.statusCode).toBe(403); + expect(JSON.parse(response.payload).errorCode).toBe('TOKEN_TYPE_NOT_ALLOWED'); + }); + }); + + describe('Token-type restriction (api_key cannot manage other keys)', () => { + it('rejects api_key auth when calling POST /api-key', async () => { + const createResponse = await createApiKeyRequest( + testServer, + { name: 'Self-replicating attempt', scopes: ALL_API_KEY_SCOPES }, + accessTokenPro, + ); + const { id, secret } = JSON.parse(createResponse.payload) as { + id: string; + secret: string; + }; + createdApiKeyIds.push(id); + + const attempt = await testServer.inject({ + method: 'POST', + url: API_KEY_API_PATH, + headers: { Authorization: `Bearer ${secret}`, 'Content-Type': 'application/json' }, + payload: { + name: 'Should not exist', + scopes: ALL_API_KEY_SCOPES, + }, + }); + expect(attempt.statusCode).toBe(403); + const body = JSON.parse(attempt.payload); + expect(body.errorCode).toBe('TOKEN_TYPE_NOT_ALLOWED'); + expect(body.providedTokenType).toBe('api_key'); + expect(body.allowedTokenTypes).toEqual(['session_token']); + }); + + it('rejects api_key auth when calling GET /api-key (list)', async () => { + const createResponse = await createApiKeyRequest( + testServer, + { name: 'List-attempt key', scopes: ALL_API_KEY_SCOPES }, + accessTokenPro, + ); + const { id, secret } = JSON.parse(createResponse.payload) as { + id: string; + secret: string; + }; + createdApiKeyIds.push(id); + + const attempt = await listApiKeysRequest(testServer, secret); + expect(attempt.statusCode).toBe(403); + expect(JSON.parse(attempt.payload).errorCode).toBe('TOKEN_TYPE_NOT_ALLOWED'); + }); + + it('rejects api_key auth when calling DELETE /api-key/:id (revoke)', async () => { + const createA = await createApiKeyRequest( + testServer, + { name: 'Mutual revoke A', scopes: ALL_API_KEY_SCOPES }, + accessTokenPro, + ); + const { id: idA, secret: secretA } = JSON.parse(createA.payload) as { + id: string; + secret: string; + }; + createdApiKeyIds.push(idA); + + const createB = await createApiKeyRequest( + testServer, + { name: 'Mutual revoke B', scopes: ALL_API_KEY_SCOPES }, + accessTokenPro, + ); + const { id: idB } = JSON.parse(createB.payload) as { id: string; secret: string }; + createdApiKeyIds.push(idB); + + const attempt = await revokeApiKeyRequest(testServer, idB, secretA); + expect(attempt.statusCode).toBe(403); + expect(JSON.parse(attempt.payload).errorCode).toBe('TOKEN_TYPE_NOT_ALLOWED'); + }); + + it('rejects api_key auth when calling PATCH /api-key/:id (update)', async () => { + const createA = await createApiKeyRequest( + testServer, + { name: 'Self-update attempt', scopes: ALL_API_KEY_SCOPES }, + accessTokenPro, + ); + const { id, secret } = JSON.parse(createA.payload) as { + id: string; + secret: string; + }; + createdApiKeyIds.push(id); + + const attempt = await updateApiKeyRequest(testServer, id, { scopes: ['read'] }, secret); + expect(attempt.statusCode).toBe(403); + expect(JSON.parse(attempt.payload).errorCode).toBe('TOKEN_TYPE_NOT_ALLOWED'); + }); + }); + + describe('PATCH /api-key/:id (update flow)', () => { + it('updates scopes, description, and reflects them in the list response', async () => { + const createResponse = await createApiKeyRequest( + testServer, + { name: 'Updatable key', scopes: ['read'], description: 'before' }, + accessTokenPro, + ); + const { id } = JSON.parse(createResponse.payload) as { id: string }; + createdApiKeyIds.push(id); + + const update = await updateApiKeyRequest( + testServer, + id, + { scopes: ['read', 'write', 'update'], description: 'after' }, + accessTokenPro, + ); + expect(update.statusCode).toBe(200); + const updated = JSON.parse(update.payload); + expect(updated.scopes).toEqual(['read', 'write', 'update']); + expect(updated.description).toBe('after'); + + const list = await listApiKeysRequest(testServer, accessTokenPro); + const found = (JSON.parse(list.payload).data as Array<{ id: string; scopes: string[] }>).find( + (k) => k.id === id, + ); + expect(found?.scopes).toEqual(['read', 'write', 'update']); + }); + + it('returns 404 when updating a key that belongs to another user', async () => { + const createResponse = await createApiKeyRequest( + testServer, + { name: 'Cross-user probe', scopes: ALL_API_KEY_SCOPES }, + accessTokenPro, + ); + const { id } = JSON.parse(createResponse.payload) as { id: string }; + createdApiKeyIds.push(id); + + // Free user (different subject) tries to update Pro user's key — must fail. + const attempt = await updateApiKeyRequest( + testServer, + id, + { scopes: ['read'] }, + accessTokenFree, + ); + expect(attempt.statusCode).toBe(404); + }); + + it('rejects updating to empty scopes (min 1 enforced)', async () => { + const createResponse = await createApiKeyRequest( + testServer, + { name: 'Empty-scopes probe', scopes: ALL_API_KEY_SCOPES }, + accessTokenPro, + ); + const { id } = JSON.parse(createResponse.payload) as { id: string }; + createdApiKeyIds.push(id); + + const attempt = await updateApiKeyRequest(testServer, id, { scopes: [] }, accessTokenPro); + expect(attempt.statusCode).toBe(400); + }); + + it('rejects an update with no fields', async () => { + const createResponse = await createApiKeyRequest( + testServer, + { name: 'No-op update probe', scopes: ALL_API_KEY_SCOPES }, + accessTokenPro, + ); + const { id } = JSON.parse(createResponse.payload) as { id: string }; + createdApiKeyIds.push(id); + + const attempt = await updateApiKeyRequest(testServer, id, {}, accessTokenPro); + expect(attempt.statusCode).toBe(400); + }); + }); }); diff --git a/apps/backend/src/modules/api-key/http/__tests__/utils.ts b/apps/backend/src/modules/api-key/http/__tests__/utils.ts index bff84ece..c2e85106 100644 --- a/apps/backend/src/modules/api-key/http/__tests__/utils.ts +++ b/apps/backend/src/modules/api-key/http/__tests__/utils.ts @@ -1,10 +1,13 @@ import { API_BASE_PATH } from '@/core/config/constants'; import { getTestContext as getGlobalTestContext } from '@/tests/shared/test-context'; +import { type ApiKeyScope } from '@shared/schemas'; import type { FastifyInstance } from 'fastify'; export const API_KEY_API_PATH = `${API_BASE_PATH}/api-key`; export const QR_CODE_API_PATH = `${API_BASE_PATH}/qr-code`; +export const ALL_API_KEY_SCOPES: ApiKeyScope[] = ['read', 'write', 'update', 'delete']; + export interface TestContext { testServer: FastifyInstance; accessToken: string; @@ -24,13 +27,38 @@ export const getTestContext = async (): Promise => { export const createApiKeyRequest = async ( testServer: FastifyInstance, - payload: { name: string; description?: string; expiresInDays?: number | null }, + payload: { + name: string; + description?: string; + expiresInDays?: number | null; + scopes?: ApiKeyScope[]; + }, token: string, ) => testServer.inject({ method: 'POST', url: API_KEY_API_PATH, headers: { Authorization: `Bearer ${token}` }, + payload: { + scopes: ALL_API_KEY_SCOPES, + ...payload, + }, + }); + +export const updateApiKeyRequest = async ( + testServer: FastifyInstance, + apiKeyId: string, + payload: { + description?: string | null; + scopes?: ApiKeyScope[]; + expiresInDays?: number | null; + }, + token?: string, +) => + testServer.inject({ + method: 'PATCH', + url: `${API_KEY_API_PATH}/${apiKeyId}`, + headers: { Authorization: token ? `Bearer ${token}` : '' }, payload, }); diff --git a/apps/backend/src/modules/api-key/http/controller/api-key.controller.ts b/apps/backend/src/modules/api-key/http/controller/api-key.controller.ts index dcb81959..f2726e1b 100644 --- a/apps/backend/src/modules/api-key/http/controller/api-key.controller.ts +++ b/apps/backend/src/modules/api-key/http/controller/api-key.controller.ts @@ -1,5 +1,5 @@ import { inject, injectable } from 'tsyringe'; -import { Delete, Get, Post } from '@/core/decorators/route'; +import { Delete, Get, Patch, Post } from '@/core/decorators/route'; import AbstractController from '@/core/http/controller/abstract.controller'; import { type IHttpRequest } from '@/core/interface/request.interface'; import { type IHttpResponse } from '@/core/interface/response.interface'; @@ -8,16 +8,21 @@ import { DeleteResponseSchema } from '@/core/domain/schema/DeleteResponseSchema' import { RateLimitPolicy } from '@/core/rate-limit/rate-limit.policy'; import { ApiKeyListResponseDto, + ApiKeyResponseDto, CreateApiKeyDto, CreateApiKeyResponseDto, + UpdateApiKeyDto, type TApiKeyListResponseDto, + type TApiKeyResponseDto, type TCreateApiKeyDto, type TCreateApiKeyResponseDto, type TIdRequestQueryDto, + type TUpdateApiKeyDto, } from '@shared/schemas'; import { CreateApiKeyUseCase } from '../../useCase/create-api-key.use-case'; import { ListApiKeysUseCase } from '../../useCase/list-api-keys.use-case'; import { RevokeApiKeyUseCase } from '../../useCase/revoke-api-key.use-case'; +import { UpdateApiKeyUseCase } from '../../useCase/update-api-key.use-case'; @injectable() export class ApiKeyController extends AbstractController { @@ -25,6 +30,7 @@ export class ApiKeyController extends AbstractController { @inject(CreateApiKeyUseCase) private readonly createApiKeyUseCase: CreateApiKeyUseCase, @inject(ListApiKeysUseCase) private readonly listApiKeysUseCase: ListApiKeysUseCase, @inject(RevokeApiKeyUseCase) private readonly revokeApiKeyUseCase: RevokeApiKeyUseCase, + @inject(UpdateApiKeyUseCase) private readonly updateApiKeyUseCase: UpdateApiKeyUseCase, ) { super(); } @@ -33,15 +39,20 @@ export class ApiKeyController extends AbstractController { responseSchema: { 200: ApiKeyListResponseDto, 401: DEFAULT_ERROR_RESPONSES[401], + 403: DEFAULT_ERROR_RESPONSES[403], 429: DEFAULT_ERROR_RESPONSES[429], }, schema: { + hide: true, tags: ['API Keys'], summary: 'List API keys', description: - 'Returns all active API keys owned by the authenticated user. Secrets are never returned here — only at creation time.', + 'Returns all active API keys owned by the authenticated user. Secrets are never returned here — only at creation time. Session-token only.', operationId: 'api-key/list', }, + config: { + allowedTokenTypes: ['session_token'], + }, }) async list( request: IHttpRequest, @@ -60,14 +71,16 @@ export class ApiKeyController extends AbstractController { 429: DEFAULT_ERROR_RESPONSES[429], }, schema: { + hide: true, tags: ['API Keys'], summary: 'Create an API key', description: - 'Creates a new personal API key for the authenticated user. The plaintext secret is returned only once in this response — store it securely. Requires a Pro plan.', + 'Creates a new personal API key for the authenticated user. The plaintext secret is returned only once in this response — store it securely. Requires a Pro plan. Session-token only.', operationId: 'api-key/create', }, config: { rateLimitPolicy: RateLimitPolicy.TAG_CREATE, + allowedTokenTypes: ['session_token'], }, }) async create( @@ -81,18 +94,59 @@ export class ApiKeyController extends AbstractController { return this.makeApiHttpResponse(201, CreateApiKeyResponseDto.parse(apiKey)); } + @Patch('/:id', { + bodySchema: UpdateApiKeyDto, + responseSchema: { + 200: ApiKeyResponseDto, + 400: DEFAULT_ERROR_RESPONSES[400], + 401: DEFAULT_ERROR_RESPONSES[401], + 403: DEFAULT_ERROR_RESPONSES[403], + 404: DEFAULT_ERROR_RESPONSES[404], + 429: DEFAULT_ERROR_RESPONSES[429], + }, + schema: { + hide: true, + tags: ['API Keys'], + summary: 'Update an API key', + description: + 'Updates the description, scopes, or expiration of an existing API key. The plaintext secret cannot be re-issued. Session-token only.', + operationId: 'api-key/update', + params: { + type: 'object', + properties: { + id: { type: 'string', description: 'Clerk API key ID' }, + }, + }, + }, + config: { + allowedTokenTypes: ['session_token'], + }, + }) + async update( + request: IHttpRequest, + ): Promise> { + const apiKey = await this.updateApiKeyUseCase.execute( + request.params.id, + request.body, + request.user.id, + ); + return this.makeApiHttpResponse(200, ApiKeyResponseDto.parse(apiKey)); + } + @Delete('/:id', { responseSchema: { 200: DeleteResponseSchema, 401: DEFAULT_ERROR_RESPONSES[401], + 403: DEFAULT_ERROR_RESPONSES[403], 404: DEFAULT_ERROR_RESPONSES[404], 429: DEFAULT_ERROR_RESPONSES[429], }, schema: { + hide: true, tags: ['API Keys'], summary: 'Revoke an API key', description: - 'Revokes an API key. Any future requests authenticated with this key will be rejected.', + 'Revokes an API key. Any future requests authenticated with this key will be rejected. Session-token only.', operationId: 'api-key/revoke', params: { type: 'object', @@ -101,6 +155,9 @@ export class ApiKeyController extends AbstractController { }, }, }, + config: { + allowedTokenTypes: ['session_token'], + }, }) async revoke(request: IHttpRequest) { await this.revokeApiKeyUseCase.execute(request.params.id, request.user.id); diff --git a/apps/backend/src/modules/api-key/useCase/create-api-key.use-case.ts b/apps/backend/src/modules/api-key/useCase/create-api-key.use-case.ts index b6332d1d..e39acd58 100644 --- a/apps/backend/src/modules/api-key/useCase/create-api-key.use-case.ts +++ b/apps/backend/src/modules/api-key/useCase/create-api-key.use-case.ts @@ -5,6 +5,7 @@ import { PlanName } from '@/core/config/plan.config'; import { type TCreateApiKeyDto, type TCreateApiKeyResponseDto } from '@shared/schemas'; import { ClerkApiKeysService } from '../service/clerk-api-keys.service'; import { ProPlanRequiredError } from '../error/http/pro-plan-required.error'; +import { filterKnownScopes } from '../util/filter-known-scopes'; @injectable() export class CreateApiKeyUseCase implements IBaseUseCase { @@ -26,6 +27,7 @@ export class CreateApiKeyUseCase implements IBaseUseCase { createdBy: userId, description: dto.description ?? null, secondsUntilExpiration: dto.expiresInDays ? dto.expiresInDays * 86400 : null, + scopes: dto.scopes, }); if (!apiKey.secret) { @@ -42,6 +44,7 @@ export class CreateApiKeyUseCase implements IBaseUseCase { lastUsedAt: apiKey.lastUsedAt, expiration: apiKey.expiration, revoked: apiKey.revoked, + scopes: filterKnownScopes(apiKey.scopes), secret: apiKey.secret, }; } diff --git a/apps/backend/src/modules/api-key/useCase/list-api-keys.use-case.ts b/apps/backend/src/modules/api-key/useCase/list-api-keys.use-case.ts index 4b5fecde..38ada1bc 100644 --- a/apps/backend/src/modules/api-key/useCase/list-api-keys.use-case.ts +++ b/apps/backend/src/modules/api-key/useCase/list-api-keys.use-case.ts @@ -2,13 +2,16 @@ import { inject, injectable } from 'tsyringe'; import { type IBaseUseCase } from '@/core/interface/base-use-case.interface'; import { type TApiKeyResponseDto } from '@shared/schemas'; import { ClerkApiKeysService } from '../service/clerk-api-keys.service'; +import { filterKnownScopes } from '../util/filter-known-scopes'; @injectable() export class ListApiKeysUseCase implements IBaseUseCase { constructor(@inject(ClerkApiKeysService) private readonly clerkApiKeys: ClerkApiKeysService) {} async execute(userId: string): Promise { - const { data } = await this.clerkApiKeys.apiKeys.list({ subject: userId }); + // Clerk's default page size is small; lift it to 100 so users with many + // keys see them all in a single list response. The UI never paginates. + const { data } = await this.clerkApiKeys.apiKeys.list({ subject: userId, limit: 100 }); return data .filter((key) => !key.revoked) @@ -20,6 +23,7 @@ export class ListApiKeysUseCase implements IBaseUseCase { lastUsedAt: key.lastUsedAt, expiration: key.expiration, revoked: key.revoked, + scopes: filterKnownScopes(key.scopes), })); } } diff --git a/apps/backend/src/modules/api-key/useCase/update-api-key.use-case.ts b/apps/backend/src/modules/api-key/useCase/update-api-key.use-case.ts new file mode 100644 index 00000000..dc640a1e --- /dev/null +++ b/apps/backend/src/modules/api-key/useCase/update-api-key.use-case.ts @@ -0,0 +1,62 @@ +import { inject, injectable } from 'tsyringe'; +import { type IBaseUseCase } from '@/core/interface/base-use-case.interface'; +import { Logger } from '@/core/logging'; +import { type TApiKeyResponseDto, type TUpdateApiKeyDto } from '@shared/schemas'; +import { ClerkApiKeysService } from '../service/clerk-api-keys.service'; +import { ApiKeyNotFoundError } from '../error/http/api-key-not-found.error'; +import { filterKnownScopes } from '../util/filter-known-scopes'; + +function isClerkClientError(err: unknown): boolean { + if (typeof err !== 'object' || err === null || !('status' in err)) return false; + const status = (err as { status: unknown }).status; + return typeof status === 'number' && status >= 400 && status < 500; +} + +@injectable() +export class UpdateApiKeyUseCase implements IBaseUseCase { + constructor( + @inject(ClerkApiKeysService) private readonly clerkApiKeys: ClerkApiKeysService, + @inject(Logger) private readonly logger: Logger, + ) {} + + async execute( + apiKeyId: string, + dto: TUpdateApiKeyDto, + userId: string, + ): Promise { + let existing; + try { + existing = await this.clerkApiKeys.apiKeys.get(apiKeyId); + } catch (err) { + if (isClerkClientError(err)) throw new ApiKeyNotFoundError(); + throw err; + } + + if (existing.subject !== userId) throw new ApiKeyNotFoundError(); + + const updated = await this.clerkApiKeys.apiKeys.update({ + apiKeyId, + subject: userId, + ...(dto.description !== undefined ? { description: dto.description } : {}), + ...(dto.scopes !== undefined ? { scopes: dto.scopes } : {}), + ...(dto.expiresInDays !== undefined + ? { + secondsUntilExpiration: dto.expiresInDays === null ? null : dto.expiresInDays * 86400, + } + : {}), + }); + + this.logger.info('api-key.updated', { apiKey: { id: apiKeyId, userId } }); + + return { + id: updated.id, + name: updated.name, + description: updated.description ?? null, + createdAt: updated.createdAt, + lastUsedAt: updated.lastUsedAt, + expiration: updated.expiration, + revoked: updated.revoked, + scopes: filterKnownScopes(updated.scopes), + }; + } +} diff --git a/apps/backend/src/modules/api-key/util/filter-known-scopes.ts b/apps/backend/src/modules/api-key/util/filter-known-scopes.ts new file mode 100644 index 00000000..f168e020 --- /dev/null +++ b/apps/backend/src/modules/api-key/util/filter-known-scopes.ts @@ -0,0 +1,11 @@ +import { API_KEY_SCOPES, type ApiKeyScope } from '@shared/schemas'; + +/** + * Filters Clerk's raw `string[]` scope output down to scopes we know about, + * so the response DTO is strictly typed and forward-compatible if Clerk ever + * surfaces additional scopes we don't recognize. + */ +export function filterKnownScopes(scopes: string[] | null | undefined): ApiKeyScope[] { + if (!scopes || scopes.length === 0) return []; + return scopes.filter((s): s is ApiKeyScope => (API_KEY_SCOPES as readonly string[]).includes(s)); +} diff --git a/apps/backend/src/modules/custom-domain/http/controller/custom-domain.controller.ts b/apps/backend/src/modules/custom-domain/http/controller/custom-domain.controller.ts index 82def715..df09d90a 100644 --- a/apps/backend/src/modules/custom-domain/http/controller/custom-domain.controller.ts +++ b/apps/backend/src/modules/custom-domain/http/controller/custom-domain.controller.ts @@ -203,6 +203,7 @@ export class CustomDomainController extends AbstractController { }, config: { rateLimitPolicy: RateLimitPolicy.DOMAIN_VERIFY, + scope: 'update', }, schema: { hide: true, @@ -264,6 +265,7 @@ export class CustomDomainController extends AbstractController { 'Sets this domain as the default for all new dynamic QR codes. The domain must have active SSL status before it can be set as default.', operationId: 'custom-domain/set-default', }, + config: { scope: 'update' }, }) async setDefault( request: IHttpRequest, @@ -291,6 +293,7 @@ export class CustomDomainController extends AbstractController { 'Removes the default domain setting. New dynamic QR codes will use the system default domain.', operationId: 'custom-domain/clear-default', }, + config: { scope: 'update' }, }) async clearDefault(request: IHttpRequest): Promise> { await this.clearDefaultCustomDomainUseCase.execute(request.user.id); diff --git a/apps/backend/src/modules/qr-code/http/__tests__/render-qr-code.test.ts b/apps/backend/src/modules/qr-code/http/__tests__/render-qr-code.test.ts index 2522ee3e..bf62965c 100644 --- a/apps/backend/src/modules/qr-code/http/__tests__/render-qr-code.test.ts +++ b/apps/backend/src/modules/qr-code/http/__tests__/render-qr-code.test.ts @@ -107,7 +107,7 @@ describe('renderQrCode', () => { }); it('rejects oversized sizePx', async () => { - const response = await render({ sizePx: 4096 }); + const response = await render({ sizePx: 4097 }); expect(response).toHaveStatusCode(400); }); diff --git a/apps/backend/src/modules/qr-code/http/controller/qr-code.controller.ts b/apps/backend/src/modules/qr-code/http/controller/qr-code.controller.ts index 695a9a8f..c51727a0 100644 --- a/apps/backend/src/modules/qr-code/http/controller/qr-code.controller.ts +++ b/apps/backend/src/modules/qr-code/http/controller/qr-code.controller.ts @@ -380,6 +380,7 @@ export class QrCodeController extends AbstractController { }, config: { rateLimitPolicy: RateLimitPolicy.QR_RENDER, + scope: 'read', }, }) async render(request: IHttpRequest): Promise> { diff --git a/apps/frontend/src/components/dashboard/api-keys/ApiKeyList.tsx b/apps/frontend/src/components/dashboard/api-keys/ApiKeyList.tsx index f4f5f64e..afe54b57 100644 --- a/apps/frontend/src/components/dashboard/api-keys/ApiKeyList.tsx +++ b/apps/frontend/src/components/dashboard/api-keys/ApiKeyList.tsx @@ -67,6 +67,7 @@ export function ApiKeyList() { {t('name')} {t('descriptionLabel')} + {t('scopesLabel')} {t('expiresAt')} {t('lastUsedAt')} {t('createdAt')} diff --git a/apps/frontend/src/components/dashboard/api-keys/ApiKeyListItem.tsx b/apps/frontend/src/components/dashboard/api-keys/ApiKeyListItem.tsx index ed4b7ee4..0dc18dda 100644 --- a/apps/frontend/src/components/dashboard/api-keys/ApiKeyListItem.tsx +++ b/apps/frontend/src/components/dashboard/api-keys/ApiKeyListItem.tsx @@ -1,10 +1,14 @@ 'use client'; +import { useState } from 'react'; import { TableCell, TableRow } from '@/components/ui/table'; +import { Badge } from '@/components/ui/badge'; import { ApiKeyListItemActions } from './ApiKeyListItemActions'; +import { EditApiKeyDialog } from './EditApiKeyDialog'; import { useRevokeApiKeyMutation } from '@/lib/api/api-key'; import { cn } from '@/lib/utils'; import type { ApiKey } from './types'; +import { API_KEY_SCOPES } from '@shared/schemas'; import { useTranslations } from 'next-intl'; import * as Sentry from '@sentry/nextjs'; import posthog from 'posthog-js'; @@ -18,12 +22,17 @@ export function ApiKeyListItem({ apiKey, handleRevalidate }: ApiKeyListItemProps const revoke = useRevokeApiKeyMutation(); const isRevoking = revoke.isPending; const t = useTranslations('settings.apiKeys'); + const [showEdit, setShowEdit] = useState(false); const formatDate = (ms: number | null | undefined) => ms ? new Date(ms).toLocaleDateString() : null; const createdAt = formatDate(apiKey.createdAt) ?? '-'; const lastUsedAt = formatDate(apiKey.lastUsedAt) ?? '-'; const expiresAt = formatDate(apiKey.expiration) ?? t('neverExpires'); + const scopes = apiKey.scopes ?? []; + const hasFullAccess = + scopes.length === 0 || + (scopes.length === API_KEY_SCOPES.length && API_KEY_SCOPES.every((s) => scopes.includes(s))); async function onRevoke() { try { @@ -38,17 +47,43 @@ export function ApiKeyListItem({ apiKey, handleRevalidate }: ApiKeyListItemProps } return ( - - {apiKey.name ?? '—'} - {apiKey.description ?? ''} - - {expiresAt} - {lastUsedAt} - {createdAt} - - - - - + <> + + {apiKey.name ?? '—'} + {apiKey.description ?? ''} + + + {hasFullAccess ? ( + {t('scopesFullAccess')} + ) : ( +
+ {scopes.map((s) => { + const labelKey = `scope${s.charAt(0).toUpperCase()}${s.slice(1)}` as + | 'scopeRead' + | 'scopeWrite' + | 'scopeUpdate' + | 'scopeDelete'; + return {t(labelKey)}; + })} +
+ )} +
+ + {expiresAt} + {lastUsedAt} + {createdAt} + + + setShowEdit(true)} + /> + +
+ + + ); } diff --git a/apps/frontend/src/components/dashboard/api-keys/ApiKeyListItemActions.tsx b/apps/frontend/src/components/dashboard/api-keys/ApiKeyListItemActions.tsx index 770a8621..0578ce49 100644 --- a/apps/frontend/src/components/dashboard/api-keys/ApiKeyListItemActions.tsx +++ b/apps/frontend/src/components/dashboard/api-keys/ApiKeyListItemActions.tsx @@ -27,12 +27,14 @@ interface ApiKeyListItemActionsProps { }; isRevoking: boolean; onRevoke: () => void; + onEdit: () => void; } export function ApiKeyListItemActions({ apiKey, isRevoking, onRevoke, + onEdit, }: ApiKeyListItemActionsProps) { const t = useTranslations('settings.apiKeys'); const [showRevokeDialog, setShowRevokeDialog] = useState(false); @@ -49,6 +51,7 @@ export function ApiKeyListItemActions({ + {t('edit')} setShowRevokeDialog(true)} className="text-destructive focus:text-destructive" diff --git a/apps/frontend/src/components/dashboard/api-keys/CreateApiKeyDialog.tsx b/apps/frontend/src/components/dashboard/api-keys/CreateApiKeyDialog.tsx index 71a149c5..d050d40c 100644 --- a/apps/frontend/src/components/dashboard/api-keys/CreateApiKeyDialog.tsx +++ b/apps/frontend/src/components/dashboard/api-keys/CreateApiKeyDialog.tsx @@ -4,10 +4,10 @@ import { useEffect, useState } from 'react'; import { useTranslations } from 'next-intl'; import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; -import { z } from 'zod'; import Link from 'next/link'; import { useHasProPlan } from '@/hooks/useHasProPlan'; import { useCreateApiKeyMutation } from '@/lib/api/api-key'; +import { API_KEY_SCOPES, CreateApiKeyDto, type TCreateApiKeyDto } from '@shared/schemas'; import { Dialog, DialogContent, @@ -19,6 +19,7 @@ import { } from '@/components/ui/dialog'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; +import { Checkbox } from '@/components/ui/checkbox'; import { Form, FormControl, @@ -40,13 +41,7 @@ import { SelectValue, } from '@/components/ui/select'; -const CreateApiKeySchema = z.object({ - name: z.string().min(1).max(100).trim(), - description: z.string().max(64).default('').optional(), - expiresInDays: z.number().int().positive().optional().or(z.literal(null)), -}); - -type CreateApiKeyFormData = z.infer; +type CreateApiKeyFormData = TCreateApiKeyDto; export function CreateApiKeyDialog() { const t = useTranslations('settings.apiKeys'); @@ -58,11 +53,12 @@ export function CreateApiKeyDialog() { const { hasProPlan, isLoading: isPlanLoading } = useHasProPlan(); const form = useForm({ - resolver: zodResolver(CreateApiKeySchema), + resolver: zodResolver(CreateApiKeyDto), defaultValues: { name: '', description: '', expiresInDays: null, + scopes: [...API_KEY_SCOPES], }, }); @@ -72,6 +68,7 @@ export function CreateApiKeyDialog() { name: data.name, description: data.description || undefined, expiresInDays: data.expiresInDays ?? null, + scopes: data.scopes, }); setCreatedKey(key.secret); form.reset(); @@ -137,14 +134,18 @@ export function CreateApiKeyDialog() { - + {createdKey ? t('createdTitle') : t('formTitle')} - {!createdKey && {t('formHint')}} + {!createdKey && ( + + {t('securityNote')} + + )} {!createdKey ? (
- + {t('descriptionLabel')} - + @@ -176,14 +181,14 @@ export function CreateApiKeyDialog() { name="expiresInDays" render={({ field }) => ( - {t('expiresAt')} + {t('expiresAt')}* - {t('expiresAtDescription')} + + + )} + /> + + ( + +
+ {t('scopesLabel')} + {t('scopesDescription')} +
+
+ {API_KEY_SCOPES.map((scope) => { + const checked = field.value?.includes(scope) ?? false; + const cap = `${scope.charAt(0).toUpperCase()}${scope.slice(1)}`; + const labelKey = `scope${cap}` as + | 'scopeRead' + | 'scopeWrite' + | 'scopeUpdate' + | 'scopeDelete'; + const hintKey: + | 'scopeReadHint' + | 'scopeWriteHint' + | 'scopeUpdateHint' + | 'scopeDeleteHint' = `${labelKey}Hint`; + return ( + + ); + })} +
)} diff --git a/apps/frontend/src/components/dashboard/api-keys/EditApiKeyDialog.tsx b/apps/frontend/src/components/dashboard/api-keys/EditApiKeyDialog.tsx new file mode 100644 index 00000000..db425819 --- /dev/null +++ b/apps/frontend/src/components/dashboard/api-keys/EditApiKeyDialog.tsx @@ -0,0 +1,155 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { useTranslations } from 'next-intl'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { useUpdateApiKeyMutation } from '@/lib/api/api-key'; +import { + API_KEY_SCOPES, + UpdateApiKeyDto, + type ApiKeyScope, + type TUpdateApiKeyDto, +} from '@shared/schemas'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Checkbox } from '@/components/ui/checkbox'; +import { Form, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'; +import { Loader2 } from 'lucide-react'; +import { toast } from '@/components/ui/use-toast'; +import * as Sentry from '@sentry/nextjs'; +import posthog from 'posthog-js'; +import type { ApiKey } from './types'; + +type ScopeKey = `scope${Capitalize}`; +type ScopeHintKey = `${ScopeKey}Hint`; + +interface EditApiKeyDialogProps { + apiKey: ApiKey; + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export function EditApiKeyDialog({ apiKey, open, onOpenChange }: EditApiKeyDialogProps) { + const t = useTranslations('settings.apiKeys'); + const update = useUpdateApiKeyMutation(); + const isUpdating = update.isPending; + const [hasInteracted, setHasInteracted] = useState(false); + + const form = useForm({ + resolver: zodResolver(UpdateApiKeyDto), + defaultValues: { + scopes: apiKey.scopes && apiKey.scopes.length > 0 ? [...apiKey.scopes] : [...API_KEY_SCOPES], + }, + }); + + useEffect(() => { + if (open) { + form.reset({ + scopes: + apiKey.scopes && apiKey.scopes.length > 0 ? [...apiKey.scopes] : [...API_KEY_SCOPES], + }); + setHasInteracted(false); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open, apiKey.id]); + + const onSubmit = async (data: TUpdateApiKeyDto) => { + try { + await update.mutateAsync({ id: apiKey.id, dto: data }); + posthog.capture('api-key:updated', { apiKeyId: apiKey.id }); + toast({ description: t('editSuccess') }); + onOpenChange(false); + } catch (err: unknown) { + const errorMessage = err instanceof Error ? err.message : t('errorDescription'); + toast({ + title: t('errorTitle'), + description: errorMessage, + variant: 'destructive', + }); + Sentry.captureException(err); + posthog.capture('error:api-key-update', { error: err, apiKeyId: apiKey.id }); + } + }; + + return ( + + + + {t('editTitle')} + + {t('securityNote')} + + + + + + ( + + {t('scopesLabel')} +
+ {API_KEY_SCOPES.map((scope) => { + const checked = field.value?.includes(scope) ?? false; + const labelKey = + `scope${scope.charAt(0).toUpperCase()}${scope.slice(1)}` as ScopeKey; + const hintKey: ScopeHintKey = `${labelKey}Hint`; + return ( + + ); + })} +
+ +
+ )} + /> + +

{t('editPropagationNote')}

+ + + + + + + +
+
+ ); +} diff --git a/apps/frontend/src/dictionaries/de.json b/apps/frontend/src/dictionaries/de.json index 96c7e62c..7b860f51 100644 --- a/apps/frontend/src/dictionaries/de.json +++ b/apps/frontend/src/dictionaries/de.json @@ -1117,6 +1117,7 @@ "description": "Verwalte deine API-Schlüssel für externe Integrationen. Sieh dir die API-Dokumentation an, um loszulegen.", "name": "Name", "descriptionLabel": "Beschreibung", + "descriptionPlaceholder": "Beschreibung (optional)", "createdAt": "Erstellt am", "expiresAt": "Ablaufdatum", "neverExpires": "Läuft nie ab", @@ -1147,7 +1148,26 @@ "day": "Tag", "days": "Tage", "year": "Jahr", - "upgradeCta": "Auf Pro upgraden" + "upgradeCta": "Auf Pro upgraden", + "securityNote": "Halten Sie diesen API-Key geheim — wer ihn besitzt, erhält jede Berechtigung, die Sie hier aktivieren.", + "scopesLabel": "Berechtigungen", + "scopesDescription": "Legen Sie fest, was dieser Key darf. Sie können das später ändern.", + "scopeRead": "Lesen", + "scopeReadHint": "Datensätze auflisten und ansehen.", + "scopeWrite": "Schreiben", + "scopeWriteHint": "Neue Datensätze anlegen.", + "scopeUpdate": "Aktualisieren", + "scopeUpdateHint": "Bestehende Datensätze ändern.", + "scopeDelete": "Löschen", + "scopeDeleteHint": "Datensätze entfernen.", + "scopesRequired": "Mindestens eine Berechtigung auswählen.", + "scopesFullAccess": "Vollzugriff", + "editPropagationNote": "Änderungen an den Berechtigungen können 1–2 Minuten brauchen, bis sie wirksam werden.", + "editTitle": "API-Key bearbeiten", + "editFormHint": "Berechtigungen, Beschreibung oder Ablauf ändern. Der Schlüssel selbst wird nicht erneut angezeigt.", + "editSuccess": "API-Key aktualisiert.", + "edit": "Bearbeiten", + "save": "Speichern" }, "billing": { "title": "Abrechnung & Abonnement", diff --git a/apps/frontend/src/dictionaries/en.json b/apps/frontend/src/dictionaries/en.json index fb81dfb5..c04def5c 100644 --- a/apps/frontend/src/dictionaries/en.json +++ b/apps/frontend/src/dictionaries/en.json @@ -1117,6 +1117,7 @@ "description": "Manage your API keys for external integrations. Check out the API documentation to get started.", "name": "Name", "descriptionLabel": "Description", + "descriptionPlaceholder": "Description (optional)", "createdAt": "Created at", "expiresAt": "Expiration", "neverExpires": "Never expires", @@ -1147,7 +1148,26 @@ "day": "Day", "days": "Days", "year": "Year", - "upgradeCta": "Upgrade to Pro" + "upgradeCta": "Upgrade to Pro", + "securityNote": "Keep this API key secret — anyone with it gets every permission you enable here.", + "scopesLabel": "Permissions", + "scopesDescription": "Choose what this key is allowed to do. You can change this later.", + "scopeRead": "Read", + "scopeReadHint": "List and view your resources.", + "scopeWrite": "Write", + "scopeWriteHint": "Create new resources.", + "scopeUpdate": "Update", + "scopeUpdateHint": "Modify existing resources.", + "scopeDelete": "Delete", + "scopeDeleteHint": "Remove resources.", + "scopesRequired": "Select at least one permission.", + "scopesFullAccess": "Full access", + "editPropagationNote": "Permission changes can take 1–2 minutes to take effect.", + "editTitle": "Edit API key", + "editFormHint": "Update permissions, description, or expiration. The secret cannot be shown again.", + "editSuccess": "API key updated.", + "edit": "Edit", + "save": "Save" }, "billing": { "title": "Billing & Subscription", diff --git a/apps/frontend/src/dictionaries/es.json b/apps/frontend/src/dictionaries/es.json index 5c85e36a..fcd678d2 100644 --- a/apps/frontend/src/dictionaries/es.json +++ b/apps/frontend/src/dictionaries/es.json @@ -1121,6 +1121,7 @@ "description": "Administra tus claves API para integraciones externas. Consulta la documentación de la API para comenzar.", "name": "Nombre", "descriptionLabel": "Descripción", + "descriptionPlaceholder": "Descripción (opcional)", "createdAt": "Creado el", "expiresAt": "Expiración", "neverExpires": "Nunca expira", @@ -1151,7 +1152,26 @@ "day": "Día", "days": "Días", "year": "Año", - "upgradeCta": "Actualizar a Pro" + "upgradeCta": "Actualizar a Pro", + "securityNote": "Mantén esta clave API en secreto — quien la posea obtiene cada permiso que habilites aquí.", + "scopesLabel": "Permisos", + "scopesDescription": "Elige qué puede hacer esta clave. Puedes cambiarlo más tarde.", + "scopeRead": "Leer", + "scopeReadHint": "Listar y ver tus recursos.", + "scopeWrite": "Escribir", + "scopeWriteHint": "Crear nuevos recursos.", + "scopeUpdate": "Actualizar", + "scopeUpdateHint": "Modificar recursos existentes.", + "scopeDelete": "Eliminar", + "scopeDeleteHint": "Eliminar recursos.", + "scopesRequired": "Selecciona al menos un permiso.", + "scopesFullAccess": "Acceso total", + "editPropagationNote": "Los cambios de permisos pueden tardar 1–2 minutos en surtir efecto.", + "editTitle": "Editar clave API", + "editFormHint": "Actualiza permisos, descripción o caducidad. La clave secreta no se mostrará de nuevo.", + "editSuccess": "Clave API actualizada.", + "edit": "Editar", + "save": "Guardar" }, "billing": { "title": "Facturación y Suscripción", diff --git a/apps/frontend/src/dictionaries/fr.json b/apps/frontend/src/dictionaries/fr.json index e4936180..9e3ae3c0 100644 --- a/apps/frontend/src/dictionaries/fr.json +++ b/apps/frontend/src/dictionaries/fr.json @@ -1121,6 +1121,7 @@ "description": "Gérez vos clés API pour les intégrations externes. Consultez la documentation de l'API pour commencer.", "name": "Nom", "descriptionLabel": "Description", + "descriptionPlaceholder": "Description (facultatif)", "createdAt": "Créé le", "expiresAt": "Expiration", "neverExpires": "N'expire jamais", @@ -1151,7 +1152,26 @@ "day": "Jour", "days": "Jours", "year": "An", - "upgradeCta": "Passer à Pro" + "upgradeCta": "Passer à Pro", + "securityNote": "Gardez cette clé API secrète — toute personne qui la possède obtient chaque autorisation que vous activez ici.", + "scopesLabel": "Autorisations", + "scopesDescription": "Choisissez ce que cette clé peut faire. Vous pourrez le modifier plus tard.", + "scopeRead": "Lecture", + "scopeReadHint": "Lister et voir vos ressources.", + "scopeWrite": "Écriture", + "scopeWriteHint": "Créer de nouvelles ressources.", + "scopeUpdate": "Mise à jour", + "scopeUpdateHint": "Modifier les ressources existantes.", + "scopeDelete": "Suppression", + "scopeDeleteHint": "Supprimer des ressources.", + "scopesRequired": "Sélectionnez au moins une autorisation.", + "scopesFullAccess": "Accès complet", + "editPropagationNote": "Les modifications d'autorisations peuvent prendre 1 à 2 minutes pour prendre effet.", + "editTitle": "Modifier la clé API", + "editFormHint": "Modifiez les autorisations, la description ou l'expiration. La clé secrète ne sera plus affichée.", + "editSuccess": "Clé API mise à jour.", + "edit": "Modifier", + "save": "Enregistrer" }, "billing": { "title": "Facturation et Abonnement", diff --git a/apps/frontend/src/dictionaries/it.json b/apps/frontend/src/dictionaries/it.json index 24da1d61..2d3655c4 100644 --- a/apps/frontend/src/dictionaries/it.json +++ b/apps/frontend/src/dictionaries/it.json @@ -1117,6 +1117,7 @@ "description": "Gestisci le tue chiavi API per integrazioni esterne. Consulta la documentazione API per iniziare.", "name": "Nome", "descriptionLabel": "Descrizione", + "descriptionPlaceholder": "Descrizione (opzionale)", "createdAt": "Creato il", "expiresAt": "Scadenza", "neverExpires": "Non scade mai", @@ -1147,7 +1148,26 @@ "day": "Giorno", "days": "Giorni", "year": "Anno", - "upgradeCta": "Passa a Pro" + "upgradeCta": "Passa a Pro", + "securityNote": "Mantieni segreta questa chiave API — chiunque la possieda ottiene ogni permesso che abiliti qui.", + "scopesLabel": "Permessi", + "scopesDescription": "Scegli cosa può fare questa chiave. Puoi cambiarlo in seguito.", + "scopeRead": "Lettura", + "scopeReadHint": "Elencare e visualizzare le tue risorse.", + "scopeWrite": "Scrittura", + "scopeWriteHint": "Creare nuove risorse.", + "scopeUpdate": "Aggiornamento", + "scopeUpdateHint": "Modificare risorse esistenti.", + "scopeDelete": "Eliminazione", + "scopeDeleteHint": "Rimuovere risorse.", + "scopesRequired": "Seleziona almeno un permesso.", + "scopesFullAccess": "Accesso completo", + "editPropagationNote": "Le modifiche ai permessi possono richiedere 1–2 minuti per avere effetto.", + "editTitle": "Modifica chiave API", + "editFormHint": "Aggiorna permessi, descrizione o scadenza. La chiave segreta non verrà più mostrata.", + "editSuccess": "Chiave API aggiornata.", + "edit": "Modifica", + "save": "Salva" }, "billing": { "title": "Fatturazione e Abbonamento", diff --git a/apps/frontend/src/dictionaries/nl.json b/apps/frontend/src/dictionaries/nl.json index fb6d51ae..74d9660a 100644 --- a/apps/frontend/src/dictionaries/nl.json +++ b/apps/frontend/src/dictionaries/nl.json @@ -1117,6 +1117,7 @@ "description": "Beheer je API-sleutels voor externe integraties. Bekijk de API-documentatie om aan de slag te gaan.", "name": "Naam", "descriptionLabel": "Beschrijving", + "descriptionPlaceholder": "Beschrijving (optioneel)", "createdAt": "Aangemaakt op", "expiresAt": "Verloopt", "neverExpires": "Verloopt nooit", @@ -1147,7 +1148,26 @@ "day": "Dag", "days": "Dagen", "year": "Jaar", - "upgradeCta": "Upgraden naar Pro" + "upgradeCta": "Upgraden naar Pro", + "securityNote": "Houd deze API-sleutel geheim — wie hem heeft, krijgt elk recht dat je hier inschakelt.", + "scopesLabel": "Rechten", + "scopesDescription": "Kies wat deze sleutel mag doen. Je kunt dit later aanpassen.", + "scopeRead": "Lezen", + "scopeReadHint": "Resources opvragen en bekijken.", + "scopeWrite": "Schrijven", + "scopeWriteHint": "Nieuwe resources aanmaken.", + "scopeUpdate": "Bijwerken", + "scopeUpdateHint": "Bestaande resources wijzigen.", + "scopeDelete": "Verwijderen", + "scopeDeleteHint": "Resources verwijderen.", + "scopesRequired": "Selecteer ten minste één recht.", + "scopesFullAccess": "Volledige toegang", + "editPropagationNote": "Wijzigingen in rechten kunnen 1–2 minuten duren voordat ze van kracht worden.", + "editTitle": "API-sleutel bewerken", + "editFormHint": "Werk rechten, beschrijving of vervaldatum bij. De geheime sleutel wordt niet opnieuw weergegeven.", + "editSuccess": "API-sleutel bijgewerkt.", + "edit": "Bewerken", + "save": "Opslaan" }, "billing": { "title": "Facturering & Abonnement", diff --git a/apps/frontend/src/dictionaries/pl.json b/apps/frontend/src/dictionaries/pl.json index 3be1aa8a..e5e37dbb 100644 --- a/apps/frontend/src/dictionaries/pl.json +++ b/apps/frontend/src/dictionaries/pl.json @@ -1117,6 +1117,7 @@ "description": "Zarządzaj kluczami API dla zewnętrznych integracji. Sprawdź dokumentację API, aby rozpocząć.", "name": "Nazwa", "descriptionLabel": "Opis", + "descriptionPlaceholder": "Opis (opcjonalnie)", "createdAt": "Utworzono", "expiresAt": "Wygasa", "neverExpires": "Nigdy nie wygasa", @@ -1147,7 +1148,26 @@ "day": "Dzień", "days": "Dni", "year": "Rok", - "upgradeCta": "Przejdź na Pro" + "upgradeCta": "Przejdź na Pro", + "securityNote": "Zachowaj ten klucz API w tajemnicy — każdy, kto go posiada, otrzymuje każde uprawnienie, które tutaj włączysz.", + "scopesLabel": "Uprawnienia", + "scopesDescription": "Wybierz, co może robić ten klucz. Możesz to później zmienić.", + "scopeRead": "Odczyt", + "scopeReadHint": "Wyświetlanie i listowanie zasobów.", + "scopeWrite": "Zapis", + "scopeWriteHint": "Tworzenie nowych zasobów.", + "scopeUpdate": "Aktualizacja", + "scopeUpdateHint": "Modyfikowanie istniejących zasobów.", + "scopeDelete": "Usuwanie", + "scopeDeleteHint": "Usuwanie zasobów.", + "scopesRequired": "Wybierz co najmniej jedno uprawnienie.", + "scopesFullAccess": "Pełny dostęp", + "editPropagationNote": "Zmiany uprawnień mogą wymagać 1–2 minut, aby zaczęły obowiązywać.", + "editTitle": "Edytuj klucz API", + "editFormHint": "Zaktualizuj uprawnienia, opis lub datę wygaśnięcia. Klucz tajny nie zostanie ponownie wyświetlony.", + "editSuccess": "Klucz API zaktualizowany.", + "edit": "Edytuj", + "save": "Zapisz" }, "billing": { "title": "Rozliczenia i Subskrypcja", diff --git a/apps/frontend/src/dictionaries/pt.json b/apps/frontend/src/dictionaries/pt.json index e4aec131..b4883593 100644 --- a/apps/frontend/src/dictionaries/pt.json +++ b/apps/frontend/src/dictionaries/pt.json @@ -1117,6 +1117,7 @@ "description": "Gerencie suas chaves de API para integrações externas. Consulte a documentação da API para começar.", "name": "Nome", "descriptionLabel": "Descrição", + "descriptionPlaceholder": "Descrição (opcional)", "createdAt": "Criada em", "expiresAt": "Expiração", "neverExpires": "Nunca expira", @@ -1147,7 +1148,26 @@ "day": "Dia", "days": "Dias", "year": "Ano", - "upgradeCta": "Atualizar para Pro" + "upgradeCta": "Atualizar para Pro", + "securityNote": "Mantenha esta chave de API em segredo — quem a possuir obtém cada permissão que você ativar aqui.", + "scopesLabel": "Permissões", + "scopesDescription": "Escolha o que esta chave pode fazer. Você pode alterar isso depois.", + "scopeRead": "Leitura", + "scopeReadHint": "Listar e visualizar seus recursos.", + "scopeWrite": "Escrita", + "scopeWriteHint": "Criar novos recursos.", + "scopeUpdate": "Atualização", + "scopeUpdateHint": "Modificar recursos existentes.", + "scopeDelete": "Exclusão", + "scopeDeleteHint": "Remover recursos.", + "scopesRequired": "Selecione pelo menos uma permissão.", + "scopesFullAccess": "Acesso total", + "editPropagationNote": "As alterações de permissões podem levar 1–2 minutos para entrar em vigor.", + "editTitle": "Editar chave de API", + "editFormHint": "Atualize permissões, descrição ou expiração. A chave secreta não será mostrada novamente.", + "editSuccess": "Chave de API atualizada.", + "edit": "Editar", + "save": "Salvar" }, "billing": { "title": "Faturamento e Assinatura", diff --git a/apps/frontend/src/dictionaries/ru.json b/apps/frontend/src/dictionaries/ru.json index 0f9c5191..8b97b81c 100644 --- a/apps/frontend/src/dictionaries/ru.json +++ b/apps/frontend/src/dictionaries/ru.json @@ -1117,6 +1117,7 @@ "description": "Управляйте API-ключами для внешних интеграций. Ознакомьтесь с документацией API, чтобы начать.", "name": "Название", "descriptionLabel": "Описание", + "descriptionPlaceholder": "Описание (необязательно)", "createdAt": "Создано", "expiresAt": "Истекает", "neverExpires": "Не истекает", @@ -1147,7 +1148,26 @@ "day": "День", "days": "Дней", "year": "Год", - "upgradeCta": "Перейти на Pro" + "upgradeCta": "Перейти на Pro", + "securityNote": "Храните этот API-ключ в секрете — любой, у кого он есть, получает каждое право, которое вы здесь включите.", + "scopesLabel": "Права", + "scopesDescription": "Выберите, что разрешено этому ключу. Это можно изменить позже.", + "scopeRead": "Чтение", + "scopeReadHint": "Просмотр и получение списка ваших ресурсов.", + "scopeWrite": "Запись", + "scopeWriteHint": "Создание новых ресурсов.", + "scopeUpdate": "Обновление", + "scopeUpdateHint": "Изменение существующих ресурсов.", + "scopeDelete": "Удаление", + "scopeDeleteHint": "Удаление ресурсов.", + "scopesRequired": "Выберите хотя бы одно право.", + "scopesFullAccess": "Полный доступ", + "editPropagationNote": "Изменения прав могут вступить в силу в течение 1–2 минут.", + "editTitle": "Редактировать API-ключ", + "editFormHint": "Обновите права, описание или срок действия. Секретный ключ больше не будет показан.", + "editSuccess": "API-ключ обновлён.", + "edit": "Редактировать", + "save": "Сохранить" }, "billing": { "title": "Оплата и Подписка", diff --git a/apps/frontend/src/lib/api/api-key.ts b/apps/frontend/src/lib/api/api-key.ts index c4f18255..5cfae742 100644 --- a/apps/frontend/src/lib/api/api-key.ts +++ b/apps/frontend/src/lib/api/api-key.ts @@ -6,6 +6,7 @@ import type { TApiKeyResponseDto, TCreateApiKeyDto, TCreateApiKeyResponseDto, + TUpdateApiKeyDto, } from '@shared/schemas'; import { apiRequest } from '../utils'; @@ -55,6 +56,28 @@ export function useCreateApiKeyMutation() { }); } +export function useUpdateApiKeyMutation() { + const { getToken, userId } = useAuth(); + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ id, dto }: { id: string; dto: TUpdateApiKeyDto }) => { + const token = await getToken(); + return apiRequest(`/api-key/${id}`, { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify(dto), + }); + }, + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: apiKeyQueryKeys.list(userId) }); + }, + }); +} + export function useRevokeApiKeyMutation() { const { getToken, userId } = useAuth(); const queryClient = useQueryClient(); diff --git a/packages/shared/src/dtos/api-key/ApiKeyResponseDto.ts b/packages/shared/src/dtos/api-key/ApiKeyResponseDto.ts index 2b9809b3..933a1649 100644 --- a/packages/shared/src/dtos/api-key/ApiKeyResponseDto.ts +++ b/packages/shared/src/dtos/api-key/ApiKeyResponseDto.ts @@ -1,4 +1,5 @@ import { z } from 'zod'; +import { ApiKeyScopeSchema } from './ApiKeyScope'; export const ApiKeyResponseDto = z.object({ id: z.string(), @@ -8,6 +9,7 @@ export const ApiKeyResponseDto = z.object({ lastUsedAt: z.number().nullable(), expiration: z.number().nullable(), revoked: z.boolean(), + scopes: z.array(ApiKeyScopeSchema), }); export type TApiKeyResponseDto = z.infer; diff --git a/packages/shared/src/dtos/api-key/ApiKeyScope.ts b/packages/shared/src/dtos/api-key/ApiKeyScope.ts new file mode 100644 index 00000000..db603349 --- /dev/null +++ b/packages/shared/src/dtos/api-key/ApiKeyScope.ts @@ -0,0 +1,7 @@ +import { z } from 'zod'; + +export const API_KEY_SCOPES = ['read', 'write', 'update', 'delete'] as const; + +export const ApiKeyScopeSchema = z.enum(API_KEY_SCOPES); + +export type ApiKeyScope = z.infer; diff --git a/packages/shared/src/dtos/api-key/CreateApiKeyDto.ts b/packages/shared/src/dtos/api-key/CreateApiKeyDto.ts index 550ca35b..2c22c054 100644 --- a/packages/shared/src/dtos/api-key/CreateApiKeyDto.ts +++ b/packages/shared/src/dtos/api-key/CreateApiKeyDto.ts @@ -1,4 +1,5 @@ import { z } from 'zod'; +import { ApiKeyScopeSchema } from './ApiKeyScope'; export const API_KEY_NAME_MAX_LENGTH = 64; // Clerk's API-key description field rejects anything above 255 characters. @@ -8,6 +9,7 @@ export const CreateApiKeyDto = z.object({ name: z.string().min(1).max(API_KEY_NAME_MAX_LENGTH), description: z.string().max(API_KEY_DESCRIPTION_MAX_LENGTH).optional(), expiresInDays: z.number().int().positive().max(3650).optional().nullable(), + scopes: z.array(ApiKeyScopeSchema).min(1), }); export type TCreateApiKeyDto = z.infer; diff --git a/packages/shared/src/dtos/api-key/UpdateApiKeyDto.ts b/packages/shared/src/dtos/api-key/UpdateApiKeyDto.ts new file mode 100644 index 00000000..a1d3cb72 --- /dev/null +++ b/packages/shared/src/dtos/api-key/UpdateApiKeyDto.ts @@ -0,0 +1,19 @@ +import { z } from 'zod'; +import { ApiKeyScopeSchema } from './ApiKeyScope'; +import { API_KEY_DESCRIPTION_MAX_LENGTH } from './CreateApiKeyDto'; + +/** + * Partial-update DTO for an API key. Clerk does not support renaming, so `name` + * is intentionally omitted. + */ +export const UpdateApiKeyDto = z + .object({ + description: z.string().max(API_KEY_DESCRIPTION_MAX_LENGTH).nullable().optional(), + scopes: z.array(ApiKeyScopeSchema).min(1).optional(), + expiresInDays: z.number().int().positive().max(3650).nullable().optional(), + }) + .refine((value) => Object.values(value).some((v) => v !== undefined), { + message: 'At least one field must be provided', + }); + +export type TUpdateApiKeyDto = z.infer; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 2d89857e..6a26dfc2 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -39,7 +39,9 @@ export * from './dtos/tag/TagRequestParamsDto'; export * from './dtos/tag/SetQrCodeTagsDto'; export * from './dtos/tag/SetShortUrlTagsDto'; +export * from './dtos/api-key/ApiKeyScope'; export * from './dtos/api-key/CreateApiKeyDto'; +export * from './dtos/api-key/UpdateApiKeyDto'; export * from './dtos/api-key/ApiKeyResponseDto'; export * from './dtos/api-key/CreateApiKeyResponseDto'; export * from './dtos/api-key/ApiKeyListResponseDto'; From 1d051a648c37baa0281f7204b32cf2c1bcaf72f7 Mon Sep 17 00:00:00 2001 From: Florian Breuer Date: Tue, 5 May 2026 20:58:42 +0200 Subject: [PATCH 2/3] chore: trim verbose comments on api-key scope feature Function-level JSDoc kept short on enforce-scope, enforce-token-type, filterKnownScopes, resolveScopeForMethod. Property comments on the route decorator config collapsed to one line each. Inline narrative comments in helpers and tests reduced to one line. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../__tests__/scope-coverage-matrix.test.ts | 3 --- apps/backend/src/core/decorators/route.ts | 24 ++----------------- .../middleware/enforce-scope.middleware.ts | 9 ++----- .../enforce-token-type.middleware.ts | 11 ++------- apps/backend/src/libs/fastify/helpers.ts | 18 +++----------- .../api-key/http/__tests__/api-key.test.ts | 9 +++---- .../api-key/useCase/list-api-keys.use-case.ts | 3 +-- .../api-key/util/filter-known-scopes.ts | 6 +---- .../src/dtos/api-key/UpdateApiKeyDto.ts | 5 +--- 9 files changed, 15 insertions(+), 73 deletions(-) diff --git a/apps/backend/src/__tests__/scope-coverage-matrix.test.ts b/apps/backend/src/__tests__/scope-coverage-matrix.test.ts index 7b2f55c2..c18d756c 100644 --- a/apps/backend/src/__tests__/scope-coverage-matrix.test.ts +++ b/apps/backend/src/__tests__/scope-coverage-matrix.test.ts @@ -2,7 +2,6 @@ import 'reflect-metadata'; import { ROUTE_METADATA_KEY, type RouteMetadata } from '@/core/decorators/route'; import { resolveScopeForMethod } from '@/libs/fastify/helpers'; -// Controllers that participate in API-key auth (not webhooks, not health) import { ApiKeyController } from '@/modules/api-key/http/controller/api-key.controller'; import { AnalyticsIntegrationController } from '@/modules/analytics-integration/http/controller/analytics-integration.controller'; import { QrCodeController } from '@/modules/qr-code/http/controller/qr-code.controller'; @@ -102,8 +101,6 @@ describe('scope-coverage-matrix', () => { it('api-key management routes are session-only', () => { const apiKeyRoutes = rows.filter((r) => r.controller === 'ApiKeyController'); - // Session-token-only restriction is non-negotiable here: an API key must - // never be able to mint, list, update, or revoke other API keys. const violations = apiKeyRoutes.filter( (r) => !r.allowedTokenTypes?.includes('session_token') || r.allowedTokenTypes.length !== 1, ); diff --git a/apps/backend/src/core/decorators/route.ts b/apps/backend/src/core/decorators/route.ts index 93081686..564c0acd 100644 --- a/apps/backend/src/core/decorators/route.ts +++ b/apps/backend/src/core/decorators/route.ts @@ -20,29 +20,9 @@ interface CustomRouteOptions { responseSchema?: Record; config?: { rateLimitPolicy?: RateLimitPolicy; - /** - * API-key scope required to call this route. - * - * When omitted, defaults are derived from the HTTP method: - * GET, HEAD → 'read' - * POST → 'write' - * PUT, PATCH → 'update' - * DELETE → 'delete' - * - * Override per route when the default doesn't match the operation's - * semantic intent (e.g. a POST that's actually a read like "verify domain"). - * - * Session-token requests are unaffected — scopes only apply to API keys. - */ + /** Override the method-default scope (e.g. a POST that's a read). */ scope?: ApiKeyScope; - /** - * Restricts which authentication token types may call this route. - * - * When omitted, any token type the auth middleware accepts is allowed. - * Set to `['session_token']` to lock a route to web-UI users — useful for - * security-critical endpoints (e.g. API-key management) that must NOT be - * callable using an API key, regardless of its scopes. - */ + /** Lock a route to specific token types — e.g. `['session_token']` for API-key management. */ allowedTokenTypes?: TTokenType[]; }; } diff --git a/apps/backend/src/core/http/middleware/enforce-scope.middleware.ts b/apps/backend/src/core/http/middleware/enforce-scope.middleware.ts index 6894a25a..30100edf 100644 --- a/apps/backend/src/core/http/middleware/enforce-scope.middleware.ts +++ b/apps/backend/src/core/http/middleware/enforce-scope.middleware.ts @@ -3,13 +3,8 @@ import { type ApiKeyScope } from '@shared/schemas'; import { InsufficientScopeError } from '@/core/error/http/insufficient-scope.error'; /** - * Returns a Fastify preHandler that enforces an API-key scope. - * - * Behaviour: - * - No `request.user` (route opted out of auth) → no-op. - * - Token type is not `api_key` (session, m2m, oauth) → no-op. Scopes only apply to API keys. - * - API key has empty scopes (legacy keys created before this feature) → no-op (grandfathered). - * - API key has scopes but doesn't include the required one → throws `InsufficientScopeError` (403). + * preHandler that rejects api_key requests missing the required scope. + * No-ops for non-api_key tokens and for legacy keys with empty scopes. */ export const enforceScope = (required: ApiKeyScope) => diff --git a/apps/backend/src/core/http/middleware/enforce-token-type.middleware.ts b/apps/backend/src/core/http/middleware/enforce-token-type.middleware.ts index 6c149641..4ec67b80 100644 --- a/apps/backend/src/core/http/middleware/enforce-token-type.middleware.ts +++ b/apps/backend/src/core/http/middleware/enforce-token-type.middleware.ts @@ -3,15 +3,8 @@ import { type TTokenType } from '@/core/domain/schema/UserSchema'; import { TokenTypeNotAllowedError } from '@/core/error/http/token-type-not-allowed.error'; /** - * Returns a Fastify preHandler that restricts which authentication token types - * may call a route. Used to lock security-critical endpoints (API-key - * management) to web-UI users only — an API-key holder cannot mint, list, - * update, or revoke other keys. - * - * Behaviour: - * - No `request.user` (route opted out of auth) → no-op. - * - Token type is in the allowed list → no-op. - * - Token type is NOT in the allowed list → throws TokenTypeNotAllowedError (403). + * preHandler that rejects requests whose auth token type isn't in `allowed`. + * Used to lock security-critical routes (e.g. API-key management) to session-only. */ export const enforceTokenType = (allowed: TTokenType[]) => diff --git a/apps/backend/src/libs/fastify/helpers.ts b/apps/backend/src/libs/fastify/helpers.ts index 064bbbf5..a7b209a8 100644 --- a/apps/backend/src/libs/fastify/helpers.ts +++ b/apps/backend/src/libs/fastify/helpers.ts @@ -165,14 +165,7 @@ function parseJsonFields(body: Record, fieldsToParse: string[] = [' } /** - * Maps an HTTP method to its default API-key scope. - * - * - GET / HEAD → 'read' - * - POST → 'write' - * - PUT / PATCH → 'update' - * - DELETE → 'delete' - * - OPTIONS → null (CORS preflights — no auth, no scope check) - * + * Default API-key scope per HTTP method. Null for OPTIONS (CORS preflights, no auth). * Exported so the scope-coverage matrix test can assert the same mapping. */ export function resolveScopeForMethod(method: string): ApiKeyScope | null { @@ -290,14 +283,9 @@ export function registerRoutes( // no-op: skip authentication for this route } - // Token-type and scope enforcement run after auth so they see the populated - // request.user. Skipped when the route opts out of auth (webhooks, health) — - // there's no user to check against. + // Token-type and scope checks run after auth; skipped when authHandler is false. if (routeMeta.options.authHandler !== false) { - // Default rule: hidden routes (schema.hide === true) are session-only. - // They are not part of the public API contract, so an API key has no - // legitimate reason to call them. Override via `config.allowedTokenTypes` - // when a hidden route deliberately needs API-key access. + // Hidden routes default to session-only; override via config.allowedTokenTypes. const explicitAllowed = routeMeta.options.config?.allowedTokenTypes; const isHidden = (routeMeta.options.schema as { hide?: boolean } | undefined)?.hide === true; const effectiveAllowed = explicitAllowed ?? (isHidden ? ['session_token' as const] : null); diff --git a/apps/backend/src/modules/api-key/http/__tests__/api-key.test.ts b/apps/backend/src/modules/api-key/http/__tests__/api-key.test.ts index afa80612..e07ff202 100644 --- a/apps/backend/src/modules/api-key/http/__tests__/api-key.test.ts +++ b/apps/backend/src/modules/api-key/http/__tests__/api-key.test.ts @@ -411,9 +411,7 @@ describe('api-key endpoints', () => { }; createdApiKeyIds.push(id); - // POST /tag is a documented authenticated write endpoint with a simple body — - // good fixture for asserting the scope check fires (returns 403, not a 400 - // from validation or 401 from auth). + // POST /tag — documented write endpoint with a simple body, good scope-check fixture. const tagCreate = await testServer.inject({ method: 'POST', url: '/api/v1/tag', @@ -482,8 +480,7 @@ describe('api-key endpoints', () => { }; createdApiKeyIds.push(id); - // Hidden routes (e.g. POST /custom-domain/clear-default) are session-only - // by default — even a fully-scoped API key is rejected. + // Hidden routes default to session-only — a fully-scoped API key still gets 403. const response = await testServer.inject({ method: 'POST', url: '/api/v1/custom-domain/clear-default', @@ -620,7 +617,7 @@ describe('api-key endpoints', () => { const { id } = JSON.parse(createResponse.payload) as { id: string }; createdApiKeyIds.push(id); - // Free user (different subject) tries to update Pro user's key — must fail. + // Free user tries to update Pro user's key — must fail. const attempt = await updateApiKeyRequest( testServer, id, diff --git a/apps/backend/src/modules/api-key/useCase/list-api-keys.use-case.ts b/apps/backend/src/modules/api-key/useCase/list-api-keys.use-case.ts index 38ada1bc..67beafd3 100644 --- a/apps/backend/src/modules/api-key/useCase/list-api-keys.use-case.ts +++ b/apps/backend/src/modules/api-key/useCase/list-api-keys.use-case.ts @@ -9,8 +9,7 @@ export class ListApiKeysUseCase implements IBaseUseCase { constructor(@inject(ClerkApiKeysService) private readonly clerkApiKeys: ClerkApiKeysService) {} async execute(userId: string): Promise { - // Clerk's default page size is small; lift it to 100 so users with many - // keys see them all in a single list response. The UI never paginates. + // UI never paginates — fetch a generous page so all keys show up. const { data } = await this.clerkApiKeys.apiKeys.list({ subject: userId, limit: 100 }); return data diff --git a/apps/backend/src/modules/api-key/util/filter-known-scopes.ts b/apps/backend/src/modules/api-key/util/filter-known-scopes.ts index f168e020..13d85acc 100644 --- a/apps/backend/src/modules/api-key/util/filter-known-scopes.ts +++ b/apps/backend/src/modules/api-key/util/filter-known-scopes.ts @@ -1,10 +1,6 @@ import { API_KEY_SCOPES, type ApiKeyScope } from '@shared/schemas'; -/** - * Filters Clerk's raw `string[]` scope output down to scopes we know about, - * so the response DTO is strictly typed and forward-compatible if Clerk ever - * surfaces additional scopes we don't recognize. - */ +/** Narrow Clerk's raw `string[]` scopes down to known values for strict response typing. */ export function filterKnownScopes(scopes: string[] | null | undefined): ApiKeyScope[] { if (!scopes || scopes.length === 0) return []; return scopes.filter((s): s is ApiKeyScope => (API_KEY_SCOPES as readonly string[]).includes(s)); diff --git a/packages/shared/src/dtos/api-key/UpdateApiKeyDto.ts b/packages/shared/src/dtos/api-key/UpdateApiKeyDto.ts index a1d3cb72..77b5b331 100644 --- a/packages/shared/src/dtos/api-key/UpdateApiKeyDto.ts +++ b/packages/shared/src/dtos/api-key/UpdateApiKeyDto.ts @@ -2,10 +2,7 @@ import { z } from 'zod'; import { ApiKeyScopeSchema } from './ApiKeyScope'; import { API_KEY_DESCRIPTION_MAX_LENGTH } from './CreateApiKeyDto'; -/** - * Partial-update DTO for an API key. Clerk does not support renaming, so `name` - * is intentionally omitted. - */ +// Clerk's update API doesn't support renaming, so `name` is intentionally omitted. export const UpdateApiKeyDto = z .object({ description: z.string().max(API_KEY_DESCRIPTION_MAX_LENGTH).nullable().optional(), From 67c321f5e38af3b2b8884bb0f859db15369e136e Mon Sep 17 00:00:00 2001 From: Florian Breuer Date: Tue, 5 May 2026 21:13:07 +0200 Subject: [PATCH 3/3] fix(api-key): handle Clerk errors on update + sanitize PostHog payloads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeRabbit feedback on PR #383: - Wrap apiKeys.update() in the same try-catch as apiKeys.get(), so 4xx Clerk responses surface as ApiKeyNotFoundError instead of opaque 500. - Strip raw error objects from posthog.capture() calls in create/edit/ revoke flows; send only sanitized name + message + ids. Skipped: CodeRabbit's suggestion to extract UpdateApiKeyDto into a dedicated schema module — local convention in dtos/api-key/ already keeps small DTOs flat. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../useCase/update-api-key.use-case.ts | 28 +++++++++++-------- .../dashboard/api-keys/ApiKeyListItem.tsx | 6 +++- .../dashboard/api-keys/CreateApiKeyDialog.tsx | 5 +++- .../dashboard/api-keys/EditApiKeyDialog.tsx | 6 +++- 4 files changed, 31 insertions(+), 14 deletions(-) diff --git a/apps/backend/src/modules/api-key/useCase/update-api-key.use-case.ts b/apps/backend/src/modules/api-key/useCase/update-api-key.use-case.ts index dc640a1e..4929e7e1 100644 --- a/apps/backend/src/modules/api-key/useCase/update-api-key.use-case.ts +++ b/apps/backend/src/modules/api-key/useCase/update-api-key.use-case.ts @@ -34,17 +34,23 @@ export class UpdateApiKeyUseCase implements IBaseUseCase { if (existing.subject !== userId) throw new ApiKeyNotFoundError(); - const updated = await this.clerkApiKeys.apiKeys.update({ - apiKeyId, - subject: userId, - ...(dto.description !== undefined ? { description: dto.description } : {}), - ...(dto.scopes !== undefined ? { scopes: dto.scopes } : {}), - ...(dto.expiresInDays !== undefined - ? { - secondsUntilExpiration: dto.expiresInDays === null ? null : dto.expiresInDays * 86400, - } - : {}), - }); + let updated; + try { + updated = await this.clerkApiKeys.apiKeys.update({ + apiKeyId, + subject: userId, + ...(dto.description !== undefined ? { description: dto.description } : {}), + ...(dto.scopes !== undefined ? { scopes: dto.scopes } : {}), + ...(dto.expiresInDays !== undefined + ? { + secondsUntilExpiration: dto.expiresInDays === null ? null : dto.expiresInDays * 86400, + } + : {}), + }); + } catch (err) { + if (isClerkClientError(err)) throw new ApiKeyNotFoundError(); + throw err; + } this.logger.info('api-key.updated', { apiKey: { id: apiKeyId, userId } }); diff --git a/apps/frontend/src/components/dashboard/api-keys/ApiKeyListItem.tsx b/apps/frontend/src/components/dashboard/api-keys/ApiKeyListItem.tsx index 0dc18dda..a0e9ed5e 100644 --- a/apps/frontend/src/components/dashboard/api-keys/ApiKeyListItem.tsx +++ b/apps/frontend/src/components/dashboard/api-keys/ApiKeyListItem.tsx @@ -41,7 +41,11 @@ export function ApiKeyListItem({ apiKey, handleRevalidate }: ApiKeyListItemProps handleRevalidate(); } catch (error) { Sentry.captureException(error); - posthog.capture('error:api-key-revoke', { error, apiKeyId: apiKey.id }); + posthog.capture('error:api-key-revoke', { + errorName: error instanceof Error ? error.name : 'UnknownError', + errorMessage: error instanceof Error ? error.message : String(error), + apiKeyId: apiKey.id, + }); throw error; } } diff --git a/apps/frontend/src/components/dashboard/api-keys/CreateApiKeyDialog.tsx b/apps/frontend/src/components/dashboard/api-keys/CreateApiKeyDialog.tsx index d050d40c..3570795a 100644 --- a/apps/frontend/src/components/dashboard/api-keys/CreateApiKeyDialog.tsx +++ b/apps/frontend/src/components/dashboard/api-keys/CreateApiKeyDialog.tsx @@ -81,7 +81,10 @@ export function CreateApiKeyDialog() { variant: 'destructive', }); Sentry.captureException(err); - posthog.capture('error:api-key-create', { error: err }); + posthog.capture('error:api-key-create', { + errorName: err instanceof Error ? err.name : 'UnknownError', + errorMessage: err instanceof Error ? err.message : String(err), + }); } }; diff --git a/apps/frontend/src/components/dashboard/api-keys/EditApiKeyDialog.tsx b/apps/frontend/src/components/dashboard/api-keys/EditApiKeyDialog.tsx index db425819..4ff0b4c9 100644 --- a/apps/frontend/src/components/dashboard/api-keys/EditApiKeyDialog.tsx +++ b/apps/frontend/src/components/dashboard/api-keys/EditApiKeyDialog.tsx @@ -75,7 +75,11 @@ export function EditApiKeyDialog({ apiKey, open, onOpenChange }: EditApiKeyDialo variant: 'destructive', }); Sentry.captureException(err); - posthog.capture('error:api-key-update', { error: err, apiKeyId: apiKey.id }); + posthog.capture('error:api-key-update', { + errorName: err instanceof Error ? err.name : 'UnknownError', + errorMessage: err instanceof Error ? err.message : String(err), + apiKeyId: apiKey.id, + }); } };