@learncard/lca-api-service 1.5.3 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/.env.example +10 -0
  2. package/CHANGELOG.md +26 -0
  3. package/bundle.mjs +1 -1
  4. package/dist/app.d.ts +4 -1
  5. package/dist/config/cacheEnvironment.d.ts +4 -4
  6. package/dist/config/environment.d.ts +145 -114
  7. package/dist/config/oidcEnvironment.d.ts +22 -22
  8. package/dist/didkit_wasm_bg.wasm +0 -0
  9. package/dist/helpers/bedrockGeneration.d.ts +17 -0
  10. package/dist/helpers/bedrockGeneration.test.d.ts +1 -0
  11. package/dist/helpers/bedrockSchema.d.ts +10 -0
  12. package/dist/helpers/imageGeneration.d.ts +42 -0
  13. package/dist/helpers/imageGeneration.test.d.ts +1 -0
  14. package/dist/index.js +2 -2
  15. package/dist/models/Notification.d.ts +3 -1
  16. package/dist/publish-metafile.json +1 -1
  17. package/dist/routes/ai.skill-icons.test.d.ts +1 -0
  18. package/dist/routes/keys.d.ts +1 -0
  19. package/dist/routes/notifications.d.ts +3 -1
  20. package/dist/types/notifications.d.ts +3 -1
  21. package/docs/bedrock-generation.md +119 -0
  22. package/docs/image-generation.md +120 -0
  23. package/package.json +20 -15
  24. package/scripts/node24-runtime.cjs +9 -0
  25. package/scripts/verify-node24-layer.cjs +49 -0
  26. package/serverless-local.yml +3 -1
  27. package/serverless.function-env.cjs +2 -4
  28. package/serverless.yml +76 -3
  29. package/src/config/environment.ts +14 -0
  30. package/src/helpers/bedrockGeneration.test.ts +222 -0
  31. package/src/helpers/bedrockGeneration.ts +110 -0
  32. package/src/helpers/bedrockSchema.ts +352 -0
  33. package/src/helpers/imageGeneration.test.ts +456 -0
  34. package/src/helpers/imageGeneration.ts +432 -0
  35. package/src/routes/ai.skill-icons.test.ts +92 -0
  36. package/src/routes/ai.test.ts +68 -91
  37. package/src/routes/ai.ts +31 -143
  38. package/src/routes/keys.ts +18 -0
  39. package/src/services/escrow-enclave/enclaveTrustAnchors.ts +19 -0
  40. package/src/services/escrow-enclave/logFailure.test.ts +8 -0
  41. package/src/services/escrow-enclave/logFailure.ts +17 -0
  42. package/src/services/escrow-enclave/remoteEnclave.tls.test.ts +10 -0
  43. package/test/keys-auth-share-escrow.spec.ts +103 -0
  44. package/test/oidc.spec.ts +41 -2
  45. package/tsconfig.bedrock.json +10 -0
