@learncard/lca-api-service 1.5.2 → 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.
- package/.env.example +10 -0
- package/CHANGELOG.md +44 -0
- package/bundle.mjs +1 -1
- package/dist/app.d.ts +23 -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/ai.test.d.ts +1 -0
- package/dist/routes/escrow.d.ts +19 -0
- package/dist/routes/keys.d.ts +1 -0
- package/dist/routes/notifications.d.ts +3 -1
- package/dist/services/escrow-enclave/logFailure.d.ts +1 -0
- package/dist/services/escrow-enclave/logFailure.test.d.ts +1 -0
- package/dist/services/escrow-enclave/types.d.ts +17 -1
- package/dist/types/notifications.d.ts +3 -1
- package/docs/bedrock-generation.md +119 -0
- package/docs/image-generation.md +120 -0
- package/esbuildPlugins.cjs +9 -6
- package/package.json +21 -16
- 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/routes/ai.skill-icons.test.ts +92 -0
- package/src/routes/ai.test.ts +80 -0
- package/src/routes/ai.ts +33 -144
- package/src/routes/escrow.ts +15 -2
- package/src/routes/keys.ts +18 -0
- package/src/services/escrow-enclave/enclaveTrustAnchors.ts +19 -0
- package/src/services/escrow-enclave/logFailure.test.ts +72 -0
- package/src/services/escrow-enclave/logFailure.ts +46 -0
- package/src/services/escrow-enclave/remoteEnclave.test.ts +101 -3
- package/src/services/escrow-enclave/remoteEnclave.tls.test.ts +192 -2
- package/src/services/escrow-enclave/remoteEnclave.ts +101 -36
- package/src/services/escrow-enclave/types.ts +26 -2
- package/test/keys-auth-share-escrow.spec.ts +103 -0
- package/test/keys-escrow.spec.ts +65 -0
- package/test/oidc.spec.ts +41 -2
- package/tsconfig.bedrock.json +10 -0
package/test/keys-escrow.spec.ts
CHANGED
|
@@ -48,6 +48,7 @@ import {
|
|
|
48
48
|
getEscrowEnclave,
|
|
49
49
|
getEscrowBlobStaleReason,
|
|
50
50
|
EscrowPolicyError,
|
|
51
|
+
EscrowCodeError,
|
|
51
52
|
EscrowBlobError,
|
|
52
53
|
EscrowPinMismatchError,
|
|
53
54
|
EscrowUnavailableError,
|
|
@@ -419,6 +420,26 @@ describe('authority enrollment persistence', () => {
|
|
|
419
420
|
expect(stored?.requiresConfirmation).toBe(true);
|
|
420
421
|
expect(stored?.startedNotificationPending).toBe(false);
|
|
421
422
|
});
|
|
423
|
+
it('persists an awaiting-confirmation hold even when code delivery failed', async () => {
|
|
424
|
+
await enroll();
|
|
425
|
+
const enclave = getEscrowEnclave();
|
|
426
|
+
const create = enclave.createHold.bind(enclave);
|
|
427
|
+
const delivery = { codeSent: false, proof: { ...receipt, kind: 'snapshot' as const } };
|
|
428
|
+
vi.spyOn(enclave, 'createHold').mockImplementation(async input => ({
|
|
429
|
+
...(await create(input)),
|
|
430
|
+
delivery,
|
|
431
|
+
}));
|
|
432
|
+
__setEscrowEnclaveForTests(
|
|
433
|
+
Object.assign(enclave, { confirmHold: async () => ({ success: true as const }) })
|
|
434
|
+
);
|
|
435
|
+
const started = await start();
|
|
436
|
+
expect(started.codeSent).toBe(false);
|
|
437
|
+
expect(started.delivery).toEqual(delivery);
|
|
438
|
+
const stored = await findEscrowHoldById(started.holdId);
|
|
439
|
+
expect(stored?.holdRecord).toEqual(started.holdRecord);
|
|
440
|
+
expect(stored?.requiresConfirmation).toBe(true);
|
|
441
|
+
expect(stored?.confirmedAt).toBeUndefined();
|
|
442
|
+
});
|
|
422
443
|
it('persists a cancellation commit receipt before clearing the durable pending marker', async () => {
|
|
423
444
|
await enroll();
|
|
424
445
|
const started = await start();
|
|
@@ -1464,6 +1485,27 @@ describe('P2-c confirmRecovery reconciliation', () => {
|
|
|
1464
1485
|
}));
|
|
1465
1486
|
beforeEach(() => idempotentConfirmHold.mockClear());
|
|
1466
1487
|
|
|
1488
|
+
it.each(['codeMismatch', 'codeExpired', 'codeLocked'] as const)(
|
|
1489
|
+
'returns sanitized confirmation reason %s without confirming',
|
|
1490
|
+
async reason => {
|
|
1491
|
+
const { hold, resumeToken } = await createConfirmableHold();
|
|
1492
|
+
const enclave = getEscrowEnclave();
|
|
1493
|
+
enclave.confirmHold = vi.fn().mockRejectedValue(new EscrowCodeError(reason));
|
|
1494
|
+
try {
|
|
1495
|
+
await expect(
|
|
1496
|
+
getClient().escrow.confirmRecovery({
|
|
1497
|
+
holdId: hold._id,
|
|
1498
|
+
resumeToken,
|
|
1499
|
+
code: '12345678',
|
|
1500
|
+
})
|
|
1501
|
+
).rejects.toMatchObject({ code: 'FORBIDDEN', message: reason });
|
|
1502
|
+
expect((await findEscrowHoldById(hold._id))?.confirmedAt).toBeUndefined();
|
|
1503
|
+
} finally {
|
|
1504
|
+
delete enclave.confirmHold;
|
|
1505
|
+
}
|
|
1506
|
+
}
|
|
1507
|
+
);
|
|
1508
|
+
|
|
1467
1509
|
it('allows the SDK commit-to-v2-snapshot readback without a second persistence or deadline shift', async () => {
|
|
1468
1510
|
const { hold, resumeToken } = await createConfirmableHold();
|
|
1469
1511
|
const snapshotReceipt = {
|
|
@@ -1908,10 +1950,14 @@ describe('escrow PIN release', () => {
|
|
|
1908
1950
|
it('burns a mismatched hold; a new hold with the correct PIN succeeds', async () => {
|
|
1909
1951
|
await enrollPin();
|
|
1910
1952
|
const first = await startPin();
|
|
1953
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
|
1911
1954
|
await expect(completePin(first, wrongProof)).rejects.toMatchObject({
|
|
1912
1955
|
code: 'FORBIDDEN',
|
|
1913
1956
|
message: 'Incorrect PIN. 9 attempts left.',
|
|
1914
1957
|
});
|
|
1958
|
+
expect(warn).toHaveBeenCalledExactlyOnceWith('[LCA escrow] enclave operation failed', {
|
|
1959
|
+
reason: 'pin_mismatch',
|
|
1960
|
+
});
|
|
1915
1961
|
expect(await findEscrowHoldById(first.holdId)).toMatchObject({
|
|
1916
1962
|
status: 'cancelled',
|
|
1917
1963
|
cancelledBy: 'system',
|
|
@@ -2259,6 +2305,25 @@ describe('escrow PIN release', () => {
|
|
|
2259
2305
|
expect((await getClient().escrow.getStatus(auth)).hold?.holdId).toBe(hold.holdId);
|
|
2260
2306
|
});
|
|
2261
2307
|
|
|
2308
|
+
it('logs an unexpected non-PIN mismatch once without refunding a PIN attempt', async () => {
|
|
2309
|
+
await enroll();
|
|
2310
|
+
const hold = await start();
|
|
2311
|
+
advanceToRelease(hold);
|
|
2312
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
|
2313
|
+
const refund = vi.spyOn(models, 'refundEscrowPinAttempt');
|
|
2314
|
+
vi.spyOn(getEscrowEnclave(), 'releaseEscrow').mockRejectedValueOnce(
|
|
2315
|
+
new EscrowPinMismatchError()
|
|
2316
|
+
);
|
|
2317
|
+
await expect(getClient().escrow.completeRecovery(resume(hold))).rejects.toMatchObject({
|
|
2318
|
+
code: 'INTERNAL_SERVER_ERROR',
|
|
2319
|
+
});
|
|
2320
|
+
expect(warn).toHaveBeenCalledExactlyOnceWith('[LCA escrow] enclave operation failed', {
|
|
2321
|
+
reason: 'pin_mismatch',
|
|
2322
|
+
});
|
|
2323
|
+
expect(refund).not.toHaveBeenCalled();
|
|
2324
|
+
expect(await findEscrowHoldById(hold.holdId)).toMatchObject({ status: 'pending' });
|
|
2325
|
+
});
|
|
2326
|
+
|
|
2262
2327
|
it.each([new EscrowUnavailableError(), new EscrowBlobError()])(
|
|
2263
2328
|
'refunds non-mismatch enclave failures: %s',
|
|
2264
2329
|
async error => {
|
package/test/oidc.spec.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { issueLoginTicket, redeemLoginTicket } from '../src/cache/login-tickets'
|
|
|
6
6
|
import cache from '@cache';
|
|
7
7
|
import { GetSecretValueCommand } from '@aws-sdk/client-secrets-manager';
|
|
8
8
|
import { getOidcSigningKey, resetOidcSigningKeyForTests } from '../src/helpers/oidc.helpers';
|
|
9
|
+
import { ACCESS_TOKEN_PREFIX } from '../src/helpers/login-ticket.helpers';
|
|
9
10
|
|
|
10
11
|
const { env, entries, sendSecret } = vi.hoisted(() => ({
|
|
11
12
|
sendSecret: vi.fn(),
|
|
@@ -188,8 +189,12 @@ beforeEach(async () => {
|
|
|
188
189
|
});
|
|
189
190
|
});
|
|
190
191
|
afterEach(async () => {
|
|
191
|
-
|
|
192
|
-
|
|
192
|
+
try {
|
|
193
|
+
await app.close();
|
|
194
|
+
} finally {
|
|
195
|
+
vi.useRealTimers();
|
|
196
|
+
vi.restoreAllMocks();
|
|
197
|
+
}
|
|
193
198
|
});
|
|
194
199
|
|
|
195
200
|
describe('OIDC provider', () => {
|
|
@@ -397,6 +402,11 @@ describe('OIDC provider', () => {
|
|
|
397
402
|
it.each<BasicMode>(['encoded', 'raw', false])(
|
|
398
403
|
'exchanges a code using Basic=%s and verifies RS256 through served JWKS',
|
|
399
404
|
async basic => {
|
|
405
|
+
// Freeze wall-clock time only; Fastify's I/O timers keep running normally.
|
|
406
|
+
// expires_in is remaining time, so an uncontrolled second boundary can
|
|
407
|
+
// make it 299 even though the signed JWT lifetime is exactly 300 seconds.
|
|
408
|
+
vi.useFakeTimers({ toFake: ['Date'] });
|
|
409
|
+
vi.setSystemTime(new Date('2026-10-14T12:00:00.999Z'));
|
|
400
410
|
const response = await exchange(await code(), {}, basic);
|
|
401
411
|
expect(response.statusCode).toBe(200);
|
|
402
412
|
const tokens = response.json();
|
|
@@ -427,6 +437,35 @@ describe('OIDC provider', () => {
|
|
|
427
437
|
expect(response.headers.pragma).toBe('no-cache');
|
|
428
438
|
}
|
|
429
439
|
);
|
|
440
|
+
it('reports 299 seconds remaining when access-token persistence crosses a second', async () => {
|
|
441
|
+
vi.useFakeTimers({ toFake: ['Date'] });
|
|
442
|
+
const start = new Date('2026-10-14T12:00:00.999Z');
|
|
443
|
+
vi.setSystemTime(start);
|
|
444
|
+
const set = cache.set.bind(cache);
|
|
445
|
+
vi.spyOn(cache, 'set').mockImplementation(async (...args) => {
|
|
446
|
+
const result = await set(...args);
|
|
447
|
+
if (args[0].startsWith(ACCESS_TOKEN_PREFIX)) {
|
|
448
|
+
// Advance after JWT signing, before expires_in is calculated.
|
|
449
|
+
vi.setSystemTime(start.getTime() + 1);
|
|
450
|
+
}
|
|
451
|
+
return result;
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
const response = await exchange(await code());
|
|
455
|
+
expect(response.statusCode).toBe(200);
|
|
456
|
+
const tokens = response.json();
|
|
457
|
+
const jwks = createLocalJWKSet((await app.inject('/oidc/jwks')).json());
|
|
458
|
+
const options = { issuer: env.OIDC_ISSUER, audience: env.OIDC_CLIENT_ID };
|
|
459
|
+
const { payload } = await jwtVerify(tokens.id_token, jwks, options);
|
|
460
|
+
expect(payload.exp! - payload.iat!).toBe(300);
|
|
461
|
+
expect(tokens.expires_in).toBe(299);
|
|
462
|
+
|
|
463
|
+
// The clock control must not hide an extended JWT lifetime.
|
|
464
|
+
vi.setSystemTime(payload.exp! * 1000);
|
|
465
|
+
await expect(jwtVerify(tokens.id_token, jwks, options)).rejects.toMatchObject({
|
|
466
|
+
code: 'ERR_JWT_EXPIRED',
|
|
467
|
+
});
|
|
468
|
+
});
|
|
430
469
|
it('URL-decodes Basic client credentials', async () => {
|
|
431
470
|
env.OIDC_CLIENT_SECRET = 'a+b:c %';
|
|
432
471
|
expect((await exchange(await code(), {}, 'encoded')).statusCode).toBe(200);
|