docs(api-key): correct stale "show-once" comments for the reveal/copy flow (#557)

The reveal/copy flow makes several pre-existing docstrings false (they claimed the
token is shown/returned only ONCE and is "never retrievable"). Comment-only fixes,
no logic change:

- api-key.service.ts create(): the token is not "returned ONCE"; no token material
  is ever stored (self-contained JWT) and it is re-obtainable by the owner via a
  deterministic re-mint under a step-up (POST /api-keys/reveal).
- api-key.controller.ts: drop "never retrievable again"; the JWT is returned in a
  body on create OR via /api-keys/reveal, never logged, never stored.
- client api-key-service.ts / api-key-query.ts / types.ts: drop "show-once modal"
  and "shown exactly once"; the create flow discards the token and it is copied
  later via the per-row reveal action.

Tests unchanged: 62 server / 28 client green; tsc clean on touched files.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-12 23:35:11 +03:00
parent 559d676cd6
commit 4f22d597b3
5 changed files with 22 additions and 16 deletions
@@ -31,12 +31,12 @@ export function useApiKeysQuery(): UseQueryResult<IApiKey[], Error> {
/**
* 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();
@@ -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<ICreateApiKeyResponse> {
@@ -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;
@@ -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 <date>" (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 <date>" (the year-default time-bomb early-warning).
return {
token,
apiKey: {
@@ -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).