diff --git a/apps/client/src/features/api-key/queries/api-key-query.ts b/apps/client/src/features/api-key/queries/api-key-query.ts index d6faffd5..71c4b3de 100644 --- a/apps/client/src/features/api-key/queries/api-key-query.ts +++ b/apps/client/src/features/api-key/queries/api-key-query.ts @@ -31,12 +31,12 @@ export function useApiKeysQuery(): UseQueryResult { /** * Create mutation. * - * SECURITY: the response contains the token exactly once. This hook deliberately - * does NOT stash it anywhere — the caller reads it from `mutateAsync`'s resolved - * value, moves it into the show-once modal's local state, then calls - * `mutation.reset()` to purge react-query's own copy immediately. `gcTime: 0` - * is a second belt so nothing lingers in the mutation cache after the observer - * unmounts. The list is invalidated here (the list carries no token). + * SECURITY: the response contains the token. This hook deliberately does NOT + * stash it anywhere — the caller reads it from `mutateAsync`'s resolved value + * and immediately calls `mutation.reset()` to purge react-query's own copy (the + * create flow discards the token; it is re-obtainable later via the reveal/copy + * action). `gcTime: 0` is a second belt so nothing lingers in the mutation cache + * after the observer unmounts. The list is invalidated here (it carries no token). */ export function useCreateApiKeyMutation() { const queryClient = useQueryClient(); diff --git a/apps/client/src/features/api-key/services/api-key-service.ts b/apps/client/src/features/api-key/services/api-key-service.ts index 8697d6eb..3646ebe7 100644 --- a/apps/client/src/features/api-key/services/api-key-service.ts +++ b/apps/client/src/features/api-key/services/api-key-service.ts @@ -6,11 +6,12 @@ import { IRevealApiKey, } from "@/features/api-key/types/api-key.types"; -// Mint a new key. The response carries the token ONCE — the caller must move it -// straight into the show-once modal's local state and never cache it. See +// Mint a new key. The response carries the token, but the create flow now +// DISCARDS it (the user copies the key later via the per-row reveal action, so +// there is no show-once modal) — the caller must never cache or persist it. See // queries/api-key-query.ts (gcTime: 0 + query invalidation) and // components/api-keys-manager.tsx `handleCreate` (createMutation.reset() right -// after reading the token) for the reset()-after-read pattern. +// after the mint) for the reset()-after-read discipline the reveal path mirrors. export async function createApiKey( data: ICreateApiKey, ): Promise { diff --git a/apps/client/src/features/api-key/types/api-key.types.ts b/apps/client/src/features/api-key/types/api-key.types.ts index dbc448b5..e1e5eccf 100644 --- a/apps/client/src/features/api-key/types/api-key.types.ts +++ b/apps/client/src/features/api-key/types/api-key.types.ts @@ -31,7 +31,8 @@ export interface ICreateApiKey { } // The metadata half of the create response. The token itself is carried -// separately (see ICreateApiKeyResponse) and is shown exactly once. +// separately (see ICreateApiKeyResponse); it is re-obtainable later by its owner +// via a deterministic re-mint under a step-up (POST /api-keys/reveal). export interface ICreatedApiKey { id: string; name: string; diff --git a/apps/server/src/core/api-key/api-key.controller.ts b/apps/server/src/core/api-key/api-key.controller.ts index 4eab95bf..80dd1c4c 100644 --- a/apps/server/src/core/api-key/api-key.controller.ts +++ b/apps/server/src/core/api-key/api-key.controller.ts @@ -98,7 +98,8 @@ export class ApiKeyController { }); // Durable audit is via DatabaseAuditService (#496); this structured log is a // second, container-log trail. No token material — the JWT is only ever - // returned in the response. + // returned in a response body (here on create, or via /api-keys/reveal), + // never logged and never stored. this.logger.log( `API key created: id=${key.id} name=${JSON.stringify( key.name, @@ -107,9 +108,9 @@ export class ApiKeyController { } ip=${this.clientIp(req)}`, ); - // Return the token ONCE (never retrievable again) and the computed expiry so - // the caller/UI can surface "expires " (the year-default time-bomb - // early-warning). + // Return the token (also re-obtainable later by the owner via + // /api-keys/reveal under a step-up) and the computed expiry so the caller/UI + // can surface "expires " (the year-default time-bomb early-warning). return { token, apiKey: { diff --git a/apps/server/src/core/api-key/api-key.service.ts b/apps/server/src/core/api-key/api-key.service.ts index 52a43222..6438f0bd 100644 --- a/apps/server/src/core/api-key/api-key.service.ts +++ b/apps/server/src/core/api-key/api-key.service.ts @@ -48,8 +48,11 @@ export class ApiKeyService { * written (inert), so a half-created key cannot exist. * 3. insert the row last. A lost response leaves an orphaned row that is * visible in `list` and self-heals (the user revokes it). - * The token is returned ONCE and never stored — the JWT is self-contained, so - * no token material lives in the table. + * No token material is ever stored — the JWT is self-contained (its `api_keys` + * row holds only metadata + lifetime, never the token). The token is returned + * here on create AND is re-obtainable any time by its owner via a deterministic + * re-mint under a password step-up (POST /api-keys/reveal); it is deterministic + * precisely because it carries no `iat`/`exp` (see TokenService). * * `expiresAt`: `undefined` -> default 1 year; `null` -> unlimited (explicit); * a Date -> that instant (a past date is rejected at the DTO layer).