@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,103 @@
|
|
|
1
|
+
import { randomUUID } from 'crypto';
|
|
2
|
+
|
|
3
|
+
import { client } from '@mongo';
|
|
4
|
+
import { createUserKeysIndexes, getUserKeysCollection, type MongoUserKeyType } from '@models';
|
|
5
|
+
|
|
6
|
+
import { getClient } from './helpers/getClient';
|
|
7
|
+
|
|
8
|
+
const makeMockToken = (email: string, uid: string): string => {
|
|
9
|
+
const payload = Buffer.from(JSON.stringify({ sub: uid, email })).toString('base64url');
|
|
10
|
+
|
|
11
|
+
return `header.${payload}.signature`;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const makeUser = (
|
|
15
|
+
uid: string,
|
|
16
|
+
email: string,
|
|
17
|
+
recoveryMethods: MongoUserKeyType['recoveryMethods']
|
|
18
|
+
): MongoUserKeyType => {
|
|
19
|
+
const now = new Date();
|
|
20
|
+
|
|
21
|
+
return {
|
|
22
|
+
contactMethod: { type: 'email', value: email },
|
|
23
|
+
authProviders: [{ type: 'firebase', id: uid }],
|
|
24
|
+
primaryDid: `did:key:z${uid}`,
|
|
25
|
+
linkedDids: [],
|
|
26
|
+
keyProvider: 'sss',
|
|
27
|
+
authShare: { encryptedData: 'auth-share', encryptedDek: '', iv: '' },
|
|
28
|
+
shareVersion: 3,
|
|
29
|
+
shareUpdatedAt: now,
|
|
30
|
+
previousAuthShares: [],
|
|
31
|
+
securityLevel: 'enhanced',
|
|
32
|
+
recoveryMethods,
|
|
33
|
+
migratedFromWeb3Auth: false,
|
|
34
|
+
sssActivationState: 'active',
|
|
35
|
+
createdAt: now,
|
|
36
|
+
updatedAt: now,
|
|
37
|
+
};
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
beforeAll(async () => {
|
|
41
|
+
process.env.IS_E2E_TEST = 'true';
|
|
42
|
+
process.env.SEED ||= 'a'.repeat(64);
|
|
43
|
+
await client.connect();
|
|
44
|
+
await createUserKeysIndexes();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
afterAll(async () => {
|
|
48
|
+
await client.close();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
describe('getAuthShare priorEscrowEnrollment', () => {
|
|
52
|
+
const statusFor = async (recoveryMethods: MongoUserKeyType['recoveryMethods']) => {
|
|
53
|
+
const uid = `auth-share-escrow-${randomUUID()}`;
|
|
54
|
+
const email = `${uid}@example.com`;
|
|
55
|
+
const collection = getUserKeysCollection();
|
|
56
|
+
await collection.insertOne(makeUser(uid, email, recoveryMethods));
|
|
57
|
+
try {
|
|
58
|
+
return await getClient().keys.getAuthShare({
|
|
59
|
+
authToken: makeMockToken(email, uid),
|
|
60
|
+
providerType: 'firebase',
|
|
61
|
+
});
|
|
62
|
+
} finally {
|
|
63
|
+
await collection.deleteMany({ 'authProviders.id': uid });
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
const now = new Date();
|
|
67
|
+
|
|
68
|
+
it('is false for an escrow method confirmed at the current share version', async () => {
|
|
69
|
+
const status = await statusFor([
|
|
70
|
+
{
|
|
71
|
+
type: 'escrow',
|
|
72
|
+
createdAt: now,
|
|
73
|
+
shareVersion: 3,
|
|
74
|
+
confirmationStatus: 'confirmed',
|
|
75
|
+
confirmedAt: now,
|
|
76
|
+
},
|
|
77
|
+
]);
|
|
78
|
+
expect(status?.priorEscrowEnrollment).toBe(false);
|
|
79
|
+
expect(status?.recoveryMethods).toHaveLength(1);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('is true for an escrow method left pending by an identity-recovery rebind', async () => {
|
|
83
|
+
const status = await statusFor([
|
|
84
|
+
{ type: 'escrow', createdAt: now, shareVersion: 3, confirmationStatus: 'pending' },
|
|
85
|
+
]);
|
|
86
|
+
expect(status?.priorEscrowEnrollment).toBe(true);
|
|
87
|
+
// The pending method itself is still never exposed.
|
|
88
|
+
expect(status?.recoveryMethods).toEqual([]);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('is false when the account has no escrow method', async () => {
|
|
92
|
+
const status = await statusFor([
|
|
93
|
+
{
|
|
94
|
+
type: 'phrase',
|
|
95
|
+
createdAt: now,
|
|
96
|
+
shareVersion: 3,
|
|
97
|
+
confirmationStatus: 'confirmed',
|
|
98
|
+
confirmedAt: now,
|
|
99
|
+
},
|
|
100
|
+
]);
|
|
101
|
+
expect(status?.priorEscrowEnrollment).toBe(false);
|
|
102
|
+
});
|
|
103
|
+
});
|
|
@@ -676,6 +676,82 @@ describe('P0-1 provisional SSS activation', () => {
|
|
|
676
676
|
});
|
|
677
677
|
});
|
|
678
678
|
|
|
679
|
+
describe('P0-1 provisional purge spares escrow-enrolled migrations', () => {
|
|
680
|
+
const expiredMigration = (
|
|
681
|
+
email: string,
|
|
682
|
+
uid: string,
|
|
683
|
+
did: string,
|
|
684
|
+
escrow: { blob: boolean; method?: 'confirmed' | 'pending' }
|
|
685
|
+
): MongoUserKeyType => {
|
|
686
|
+
const key = makeUserKey(email, uid, did, 'migrated-share');
|
|
687
|
+
|
|
688
|
+
Object.assign(key, {
|
|
689
|
+
keyProvider: 'web3auth' as const,
|
|
690
|
+
sssActivationState: 'provisional' as const,
|
|
691
|
+
provisionalCreatedAt: new Date(Date.now() - PROVISIONAL_MIGRATION_TTL_MS - 1_000),
|
|
692
|
+
recoveryMethods: escrow.method
|
|
693
|
+
? [
|
|
694
|
+
{
|
|
695
|
+
type: 'escrow' as const,
|
|
696
|
+
createdAt: new Date(),
|
|
697
|
+
confirmationStatus: escrow.method,
|
|
698
|
+
...(escrow.method === 'confirmed' ? { confirmedAt: new Date() } : {}),
|
|
699
|
+
shareVersion: 1,
|
|
700
|
+
},
|
|
701
|
+
]
|
|
702
|
+
: [],
|
|
703
|
+
// Only presence matters to the purge filter.
|
|
704
|
+
...(escrow.blob
|
|
705
|
+
? { escrowBlob: { shareVersion: 1 } as unknown as MongoUserKeyType['escrowBlob'] }
|
|
706
|
+
: {}),
|
|
707
|
+
});
|
|
708
|
+
|
|
709
|
+
return key;
|
|
710
|
+
};
|
|
711
|
+
|
|
712
|
+
it.each([
|
|
713
|
+
['confirmed escrow enrollment', false, { blob: true, method: 'confirmed' as const }],
|
|
714
|
+
[
|
|
715
|
+
'escrow blob without a confirmed method',
|
|
716
|
+
true,
|
|
717
|
+
{ blob: true, method: 'pending' as const },
|
|
718
|
+
],
|
|
719
|
+
['confirmed method without a blob', true, { blob: false, method: 'confirmed' as const }],
|
|
720
|
+
])('%s → purged: %s', async (_name, purged, escrow) => {
|
|
721
|
+
const suffix = `${Date.now()}-${randomUUID()}`;
|
|
722
|
+
const email = `escrow-purge-${suffix}@example.com`;
|
|
723
|
+
const uid = `uid-${suffix}`;
|
|
724
|
+
const { learnCard } = await getUser('5'.repeat(64));
|
|
725
|
+
const collection = getUserKeysCollection();
|
|
726
|
+
|
|
727
|
+
await collection.insertOne(expiredMigration(email, uid, learnCard.id.did(), escrow));
|
|
728
|
+
|
|
729
|
+
try {
|
|
730
|
+
const result = await getClient().keys.getAuthShare({
|
|
731
|
+
authToken: makeMockToken(email, uid),
|
|
732
|
+
providerType: 'firebase',
|
|
733
|
+
});
|
|
734
|
+
const stored = await collection.findOne({
|
|
735
|
+
authProviders: { $elemMatch: { type: 'firebase', id: uid } },
|
|
736
|
+
});
|
|
737
|
+
|
|
738
|
+
if (purged) {
|
|
739
|
+
expect(result?.authShare).toBeNull();
|
|
740
|
+
expect(stored?.authShare).toBeUndefined();
|
|
741
|
+
expect(stored?.escrowBlob).toBeUndefined();
|
|
742
|
+
expect(stored?.sssActivationState).toBeUndefined();
|
|
743
|
+
} else {
|
|
744
|
+
expect(stored?.authShare?.encryptedData).toBe('migrated-share');
|
|
745
|
+
expect(stored?.escrowBlob).toBeDefined();
|
|
746
|
+
expect(stored?.sssActivationState).toBe('provisional');
|
|
747
|
+
expect(stored?.keyProvider).toBe('web3auth');
|
|
748
|
+
}
|
|
749
|
+
} finally {
|
|
750
|
+
await collection.deleteMany({ 'contactMethod.value': email });
|
|
751
|
+
}
|
|
752
|
+
});
|
|
753
|
+
});
|
|
754
|
+
|
|
679
755
|
describe('P0-6 fresh DID authorization', () => {
|
|
680
756
|
it('issues short-lived challenges that can only be consumed once', async () => {
|
|
681
757
|
const did = `did:key:${randomUUID()}`;
|
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);
|
package/tsconfig.json
CHANGED
|
@@ -17,21 +17,20 @@
|
|
|
17
17
|
"emitDeclarationOnly": true,
|
|
18
18
|
"sourceMap": true,
|
|
19
19
|
"rootDir": ".",
|
|
20
|
-
"baseUrl": ".",
|
|
21
20
|
"paths": {
|
|
22
|
-
"@accesslayer/*": ["src/accesslayer/*"],
|
|
23
|
-
"@cache": ["src/cache/index.ts"],
|
|
24
|
-
"@cache/*": ["src/cache/*"],
|
|
25
|
-
"@config/*": ["src/config/*"],
|
|
26
|
-
"@environment": ["src/config/environment.ts"],
|
|
27
|
-
"@helpers/*": ["src/helpers/*"],
|
|
28
|
-
"@instance": ["src/instance.ts"],
|
|
29
|
-
"@firebase": ["src/firebase.ts"],
|
|
30
|
-
"@mongo": ["src/mongo.ts"],
|
|
31
|
-
"@models": ["src/models/index.ts"],
|
|
32
|
-
"@routes": ["src/routes/index.ts"],
|
|
33
|
-
"@routes/*": ["src/routes/*"],
|
|
34
|
-
"types/*": ["src/types/*"]
|
|
21
|
+
"@accesslayer/*": ["./src/accesslayer/*"],
|
|
22
|
+
"@cache": ["./src/cache/index.ts"],
|
|
23
|
+
"@cache/*": ["./src/cache/*"],
|
|
24
|
+
"@config/*": ["./src/config/*"],
|
|
25
|
+
"@environment": ["./src/config/environment.ts"],
|
|
26
|
+
"@helpers/*": ["./src/helpers/*"],
|
|
27
|
+
"@instance": ["./src/instance.ts"],
|
|
28
|
+
"@firebase": ["./src/firebase.ts"],
|
|
29
|
+
"@mongo": ["./src/mongo.ts"],
|
|
30
|
+
"@models": ["./src/models/index.ts"],
|
|
31
|
+
"@routes": ["./src/routes/index.ts"],
|
|
32
|
+
"@routes/*": ["./src/routes/*"],
|
|
33
|
+
"types/*": ["./src/types/*"]
|
|
35
34
|
},
|
|
36
35
|
"allowJs": false
|
|
37
36
|
},
|