@@ -0,0 +1,456 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import type OpenAI from 'openai';
3
+ import { PNG } from 'pngjs';
4
+ import { deflateSync } from 'node:zlib';
5
+ const { send, destroy, constructor } = vi.hoisted(() => ({
6
+ send: vi.fn(),
7
+ destroy: vi.fn(),
8
+ constructor: vi.fn(),
9
+ }));
10
+ vi.mock('@aws-sdk/client-bedrock-runtime', () => ({
11
+ BedrockRuntimeClient: class {
12
+ constructor(options: unknown) {
13
+ constructor(options);
14
+ }
15
+ send = send;
16
+ destroy = destroy;
17
+ },
18
+ InvokeModelCommand: class {
19
+ constructor(public input: unknown) {}
20
+ },
21
+ }));
22
+ import { decodeBedrockImage, generateStoredImage } from './imageGeneration';
23
+ import { parseLcaApiEnvironment } from '../config/environment';
24
+ const config = {
25
+ IMAGE_PROVIDER: 'bedrock' as const,
26
+ BEDROCK_IMAGE_REGION: 'us-west-2',
27
+ BEDROCK_IMAGE_MODEL_ID: 'stability.stable-image-core-v1:1',
28
+ IMAGE_TIMEOUT_MS: 1000,
29
+ };
30
+ const png = PNG.sync.write(new PNG({ width: 1024, height: 1024 }));
31
+ const payload = (overrides = {}): Buffer =>
32
+ Buffer.from(
33
+ JSON.stringify({ images: [png.toString('base64')], finish_reasons: [null], ...overrides })
34
+ );
35
+ const storage = { upload: vi.fn(), storeURL: vi.fn() };
36
+ const logs = { error: vi.fn(), warn: vi.fn(), info: vi.fn() };
37
+ beforeEach(() => {
38
+ vi.restoreAllMocks();
39
+ vi.clearAllMocks();
40
+ logs.error = vi.spyOn(console, 'error').mockImplementation(() => undefined) as never;
41
+ logs.warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) as never;
42
+ logs.info = vi.spyOn(console, 'info').mockImplementation(() => undefined) as never;
43
+ send.mockResolvedValue({ body: payload() });
44
+ storage.upload.mockResolvedValue({ url: 'https://stored/image' });
45
+ });
46
+ describe('image adapter', () => {
47
+ it('rejects duplicate headers and decompression bombs before PNG decoding', () => {
48
+ const duplicate = Buffer.concat([png.subarray(0, 33), png.subarray(8)]);
49
+ expect(() =>
50
+ decodeBedrockImage(payload({ images: [duplicate.toString('base64')] }))
51
+ ).toThrow();
52
+ const compressed = deflateSync(Buffer.alloc(20 * 1024 * 1024 + 1));
53
+ const chunk = Buffer.alloc(compressed.length + 12);
54
+ chunk.writeUInt32BE(compressed.length, 0);
55
+ chunk.write('IDAT', 4);
56
+ compressed.copy(chunk, 8);
57
+ const bomb = Buffer.concat([png.subarray(0, 33), chunk, png.subarray(png.length - 12)]);
58
+ expect(() => decodeBedrockImage(payload({ images: [bomb.toString('base64')] }))).toThrow();
59
+ });
60
+ it('rejects truncated and corrupt PNG data', () => {
61
+ for (const bytes of [png.subarray(0, 33), png.subarray(0, png.length - 12)]) {
62
+ expect(() =>
63
+ decodeBedrockImage(payload({ images: [bytes.toString('base64')] }))
64
+ ).toThrow();
65
+ }
66
+ const corrupt = Buffer.from(png);
67
+ corrupt[32] = corrupt[32]! ^ 1;
68
+ expect(() =>
69
+ decodeBedrockImage(payload({ images: [corrupt.toString('base64')] }))
70
+ ).toThrow();
71
+ });
72
+ it('invokes the candidate once with square PNG and uploads bytes', async () => {
73
+ await expect(
74
+ generateStoredImage('badge', 'did:example:1', config, undefined, storage)
75
+ ).resolves.toEqual({ url: 'https://stored/image' });
76
+ expect(constructor).toHaveBeenCalledWith({
77
+ region: 'us-west-2',
78
+ maxAttempts: 1,
79
+ authSchemePreference: ['sigv4'],
80
+ });
81
+ expect(send).toHaveBeenCalledTimes(1);
82
+ expect(JSON.parse(send.mock.calls[0]![0].input.body)).toEqual({
83
+ prompt: 'badge',
84
+ aspect_ratio: '1:1',
85
+ output_format: 'png',
86
+ });
87
+ expect(storage.upload).toHaveBeenCalledWith(
88
+ png,
89
+ { timeout: 1000 },
90
+ { filename: 'generated-image.png' }
91
+ );
92
+ expect(storage.storeURL).not.toHaveBeenCalled();
93
+ expect(destroy).toHaveBeenCalledOnce();
94
+ });
95
+ it('keeps the explicit DALL-E fallback: URL upload, no Bedrock call', async () => {
96
+ const generate = vi.fn().mockResolvedValue({ data: [{ url: 'https://openai/image' }] });
97
+ storage.storeURL.mockResolvedValue({ url: 'https://stored/image' });
98
+ await expect(
99
+ generateStoredImage(
100
+ 'badge',
101
+ 'did:example:1',
102
+ { ...config, IMAGE_PROVIDER: 'openai' },
103
+ { images: { generate } } as unknown as OpenAI,
104
+ storage
105
+ )
106
+ ).resolves.toEqual({ url: 'https://stored/image' });
107
+ expect(generate.mock.calls[0]![0]).toEqual({
108
+ prompt: 'badge',
109
+ model: 'dall-e-3',
110
+ size: '1024x1024',
111
+ n: 1,
112
+ user: 'did:example:1',
113
+ });
114
+ expect(generate.mock.calls[0]![1]).toMatchObject({ maxRetries: 0, timeout: 1000 });
115
+ expect(storage.storeURL).toHaveBeenCalledWith('https://openai/image');
116
+ expect(send).not.toHaveBeenCalled();
117
+ });
118
+ it.each(['Filter reason: prompt', 'Filter reason: output image', 'Filter reason: input image'])(
119
+ 'rejects filtering: %s',
120
+ async reason => {
121
+ send.mockResolvedValue({ body: payload({ finish_reasons: [reason] }) });
122
+ await expect(
123
+ generateStoredImage('badge', 'did', config, undefined, storage)
124
+ ).rejects.toMatchObject({
125
+ code: 'BAD_REQUEST',
126
+ message: 'The image request was filtered.',
127
+ });
128
+ expect(storage.upload).not.toHaveBeenCalled();
129
+ }
130
+ );
131
+ it.each([
132
+ { images: [] },
133
+ { images: ['!!!'] },
134
+ { images: ['AAAA'] },
135
+ { images: [png.toString('base64'), png.toString('base64')] },
136
+ { finish_reasons: [] },
137
+ { finish_reasons: ['Inference error'] },
138
+ { finish_reasons: [42] },
139
+ ])('rejects malformed payload %j', value => {
140
+ expect(() => decodeBedrockImage(payload(value))).toThrow();
141
+ });
142
+ it('rejects invalid JSON, oversized response, non-square or out-of-bounds PNG', () => {
143
+ expect(() => decodeBedrockImage(Buffer.from('{'))).toThrow();
144
+ expect(() => decodeBedrockImage(Buffer.alloc(14 * 1024 * 1024 + 1))).toThrow();
145
+ for (const width of [639, 1537, 800]) {
146
+ const image = Buffer.from(png);
147
+ image.writeUInt32BE(width, 16);
148
+ expect(() =>
149
+ decodeBedrockImage(payload({ images: [image.toString('base64')] }))
150
+ ).toThrow();
151
+ }
152
+ });
153
+ it('sanitizes provider failures without fallback or duplicate calls', async () => {
154
+ send.mockRejectedValue(new Error('secret prompt credentials'));
155
+ await expect(
156
+ generateStoredImage('badge', 'did', config, undefined, storage)
157
+ ).rejects.toMatchObject({ message: 'Image generation failed. Please try again later.' });
158
+ expect(send).toHaveBeenCalledOnce();
159
+ expect(storage.upload).not.toHaveBeenCalled();
160
+ });
161
+ it.each([
162
+ [429, 'TOO_MANY_REQUESTS', 'AI generation rate limited'],
163
+ [503, 'SERVICE_UNAVAILABLE', 'Image generation unavailable. Please try again later.'],
164
+ [504, 'SERVICE_UNAVAILABLE', 'Image generation unavailable. Please try again later.'],
165
+ [400, 'INTERNAL_SERVER_ERROR', 'Image generation failed. Please try again later.'],
166
+ ])('maps Bedrock HTTP %i to a sanitized %s', async (status, code, message) => {
167
+ send.mockRejectedValue(
168
+ Object.assign(new Error('secret provider body'), {
169
+ $metadata: { httpStatusCode: status },
170
+ })
171
+ );
172
+ const error = await generateStoredImage('badge', 'did', config, undefined, storage).catch(
173
+ (e: unknown) => e
174
+ );
175
+ expect(error).toMatchObject({ code, message });
176
+ expect(JSON.stringify(error)).not.toContain('secret');
177
+ expect(send).toHaveBeenCalledOnce();
178
+ expect(storage.upload).not.toHaveBeenCalled();
179
+ });
180
+ it('maps DALL-E rate limits without retrying', async () => {
181
+ const generate = vi
182
+ .fn()
183
+ .mockRejectedValue(Object.assign(new Error('secret body'), { status: 429 }));
184
+ await expect(
185
+ generateStoredImage(
186
+ 'badge',
187
+ 'did',
188
+ { ...config, IMAGE_PROVIDER: 'openai' },
189
+ { images: { generate } } as unknown as OpenAI,
190
+ storage
191
+ )
192
+ ).rejects.toMatchObject({ code: 'TOO_MANY_REQUESTS' });
193
+ expect(generate).toHaveBeenCalledOnce();
194
+ expect(storage.storeURL).not.toHaveBeenCalled();
195
+ });
196
+ it('does not map storage failures as provider throttling', async () => {
197
+ storage.upload.mockRejectedValue(Object.assign(new Error('x'), { status: 429 }));
198
+ await expect(
199
+ generateStoredImage('badge', 'did', config, undefined, storage)
200
+ ).rejects.toMatchObject({ code: 'INTERNAL_SERVER_ERROR' });
201
+ });
202
+ it('bounds the request timeout, aborts the invocation and maps it to 503', async () => {
203
+ vi.useFakeTimers();
204
+ send.mockImplementation(() => new Promise(() => {}));
205
+ const pending = generateStoredImage('badge', 'did', config, undefined, storage);
206
+ const check = expect(pending).rejects.toMatchObject({ code: 'SERVICE_UNAVAILABLE' });
207
+ await vi.advanceTimersByTimeAsync(1000);
208
+ await check;
209
+ expect(send.mock.calls[0]![1].abortSignal.aborted).toBe(true);
210
+ expect(logs.error.mock.calls[0]![0]).toMatchObject({
211
+ event: 'image_generation_failure',
212
+ stage: 'timeout',
213
+ clientCode: 'SERVICE_UNAVAILABLE',
214
+ });
215
+ vi.useRealTimers();
216
+ });
217
+ it('rejects oversized prompts before invoking or uploading', async () => {
218
+ await expect(
219
+ generateStoredImage('a'.repeat(10001), 'did', config, undefined, storage)
220
+ ).rejects.toMatchObject({ code: 'BAD_REQUEST' });
221
+ expect(send).not.toHaveBeenCalled();
222
+ });
223
+ it('sanitizes storage failures and missing URLs', async () => {
224
+ storage.upload.mockResolvedValue({});
225
+ await expect(
226
+ generateStoredImage('badge', 'did', config, undefined, storage)
227
+ ).rejects.toMatchObject({ code: 'INTERNAL_SERVER_ERROR' });
228
+ storage.upload.mockRejectedValue(new Error('secret storage URL'));
229
+ await expect(
230
+ generateStoredImage('badge', 'did', config, undefined, storage)
231
+ ).rejects.toMatchObject({ message: 'Image generation failed. Please try again later.' });
232
+ });
233
+ it('defaults to Bedrock and validates explicit image configuration', () => {
234
+ expect(parseLcaApiEnvironment({ NODE_ENV: 'test' })).toMatchObject({
235
+ ...config,
236
+ IMAGE_PROVIDER: 'bedrock',
237
+ IMAGE_TIMEOUT_MS: 25000,
238
+ });
239
+ expect(parseLcaApiEnvironment({ NODE_ENV: 'test', IMAGE_PROVIDER: '' })).toMatchObject({
240
+ IMAGE_PROVIDER: 'bedrock',
241
+ });
242
+ expect(
243
+ parseLcaApiEnvironment({ NODE_ENV: 'test', IMAGE_PROVIDER: 'openai' })
244
+ ).toMatchObject({ IMAGE_PROVIDER: 'openai' });
245
+ expect(
246
+ parseLcaApiEnvironment({ NODE_ENV: 'test', IMAGE_TIMEOUT_MS: '25000' })
247
+ ).toMatchObject({ IMAGE_TIMEOUT_MS: 25000 });
248
+ for (const bad of [
249
+ { IMAGE_PROVIDER: 'typo' },
250
+ { BEDROCK_IMAGE_REGION: 'us-east-1' },
251
+ { BEDROCK_IMAGE_MODEL_ID: 'amazon.nova-canvas-v1:0' },
252
+ { IMAGE_TIMEOUT_MS: '0' },
253
+ { IMAGE_TIMEOUT_MS: '25001' },
254
+ { IMAGE_TIMEOUT_MS: '90000' },
255
+ ])
256
+ expect(() => parseLcaApiEnvironment({ NODE_ENV: 'test', ...bad })).toThrow();
257
+ });
258
+
259
+ const awsError = (name: string, status: number, message = 'secret provider body') =>
260
+ Object.assign(new Error(message), {
261
+ name,
262
+ $fault: status >= 500 ? 'server' : 'client',
263
+ $metadata: { httpStatusCode: status, requestId: 'req-123e4567-e89b' },
264
+ });
265
+ const budget = { ...config, IMAGE_TIMEOUT_MS: 25_000 };
266
+ it.each([
267
+ // Bedrock reports some account quotas as HTTP 400, which previously became a 500.
268
+ ['ServiceQuotaExceededException', 400, 'TOO_MANY_REQUESTS'],
269
+ ['ThrottlingException', 429, 'TOO_MANY_REQUESTS'],
270
+ ['ModelNotReadyException', 429, 'TOO_MANY_REQUESTS'],
271
+ ['ServiceUnavailableException', 503, 'SERVICE_UNAVAILABLE'],
272
+ ['InternalServerException', 500, 'SERVICE_UNAVAILABLE'],
273
+ ['ModelTimeoutException', 408, 'SERVICE_UNAVAILABLE'],
274
+ ['AccessDeniedException', 403, 'INTERNAL_SERVER_ERROR'],
275
+ ['ResourceNotFoundException', 404, 'INTERNAL_SERVER_ERROR'],
276
+ ['ValidationException', 400, 'INTERNAL_SERVER_ERROR'],
277
+ ['ModelErrorException', 424, 'INTERNAL_SERVER_ERROR'],
278
+ ])('maps Bedrock %s (HTTP %i) to %s', async (name, status, code) => {
279
+ send.mockRejectedValue(awsError(name, status));
280
+ const error = await generateStoredImage('badge', 'did', config, undefined, storage).catch(
281
+ (e: unknown) => e
282
+ );
283
+ expect(error).toMatchObject({ code });
284
+ expect(JSON.stringify(error)).not.toContain('secret');
285
+ expect((error as Error).message).not.toContain('secret');
286
+ expect(storage.upload).not.toHaveBeenCalled();
287
+ });
288
+ it('maps a content-filter ValidationException to a sanitized 400', async () => {
289
+ send.mockRejectedValue(
290
+ awsError(
291
+ 'ValidationException',
292
+ 400,
293
+ 'This request has been blocked by our content filters.'
294
+ )
295
+ );
296
+ await expect(
297
+ generateStoredImage('badge', 'did', budget, undefined, storage)
298
+ ).rejects.toMatchObject({
299
+ code: 'BAD_REQUEST',
300
+ message: 'The image request was filtered.',
301
+ });
302
+ expect(send).toHaveBeenCalledOnce();
303
+ });
304
+ it('gives the upload only the time left in the request budget', async () => {
305
+ vi.useFakeTimers();
306
+ try {
307
+ send.mockImplementation(
308
+ () => new Promise(resolve => setTimeout(() => resolve({ body: payload() }), 20_000))
309
+ );
310
+ const pending = generateStoredImage('badge', 'did', budget, undefined, storage);
311
+ await vi.advanceTimersByTimeAsync(20_000);
312
+ await expect(pending).resolves.toEqual({ url: 'https://stored/image' });
313
+ // 20 s of the 25 s budget went to Bedrock, so storage gets the remaining 5 s.
314
+ expect(storage.upload).toHaveBeenCalledWith(
315
+ png,
316
+ { timeout: 5_000 },
317
+ { filename: 'generated-image.png' }
318
+ );
319
+ } finally {
320
+ vi.useRealTimers();
321
+ }
322
+ });
323
+ it('retries throttling with jitter inside the deadline, then succeeds', async () => {
324
+ vi.spyOn(Math, 'random').mockReturnValue(0);
325
+ send.mockRejectedValueOnce(awsError('ThrottlingException', 429))
326
+ .mockRejectedValueOnce(awsError('ServiceQuotaExceededException', 400))
327
+ .mockResolvedValueOnce({ body: payload() });
328
+ await expect(
329
+ generateStoredImage('badge', 'did', budget, undefined, storage)
330
+ ).resolves.toEqual({ url: 'https://stored/image' });
331
+ expect(send).toHaveBeenCalledTimes(3);
332
+ expect(storage.upload).toHaveBeenCalledOnce();
333
+ expect(destroy).toHaveBeenCalledOnce();
334
+ expect(logs.warn).toHaveBeenCalledTimes(2);
335
+ expect(logs.warn.mock.calls[0]![0]).toMatchObject({
336
+ event: 'image_generation_retry',
337
+ errorName: 'ThrottlingException',
338
+ attempt: 1,
339
+ });
340
+ expect(logs.info.mock.calls[0]![0]).toMatchObject({
341
+ event: 'image_generation_success',
342
+ attempts: 3,
343
+ });
344
+ expect(logs.error).not.toHaveBeenCalled();
345
+ });
346
+ it('stops after three attempts and returns 429', async () => {
347
+ vi.spyOn(Math, 'random').mockReturnValue(0);
348
+ send.mockRejectedValue(awsError('ThrottlingException', 429));
349
+ await expect(
350
+ generateStoredImage('badge', 'did', budget, undefined, storage)
351
+ ).rejects.toMatchObject({ code: 'TOO_MANY_REQUESTS' });
352
+ expect(send).toHaveBeenCalledTimes(3);
353
+ expect(logs.error.mock.calls[0]![0]).toMatchObject({ attempts: 3, retryable: true });
354
+ });
355
+ it('does not retry when a full attempt no longer fits the deadline', async () => {
356
+ send.mockRejectedValue(awsError('ThrottlingException', 429));
357
+ await expect(
358
+ generateStoredImage('badge', 'did', config, undefined, storage)
359
+ ).rejects.toMatchObject({ code: 'TOO_MANY_REQUESTS' });
360
+ expect(send).toHaveBeenCalledOnce();
361
+ });
362
+ it.each([
363
+ ['AccessDeniedException', 403],
364
+ ['ValidationException', 400],
365
+ ['ModelTimeoutException', 408],
366
+ ])('never retries %s (possibly billed or permanent)', async (name, status) => {
367
+ send.mockRejectedValue(awsError(name, status));
368
+ await generateStoredImage('badge', 'did', budget, undefined, storage).catch(() => {});
369
+ expect(send).toHaveBeenCalledOnce();
370
+ });
371
+ it('logs one sanitized failure record without prompt, provider message or secrets', async () => {
372
+ const prompt = 'SECRET-PROMPT a badge for Jane Doe jane@example.com';
373
+ send.mockRejectedValue(
374
+ Object.assign(
375
+ awsError('ServiceQuotaExceededException', 400, 'quota detail AKIASECRETKEY'),
376
+ {
377
+ $response: {
378
+ headers: { authorization: 'AWS4-HMAC-SHA256 Credential=AKIASECRETKEY' },
379
+ },
380
+ $retryable: { throttling: true },
381
+ }
382
+ )
383
+ );
384
+ await generateStoredImage(prompt, 'did:example:user', config, undefined, storage).catch(
385
+ () => {}
386
+ );
387
+ expect(logs.error).toHaveBeenCalledOnce();
388
+ const record = logs.error.mock.calls[0]![0];
389
+ expect(record).toEqual({
390
+ event: 'image_generation_failure',
391
+ provider: 'bedrock',
392
+ modelId: 'stability.stable-image-core-v1:1',
393
+ region: 'us-west-2',
394
+ durationMs: expect.any(Number),
395
+ promptLength: prompt.length,
396
+ promptHash: expect.stringMatching(/^[0-9a-f]{12}$/),
397
+ stage: 'invoke',
398
+ attempts: 1,
399
+ category: 'throttled',
400
+ errorName: 'ServiceQuotaExceededException',
401
+ errorCode: undefined,
402
+ httpStatus: 400,
403
+ awsRequestId: 'req-123e4567-e89b',
404
+ sdkRetryable: true,
405
+ retryable: true,
406
+ finishReason: undefined,
407
+ decodeReason: undefined,
408
+ clientCode: 'TOO_MANY_REQUESTS',
409
+ });
410
+ const logged = JSON.stringify([logs.error.mock.calls, logs.warn.mock.calls]);
411
+ for (const secret of [
412
+ 'SECRET',
413
+ 'jane',
414
+ 'AKIA',
415
+ 'Credential',
416
+ 'authorization',
417
+ 'quota detail',
418
+ 'did:example',
419
+ ])
420
+ expect(logged).not.toContain(secret);
421
+ });
422
+ it('logs filter finish reasons and decode reasons, never image bytes', async () => {
423
+ send.mockResolvedValue({ body: payload({ finish_reasons: ['Filter reason: prompt'] }) });
424
+ await generateStoredImage('badge', 'did', config, undefined, storage).catch(() => {});
425
+ expect(logs.error.mock.calls[0]![0]).toMatchObject({
426
+ stage: 'decode',
427
+ finishReason: 'Filter reason: prompt',
428
+ decodeReason: 'filtered',
429
+ clientCode: 'BAD_REQUEST',
430
+ });
431
+ const image = Buffer.from(png);
432
+ image.writeUInt32BE(800, 16);
433
+ send.mockResolvedValue({ body: payload({ images: [image.toString('base64')] }) });
434
+ await generateStoredImage('badge', 'did', config, undefined, storage).catch(() => {});
435
+ expect(logs.error.mock.calls[1]![0]).toMatchObject({
436
+ stage: 'decode',
437
+ finishReason: null,
438
+ decodeReason: 'png_dimensions_invalid',
439
+ clientCode: 'INTERNAL_SERVER_ERROR',
440
+ });
441
+ expect(JSON.stringify(logs.error.mock.calls)).not.toContain(
442
+ png.toString('base64').slice(0, 40)
443
+ );
444
+ });
445
+ it('logs storage failures by stage without the storage message or URL', async () => {
446
+ storage.upload.mockRejectedValue(
447
+ Object.assign(new Error('https://secret.storage/url?signature=abc'), { status: 403 })
448
+ );
449
+ await expect(
450
+ generateStoredImage('badge', 'did', config, undefined, storage)
451
+ ).rejects.toMatchObject({ code: 'INTERNAL_SERVER_ERROR' });
452
+ const record = logs.error.mock.calls[0]![0];
453
+ expect(record).toMatchObject({ stage: 'storage', storage: { httpStatus: 403 } });
454
+ expect(JSON.stringify(record)).not.toContain('secret');
455
+ });
456
+ });