@learncard/lca-api-service 1.6.3 → 1.7.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 (79) hide show
  1. package/.env.example +2 -0
  2. package/CHANGELOG.md +114 -0
  3. package/config/config.dev.json +1 -0
  4. package/config/config.example.json +1 -0
  5. package/config/config.production.json +1 -0
  6. package/config/config.scouts.dev.json +1 -0
  7. package/config/config.scouts.production.json +1 -0
  8. package/dist/accesslayer/notifications/create.d.ts +2 -1
  9. package/dist/app.d.ts +70 -8
  10. package/dist/config/environment.d.ts +6 -0
  11. package/dist/config/stageConfig.d.ts +4 -0
  12. package/dist/helpers/analyticsEligibility.d.ts +6 -0
  13. package/dist/helpers/brainUrl.d.ts +1 -0
  14. package/dist/helpers/brainUrl.test.d.ts +1 -0
  15. package/dist/helpers/currentProfile.d.ts +6 -0
  16. package/dist/helpers/currentProfile.test.d.ts +1 -0
  17. package/dist/helpers/retryingOnce.d.ts +1 -0
  18. package/dist/helpers/retryingOnce.test.d.ts +1 -0
  19. package/dist/index.js +2 -2
  20. package/dist/models/EscrowHold.d.ts +1 -0
  21. package/dist/models/EscrowHold.indexes.test.d.ts +1 -0
  22. package/dist/models/Notification.d.ts +114 -2
  23. package/dist/models/SigningAuthority.d.ts +1 -1
  24. package/dist/models/UserKey.d.ts +23 -0
  25. package/dist/publish-metafile.json +1 -1
  26. package/dist/routes/credentials.d.ts +1 -1
  27. package/dist/routes/index.d.ts +5 -0
  28. package/dist/routes/keys.d.ts +1 -0
  29. package/dist/routes/notifications.d.ts +65 -4
  30. package/dist/routes/preferences.test.d.ts +1 -0
  31. package/dist/routes/signing-authority.d.ts +3 -3
  32. package/dist/services/escrow-enclave/index.d.ts +2 -0
  33. package/dist/services/escrow-enclave/logFailure.d.ts +1 -0
  34. package/dist/types/credentials.d.ts +1 -1
  35. package/dist/types/notifications.d.ts +118 -2
  36. package/docs/bedrock-generation.md +7 -6
  37. package/lambdaApp.test.ts +114 -0
  38. package/lambdaApp.ts +23 -11
  39. package/package.json +11 -11
  40. package/scripts/escrow-lambda-http.ts +74 -0
  41. package/scripts/verify-escrow-startup.mjs +463 -0
  42. package/serverless-local.yml +1 -1
  43. package/serverless.yml +1 -1
  44. package/src/accesslayer/notifications/create.test.ts +80 -0
  45. package/src/accesslayer/notifications/create.ts +97 -2
  46. package/src/config/environment.test.ts +27 -1
  47. package/src/config/environment.ts +16 -0
  48. package/src/config/escrowDeployment.test.ts +1 -0
  49. package/src/docker-entry.ts +1 -2
  50. package/src/helpers/analyticsEligibility.ts +15 -0
  51. package/src/helpers/brainUrl.test.ts +13 -0
  52. package/src/helpers/brainUrl.ts +3 -0
  53. package/src/helpers/currentProfile.test.ts +55 -0
  54. package/src/helpers/currentProfile.ts +58 -0
  55. package/src/helpers/locale.helpers.test.ts +4 -1
  56. package/src/helpers/locale.helpers.ts +4 -2
  57. package/src/helpers/retryingOnce.test.ts +33 -0
  58. package/src/helpers/retryingOnce.ts +18 -0
  59. package/src/models/EscrowHold.indexes.test.ts +208 -0
  60. package/src/models/EscrowHold.ts +106 -51
  61. package/src/models/UserKey.ts +69 -1
  62. package/src/routes/escrow.ts +18 -11
  63. package/src/routes/index.ts +5 -8
  64. package/src/routes/keys.ts +8 -0
  65. package/src/routes/notifications.ts +34 -1
  66. package/src/routes/preferences.test.ts +216 -0
  67. package/src/routes/preferences.ts +54 -4
  68. package/src/services/escrow-enclave/index.ts +38 -1
  69. package/src/services/escrow-enclave/logFailure.ts +5 -1
  70. package/src/services/escrow-enclave/production.test.ts +64 -0
  71. package/src/services/escrow-enclave/softwareEnclave.test.ts +14 -2
  72. package/test/jobs/escrowHoldReminders.spec.ts +21 -4
  73. package/test/keys-escrow.spec.ts +131 -3
  74. package/test/keys-lifecycle-invariants.spec.ts +235 -0
  75. package/test/notifications.spec.ts +102 -0
  76. package/tsconfig.entrypoints.json +1 -1
  77. package/vitest.config.ts +1 -0
  78. package/scripts/node24-runtime.cjs +0 -9
  79. package/scripts/verify-node24-layer.cjs +0 -49
