@learncard/lca-api-service 1.5.3 → 1.6.1
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.
- package/.env.example +10 -0
- package/CHANGELOG.md +40 -0
- package/bundle.mjs +1 -1
- package/dist/app.d.ts +4 -1
- package/dist/config/cacheEnvironment.d.ts +4 -4
- package/dist/config/environment.d.ts +145 -114
- package/dist/config/oidcEnvironment.d.ts +22 -22
- package/dist/didkit_wasm_bg.wasm +0 -0
- package/dist/helpers/bedrockGeneration.d.ts +17 -0
- package/dist/helpers/bedrockGeneration.test.d.ts +1 -0
- package/dist/helpers/bedrockSchema.d.ts +10 -0
- package/dist/helpers/imageGeneration.d.ts +42 -0
- package/dist/helpers/imageGeneration.test.d.ts +1 -0
- package/dist/index.js +2 -2
- package/dist/models/Notification.d.ts +3 -1
- package/dist/publish-metafile.json +1 -1
- package/dist/routes/ai.skill-icons.test.d.ts +1 -0
- package/dist/routes/keys.d.ts +1 -0
- package/dist/routes/notifications.d.ts +3 -1
- package/dist/types/notifications.d.ts +3 -1
- package/docs/bedrock-generation.md +119 -0
- package/docs/image-generation.md +120 -0
- package/package.json +21 -16
- package/project.json +1 -1
- package/scripts/node24-runtime.cjs +9 -0
- package/scripts/verify-node24-layer.cjs +49 -0
- package/serverless-local.yml +3 -1
- package/serverless.function-env.cjs +2 -4
- package/serverless.yml +76 -3
- package/src/config/environment.ts +14 -0
- package/src/helpers/bedrockGeneration.test.ts +222 -0
- package/src/helpers/bedrockGeneration.ts +110 -0
- package/src/helpers/bedrockSchema.ts +352 -0
- package/src/helpers/imageGeneration.test.ts +456 -0
- package/src/helpers/imageGeneration.ts +432 -0
- package/src/models/EscrowHold.ts +6 -3
- package/src/models/UserKey.ts +14 -0
- package/src/routes/ai.skill-icons.test.ts +92 -0
- package/src/routes/ai.test.ts +68 -91
- package/src/routes/ai.ts +31 -143
- package/src/routes/keys.ts +18 -0
- package/src/services/escrow-enclave/enclaveTrustAnchors.ts +19 -0
- package/src/services/escrow-enclave/logFailure.test.ts +8 -0
- package/src/services/escrow-enclave/logFailure.ts +17 -0
- package/src/services/escrow-enclave/remoteEnclave.tls.test.ts +10 -0
- package/test/jobs/escrowHoldReminders.spec.ts +88 -4
- package/test/keys-auth-share-escrow.spec.ts +103 -0
- package/test/keys-lifecycle-invariants.spec.ts +76 -0
- package/test/oidc.spec.ts +41 -2
- package/tsconfig.bedrock.json +10 -0
- package/tsconfig.json +13 -14
|
@@ -0,0 +1,432 @@
|
|
|
1
|
+
import { BedrockRuntimeClient, InvokeModelCommand } from '@aws-sdk/client-bedrock-runtime';
|
|
2
|
+
import { TRPCError } from '@trpc/server';
|
|
3
|
+
import type OpenAI from 'openai';
|
|
4
|
+
import { PNG } from 'pngjs';
|
|
5
|
+
import { createHash } from 'node:crypto';
|
|
6
|
+
import { inflateSync } from 'node:zlib';
|
|
7
|
+
|
|
8
|
+
export interface ImageGenerationConfig {
|
|
9
|
+
IMAGE_PROVIDER: 'openai' | 'bedrock';
|
|
10
|
+
BEDROCK_IMAGE_REGION: string;
|
|
11
|
+
BEDROCK_IMAGE_MODEL_ID: string;
|
|
12
|
+
IMAGE_TIMEOUT_MS: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface ImageStorage {
|
|
16
|
+
storeURL: (url: string) => Promise<{ url?: string }>;
|
|
17
|
+
upload: (
|
|
18
|
+
bytes: Buffer,
|
|
19
|
+
options: { timeout: number },
|
|
20
|
+
storeOptions: { filename: string }
|
|
21
|
+
) => Promise<{ url?: string }>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const MAX_IMAGE_BYTES = 10 * 1024 * 1024;
|
|
25
|
+
const MAX_RESPONSE_BYTES = 14 * 1024 * 1024;
|
|
26
|
+
/** At most two quick retries, and only for rejections Bedrock does not bill (throttling/unavailable). */
|
|
27
|
+
const MAX_BEDROCK_ATTEMPTS = 3;
|
|
28
|
+
const RETRY_BASE_DELAY_MS = 250;
|
|
29
|
+
const RETRY_MAX_DELAY_MS = 2_000;
|
|
30
|
+
/** Do not start another attempt unless a full generation (~6 s) plus upload still fits the deadline. */
|
|
31
|
+
const MIN_ATTEMPT_BUDGET_MS = 12_000;
|
|
32
|
+
|
|
33
|
+
const failure = (): TRPCError =>
|
|
34
|
+
new TRPCError({
|
|
35
|
+
code: 'INTERNAL_SERVER_ERROR',
|
|
36
|
+
message: 'Image generation failed. Please try again later.',
|
|
37
|
+
});
|
|
38
|
+
const rateLimited = (): TRPCError =>
|
|
39
|
+
new TRPCError({ code: 'TOO_MANY_REQUESTS', message: 'AI generation rate limited' });
|
|
40
|
+
const unavailable = (): TRPCError =>
|
|
41
|
+
new TRPCError({
|
|
42
|
+
code: 'SERVICE_UNAVAILABLE',
|
|
43
|
+
message: 'Image generation unavailable. Please try again later.',
|
|
44
|
+
});
|
|
45
|
+
const filtered = (): TRPCError =>
|
|
46
|
+
new TRPCError({ code: 'BAD_REQUEST', message: 'The image request was filtered.' });
|
|
47
|
+
|
|
48
|
+
/** Sanitized, allowlisted diagnostics for one request. Never holds prompts, bodies or headers. */
|
|
49
|
+
export interface ImageDiagnostics {
|
|
50
|
+
stage?: 'invoke' | 'decode' | 'storage' | 'timeout' | 'config';
|
|
51
|
+
category?: ProviderErrorCategory;
|
|
52
|
+
decodeReason?: string;
|
|
53
|
+
finishReason?: string | null;
|
|
54
|
+
errorName?: string;
|
|
55
|
+
errorCode?: string;
|
|
56
|
+
httpStatus?: number;
|
|
57
|
+
awsRequestId?: string;
|
|
58
|
+
sdkRetryable?: boolean;
|
|
59
|
+
attempts?: number;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
type ProviderErrorCategory = 'throttled' | 'unavailable' | 'timeout' | 'filtered' | 'rejected';
|
|
63
|
+
|
|
64
|
+
const SAFE_IDENTIFIER = /^[A-Za-z][\w.]{0,63}$/;
|
|
65
|
+
const SAFE_REQUEST_ID = /^[\w-]{1,128}$/;
|
|
66
|
+
const SAFE_FINISH_REASON = /^Filter reason: [a-z ]{1,32}$/;
|
|
67
|
+
|
|
68
|
+
/** HTTP status from an OpenAI SDK APIError or an AWS SDK service exception, if any. */
|
|
69
|
+
const providerStatus = (error: unknown): number | undefined => {
|
|
70
|
+
if (!error || typeof error !== 'object') return undefined;
|
|
71
|
+
const { status, $metadata: metadata } = error as {
|
|
72
|
+
status?: unknown;
|
|
73
|
+
$metadata?: { httpStatusCode?: unknown };
|
|
74
|
+
};
|
|
75
|
+
if (typeof status === 'number') return status;
|
|
76
|
+
return typeof metadata?.httpStatusCode === 'number' ? metadata.httpStatusCode : undefined;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
/** Allowlisted metadata from an AWS SDK / OpenAI SDK / Node network error. Messages are never read. */
|
|
80
|
+
export const providerErrorInfo = (error: unknown): Partial<ImageDiagnostics> => {
|
|
81
|
+
if (!error || typeof error !== 'object') return {};
|
|
82
|
+
const {
|
|
83
|
+
name,
|
|
84
|
+
code,
|
|
85
|
+
$metadata: metadata,
|
|
86
|
+
$retryable: retryable,
|
|
87
|
+
} = error as {
|
|
88
|
+
name?: unknown;
|
|
89
|
+
code?: unknown;
|
|
90
|
+
$metadata?: { requestId?: unknown };
|
|
91
|
+
$retryable?: unknown;
|
|
92
|
+
};
|
|
93
|
+
const requestId = metadata?.requestId;
|
|
94
|
+
return {
|
|
95
|
+
errorName: typeof name === 'string' && SAFE_IDENTIFIER.test(name) ? name : undefined,
|
|
96
|
+
errorCode: typeof code === 'string' && SAFE_IDENTIFIER.test(code) ? code : undefined,
|
|
97
|
+
httpStatus: providerStatus(error),
|
|
98
|
+
awsRequestId:
|
|
99
|
+
typeof requestId === 'string' && SAFE_REQUEST_ID.test(requestId)
|
|
100
|
+
? requestId
|
|
101
|
+
: undefined,
|
|
102
|
+
sdkRetryable: retryable ? true : undefined,
|
|
103
|
+
};
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const THROTTLED_NAMES = new Set([
|
|
107
|
+
'ThrottlingException',
|
|
108
|
+
// Bedrock reports some account quotas (e.g. requests/tokens per day or minute for a newly
|
|
109
|
+
// enabled model) as HTTP 400 ServiceQuotaExceededException rather than 429.
|
|
110
|
+
'ServiceQuotaExceededException',
|
|
111
|
+
'ModelNotReadyException',
|
|
112
|
+
'TooManyRequestsException',
|
|
113
|
+
]);
|
|
114
|
+
const UNAVAILABLE_NAMES = new Set(['ServiceUnavailableException', 'InternalServerException']);
|
|
115
|
+
const TIMEOUT_NAMES = new Set(['ModelTimeoutException', 'TimeoutError', 'RequestTimeout']);
|
|
116
|
+
/** Matched only to classify; the provider message is never logged or returned. */
|
|
117
|
+
const CONTENT_FILTER_MESSAGE = /content filter|blocked by our|moderation|flagged/i;
|
|
118
|
+
|
|
119
|
+
/** Classify a provider failure. Only throttling/unavailable rejections are retried. */
|
|
120
|
+
export const classifyProviderError = (
|
|
121
|
+
error: unknown
|
|
122
|
+
): { category: ProviderErrorCategory; retryable: boolean } => {
|
|
123
|
+
const { errorName, httpStatus } = providerErrorInfo(error);
|
|
124
|
+
const name = errorName ?? '';
|
|
125
|
+
if (THROTTLED_NAMES.has(name) || httpStatus === 429) {
|
|
126
|
+
return { category: 'throttled', retryable: true };
|
|
127
|
+
}
|
|
128
|
+
if (UNAVAILABLE_NAMES.has(name) || httpStatus === 503 || httpStatus === 500) {
|
|
129
|
+
return { category: 'unavailable', retryable: true };
|
|
130
|
+
}
|
|
131
|
+
if (TIMEOUT_NAMES.has(name) || httpStatus === 504 || httpStatus === 408) {
|
|
132
|
+
// The model may have run (and billed); never retry inside the same request.
|
|
133
|
+
return { category: 'timeout', retryable: false };
|
|
134
|
+
}
|
|
135
|
+
const message = (error as { message?: unknown } | null)?.message;
|
|
136
|
+
if (
|
|
137
|
+
name === 'ValidationException' &&
|
|
138
|
+
typeof message === 'string' &&
|
|
139
|
+
CONTENT_FILTER_MESSAGE.test(message)
|
|
140
|
+
) {
|
|
141
|
+
return { category: 'filtered', retryable: false };
|
|
142
|
+
}
|
|
143
|
+
return { category: 'rejected', retryable: false };
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
const clientError = (category: ProviderErrorCategory): TRPCError => {
|
|
147
|
+
if (category === 'throttled') return rateLimited();
|
|
148
|
+
if (category === 'unavailable' || category === 'timeout') return unavailable();
|
|
149
|
+
if (category === 'filtered') return filtered();
|
|
150
|
+
return failure();
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
const PASSTHROUGH_CODES = new Set(['BAD_REQUEST', 'TOO_MANY_REQUESTS', 'SERVICE_UNAVAILABLE']);
|
|
154
|
+
|
|
155
|
+
const promptHash = (prompt: string): string =>
|
|
156
|
+
createHash('sha256').update(prompt).digest('hex').slice(0, 12);
|
|
157
|
+
|
|
158
|
+
const abortableDelay = (ms: number, signal: AbortSignal): Promise<void> =>
|
|
159
|
+
new Promise((resolve, reject) => {
|
|
160
|
+
if (signal.aborted) reject(unavailable());
|
|
161
|
+
const timer = setTimeout(() => {
|
|
162
|
+
signal.removeEventListener('abort', onAbort);
|
|
163
|
+
resolve();
|
|
164
|
+
}, ms);
|
|
165
|
+
const onAbort = () => {
|
|
166
|
+
clearTimeout(timer);
|
|
167
|
+
reject(unavailable());
|
|
168
|
+
};
|
|
169
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
/** Full jitter: uniform in [0, min(cap, base * 2^(attempt-1))]. */
|
|
173
|
+
const retryDelay = (attempt: number): number =>
|
|
174
|
+
Math.floor(Math.random() * Math.min(RETRY_MAX_DELAY_MS, RETRY_BASE_DELAY_MS * 2 ** attempt));
|
|
175
|
+
|
|
176
|
+
/** Validate a single unfiltered, bounded square PNG before handing bytes to storage. */
|
|
177
|
+
export const decodeBedrockImage = (
|
|
178
|
+
body: Uint8Array,
|
|
179
|
+
diagnostics: ImageDiagnostics = {}
|
|
180
|
+
): Buffer => {
|
|
181
|
+
function reject(reason: string): never {
|
|
182
|
+
diagnostics.decodeReason = reason;
|
|
183
|
+
throw failure();
|
|
184
|
+
}
|
|
185
|
+
if (body.byteLength > MAX_RESPONSE_BYTES) reject('response_too_large');
|
|
186
|
+
let result: unknown;
|
|
187
|
+
try {
|
|
188
|
+
result = JSON.parse(Buffer.from(body).toString('utf8'));
|
|
189
|
+
} catch {
|
|
190
|
+
reject('json_invalid');
|
|
191
|
+
}
|
|
192
|
+
if (!result || typeof result !== 'object') reject('json_invalid');
|
|
193
|
+
const { images, finish_reasons: reasons } = result as Record<string, unknown>;
|
|
194
|
+
if (!Array.isArray(reasons) || reasons.length !== 1) reject('finish_reasons_invalid');
|
|
195
|
+
const reason: unknown = reasons[0];
|
|
196
|
+
diagnostics.finishReason =
|
|
197
|
+
reason === null
|
|
198
|
+
? null
|
|
199
|
+
: typeof reason === 'string' && SAFE_FINISH_REASON.test(reason)
|
|
200
|
+
? reason
|
|
201
|
+
: 'other';
|
|
202
|
+
if (typeof reason === 'string' && reason.startsWith('Filter reason:')) {
|
|
203
|
+
diagnostics.decodeReason = 'filtered';
|
|
204
|
+
throw filtered();
|
|
205
|
+
}
|
|
206
|
+
if (reason !== null) reject('not_success');
|
|
207
|
+
if (!Array.isArray(images) || images.length !== 1) reject('image_count_invalid');
|
|
208
|
+
const encoded: unknown = images[0];
|
|
209
|
+
if (
|
|
210
|
+
typeof encoded !== 'string' ||
|
|
211
|
+
!encoded.length ||
|
|
212
|
+
encoded.length > Math.ceil(MAX_IMAGE_BYTES / 3) * 4 ||
|
|
213
|
+
!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(encoded)
|
|
214
|
+
)
|
|
215
|
+
reject('base64_invalid');
|
|
216
|
+
const bytes = Buffer.from(encoded, 'base64');
|
|
217
|
+
if (
|
|
218
|
+
bytes.length < 33 ||
|
|
219
|
+
bytes.length > MAX_IMAGE_BYTES ||
|
|
220
|
+
bytes.toString('base64') !== encoded ||
|
|
221
|
+
!bytes.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])) ||
|
|
222
|
+
bytes.readUInt32BE(8) !== 13 ||
|
|
223
|
+
bytes.toString('ascii', 12, 16) !== 'IHDR'
|
|
224
|
+
)
|
|
225
|
+
reject('png_header_invalid');
|
|
226
|
+
const width = bytes.readUInt32BE(16);
|
|
227
|
+
if (width !== bytes.readUInt32BE(20) || width < 640 || width > 1536) {
|
|
228
|
+
reject('png_dimensions_invalid');
|
|
229
|
+
}
|
|
230
|
+
// Reject duplicate headers and bound decompression, including interlaced PNGs.
|
|
231
|
+
const compressed: Buffer[] = [];
|
|
232
|
+
let ended = false;
|
|
233
|
+
for (let offset = 8; offset < bytes.length;) {
|
|
234
|
+
if (offset + 12 > bytes.length || ended) reject('png_structure_invalid');
|
|
235
|
+
const length = bytes.readUInt32BE(offset);
|
|
236
|
+
const end = offset + 12 + length;
|
|
237
|
+
if (end > bytes.length) reject('png_structure_invalid');
|
|
238
|
+
const type = bytes.toString('ascii', offset + 4, offset + 8);
|
|
239
|
+
if (type === 'IHDR' && offset !== 8) reject('png_structure_invalid');
|
|
240
|
+
if (type === 'IDAT') compressed.push(bytes.subarray(offset + 8, end - 4));
|
|
241
|
+
if (type === 'IEND') {
|
|
242
|
+
if (length !== 0) reject('png_structure_invalid');
|
|
243
|
+
ended = true;
|
|
244
|
+
}
|
|
245
|
+
offset = end;
|
|
246
|
+
}
|
|
247
|
+
if (!ended || !compressed.length) reject('png_structure_invalid');
|
|
248
|
+
try {
|
|
249
|
+
inflateSync(Buffer.concat(compressed), { maxOutputLength: 20 * 1024 * 1024 });
|
|
250
|
+
// Dimensions are bounded before decode, preventing oversized pixel allocations.
|
|
251
|
+
const image = PNG.sync.read(bytes, { checkCRC: true });
|
|
252
|
+
if (image.width !== width || image.height !== width) reject('png_dimensions_invalid');
|
|
253
|
+
} catch (error) {
|
|
254
|
+
if (error instanceof TRPCError) throw error;
|
|
255
|
+
reject('png_decode_failed');
|
|
256
|
+
}
|
|
257
|
+
return bytes;
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* One successful paid invocation per request, no provider fallback. Bedrock throttling/unavailable
|
|
262
|
+
* rejections (not billed) get at most two jittered retries, only while a full attempt still fits
|
|
263
|
+
* inside IMAGE_TIMEOUT_MS. Every failure logs one sanitized `image_generation_failure` record.
|
|
264
|
+
*/
|
|
265
|
+
export const generateStoredImage = async (
|
|
266
|
+
prompt: string,
|
|
267
|
+
did: string,
|
|
268
|
+
config: ImageGenerationConfig,
|
|
269
|
+
openai: OpenAI | undefined,
|
|
270
|
+
storage: ImageStorage
|
|
271
|
+
): Promise<{ url: string }> => {
|
|
272
|
+
if (config.IMAGE_PROVIDER === 'bedrock' && prompt.length > 10_000) {
|
|
273
|
+
throw new TRPCError({
|
|
274
|
+
code: 'BAD_REQUEST',
|
|
275
|
+
message: 'Image prompt exceeds 10000 characters.',
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
const startedAt = Date.now();
|
|
279
|
+
const deadline = startedAt + config.IMAGE_TIMEOUT_MS;
|
|
280
|
+
const bedrock = config.IMAGE_PROVIDER === 'bedrock';
|
|
281
|
+
const logBase = () => ({
|
|
282
|
+
provider: config.IMAGE_PROVIDER,
|
|
283
|
+
modelId: bedrock ? config.BEDROCK_IMAGE_MODEL_ID : 'dall-e-3',
|
|
284
|
+
region: bedrock ? config.BEDROCK_IMAGE_REGION : undefined,
|
|
285
|
+
durationMs: Date.now() - startedAt,
|
|
286
|
+
promptLength: prompt.length,
|
|
287
|
+
promptHash: promptHash(prompt),
|
|
288
|
+
});
|
|
289
|
+
const diagnostics: ImageDiagnostics = { attempts: 0 };
|
|
290
|
+
const providerError = (error: unknown): TRPCError => {
|
|
291
|
+
if (error instanceof TRPCError) return error;
|
|
292
|
+
const { category } = classifyProviderError(error);
|
|
293
|
+
Object.assign(diagnostics, providerErrorInfo(error), { stage: 'invoke', category });
|
|
294
|
+
return clientError(category);
|
|
295
|
+
};
|
|
296
|
+
const controller = new AbortController();
|
|
297
|
+
let timeout: ReturnType<typeof setTimeout> | undefined;
|
|
298
|
+
const work = async (): Promise<{ url: string }> => {
|
|
299
|
+
let stored: { url?: string };
|
|
300
|
+
if (bedrock) {
|
|
301
|
+
// SigV4 with the execution role's rotating credentials only: never a long-term
|
|
302
|
+
// AWS_BEARER_TOKEN_BEDROCK API key, matching the text adapter's credential policy.
|
|
303
|
+
const client = new BedrockRuntimeClient({
|
|
304
|
+
region: config.BEDROCK_IMAGE_REGION,
|
|
305
|
+
maxAttempts: 1,
|
|
306
|
+
authSchemePreference: ['sigv4'],
|
|
307
|
+
});
|
|
308
|
+
try {
|
|
309
|
+
let response: { body: Uint8Array } | undefined;
|
|
310
|
+
for (let attempt = 1; !response; attempt += 1) {
|
|
311
|
+
diagnostics.attempts = attempt;
|
|
312
|
+
try {
|
|
313
|
+
response = await client.send(
|
|
314
|
+
new InvokeModelCommand({
|
|
315
|
+
modelId: config.BEDROCK_IMAGE_MODEL_ID,
|
|
316
|
+
contentType: 'application/json',
|
|
317
|
+
accept: 'application/json',
|
|
318
|
+
body: JSON.stringify({
|
|
319
|
+
prompt,
|
|
320
|
+
aspect_ratio: '1:1',
|
|
321
|
+
output_format: 'png',
|
|
322
|
+
}),
|
|
323
|
+
}),
|
|
324
|
+
{ abortSignal: controller.signal }
|
|
325
|
+
);
|
|
326
|
+
} catch (error) {
|
|
327
|
+
// Our own deadline aborted the call: the timeout path owns the outcome/log.
|
|
328
|
+
if (controller.signal.aborted) throw unavailable();
|
|
329
|
+
const mapped = providerError(error);
|
|
330
|
+
const delay = retryDelay(attempt);
|
|
331
|
+
if (
|
|
332
|
+
!classifyProviderError(error).retryable ||
|
|
333
|
+
attempt >= MAX_BEDROCK_ATTEMPTS ||
|
|
334
|
+
controller.signal.aborted ||
|
|
335
|
+
deadline - Date.now() - delay < MIN_ATTEMPT_BUDGET_MS
|
|
336
|
+
) {
|
|
337
|
+
throw mapped;
|
|
338
|
+
}
|
|
339
|
+
console.warn({
|
|
340
|
+
event: 'image_generation_retry',
|
|
341
|
+
...logBase(),
|
|
342
|
+
...providerErrorInfo(error),
|
|
343
|
+
category: diagnostics.category,
|
|
344
|
+
attempt,
|
|
345
|
+
delayMs: delay,
|
|
346
|
+
});
|
|
347
|
+
await abortableDelay(delay, controller.signal);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
diagnostics.stage = 'decode';
|
|
351
|
+
const bytes = decodeBedrockImage(response.body, diagnostics);
|
|
352
|
+
if (controller.signal.aborted) throw failure();
|
|
353
|
+
diagnostics.stage = 'storage';
|
|
354
|
+
// The upload isn't tied to the abort signal, so cap it at the remaining budget
|
|
355
|
+
// rather than a fresh IMAGE_TIMEOUT_MS that would outlive the request.
|
|
356
|
+
const uploadTimeoutMs = Math.max(1000, deadline - Date.now());
|
|
357
|
+
stored = await storage.upload(
|
|
358
|
+
bytes,
|
|
359
|
+
{ timeout: uploadTimeoutMs },
|
|
360
|
+
{ filename: 'generated-image.png' }
|
|
361
|
+
);
|
|
362
|
+
} finally {
|
|
363
|
+
client.destroy();
|
|
364
|
+
}
|
|
365
|
+
} else {
|
|
366
|
+
if (!openai) {
|
|
367
|
+
diagnostics.stage = 'config';
|
|
368
|
+
throw failure();
|
|
369
|
+
}
|
|
370
|
+
diagnostics.attempts = 1;
|
|
371
|
+
diagnostics.stage = 'invoke';
|
|
372
|
+
const response = await openai.images
|
|
373
|
+
.generate(
|
|
374
|
+
{ prompt, model: 'dall-e-3', size: '1024x1024', n: 1, user: did },
|
|
375
|
+
{ signal: controller.signal, timeout: config.IMAGE_TIMEOUT_MS, maxRetries: 0 }
|
|
376
|
+
)
|
|
377
|
+
.catch((error: unknown) => {
|
|
378
|
+
throw providerError(error);
|
|
379
|
+
});
|
|
380
|
+
if (!response.data?.[0]?.url || controller.signal.aborted) throw failure();
|
|
381
|
+
diagnostics.stage = 'storage';
|
|
382
|
+
stored = await storage.storeURL(response.data[0].url);
|
|
383
|
+
}
|
|
384
|
+
if (!stored?.url) throw failure();
|
|
385
|
+
return { url: stored.url };
|
|
386
|
+
};
|
|
387
|
+
try {
|
|
388
|
+
const result = await Promise.race([
|
|
389
|
+
work(),
|
|
390
|
+
new Promise<never>((_, reject) => {
|
|
391
|
+
timeout = setTimeout(() => {
|
|
392
|
+
diagnostics.stage = 'timeout';
|
|
393
|
+
controller.abort();
|
|
394
|
+
reject(unavailable());
|
|
395
|
+
}, config.IMAGE_TIMEOUT_MS);
|
|
396
|
+
}),
|
|
397
|
+
]);
|
|
398
|
+
console.info({
|
|
399
|
+
event: 'image_generation_success',
|
|
400
|
+
...logBase(),
|
|
401
|
+
attempts: diagnostics.attempts,
|
|
402
|
+
});
|
|
403
|
+
return result;
|
|
404
|
+
} catch (error) {
|
|
405
|
+
// Storage/config/decode failures are not provider statuses: keep them generic.
|
|
406
|
+
const clientFacing =
|
|
407
|
+
error instanceof TRPCError && PASSTHROUGH_CODES.has(error.code) ? error : failure();
|
|
408
|
+
// Allowlisted fields only: no prompt text, provider messages, headers, URLs or bytes.
|
|
409
|
+
console.error({
|
|
410
|
+
event: 'image_generation_failure',
|
|
411
|
+
...logBase(),
|
|
412
|
+
stage: diagnostics.stage,
|
|
413
|
+
attempts: diagnostics.attempts,
|
|
414
|
+
category: diagnostics.category,
|
|
415
|
+
errorName: diagnostics.errorName,
|
|
416
|
+
errorCode: diagnostics.errorCode,
|
|
417
|
+
httpStatus: diagnostics.httpStatus,
|
|
418
|
+
awsRequestId: diagnostics.awsRequestId,
|
|
419
|
+
sdkRetryable: diagnostics.sdkRetryable,
|
|
420
|
+
retryable: diagnostics.category
|
|
421
|
+
? diagnostics.category === 'throttled' || diagnostics.category === 'unavailable'
|
|
422
|
+
: undefined,
|
|
423
|
+
finishReason: diagnostics.finishReason,
|
|
424
|
+
decodeReason: diagnostics.decodeReason,
|
|
425
|
+
...(diagnostics.stage === 'storage' ? { storage: providerErrorInfo(error) } : {}),
|
|
426
|
+
clientCode: clientFacing.code,
|
|
427
|
+
});
|
|
428
|
+
throw clientFacing;
|
|
429
|
+
} finally {
|
|
430
|
+
clearTimeout(timeout);
|
|
431
|
+
}
|
|
432
|
+
};
|
package/src/models/EscrowHold.ts
CHANGED
|
@@ -282,7 +282,8 @@ export const findEscrowHoldsPendingEnclaveCancellation = async (
|
|
|
282
282
|
enclaveCancelPendingAt: { $exists: true },
|
|
283
283
|
enclaveCancelConfirmedAt: { $exists: false },
|
|
284
284
|
})
|
|
285
|
-
|
|
285
|
+
// Mongo sorts are unstable on ties; the unique ID makes bounded batches repeatable.
|
|
286
|
+
.sort({ enclaveCancelAttempts: 1, releaseAfter: 1, _id: 1 })
|
|
286
287
|
.limit(limit)
|
|
287
288
|
.toArray();
|
|
288
289
|
|
|
@@ -499,7 +500,8 @@ export const findEscrowHoldsPendingStartNotification = async (
|
|
|
499
500
|
): Promise<EscrowHold[]> =>
|
|
500
501
|
getEscrowHoldsCollection()
|
|
501
502
|
.find({ status: 'pending', startedNotificationPending: true })
|
|
502
|
-
|
|
503
|
+
// Keep tied start-notification batches deterministic before the limit.
|
|
504
|
+
.sort({ startedNotificationAttemptedAt: 1, requestedAt: 1, _id: 1 })
|
|
503
505
|
.limit(limit)
|
|
504
506
|
.toArray();
|
|
505
507
|
|
|
@@ -563,7 +565,8 @@ export const findEscrowHoldsDueForReminder = async (
|
|
|
563
565
|
releaseAfter: { $lte: new Date(now.getTime() + ESCROW_HOLD_REMINDER_WINDOW_MS) },
|
|
564
566
|
'notifications.kind': { $ne: 'reminder' },
|
|
565
567
|
})
|
|
566
|
-
|
|
568
|
+
// Keep tied reminder batches deterministic before the limit.
|
|
569
|
+
.sort({ reminderAttemptedAt: 1, releaseAfter: 1, _id: 1 })
|
|
567
570
|
.limit(limit)
|
|
568
571
|
.toArray();
|
|
569
572
|
|
package/src/models/UserKey.ts
CHANGED
|
@@ -1301,6 +1301,12 @@ export const activateUserKeyByAuthProvider = async (
|
|
|
1301
1301
|
/**
|
|
1302
1302
|
* Lazily discard abandoned migration shares after the P0-1 TTL. New-user
|
|
1303
1303
|
* provisional records are excluded because their keyProvider is already SSS.
|
|
1304
|
+
*
|
|
1305
|
+
* A migration with a confirmed escrow enrollment is not abandoned: the user
|
|
1306
|
+
* skipped the PIN, but escrow can recover them, and staying provisional keeps
|
|
1307
|
+
* the faster Web3Auth re-derive on new devices. Such records stay until the
|
|
1308
|
+
* user activates (e.g. sets a PIN) or operators activate them before Web3Auth
|
|
1309
|
+
* is retired (internal-docs/architecture/web3auth-sss-migration-runbook.md).
|
|
1304
1310
|
*/
|
|
1305
1311
|
export const purgeExpiredProvisionalMigrationByAuthProvider = async (
|
|
1306
1312
|
authProvider: AuthProviderMapping,
|
|
@@ -1313,6 +1319,14 @@ export const purgeExpiredProvisionalMigrationByAuthProvider = async (
|
|
|
1313
1319
|
keyProvider: 'web3auth',
|
|
1314
1320
|
sssActivationState: 'provisional',
|
|
1315
1321
|
provisionalCreatedAt: { $lte: cutoff },
|
|
1322
|
+
$nor: [
|
|
1323
|
+
{
|
|
1324
|
+
escrowBlob: { $exists: true },
|
|
1325
|
+
recoveryMethods: {
|
|
1326
|
+
$elemMatch: { type: 'escrow', confirmationStatus: 'confirmed' },
|
|
1327
|
+
},
|
|
1328
|
+
},
|
|
1329
|
+
],
|
|
1316
1330
|
},
|
|
1317
1331
|
{
|
|
1318
1332
|
$set: {
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
SKILL_ICON_MAX_CHARACTERS_PER_REQUEST,
|
|
4
|
+
SKILL_ICON_MAX_NAMES_PER_REQUEST,
|
|
5
|
+
} from '@learncard/types';
|
|
6
|
+
|
|
7
|
+
const mocks = vi.hoisted(() => ({
|
|
8
|
+
create: vi.fn(),
|
|
9
|
+
}));
|
|
10
|
+
|
|
11
|
+
vi.mock('@environment', () => ({ environment: {} }));
|
|
12
|
+
vi.mock('@helpers/bedrockGeneration', () => ({
|
|
13
|
+
createBedrockClient: vi.fn(() => ({})),
|
|
14
|
+
generateStructuredOutput: mocks.create,
|
|
15
|
+
}));
|
|
16
|
+
vi.mock('filestack-js', () => ({ init: () => ({}) }));
|
|
17
|
+
vi.mock('@routes', async () => {
|
|
18
|
+
const { initTRPC } = await import('@trpc/server');
|
|
19
|
+
const t = initTRPC.create();
|
|
20
|
+
return { t, didAndChallengeRoute: t.procedure };
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
import { aiRouter } from './ai';
|
|
24
|
+
|
|
25
|
+
const caller = aiRouter.createCaller({ user: { did: 'did:example:123' } } as never);
|
|
26
|
+
|
|
27
|
+
const respondWithIcons = (): void => {
|
|
28
|
+
mocks.create.mockImplementation(async (_client: unknown, { input }: { input: string }) => {
|
|
29
|
+
const names = [...input.matchAll(/^- (.*)$/gm)].map(match => match[1]);
|
|
30
|
+
|
|
31
|
+
return { items: names.map(name => ({ name, icon: '🎯' })) };
|
|
32
|
+
});
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
describe('generateSkillIcons input validation', () => {
|
|
36
|
+
beforeEach(() => {
|
|
37
|
+
vi.clearAllMocks();
|
|
38
|
+
respondWithIcons();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('accepts a batch at both limits', async () => {
|
|
42
|
+
const names = Array.from({ length: SKILL_ICON_MAX_NAMES_PER_REQUEST }, (_, i) =>
|
|
43
|
+
`${i}`.padStart(
|
|
44
|
+
SKILL_ICON_MAX_CHARACTERS_PER_REQUEST / SKILL_ICON_MAX_NAMES_PER_REQUEST,
|
|
45
|
+
'x'
|
|
46
|
+
)
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
const result = await caller.generateSkillIcons({ names });
|
|
50
|
+
|
|
51
|
+
expect(Object.keys(result)).toHaveLength(SKILL_ICON_MAX_NAMES_PER_REQUEST);
|
|
52
|
+
expect(mocks.create).toHaveBeenCalledTimes(1);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('rejects too many names with BAD_REQUEST before calling the model', async () => {
|
|
56
|
+
const names = Array.from(
|
|
57
|
+
{ length: SKILL_ICON_MAX_NAMES_PER_REQUEST + 1 },
|
|
58
|
+
(_, i) => `s${i}`
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
await expect(caller.generateSkillIcons({ names })).rejects.toMatchObject({
|
|
62
|
+
code: 'BAD_REQUEST',
|
|
63
|
+
});
|
|
64
|
+
expect(mocks.create).not.toHaveBeenCalled();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('rejects names totalling more than the character limit with BAD_REQUEST', async () => {
|
|
68
|
+
const names = ['a'.repeat(SKILL_ICON_MAX_CHARACTERS_PER_REQUEST), 'b'];
|
|
69
|
+
|
|
70
|
+
await expect(caller.generateSkillIcons({ names })).rejects.toMatchObject({
|
|
71
|
+
code: 'BAD_REQUEST',
|
|
72
|
+
});
|
|
73
|
+
expect(mocks.create).not.toHaveBeenCalled();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('rejects a single name over the character limit', async () => {
|
|
77
|
+
const names = ['a'.repeat(SKILL_ICON_MAX_CHARACTERS_PER_REQUEST + 1)];
|
|
78
|
+
|
|
79
|
+
await expect(caller.generateSkillIcons({ names })).rejects.toMatchObject({
|
|
80
|
+
code: 'BAD_REQUEST',
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('still rejects empty lists and empty names', async () => {
|
|
85
|
+
await expect(caller.generateSkillIcons({ names: [] })).rejects.toMatchObject({
|
|
86
|
+
code: 'BAD_REQUEST',
|
|
87
|
+
});
|
|
88
|
+
await expect(caller.generateSkillIcons({ names: [''] })).rejects.toMatchObject({
|
|
89
|
+
code: 'BAD_REQUEST',
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
});
|