@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.
- package/.env.example +2 -0
- package/CHANGELOG.md +114 -0
- package/config/config.dev.json +1 -0
- package/config/config.example.json +1 -0
- package/config/config.production.json +1 -0
- package/config/config.scouts.dev.json +1 -0
- package/config/config.scouts.production.json +1 -0
- package/dist/accesslayer/notifications/create.d.ts +2 -1
- package/dist/app.d.ts +70 -8
- package/dist/config/environment.d.ts +6 -0
- package/dist/config/stageConfig.d.ts +4 -0
- package/dist/helpers/analyticsEligibility.d.ts +6 -0
- package/dist/helpers/brainUrl.d.ts +1 -0
- package/dist/helpers/brainUrl.test.d.ts +1 -0
- package/dist/helpers/currentProfile.d.ts +6 -0
- package/dist/helpers/currentProfile.test.d.ts +1 -0
- package/dist/helpers/retryingOnce.d.ts +1 -0
- package/dist/helpers/retryingOnce.test.d.ts +1 -0
- package/dist/index.js +2 -2
- package/dist/models/EscrowHold.d.ts +1 -0
- package/dist/models/EscrowHold.indexes.test.d.ts +1 -0
- package/dist/models/Notification.d.ts +114 -2
- package/dist/models/SigningAuthority.d.ts +1 -1
- package/dist/models/UserKey.d.ts +23 -0
- package/dist/publish-metafile.json +1 -1
- package/dist/routes/credentials.d.ts +1 -1
- package/dist/routes/index.d.ts +5 -0
- package/dist/routes/keys.d.ts +1 -0
- package/dist/routes/notifications.d.ts +65 -4
- package/dist/routes/preferences.test.d.ts +1 -0
- package/dist/routes/signing-authority.d.ts +3 -3
- package/dist/services/escrow-enclave/index.d.ts +2 -0
- package/dist/services/escrow-enclave/logFailure.d.ts +1 -0
- package/dist/types/credentials.d.ts +1 -1
- package/dist/types/notifications.d.ts +118 -2
- package/docs/bedrock-generation.md +7 -6
- package/lambdaApp.test.ts +114 -0
- package/lambdaApp.ts +23 -11
- package/package.json +11 -11
- package/scripts/escrow-lambda-http.ts +74 -0
- package/scripts/verify-escrow-startup.mjs +463 -0
- package/serverless-local.yml +1 -1
- package/serverless.yml +1 -1
- package/src/accesslayer/notifications/create.test.ts +80 -0
- package/src/accesslayer/notifications/create.ts +97 -2
- package/src/config/environment.test.ts +27 -1
- package/src/config/environment.ts +16 -0
- package/src/config/escrowDeployment.test.ts +1 -0
- package/src/docker-entry.ts +1 -2
- package/src/helpers/analyticsEligibility.ts +15 -0
- package/src/helpers/brainUrl.test.ts +13 -0
- package/src/helpers/brainUrl.ts +3 -0
- package/src/helpers/currentProfile.test.ts +55 -0
- package/src/helpers/currentProfile.ts +58 -0
- package/src/helpers/locale.helpers.test.ts +4 -1
- package/src/helpers/locale.helpers.ts +4 -2
- package/src/helpers/retryingOnce.test.ts +33 -0
- package/src/helpers/retryingOnce.ts +18 -0
- package/src/models/EscrowHold.indexes.test.ts +208 -0
- package/src/models/EscrowHold.ts +106 -51
- package/src/models/UserKey.ts +69 -1
- package/src/routes/escrow.ts +18 -11
- package/src/routes/index.ts +5 -8
- package/src/routes/keys.ts +8 -0
- package/src/routes/notifications.ts +34 -1
- package/src/routes/preferences.test.ts +216 -0
- package/src/routes/preferences.ts +54 -4
- package/src/services/escrow-enclave/index.ts +38 -1
- package/src/services/escrow-enclave/logFailure.ts +5 -1
- package/src/services/escrow-enclave/production.test.ts +64 -0
- package/src/services/escrow-enclave/softwareEnclave.test.ts +14 -2
- package/test/jobs/escrowHoldReminders.spec.ts +21 -4
- package/test/keys-escrow.spec.ts +131 -3
- package/test/keys-lifecycle-invariants.spec.ts +235 -0
- package/test/notifications.spec.ts +102 -0
- package/tsconfig.entrypoints.json +1 -1
- package/vitest.config.ts +1 -0
- package/scripts/node24-runtime.cjs +0 -9
- 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
|
+
});
|
package/src/models/EscrowHold.ts
CHANGED
|
@@ -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
|
|
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
|
|
175
|
-
|
|
176
|
-
//
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
throw new Error('Escrow hold index initialization failed', { cause:
|
|
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
|
|
201
|
-
|
|
202
|
-
|
|
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
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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
|
-
|
|
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(() =>
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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
|
|
231
|
-
|
|
232
|
-
|
|
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
|
|
235
|
-
|
|
236
|
-
|
|
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 },
|
package/src/models/UserKey.ts
CHANGED
|
@@ -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
|
|
1653
|
+
return marked;
|
|
1586
1654
|
};
|
|
1587
1655
|
|
|
1588
1656
|
/**
|
package/src/routes/escrow.ts
CHANGED
|
@@ -54,7 +54,7 @@ import {
|
|
|
54
54
|
clearEscrowByAuthProvider,
|
|
55
55
|
setEscrowOptInByAuthProvider,
|
|
56
56
|
createEscrowHold,
|
|
57
|
-
|
|
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
|
-
|
|
193
|
-
|
|
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 !==
|
|
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 (
|
|
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 !==
|
|
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({
|
package/src/routes/index.ts
CHANGED
|
@@ -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
|
-
//
|
|
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
|
}
|
package/src/routes/keys.ts
CHANGED
|
@@ -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
|