@@ -0,0 +1,208 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { inspect } from 'node:util';
3
+ import { MongoClient, type Db } from 'mongodb';
4
+ import { MongoMemoryServer } from 'mongodb-memory-server';
5
+ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
6
+ import type { CreateEscrowHoldInput } from './EscrowHold';
7
+
8
+ const database = vi.hoisted(() => {
9
+ const state: { current?: Db } = {};
10
+ return state;
11
+ });
12
+ vi.mock('@mongo', () => ({
13
+ default: {
14
+ collection: (name: string) => {
15
+ if (!database.current) throw new Error('Disposable Mongo is not ready');
16
+ return database.current.collection(name);
17
+ },
18
+ },
19
+ }));
20
+ vi.mock('@environment', () => ({
21
+ environment: { SEED: 'a'.repeat(64), ESCROW_HOLD_RESTART_MIN_AGE_MS: 0 },
22
+ }));
23
+
24
+ const key = { 'authProvider.type': 1, 'authProvider.id': 1, releasePolicy: 1 };
25
+ const name = 'pending_escrow_identity_policy_unique';
26
+ const unique = { unique: true, partialFilterExpression: { status: 'pending' } };
27
+ const identity = { type: 'firebase', id: 'synthetic-private-identity' } as const;
28
+ const input = (): CreateEscrowHoldInput => ({
29
+ holdRecord: {
30
+ hold: {
31
+ holdId: randomUUID(),
32
+ did: 'did:key:test',
33
+ shareVersion: 1,
34
+ blobHash: 'ab'.repeat(32),
35
+ enrollmentEpoch: 1,
36
+ releasePolicy: 'hold',
37
+ clientEphemeralPublicKey: 'test-key',
38
+ createdLo: 0,
39
+ createdHi: 0,
40
+ policyVersion: 1,
41
+ signature: 'test-signature',
42
+ },
43
+ holdDurationMs: 0,
44
+ ledgerSeq: 0,
45
+ },
46
+ authProvider: identity,
47
+ primaryDid: 'did:key:test',
48
+ shareVersion: 1,
49
+ identityProofType: 'auth-token',
50
+ requestedAt: new Date(),
51
+ releaseAfter: new Date(),
52
+ releasePolicy: 'hold',
53
+ clientEphemeralPublicKey: 'test-key',
54
+ resumeTokenHash: 'ab'.repeat(32),
55
+ });
56
+
57
+ let server: MongoMemoryServer;
58
+ let client: MongoClient;
59
+ let model: typeof import('./EscrowHold');
60
+ beforeAll(async () => {
61
+ // Never uses MONGO_URI, the service environment, or a running/shared Mongo.
62
+ server = await MongoMemoryServer.create({ instance: { ip: '127.0.0.1' } });
63
+ client = new MongoClient(server.getUri());
64
+ database.current = client.db('escrow_index_regression');
65
+ });
66
+ beforeEach(async () => {
67
+ await database.current?.dropDatabase();
68
+ vi.resetModules();
69
+ vi.spyOn(console, 'error').mockImplementation(() => undefined);
70
+ model = await import('./EscrowHold');
71
+ });
72
+ afterEach(() => vi.restoreAllMocks());
73
+ afterAll(async () => {
74
+ await client?.close();
75
+ await server?.stop();
76
+ });
77
+
78
+ const collection = () => {
79
+ if (!database.current) throw new Error('Disposable Mongo is not ready');
80
+ return database.current.collection('escrowholds');
81
+ };
82
+
83
+ describe('escrow index migration with real Mongo', () => {
84
+ it('surfaces E11000, shares failing waiters, blocks writes and retries after repair', async () => {
85
+ await collection().insertMany([
86
+ { authProvider: identity, status: 'pending' },
87
+ { authProvider: identity, status: 'pending', releasePolicy: 'hold' },
88
+ ]);
89
+ const ready = model.ensureEscrowHoldsIndexes();
90
+ expect(model.ensureEscrowHoldsIndexes()).toBe(ready);
91
+ await expect(ready).rejects.toMatchObject({
92
+ message: 'Escrow hold index initialization failed',
93
+ cause: { code: 11000, codeName: 'DuplicateKey' },
94
+ });
95
+ const failure = await ready.catch((error: unknown) => error);
96
+ expect(inspect(failure, { depth: null })).not.toContain(identity.id);
97
+ expect(console.error).toHaveBeenCalledWith(
98
+ '[escrow-hold-indexes] initialization failed',
99
+ expect.objectContaining({
100
+ operation: name,
101
+ code: 11000,
102
+ codeName: 'DuplicateKey',
103
+ errmsg: expect.stringContaining('E11000'),
104
+ keyValue: {
105
+ 'authProvider.type': '[redacted]',
106
+ 'authProvider.id': '[redacted]',
107
+ releasePolicy: '[redacted]',
108
+ },
109
+ })
110
+ );
111
+ expect(JSON.stringify(vi.mocked(console.error).mock.calls)).not.toContain(identity.id);
112
+ await expect(model.createEscrowHold(input())).rejects.toMatchObject({
113
+ cause: { code: 11000 },
114
+ });
115
+ await expect(model.retryClaimedEscrowHold(randomUUID(), new Date())).rejects.toMatchObject({
116
+ cause: { code: 11000 },
117
+ });
118
+ expect(await collection().countDocuments()).toBe(2);
119
+ // This repair is ONLY to this test's owned, temporary database.
120
+ await collection().deleteOne({ authProvider: identity });
121
+ await expect(model.ensureEscrowHoldsIndexes()).resolves.toBeUndefined();
122
+ await expect(model.createEscrowHold(input())).rejects.toMatchObject({ code: 11000 });
123
+ expect(await collection().countDocuments()).toBe(1);
124
+ });
125
+
126
+ it('is idempotent when the old index is absent and preserves per-policy uniqueness', async () => {
127
+ await expect(model.createEscrowHoldsIndexes()).resolves.toBeUndefined();
128
+ await expect(model.createEscrowHoldsIndexes()).resolves.toBeUndefined();
129
+ await model.createEscrowHold(input());
130
+ await expect(model.createEscrowHold(input())).rejects.toMatchObject({ code: 11000 });
131
+ await expect(
132
+ model.createEscrowHold({ ...input(), releasePolicy: 'pin' })
133
+ ).resolves.toBeDefined();
134
+ expect(console.error).not.toHaveBeenCalled();
135
+ });
136
+
137
+ it('normalizes legacy rows and installs the replacement before dropping the old index', async () => {
138
+ await collection().createIndex(
139
+ { 'authProvider.type': 1, 'authProvider.id': 1 },
140
+ { name: 'pending_escrow_identity_unique', ...unique }
141
+ );
142
+ await collection().insertOne({ authProvider: identity, status: 'pending' });
143
+ await model.createEscrowHoldsIndexes();
144
+ expect(await collection().findOne()).toMatchObject({ releasePolicy: 'hold' });
145
+ const indexes = await collection().listIndexes().toArray();
146
+ expect(indexes.find(index => index.name === name)).toMatchObject({ key, ...unique });
147
+ expect(indexes.some(index => index.name === 'pending_escrow_identity_unique')).toBe(false);
148
+ });
149
+
150
+ it('preserves the old guard when a conflicting same-name nonunique index blocks migration', async () => {
151
+ await collection().createIndex(
152
+ { 'authProvider.type': 1, 'authProvider.id': 1 },
153
+ { name: 'pending_escrow_identity_unique', ...unique }
154
+ );
155
+ await collection().createIndex(key, { name });
156
+ await expect(model.ensureEscrowHoldsIndexes()).rejects.toMatchObject({
157
+ cause: { code: 86 },
158
+ });
159
+ expect(console.error).toHaveBeenCalledWith(
160
+ expect.any(String),
161
+ expect.objectContaining({
162
+ operation: name,
163
+ code: 86,
164
+ codeName: 'IndexKeySpecsConflict',
165
+ })
166
+ );
167
+ const indexes = await collection().listIndexes().toArray();
168
+ expect(indexes.find(index => index.name === 'pending_escrow_identity_unique')?.unique).toBe(
169
+ true
170
+ );
171
+ expect(indexes.find(index => index.name === name)?.unique).not.toBe(true);
172
+ await expect(model.createEscrowHold(input())).rejects.toMatchObject({
173
+ cause: { code: 86 },
174
+ });
175
+ expect(await collection().countDocuments()).toBe(0);
176
+ });
177
+
178
+ it('accepts a verified equivalent unique index under another name', async () => {
179
+ await collection().createIndex(key, { name: 'existing_equivalent', ...unique });
180
+ await expect(model.ensureEscrowHoldsIndexes()).resolves.toBeUndefined();
181
+ await model.createEscrowHold(input());
182
+ await expect(model.createEscrowHold(input())).rejects.toMatchObject({ code: 11000 });
183
+ });
184
+
185
+ it('does not hide a lookup conflict just because a unique index exists', async () => {
186
+ await collection().createIndex(key, { name, ...unique });
187
+ await collection().createIndex({ releaseAfter: 1 }, { expireAfterSeconds: 60 });
188
+ await expect(model.ensureEscrowHoldsIndexes()).rejects.toMatchObject({
189
+ cause: { code: 85 },
190
+ });
191
+ expect(console.error).toHaveBeenCalledWith(
192
+ expect.any(String),
193
+ expect.objectContaining({ operation: 'releaseAfter_1', code: 85 })
194
+ );
195
+ });
196
+
197
+ it('allows only one winner when the first concurrent creates initialize the indexes', async () => {
198
+ const results = await Promise.allSettled([
199
+ model.createEscrowHold(input()),
200
+ model.createEscrowHold(input()),
201
+ ]);
202
+ expect(results.filter(result => result.status === 'fulfilled')).toHaveLength(1);
203
+ expect(results.find(result => result.status === 'rejected')).toMatchObject({
204
+ reason: { code: 11000 },
205
+ });
206
+ expect(await collection().countDocuments()).toBe(1);
207
+ });
208
+ });
@@ -7,6 +7,7 @@ import {
7
7
  import type { Collection } from 'mongodb';
8
8
  import mongodb from '@mongo';
9
9
  import { environment } from '@environment';
10
+ import { createRetryingOnce } from '../helpers/retryingOnce';
10
11
  import {
11
12
  AuthProviderMappingValidator,
12
13
  EscrowEnvelopeValidator,
@@ -167,77 +168,130 @@ export type CreateEscrowHoldInput = Omit<
167
168
  export const getEscrowHoldsCollection = (): Collection<EscrowHold> =>
168
169
  mongodb.collection<EscrowHold>(ESCROW_HOLDS_COLLECTION);
169
170
 
170
- const runIndexMigrationOperation = async (operation: () => Promise<unknown>): Promise<void> => {
171
+ const mongoIndexError = z.object({
172
+ code: z.union([z.number(), z.string()]).optional(),
173
+ codeName: z.string().optional(),
174
+ errmsg: z.string().optional(),
175
+ message: z.string().optional(),
176
+ keyValue: z.record(z.string(), z.unknown()).optional(),
177
+ });
178
+
179
+ const runIndexMigrationOperation = async (
180
+ operationName: string,
181
+ operation: () => Promise<unknown>
182
+ ): Promise<void> => {
171
183
  try {
172
184
  await operation();
173
185
  } catch (error) {
174
- const code =
175
- typeof error === 'object' && error !== null && 'code' in error ? error.code : undefined;
176
- // Unlike lookup indexes, uniqueness is a security invariant: fail closed on duplicates.
177
- if (code === 27 || code === 85 || code === 86) {
178
- const indexes = await getEscrowHoldsCollection().listIndexes().toArray();
179
- if (
180
- indexes.some(
181
- index =>
182
- index.unique &&
183
- Object.keys(index.key ?? {}).length === 3 &&
184
- index.key?.['authProvider.type'] === 1 &&
185
- index.key?.['authProvider.id'] === 1 &&
186
- index.key?.releasePolicy === 1 &&
187
- Object.keys(index.partialFilterExpression ?? {}).length === 1 &&
188
- index.partialFilterExpression?.status === 'pending'
189
- )
190
- )
191
- return;
192
- }
193
- throw new Error('Escrow hold index initialization failed', { cause: error });
186
+ const parsed = mongoIndexError.safeParse(error);
187
+ const details = parsed.success ? parsed.data : {};
188
+ // E11000 embeds the identity in both errmsg and keyValue. Keep the Mongo
189
+ // diagnosis in CloudWatch without copying account identifiers into logs.
190
+ const redactDuplicateKey = (message: string | undefined): string | undefined =>
191
+ message?.replace(/dup key:.*$/s, 'dup key: [redacted]');
192
+ const diagnostic = {
193
+ operation: operationName,
194
+ code: details.code,
195
+ codeName: details.codeName,
196
+ errmsg: redactDuplicateKey(details.errmsg),
197
+ message: redactDuplicateKey(details.message),
198
+ keyValue: details.keyValue
199
+ ? Object.fromEntries(Object.keys(details.keyValue).map(key => [key, '[redacted]']))
200
+ : undefined,
201
+ };
202
+ console.error('[escrow-hold-indexes] initialization failed', diagnostic);
203
+ // Generic route loggers also inspect Error.cause; never retain the raw identity there.
204
+ // eslint-disable-next-line preserve-caught-error -- MongoError retains PII; keep only the sanitized cause.
205
+ throw new Error('Escrow hold index initialization failed', { cause: diagnostic });
194
206
  }
195
207
  };
196
208
 
197
209
  export const createEscrowHoldsIndexes = async (): Promise<void> => {
198
210
  const collection = getEscrowHoldsCollection();
199
211
  // Normalize legacy rows while the old, stricter index still protects them.
200
- await collection.updateMany(
201
- { releasePolicy: { $exists: false } },
202
- { $set: { releasePolicy: 'hold' } }
203
- );
204
- await runIndexMigrationOperation(() =>
205
- collection.createIndex(
206
- { 'authProvider.type': 1, 'authProvider.id': 1, releasePolicy: 1 },
207
- {
208
- name: 'pending_escrow_identity_policy_unique',
209
- unique: true,
210
- partialFilterExpression: { status: 'pending' },
211
- }
212
+ await runIndexMigrationOperation('normalize-legacy-release-policy', () =>
213
+ collection.updateMany(
214
+ { releasePolicy: { $exists: false } },
215
+ { $set: { releasePolicy: 'hold' } }
212
216
  )
213
217
  );
214
- // Install the replacement before dropping the old index: no uniqueness gap.
215
- try {
216
- await collection.dropIndex('pending_escrow_identity_unique');
217
- } catch (error) {
218
- if (!(typeof error === 'object' && error !== null && 'code' in error && error.code === 27))
218
+ await runIndexMigrationOperation('pending_escrow_identity_policy_unique', async () => {
219
+ try {
220
+ await collection.createIndex(
221
+ { 'authProvider.type': 1, 'authProvider.id': 1, releasePolicy: 1 },
222
+ {
223
+ name: 'pending_escrow_identity_policy_unique',
224
+ unique: true,
225
+ partialFilterExpression: { status: 'pending' },
226
+ }
227
+ );
228
+ } catch (error) {
229
+ const code = mongoIndexError.safeParse(error);
230
+ if (code.success && (code.data.code === 85 || code.data.code === 86)) {
231
+ const indexes = await collection.listIndexes().toArray();
232
+ // Accept an equivalent unique index under another name. Never
233
+ // drop/rebuild a conflicting spec or mistake a lookup for protection.
234
+ if (
235
+ indexes.some(
236
+ index =>
237
+ index.unique === true &&
238
+ index.sparse !== true &&
239
+ (!index.collation || index.collation.locale === 'simple') &&
240
+ Object.keys(index.key ?? {}).join(',') ===
241
+ 'authProvider.type,authProvider.id,releasePolicy' &&
242
+ index.key?.['authProvider.type'] === 1 &&
243
+ index.key?.['authProvider.id'] === 1 &&
244
+ index.key?.releasePolicy === 1 &&
245
+ Object.keys(index.partialFilterExpression ?? {}).length === 1 &&
246
+ index.partialFilterExpression?.status === 'pending'
247
+ )
248
+ )
249
+ return;
250
+ }
219
251
  throw error;
220
- }
221
- await runIndexMigrationOperation(() =>
252
+ }
253
+ });
254
+ // Install the replacement before dropping the old index: no uniqueness gap.
255
+ await runIndexMigrationOperation('drop-pending_escrow_identity_unique', async () => {
256
+ try {
257
+ await collection.dropIndex('pending_escrow_identity_unique');
258
+ } catch (error) {
259
+ const parsed = mongoIndexError.safeParse(error);
260
+ if (!parsed.success || parsed.data.code !== 27) throw error;
261
+ }
262
+ });
263
+ await runIndexMigrationOperation('authProvider.type_1_authProvider.id_1_status_1', () =>
222
264
  collection.createIndex({ 'authProvider.type': 1, 'authProvider.id': 1, status: 1 })
223
265
  );
224
- await runIndexMigrationOperation(() => collection.createIndex({ releaseAfter: 1 }));
225
- // Backs findEscrowHoldsDueForReminder's { status:'pending', releaseAfter: {$gt,$lte} } scan.
226
- await collection.createIndex(
227
- { status: 1, releaseAfter: 1 },
228
- { name: 'pending_escrow_hold_reminder_lookup' }
266
+ await runIndexMigrationOperation('releaseAfter_1', () =>
267
+ collection.createIndex({ releaseAfter: 1 })
268
+ );
269
+ await runIndexMigrationOperation('pending_escrow_hold_reminder_lookup', () =>
270
+ collection.createIndex(
271
+ { status: 1, releaseAfter: 1 },
272
+ { name: 'pending_escrow_hold_reminder_lookup' }
273
+ )
229
274
  );
230
- await collection.createIndex(
231
- { status: 1, enclaveCancelConfirmedAt: 1, enclaveCancelAttempts: 1, releaseAfter: 1 },
232
- { name: 'escrow_enclave_cancel_retry_lookup' }
275
+ await runIndexMigrationOperation('escrow_enclave_cancel_retry_lookup', () =>
276
+ collection.createIndex(
277
+ { status: 1, enclaveCancelConfirmedAt: 1, enclaveCancelAttempts: 1, releaseAfter: 1 },
278
+ { name: 'escrow_enclave_cancel_retry_lookup' }
279
+ )
233
280
  );
234
- await collection.createIndex(
235
- { primaryDid: 1, status: 1, enclaveCancelConfirmedAt: 1 },
236
- { name: 'escrow_enclave_cancel_identity_lookup' }
281
+ await runIndexMigrationOperation('escrow_enclave_cancel_identity_lookup', () =>
282
+ collection.createIndex(
283
+ { primaryDid: 1, status: 1, enclaveCancelConfirmedAt: 1 },
284
+ { name: 'escrow_enclave_cancel_identity_lookup' }
285
+ )
237
286
  );
238
287
  };
239
288
 
289
+ /** Only escrow writers wait for this migration; a failure can retry on the next request. */
290
+ export const ensureEscrowHoldsIndexes: () => Promise<void> =
291
+ createRetryingOnce(createEscrowHoldsIndexes);
292
+
240
293
  export const createEscrowHold = async (input: CreateEscrowHoldInput): Promise<EscrowHold> => {
294
+ await ensureEscrowHoldsIndexes();
241
295
  const now = new Date();
242
296
  const parsed = EscrowHoldValidator.safeParse({
243
297
  ...input,
@@ -491,6 +545,7 @@ export const markClaimedEscrowHoldFailed = async (
491
545
  };
492
546
  /** Reopen only our own failed release claim, never another request's terminal row. */
493
547
  export const retryClaimedEscrowHold = async (id: string, completedAt: Date): Promise<void> => {
548
+ await ensureEscrowHoldsIndexes();
494
549
  try {
495
550
  await getEscrowHoldsCollection().updateOne(
496
551
  { _id: id, status: 'completed', completedAt },
@@ -241,6 +241,15 @@ const MongoUserKeyBaseValidator = z.object({
241
241
 
242
242
  migratedFromWeb3Auth: z.boolean().default(false),
243
243
  migratedAt: z.date().optional(),
244
+ /**
245
+ * The login whose Web3Auth key this account was migrated from, recorded by
246
+ * `/keys/migrate` only when that login is the account's sole login at security
247
+ * epoch 0. Web3Auth derives a key from the login alone, so only this login
248
+ * still derives the account's key: a Web3Auth-mode client may sign in an SSS
249
+ * account only through it (see `isWeb3AuthLogin`). Absent means no login is
250
+ * known to be safe; migrations from before this field was added have none.
251
+ */
252
+ web3AuthLogin: AuthProviderMappingValidator.optional(),
244
253
 
245
254
  sssActivationState: z.enum(['provisional', 'active']).optional(),
246
255
  provisionalCreatedAt: z.date().optional(),
@@ -819,6 +828,47 @@ const getAuthProviderFilter = (authProvider: AuthProviderMapping): Filter<MongoU
819
828
  authProviders: { $elemMatch: authProvider },
820
829
  });
821
830
 
831
+ /**
832
+ * Whether Web3Auth, signing in through `authProvider`, derives this account's key.
833
+ * Web3Auth derives a key from the login alone, so this holds only for the login
834
+ * the account's key came from; any other login (added by PIN sign-in, recovery or
835
+ * a revoke) derives a different, empty identity. Web3Auth-mode clients stop every
836
+ * existing account for which this is false.
837
+ *
838
+ * - A recorded `web3AuthLogin` decides it: true only for that login, on a record
839
+ * that is still Web3Auth (mid-migration) or finished migrating from it.
840
+ * - Without one, only a still-Web3Auth record (e.g. a migration that started
841
+ * before logins were recorded) qualifies, and only through its sole login at
842
+ * security epoch 0: the same evidence `/keys/migrate` requires to record it.
843
+ * A migrated or SSS-native record without one is never confirmed.
844
+ */
845
+ export const isWeb3AuthLogin = (
846
+ userKey: Pick<
847
+ MongoUserKeyType,
848
+ 'keyProvider' | 'migratedFromWeb3Auth' | 'web3AuthLogin' | 'authProviders' | 'securityEpoch'
849
+ >,
850
+ authProvider: AuthProviderMapping
851
+ ): boolean => {
852
+ const isThisLogin = (login: AuthProviderMapping | undefined): boolean =>
853
+ login?.type === authProvider.type && login?.id === authProvider.id;
854
+ const stillWeb3Auth = (userKey.keyProvider ?? 'sss') === 'web3auth';
855
+
856
+ if (userKey.web3AuthLogin) {
857
+ return (
858
+ isThisLogin(userKey.web3AuthLogin) &&
859
+ (stillWeb3Auth || userKey.migratedFromWeb3Auth === true)
860
+ );
861
+ }
862
+
863
+ const logins = userKey.authProviders ?? [];
864
+ return (
865
+ stillWeb3Auth &&
866
+ logins.length === 1 &&
867
+ isThisLogin(logins[0]) &&
868
+ getSecurityEpoch(userKey) === 0
869
+ );
870
+ };
871
+
822
872
  /** The account's security epoch; a legacy record without the field is at epoch 0. */
823
873
  export const getSecurityEpoch = (userKey: Pick<MongoUserKeyType, 'securityEpoch'>): number =>
824
874
  userKey.securityEpoch ?? 0;
@@ -1581,8 +1631,26 @@ export const markUserKeyMigrationProvisionalByAuthProvider = async (
1581
1631
  },
1582
1632
  }
1583
1633
  );
1634
+ const marked = result.modifiedCount > 0 || result.matchedCount > 0;
1635
+
1636
+ if (marked) {
1637
+ // Record the login that derived the legacy key, but only when it provably
1638
+ // did: it is the account's sole login and no rebind/revoke has happened. A
1639
+ // second login (PIN sign-in) holding the account key could otherwise claim
1640
+ // it. Never replace a recorded login; when unsure, record nothing (Web3Auth
1641
+ // clients then stop the account: a false negative, never a split).
1642
+ await getUserKeysCollection().updateOne(
1643
+ {
1644
+ ...getAuthProviderFilter(authProvider),
1645
+ authProviders: { $size: 1, $elemMatch: authProvider },
1646
+ web3AuthLogin: { $exists: false },
1647
+ ...getSecurityEpochFilter(0),
1648
+ },
1649
+ { $set: { web3AuthLogin: authProvider } }
1650
+ );
1651
+ }
1584
1652
 
1585
- return result.modifiedCount > 0 || result.matchedCount > 0;
1653
+ return marked;
1586
1654
  };
1587
1655
 
1588
1656
  /**
@@ -54,7 +54,7 @@ import {
54
54
  clearEscrowByAuthProvider,
55
55
  setEscrowOptInByAuthProvider,
56
56
  createEscrowHold,
57
- createEscrowHoldsIndexes,
57
+ ensureEscrowHoldsIndexes,
58
58
  findPendingEscrowHoldByAuthProvider,
59
59
  findEscrowHoldById,
60
60
  cancelEscrowHold,
@@ -90,6 +90,7 @@ import {
90
90
  getEscrowHoldDurationMs,
91
91
  getEscrowHoldRestartMinAgeMs,
92
92
  isEscrowRemoteMode,
93
+ getEscrowAuthorityTenant,
93
94
  activeEscrowClientMode,
94
95
  getEnclaveAttestationIdentity,
95
96
  notifyEscrowHoldEvent,
@@ -185,18 +186,16 @@ const lockPin = async (
185
186
  }
186
187
  }
187
188
  };
188
- let holdIndexes: Promise<void> | undefined;
189
-
190
- // Lambda and in-process callers do not run docker-entry's index initialization.
191
189
  const ensureHoldIndexes = async (): Promise<void> => {
192
- holdIndexes ??= createEscrowHoldsIndexes().catch(() => {
193
- holdIndexes = undefined;
190
+ try {
191
+ await ensureEscrowHoldsIndexes();
192
+ } catch (cause) {
194
193
  throw new TRPCError({
195
194
  code: 'INTERNAL_SERVER_ERROR',
196
195
  message: 'Recovery could not be started.',
196
+ cause,
197
197
  });
198
- });
199
- await holdIndexes;
198
+ }
200
199
  };
201
200
  const successValidator = z.object({ success: z.literal(true) });
202
201
  const cancelByLinkInput = z
@@ -498,7 +497,7 @@ const removeEscrow = async (
498
497
  !enclave.applyEnrollmentIntent ||
499
498
  signed.intent.action !== 'disable' ||
500
499
  signed.intent.did !== did ||
501
- signed.intent.tenant !== tenant.id
500
+ signed.intent.tenant !== getEscrowAuthorityTenant()
502
501
  )
503
502
  throw new TRPCError({
504
503
  code: 'BAD_REQUEST',
@@ -656,7 +655,10 @@ export const escrowRouter = t.router({
656
655
  const result = await enclaveOperation(() =>
657
656
  enclave.getEnrollmentState!({ expectedDid: ctx.user.did, nonce: input.nonce })
658
657
  );
659
- if (result.state.did !== ctx.user.did || result.state.tenant !== ctx.tenant.id)
658
+ if (
659
+ result.state.did !== ctx.user.did ||
660
+ result.state.tenant !== getEscrowAuthorityTenant()
661
+ )
660
662
  throw new TRPCError({ code: 'FORBIDDEN', message: invalidMessage });
661
663
  return result;
662
664
  }),
@@ -726,7 +728,7 @@ export const escrowRouter = t.router({
726
728
  const intent = signed.intent;
727
729
  if (
728
730
  intent.did !== ctx.user.did ||
729
- intent.tenant !== ctx.tenant.id ||
731
+ intent.tenant !== getEscrowAuthorityTenant() ||
730
732
  intent.action === 'disable' ||
731
733
  intent.shareVersion !== input.shareVersion ||
732
734
  !!input.pinSalt !==
@@ -740,6 +742,10 @@ export const escrowRouter = t.router({
740
742
  nonce: randomBytes(32).toString('hex'),
741
743
  })
742
744
  );
745
+ // The enclave signs its own (measured) tenant; a different one means this
746
+ // lca-api's ESCROW_AUTHORITY_TENANT does not match the enclave it talks to.
747
+ if (current.state.tenant !== intent.tenant)
748
+ throw new TRPCError({ code: 'FORBIDDEN', message: invalidMessage });
743
749
  if (
744
750
  current.state.enrollmentVersion !== intent.expectedEnrollmentVersion ||
745
751
  current.state.authorityGeneration !== intent.authorityGeneration
@@ -1398,6 +1404,7 @@ export const escrowRouter = t.router({
1398
1404
  // Legacy Mongo rows can predate the required creation-time record.
1399
1405
  if (hold.status !== 'pending' || !hold.holdRecord)
1400
1406
  throw new TRPCError({ code: 'FORBIDDEN', message: invalidMessage });
1407
+ await ensureHoldIndexes();
1401
1408
  const now = new Date();
1402
1409
  if (now < hold.releaseAfter) {
1403
1410
  throw new TRPCError({
@@ -35,13 +35,8 @@ export type Context = {
35
35
  tenant: ResolvedTenant;
36
36
  debug?: boolean;
37
37
  clientIp?: string;
38
- // Value of the X-Auth-Token header — a caller's own provider token
39
- // (e.g. a Firebase ID token), distinct from the Authorization header
40
- // (reserved for the DID-Auth VP). Lets GET routes that verify a
41
- // provider token (e.g. keys.getRecoveryShare, P0-4) read it without
42
- // putting it in a query string, without colliding with clients that
43
- // send a DID-Auth VP as Authorization for the same route.
44
38
  providerToken?: string;
39
+ authorizationHeader?: string;
45
40
  };
46
41
 
47
42
  export const t = initTRPC.context<Context>().meta<OpenApiMeta>().create();
@@ -57,12 +52,12 @@ export const createContext = async (
57
52
  const domainName = 'requestContext' in event ? event.requestContext.domainName : '';
58
53
  const debug = environment.NODE_ENV === 'test';
59
54
 
60
- // See Context.providerToken — a separate header from Authorization,
61
- // which is reserved for the DID-Auth VP handled below.
55
+ // The original signed DID VP may be forwarded only to brain-service's authenticated self-profile route.
62
56
  const providerTokenHeader = (event.headers as Record<string, string | undefined>)[
63
57
  'x-auth-token'
64
58
  ];
65
59
  const providerToken = providerTokenHeader || undefined;
60
+ const authorizationHeader = authHeader || undefined;
66
61
 
67
62
  const domain =
68
63
  !domainName || environment.IS_OFFLINE
@@ -132,6 +127,7 @@ export const createContext = async (
132
127
  debug,
133
128
  clientIp,
134
129
  providerToken,
130
+ authorizationHeader,
135
131
  };
136
132
 
137
133
  const cacheResponse = await consumeChallengeForDid(did, challenge);
@@ -143,6 +139,7 @@ export const createContext = async (
143
139
  debug,
144
140
  clientIp,
145
141
  providerToken,
142
+ authorizationHeader,
146
143
  };
147
144
  }
148
145
  }
@@ -42,6 +42,7 @@ import {
42
42
  setRecoveryEmailByAuthProvider,
43
43
  markUserKeyMigrationProvisionalByAuthProvider,
44
44
  activateUserKeyByAuthProvider,
45
+ isWeb3AuthLogin,
45
46
  purgeExpiredProvisionalMigrationByAuthProvider,
46
47
  getSssActivationState,
47
48
  hasActivatableRecoveryMethod,
@@ -1058,6 +1059,12 @@ export const keysRouter = t.router({
1058
1059
  */
1059
1060
  priorEscrowEnrollment: z.boolean(),
1060
1061
  sssActivationState: z.enum(['provisional', 'active']),
1062
+ /**
1063
+ * Web3Auth, signing in through this login, derives this account's
1064
+ * key (see `isWeb3AuthLogin`). Web3Auth-mode clients stop any
1065
+ * existing account where this is false.
1066
+ */
1067
+ isWeb3AuthLogin: z.boolean(),
1061
1068
  })
1062
1069
  .nullable()
1063
1070
  )
@@ -1133,6 +1140,7 @@ export const keysRouter = t.router({
1133
1140
  ? maskEmail(userKey.recoveryEmail)
1134
1141
  : null,
1135
1142
  sssActivationState: getSssActivationState(userKey),
1143
+ isWeb3AuthLogin: isWeb3AuthLogin(userKey, authProvider),
1136
1144
  escrowOptedOut: Boolean(userKey.escrowOptedOutAt),
1137
1145
  escrowPin: getEscrowPinStatus(userKey),
1138
1146
  escrowStale: userKey.escrowBlob