@learncard/lca-api-service 1.3.2 → 1.5.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 (240) hide show
  1. package/.env.example +99 -2
  2. package/AGENTS.md +155 -44
  3. package/CHANGELOG.md +84 -0
  4. package/bootstrapLambda.test.ts +76 -0
  5. package/compose.env +5 -0
  6. package/config/config.dev.json +23 -0
  7. package/config/config.example.json +22 -0
  8. package/config/config.json +1 -0
  9. package/config/config.production.json +12 -0
  10. package/config/config.scouts.dev.json +11 -0
  11. package/config/config.scouts.production.json +11 -0
  12. package/dist/accesslayer/signing-authority/index.d.ts +8 -2
  13. package/dist/app.d.ts +361 -8
  14. package/dist/cache/challenges.d.ts +2 -0
  15. package/dist/cache/getDel.d.ts +2 -0
  16. package/dist/cache/getDel.test.d.ts +1 -0
  17. package/dist/cache/login-tickets.d.ts +12 -0
  18. package/dist/cache/recoverySessions.d.ts +42 -0
  19. package/dist/config/applyDockerStageConfig.d.ts +1 -0
  20. package/dist/config/cacheEnvironment.d.ts +16 -0
  21. package/dist/config/environment.d.ts +253 -2
  22. package/dist/config/environment.test.d.ts +1 -0
  23. package/dist/config/escrowBundleMode.test.d.ts +1 -0
  24. package/dist/config/escrowDeployment.test.d.ts +1 -0
  25. package/dist/config/oidcEnvironment.d.ts +74 -0
  26. package/dist/config/serverlessFunctionEnv.test.d.ts +1 -0
  27. package/dist/config/stageConfig.d.ts +62 -0
  28. package/dist/config/stageConfig.test.d.ts +1 -0
  29. package/dist/didkit_wasm_bg.wasm +0 -0
  30. package/dist/docker-entry.d.ts +1 -1
  31. package/dist/helpers/auth.helpers.d.ts +1 -1
  32. package/dist/helpers/escrowCancelLink.d.ts +7 -0
  33. package/dist/helpers/escrowCancelLink.test.d.ts +1 -0
  34. package/dist/helpers/escrowCancelToken.d.ts +4 -0
  35. package/dist/helpers/escrowCancelToken.test.d.ts +1 -0
  36. package/dist/helpers/keycloak.helpers.d.ts +23 -0
  37. package/dist/helpers/learnCard.signingAuthority.test.d.ts +1 -0
  38. package/dist/helpers/login-ticket.helpers.d.ts +31 -0
  39. package/dist/helpers/login-ticket.helpers.test.d.ts +1 -0
  40. package/dist/helpers/oidc.helpers.d.ts +47 -0
  41. package/dist/helpers/rate-limit.helpers.d.ts +6 -0
  42. package/dist/helpers/rate-limit.helpers.test.d.ts +1 -0
  43. package/dist/helpers/rateLimit.helpers.d.ts +4 -0
  44. package/dist/helpers/seedEncryption.helpers.d.ts +23 -0
  45. package/dist/helpers/seedEncryption.helpers.test.d.ts +1 -0
  46. package/dist/helpers/social-token.helpers.d.ts +19 -0
  47. package/dist/index.d.ts +1 -0
  48. package/dist/index.js +2 -2
  49. package/dist/jobs/escrowBlobRewrap.d.ts +9 -0
  50. package/dist/jobs/escrowHoldReminders.d.ts +12 -0
  51. package/dist/migrations/signingAuthoritySeeds.d.ts +13 -0
  52. package/dist/migrations/signingAuthoritySeedsCli.test.d.ts +1 -0
  53. package/dist/models/AuthSubject.d.ts +20 -0
  54. package/dist/models/EscrowHold.d.ts +153 -0
  55. package/dist/models/Notification.d.ts +5 -0
  56. package/dist/models/SigningAuthority.d.ts +7 -1
  57. package/dist/models/UserKey.d.ts +326 -3
  58. package/dist/models/UserKey.escrowPin.test.d.ts +1 -0
  59. package/dist/models/index.d.ts +2 -0
  60. package/dist/oidc.d.ts +5 -0
  61. package/dist/routes/auth.d.ts +31 -0
  62. package/dist/routes/escrow.d.ts +164 -0
  63. package/dist/routes/index.d.ts +6 -0
  64. package/dist/routes/keys.d.ts +182 -7
  65. package/dist/routes/notifications.d.ts +5 -0
  66. package/dist/routes/qr-login.d.ts +1 -1
  67. package/dist/routes/signing-authority.d.ts +14 -0
  68. package/dist/services/escrow-enclave/blobIdentity.d.ts +10 -0
  69. package/dist/services/escrow-enclave/blobIdentity.test.d.ts +1 -0
  70. package/dist/services/escrow-enclave/cancellation.d.ts +3 -0
  71. package/dist/services/escrow-enclave/enclaveTrustAnchors.d.ts +1 -0
  72. package/dist/services/escrow-enclave/index.d.ts +25 -0
  73. package/dist/services/escrow-enclave/notifications.d.ts +24 -0
  74. package/dist/services/escrow-enclave/notifications.test.d.ts +1 -0
  75. package/dist/services/escrow-enclave/production.test.d.ts +1 -0
  76. package/dist/services/escrow-enclave/remoteEnclave.contract.test.d.ts +1 -0
  77. package/dist/services/escrow-enclave/remoteEnclave.d.ts +20 -0
  78. package/dist/services/escrow-enclave/remoteEnclave.test.d.ts +1 -0
  79. package/dist/services/escrow-enclave/remoteEnclave.tls.test.d.ts +1 -0
  80. package/dist/services/escrow-enclave/softwareEnclave.d.ts +27 -0
  81. package/dist/services/escrow-enclave/softwareEnclave.test.d.ts +1 -0
  82. package/dist/services/escrow-enclave/types.d.ts +111 -0
  83. package/dist/types/notifications.d.ts +5 -0
  84. package/dist/types/seed-encryption.d.ts +31 -0
  85. package/dist/types/seed-migration.d.ts +49 -0
  86. package/esbuildPlugins.cjs +1 -0
  87. package/lambda.ts +27 -112
  88. package/lambdaApp.ts +146 -0
  89. package/oidcLambda.test.ts +38 -0
  90. package/oidcLambda.ts +18 -0
  91. package/oidcLambdaApp.ts +11 -0
  92. package/package.json +30 -14
  93. package/scripts/README.md +88 -0
  94. package/scripts/check-seed-encryption-infrastructure.mjs +139 -0
  95. package/scripts/check-signing-authority-redis.mjs +93 -0
  96. package/scripts/escrow-blob-mode-report.test.ts +227 -0
  97. package/scripts/escrow-blob-mode-report.ts +465 -0
  98. package/scripts/keycloak-admin.ts +108 -0
  99. package/scripts/keycloak-provider-links.ts +147 -0
  100. package/scripts/migrate-signing-authority-seeds-local.sh +51 -0
  101. package/scripts/migrate-signing-authority-seeds.mjs +105 -0
  102. package/scripts/print-seed-rollout-flags.ts +21 -0
  103. package/scripts/provision-keycloak-users.ts +419 -0
  104. package/seedMigrationApp.test.ts +145 -0
  105. package/seedMigrationApp.ts +50 -0
  106. package/seedMigrationLambda.test.ts +40 -0
  107. package/seedMigrationLambda.ts +19 -0
  108. package/serverless-local.yml +28 -1
  109. package/serverless.function-env.cjs +58 -0
  110. package/serverless.yml +294 -26
  111. package/src/accesslayer/notifications/index.ts +9 -2
  112. package/src/accesslayer/signing-authority/create.ts +11 -7
  113. package/src/accesslayer/signing-authority/index.ts +4 -1
  114. package/src/app.ts +4 -0
  115. package/src/cache/challenges.ts +12 -2
  116. package/src/cache/getDel.test.ts +34 -0
  117. package/src/cache/getDel.ts +32 -0
  118. package/src/cache/in-memory-lru.ts +1 -0
  119. package/src/cache/index.ts +1 -1
  120. package/src/cache/login-tickets.ts +26 -0
  121. package/src/cache/recoverySessions.ts +252 -0
  122. package/src/config/applyDockerStageConfig.ts +6 -0
  123. package/src/config/cacheEnvironment.ts +34 -0
  124. package/src/config/environment.test.ts +159 -0
  125. package/src/config/environment.ts +164 -0
  126. package/src/config/escrowBundleMode.test.ts +52 -0
  127. package/src/config/escrowDeployment.test.ts +114 -0
  128. package/src/config/oidcEnvironment.ts +59 -0
  129. package/src/config/serverlessFunctionEnv.test.ts +98 -0
  130. package/src/config/stageConfig.test.ts +26 -0
  131. package/src/config/stageConfig.ts +24 -0
  132. package/src/docker-entry.ts +13 -0
  133. package/src/helpers/auth.helpers.ts +68 -30
  134. package/src/helpers/escrowCancelLink.test.ts +60 -0
  135. package/src/helpers/escrowCancelLink.ts +38 -0
  136. package/src/helpers/escrowCancelToken.test.ts +105 -0
  137. package/src/helpers/escrowCancelToken.ts +47 -0
  138. package/src/helpers/keycloak.helpers.ts +120 -0
  139. package/src/helpers/learnCard.helpers.test.ts +11 -1
  140. package/src/helpers/learnCard.helpers.ts +57 -45
  141. package/src/helpers/learnCard.signingAuthority.test.ts +194 -0
  142. package/src/helpers/login-ticket.helpers.test.ts +45 -0
  143. package/src/helpers/login-ticket.helpers.ts +91 -0
  144. package/src/helpers/oidc.helpers.ts +354 -0
  145. package/src/helpers/rate-limit.helpers.test.ts +61 -0
  146. package/src/helpers/rate-limit.helpers.ts +73 -0
  147. package/src/helpers/rateLimit.helpers.ts +112 -0
  148. package/src/helpers/seedEncryption.helpers.test.ts +427 -0
  149. package/src/helpers/seedEncryption.helpers.ts +372 -0
  150. package/src/helpers/signingAuthority.helpers.ts +4 -1
  151. package/src/helpers/social-token.helpers.ts +144 -0
  152. package/src/index.ts +1 -0
  153. package/src/jobs/escrowBlobRewrap.ts +121 -0
  154. package/src/jobs/escrowHoldReminders.ts +177 -0
  155. package/src/migrations/signingAuthoritySeeds.ts +430 -0
  156. package/src/migrations/signingAuthoritySeedsCli.test.ts +70 -0
  157. package/src/models/AuthSubject.ts +147 -0
  158. package/src/models/EscrowHold.ts +541 -0
  159. package/src/models/SigningAuthority.ts +14 -3
  160. package/src/models/UserKey.escrowPin.test.ts +49 -0
  161. package/src/models/UserKey.ts +1109 -40
  162. package/src/models/index.ts +2 -0
  163. package/src/models/pruneOrphanedRecoveryMethods.ts +3 -1
  164. package/src/oidc.ts +371 -0
  165. package/src/routes/auth.ts +173 -0
  166. package/src/routes/credentials.ts +8 -0
  167. package/src/routes/escrow.ts +1073 -0
  168. package/src/routes/firebase.ts +90 -48
  169. package/src/routes/index.ts +98 -5
  170. package/src/routes/keys.ts +1022 -174
  171. package/src/routes/qr-login.ts +51 -53
  172. package/src/routes/signing-authority.ts +39 -1
  173. package/src/services/delivery/adapters/postmark.adapter.ts +48 -1
  174. package/src/services/escrow-enclave/README.md +122 -0
  175. package/src/services/escrow-enclave/__fixtures__/tls/pinned-ca.pem +11 -0
  176. package/src/services/escrow-enclave/__fixtures__/tls/pinned-leaf.key +5 -0
  177. package/src/services/escrow-enclave/__fixtures__/tls/pinned-leaf.pem +12 -0
  178. package/src/services/escrow-enclave/__fixtures__/tls/rogue-ca.pem +11 -0
  179. package/src/services/escrow-enclave/__fixtures__/tls/rogue-leaf.key +5 -0
  180. package/src/services/escrow-enclave/__fixtures__/tls/rogue-leaf.pem +12 -0
  181. package/src/services/escrow-enclave/blobIdentity.test.ts +42 -0
  182. package/src/services/escrow-enclave/blobIdentity.ts +55 -0
  183. package/src/services/escrow-enclave/cancellation.ts +59 -0
  184. package/src/services/escrow-enclave/enclaveTrustAnchors.ts +34 -0
  185. package/src/services/escrow-enclave/index.ts +174 -0
  186. package/src/services/escrow-enclave/notifications.test.ts +377 -0
  187. package/src/services/escrow-enclave/notifications.ts +348 -0
  188. package/src/services/escrow-enclave/production.test.ts +34 -0
  189. package/src/services/escrow-enclave/remoteEnclave.contract.test.ts +344 -0
  190. package/src/services/escrow-enclave/remoteEnclave.test.ts +490 -0
  191. package/src/services/escrow-enclave/remoteEnclave.tls.test.ts +62 -0
  192. package/src/services/escrow-enclave/remoteEnclave.ts +263 -0
  193. package/src/services/escrow-enclave/softwareEnclave.test.ts +450 -0
  194. package/src/services/escrow-enclave/softwareEnclave.ts +254 -0
  195. package/src/services/escrow-enclave/types.ts +122 -0
  196. package/src/types/seed-encryption.ts +51 -0
  197. package/src/types/seed-migration.ts +61 -0
  198. package/test/auth-tickets.spec.ts +480 -0
  199. package/test/credentials.spec.ts +2 -0
  200. package/test/helpers/keycloak-broker.ts +181 -0
  201. package/test/helpers/live-mongo.ts +9 -0
  202. package/test/jobs/escrowBlobRewrap.spec.ts +262 -0
  203. package/test/jobs/escrowHoldReminders.spec.ts +319 -0
  204. package/test/keycloak-admin.spec.ts +58 -0
  205. package/test/keycloak-broker-roundtrip.integration.spec.ts +40 -0
  206. package/test/keycloak-migration.integration.spec.ts +266 -0
  207. package/test/keycloak-provider-links.spec.ts +201 -0
  208. package/test/keycloak-social-broker.integration.spec.ts +228 -0
  209. package/test/keycloak-verify.integration.spec.ts +100 -0
  210. package/test/keycloak-verify.spec.ts +307 -0
  211. package/test/keys-escrow.spec.ts +2328 -0
  212. package/test/keys-identity-rebind.spec.ts +456 -0
  213. package/test/keys-lifecycle-invariants.spec.ts +785 -0
  214. package/test/keys-recovery-confirmation.spec.ts +586 -0
  215. package/test/keys-recovery-rate-limit.spec.ts +123 -0
  216. package/test/keys-secret-leakage.spec.ts +265 -0
  217. package/test/models/authSubjectIndexes.spec.ts +132 -0
  218. package/test/models/escrow.spec.ts +572 -0
  219. package/test/notifications.spec.ts +4 -0
  220. package/test/oidc-e2e/broker.spec.ts +116 -0
  221. package/test/oidc-e2e/compose.yaml +33 -0
  222. package/test/oidc-e2e/fixtures.ts +245 -0
  223. package/test/oidc-e2e/lock.test.mjs +73 -0
  224. package/test/oidc-e2e/playwright.config.ts +21 -0
  225. package/test/oidc-e2e/run.mjs +312 -0
  226. package/test/oidc-e2e/tsconfig.json +10 -0
  227. package/test/oidc-e2e/types.ts +21 -0
  228. package/test/oidc.integration.spec.ts +54 -0
  229. package/test/oidc.spec.ts +697 -0
  230. package/test/oidcLambdaApp.boot.spec.ts +105 -0
  231. package/test/provision-keycloak-users.spec.ts +299 -0
  232. package/test/signing-authority-http.spec.ts +161 -0
  233. package/test/signing-authority-seed-migration.spec.ts +414 -0
  234. package/test/signing-authority.spec.ts +90 -0
  235. package/tsconfig.build.json +1 -1
  236. package/tsconfig.json +1 -0
  237. package/vitest.config.ts +24 -1
  238. package/vitest.integration.config.ts +25 -3
  239. package/dist/cache/signing-authority.d.ts +0 -6
  240. package/src/cache/signing-authority.ts +0 -37
@@ -0,0 +1,147 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import type { Collection } from 'mongodb';
3
+ import mongodb from '@mongo';
4
+
5
+ export const AUTH_SUBJECTS_COLLECTION = 'authsubjects';
6
+
7
+ export interface AuthSubjectAttributes {
8
+ email?: string;
9
+ emailVerified: boolean;
10
+ displayName?: string;
11
+ pictureUrl?: string;
12
+ }
13
+
14
+ export interface MongoAuthSubjectType extends AuthSubjectAttributes {
15
+ subject: string;
16
+ identityKey: string;
17
+ createdAt: Date;
18
+ lastLoginAt: Date;
19
+ }
20
+
21
+ export const getAuthSubjectsCollection = (): Collection<MongoAuthSubjectType> =>
22
+ mongodb.collection<MongoAuthSubjectType>(AUTH_SUBJECTS_COLLECTION);
23
+
24
+ const MONGO_NAMESPACE_NOT_FOUND = 26;
25
+ const MONGO_INDEX_NOT_FOUND = 27;
26
+
27
+ const mongoErrorCode = (error: unknown): unknown =>
28
+ typeof error === 'object' && error !== null ? (error as { code?: unknown }).code : undefined;
29
+
30
+ /**
31
+ * `identityKey` is unique: each sign-in method maps to exactly one subject.
32
+ * `subject` is deliberately NOT unique — linked sign-in methods for the same
33
+ * person (email code + native Google/Apple with the same verified email) share
34
+ * one subject, so Keycloak and the wallet see a single account. Databases
35
+ * created before linking existed carry a unique `subject` index; drop it.
36
+ */
37
+ export const createAuthSubjectIndexes = async (): Promise<void> => {
38
+ const collection = getAuthSubjectsCollection();
39
+
40
+ const existing = await collection.indexes().catch(error => {
41
+ if (mongoErrorCode(error) === MONGO_NAMESPACE_NOT_FOUND) return [];
42
+ throw error;
43
+ });
44
+ const legacyUniqueSubject = existing.find(
45
+ index =>
46
+ index.unique === true && index.key?.subject === 1 && Object.keys(index.key).length === 1
47
+ );
48
+ if (legacyUniqueSubject?.name) {
49
+ await collection.dropIndex(legacyUniqueSubject.name).catch(error => {
50
+ if (mongoErrorCode(error) !== MONGO_INDEX_NOT_FOUND) throw error;
51
+ });
52
+ }
53
+
54
+ await collection.createIndex({ subject: 1 });
55
+ await collection.createIndex({ identityKey: 1 }, { unique: true });
56
+ };
57
+
58
+ let authSubjectIndexesReady: Promise<void> | undefined;
59
+
60
+ /** Create the indexes once per process (startup in Docker, first login in Lambda). */
61
+ export const ensureAuthSubjectIndexes = (): Promise<void> => {
62
+ if (!authSubjectIndexesReady) {
63
+ authSubjectIndexesReady = createAuthSubjectIndexes().catch(error => {
64
+ authSubjectIndexesReady = undefined;
65
+ console.error('Unable to create AuthSubject indexes:', error);
66
+ throw error;
67
+ });
68
+ }
69
+ return authSubjectIndexesReady;
70
+ };
71
+
72
+ const MONGO_DUPLICATE_KEY = 11000;
73
+
74
+ const isDuplicateKeyError = (error: unknown): boolean =>
75
+ typeof error === 'object' &&
76
+ error !== null &&
77
+ (error as { code?: unknown }).code === MONGO_DUPLICATE_KEY;
78
+
79
+ /**
80
+ * Shared upsert for both `getOrCreateAuthSubject` and `getOrCreateAuthSubjectLinkedTo`.
81
+ *
82
+ * Two concurrent first logins for the same identity can both miss the lookup
83
+ * and race on the insert; the unique `identityKey` index rejects the loser
84
+ * with E11000. Retrying once then finds the winner's document and only runs
85
+ * the `$set` half of the upsert. `subjectForInsert` only takes effect when this
86
+ * `identityKey` doesn't exist yet (`$setOnInsert`) — an existing record always
87
+ * keeps its own `subject`, regardless of what's passed here.
88
+ */
89
+ const upsertAuthSubject = async (
90
+ identityKey: string,
91
+ subjectForInsert: string,
92
+ attrs: AuthSubjectAttributes
93
+ ): Promise<MongoAuthSubjectType> => {
94
+ await ensureAuthSubjectIndexes();
95
+ const now = new Date();
96
+ const fields: Partial<MongoAuthSubjectType> = {
97
+ lastLoginAt: now,
98
+ emailVerified: attrs.emailVerified,
99
+ };
100
+ if (attrs.email !== undefined) fields.email = attrs.email;
101
+ if (attrs.displayName !== undefined) fields.displayName = attrs.displayName;
102
+ if (attrs.pictureUrl !== undefined) fields.pictureUrl = attrs.pictureUrl;
103
+
104
+ const upsert = () =>
105
+ getAuthSubjectsCollection().findOneAndUpdate(
106
+ { identityKey },
107
+ {
108
+ $setOnInsert: { subject: subjectForInsert, identityKey, createdAt: now },
109
+ $set: fields,
110
+ },
111
+ { upsert: true, returnDocument: 'after' }
112
+ );
113
+
114
+ let record: MongoAuthSubjectType | null;
115
+ try {
116
+ record = await upsert();
117
+ } catch (error) {
118
+ if (!isDuplicateKeyError(error)) throw error;
119
+ record = await upsert();
120
+ }
121
+ if (!record) throw new Error('Unable to persist authentication subject');
122
+ return record;
123
+ };
124
+
125
+ /** Persist a random, permanent subject independently of Firebase and UserKey. */
126
+ export const getOrCreateAuthSubject = (
127
+ identityKey: string,
128
+ attrs: AuthSubjectAttributes
129
+ ): Promise<MongoAuthSubjectType> => upsertAuthSubject(identityKey, randomUUID(), attrs);
130
+
131
+ /**
132
+ * Same as `getOrCreateAuthSubject`, but a brand-new `identityKey` adopts the given
133
+ * `subject` instead of minting a random one — used to unify a native social sign-in
134
+ * onto an already-existing email-code identity for the same verified email. If
135
+ * `identityKey` already has its own record (e.g. this provider/sub was seen before),
136
+ * that record's existing `subject` is kept untouched; it is never overwritten.
137
+ */
138
+ export const getOrCreateAuthSubjectLinkedTo = (
139
+ identityKey: string,
140
+ subject: string,
141
+ attrs: AuthSubjectAttributes
142
+ ): Promise<MongoAuthSubjectType> => upsertAuthSubject(identityKey, subject, attrs);
143
+
144
+ /** Read-only lookup used to find an existing identity to link a new one to. */
145
+ export const findAuthSubjectByIdentityKey = (
146
+ identityKey: string
147
+ ): Promise<MongoAuthSubjectType | null> => getAuthSubjectsCollection().findOne({ identityKey });
@@ -0,0 +1,541 @@
1
+ import { createHash, createHmac, randomBytes } from 'crypto';
2
+ import { z } from 'zod';
3
+ import type { Collection } from 'mongodb';
4
+ import mongodb from '@mongo';
5
+ import { environment } from '@environment';
6
+ import {
7
+ AuthProviderMappingValidator,
8
+ EscrowEnvelopeValidator,
9
+ type AuthProviderMapping,
10
+ } from './UserKey';
11
+
12
+ export const ESCROW_HOLDS_COLLECTION = 'escrowholds';
13
+ export const ESCROW_HOLD_STALE_WINDOW_MS = 30 * 24 * 60 * 60 * 1000;
14
+ export const ESCROW_HOLD_RESTART_MIN_AGE_MS = environment.ESCROW_HOLD_RESTART_MIN_AGE_MS;
15
+ export const ESCROW_HOLD_REMINDER_WINDOW_MS = 24 * 60 * 60 * 1000;
16
+ // Opaque JSON validated for shape, not signature authenticity. Preserve signed extensions.
17
+ export const EscrowHoldRecordValidator = z
18
+ .object({
19
+ hold: z
20
+ .object({
21
+ holdId: z.string().min(1),
22
+ did: z.string().min(1),
23
+ shareVersion: z.number().int().positive(),
24
+ blobHash: z.string().regex(/^[0-9a-f]{64}$/),
25
+ enrollmentEpoch: z.number().int().positive(),
26
+ releasePolicy: z.enum(['hold', 'pin']),
27
+ clientEphemeralPublicKey: z.string().min(1),
28
+ createdLo: z.number().int().nonnegative(),
29
+ createdHi: z.number().int().nonnegative(),
30
+ policyVersion: z.number().int().positive(),
31
+ signature: z.string().min(1),
32
+ })
33
+ .passthrough(),
34
+ holdDurationMs: z.number().int().nonnegative(),
35
+ ledgerSeq: z.number().int().nonnegative(),
36
+ })
37
+ .passthrough();
38
+ export const EscrowHoldValidator = z.object({
39
+ // Required for new holds before escrow production launch. Raw Mongo reads can
40
+ // still return legacy rows without it; cancellation/release must check at runtime.
41
+ holdRecord: EscrowHoldRecordValidator,
42
+ _id: z.string().uuid(),
43
+ authProvider: AuthProviderMappingValidator,
44
+ primaryDid: z.string(),
45
+ shareVersion: z.number().int().positive(),
46
+ status: z.enum(['pending', 'cancelled', 'completed', 'expired']),
47
+ identityProofType: z.enum(['auth-token', 'recovery-session']),
48
+ requestedAt: z.date(),
49
+ releaseAfter: z.date(),
50
+ releasePolicy: z.enum(['hold', 'pin']).default('hold'),
51
+ cancelReason: z.enum(['pin-mismatch', 'pin-locked', 'superseded', 'release-failed']).optional(),
52
+ cancelledAt: z.date().optional(),
53
+ enclaveCancelPendingAt: z.date().optional(),
54
+ enclaveCancelConfirmedAt: z.date().optional(),
55
+ enclaveCancelAttempts: z.number().int().nonnegative().optional(),
56
+ // Retain ciphertext for revocation even after account removal or re-enrollment.
57
+ enclaveEnvelope: EscrowEnvelopeValidator.optional(),
58
+ cancelledBy: z.enum(['did', 'system', 'link']).optional(),
59
+ completedAt: z.date().optional(),
60
+ clientEphemeralPublicKey: z.string().min(1).max(512),
61
+ resumeTokenHash: z.string().regex(/^[0-9a-f]{64}$/),
62
+ cancelTokenHash: z
63
+ .string()
64
+ .regex(/^[0-9a-f]{64}$/)
65
+ .optional(),
66
+ cancelTokenUsedAt: z.date().optional(),
67
+ cancelTokenHashes: z
68
+ .array(z.string().regex(/^[0-9a-f]{64}$/))
69
+ .max(4)
70
+ .optional(),
71
+ startedNotificationPending: z.boolean().optional(),
72
+ reminderClaimedUntil: z.date().optional(),
73
+ startedNotificationAttemptedAt: z.date().optional(),
74
+ reminderAttemptedAt: z.date().optional(),
75
+ notifications: z
76
+ .array(
77
+ z.object({
78
+ kind: z.enum(['started', 'reminder', 'completed', 'cancelled', 'pin-locked']),
79
+ sentAt: z.date(),
80
+ })
81
+ )
82
+ .default([]),
83
+ requestIp: z.string().optional(),
84
+ /**
85
+ * Resolved tenant id (`ResolvedTenant.id`) at the moment `startRecovery`
86
+ * created this hold. Optional because holds created before this field
87
+ * existed have none; the reminders job (P5.5) falls back to the default
88
+ * tenant via the email-templates registry when absent.
89
+ */
90
+ tenantId: z.string().optional(),
91
+ createdAt: z.date(),
92
+ updatedAt: z.date(),
93
+ });
94
+ export type EscrowHold = z.infer<typeof EscrowHoldValidator>;
95
+ export type CreateEscrowHoldInput = Omit<
96
+ z.input<typeof EscrowHoldValidator>,
97
+ | '_id'
98
+ | 'status'
99
+ | 'createdAt'
100
+ | 'updatedAt'
101
+ | 'notifications'
102
+ | 'cancelledAt'
103
+ | 'cancelledBy'
104
+ | 'completedAt'
105
+ | 'cancelReason'
106
+ | 'cancelTokenUsedAt'
107
+ | 'enclaveCancelPendingAt'
108
+ | 'enclaveCancelConfirmedAt'
109
+ | 'enclaveCancelAttempts'
110
+ >;
111
+
112
+ export const getEscrowHoldsCollection = (): Collection<EscrowHold> =>
113
+ mongodb.collection<EscrowHold>(ESCROW_HOLDS_COLLECTION);
114
+
115
+ const runIndexMigrationOperation = async (operation: () => Promise<unknown>): Promise<void> => {
116
+ try {
117
+ await operation();
118
+ } catch (error) {
119
+ const code =
120
+ typeof error === 'object' && error !== null && 'code' in error ? error.code : undefined;
121
+ // Unlike lookup indexes, uniqueness is a security invariant: fail closed on duplicates.
122
+ if (code === 27 || code === 85 || code === 86) {
123
+ const indexes = await getEscrowHoldsCollection().listIndexes().toArray();
124
+ if (
125
+ indexes.some(
126
+ index =>
127
+ index.unique &&
128
+ Object.keys(index.key ?? {}).length === 3 &&
129
+ index.key?.['authProvider.type'] === 1 &&
130
+ index.key?.['authProvider.id'] === 1 &&
131
+ index.key?.releasePolicy === 1 &&
132
+ Object.keys(index.partialFilterExpression ?? {}).length === 1 &&
133
+ index.partialFilterExpression?.status === 'pending'
134
+ )
135
+ )
136
+ return;
137
+ }
138
+ throw new Error('Escrow hold index initialization failed', { cause: error });
139
+ }
140
+ };
141
+
142
+ export const createEscrowHoldsIndexes = async (): Promise<void> => {
143
+ const collection = getEscrowHoldsCollection();
144
+ // Normalize legacy rows while the old, stricter index still protects them.
145
+ await collection.updateMany(
146
+ { releasePolicy: { $exists: false } },
147
+ { $set: { releasePolicy: 'hold' } }
148
+ );
149
+ await runIndexMigrationOperation(() =>
150
+ collection.createIndex(
151
+ { 'authProvider.type': 1, 'authProvider.id': 1, releasePolicy: 1 },
152
+ {
153
+ name: 'pending_escrow_identity_policy_unique',
154
+ unique: true,
155
+ partialFilterExpression: { status: 'pending' },
156
+ }
157
+ )
158
+ );
159
+ // Install the replacement before dropping the old index: no uniqueness gap.
160
+ try {
161
+ await collection.dropIndex('pending_escrow_identity_unique');
162
+ } catch (error) {
163
+ if (!(typeof error === 'object' && error !== null && 'code' in error && error.code === 27))
164
+ throw error;
165
+ }
166
+ await runIndexMigrationOperation(() =>
167
+ collection.createIndex({ 'authProvider.type': 1, 'authProvider.id': 1, status: 1 })
168
+ );
169
+ await runIndexMigrationOperation(() => collection.createIndex({ releaseAfter: 1 }));
170
+ // Backs findEscrowHoldsDueForReminder's { status:'pending', releaseAfter: {$gt,$lte} } scan.
171
+ await collection.createIndex(
172
+ { status: 1, releaseAfter: 1 },
173
+ { name: 'pending_escrow_hold_reminder_lookup' }
174
+ );
175
+ await collection.createIndex(
176
+ { status: 1, enclaveCancelConfirmedAt: 1, enclaveCancelAttempts: 1, releaseAfter: 1 },
177
+ { name: 'escrow_enclave_cancel_retry_lookup' }
178
+ );
179
+ await collection.createIndex(
180
+ { primaryDid: 1, status: 1, enclaveCancelConfirmedAt: 1 },
181
+ { name: 'escrow_enclave_cancel_identity_lookup' }
182
+ );
183
+ };
184
+
185
+ export const createEscrowHold = async (input: CreateEscrowHoldInput): Promise<EscrowHold> => {
186
+ const now = new Date();
187
+ const parsed = EscrowHoldValidator.safeParse({
188
+ ...input,
189
+ _id: input.holdRecord?.hold?.holdId,
190
+ status: 'pending',
191
+ notifications: [],
192
+ createdAt: now,
193
+ updatedAt: now,
194
+ });
195
+ if (!parsed.success) throw new Error('Invalid escrow hold');
196
+ const hold = parsed.data;
197
+ await getEscrowHoldsCollection().insertOne(hold);
198
+ return hold;
199
+ };
200
+ export const findPendingEscrowHoldByAuthProvider = async (
201
+ authProvider: AuthProviderMapping,
202
+ releasePolicy?: EscrowHold['releasePolicy']
203
+ ): Promise<EscrowHold | null> =>
204
+ getEscrowHoldsCollection().findOne({
205
+ 'authProvider.type': authProvider.type,
206
+ 'authProvider.id': authProvider.id,
207
+ status: 'pending',
208
+ ...(releasePolicy === 'hold'
209
+ ? { $or: [{ releasePolicy: 'hold' as const }, { releasePolicy: { $exists: false } }] }
210
+ : releasePolicy
211
+ ? { releasePolicy }
212
+ : {}),
213
+ });
214
+ export const findEscrowHoldById = async (id: string): Promise<EscrowHold | null> =>
215
+ getEscrowHoldsCollection().findOne({ _id: id });
216
+ /** Fail closed across linked providers and enrollment changes until revocation is confirmed. */
217
+ export const hasUnconfirmedEscrowCancellation = async (primaryDid: string): Promise<boolean> =>
218
+ (await getEscrowHoldsCollection().countDocuments(
219
+ {
220
+ primaryDid,
221
+ status: 'cancelled',
222
+ enclaveCancelPendingAt: { $exists: true },
223
+ enclaveCancelConfirmedAt: { $exists: false },
224
+ },
225
+ { limit: 1 }
226
+ )) > 0;
227
+
228
+ export const findEscrowHoldsPendingEnclaveCancellation = async (
229
+ limit: number
230
+ ): Promise<EscrowHold[]> =>
231
+ getEscrowHoldsCollection()
232
+ .find({
233
+ status: 'cancelled',
234
+ enclaveCancelPendingAt: { $exists: true },
235
+ enclaveCancelConfirmedAt: { $exists: false },
236
+ })
237
+ .sort({ enclaveCancelAttempts: 1, releaseAfter: 1 })
238
+ .limit(limit)
239
+ .toArray();
240
+
241
+ export const countEscrowCancellationsNearRelease = async (now: Date): Promise<number> =>
242
+ getEscrowHoldsCollection().countDocuments({
243
+ status: 'cancelled',
244
+ enclaveCancelPendingAt: { $exists: true },
245
+ enclaveCancelConfirmedAt: { $exists: false },
246
+ releaseAfter: { $lte: new Date(now.getTime() + ESCROW_HOLD_REMINDER_WINDOW_MS) },
247
+ });
248
+
249
+ export const recordEscrowEnclaveCancelAttempt = async (id: string): Promise<boolean> => {
250
+ const result = await getEscrowHoldsCollection().updateOne(
251
+ {
252
+ _id: id,
253
+ status: 'cancelled',
254
+ enclaveCancelPendingAt: { $exists: true },
255
+ enclaveCancelConfirmedAt: { $exists: false },
256
+ },
257
+ { $inc: { enclaveCancelAttempts: 1 }, $set: { updatedAt: new Date() } }
258
+ );
259
+ return result.modifiedCount > 0;
260
+ };
261
+
262
+ export const confirmEscrowEnclaveCancellation = async (id: string): Promise<void> => {
263
+ const now = new Date();
264
+ await getEscrowHoldsCollection().updateOne(
265
+ {
266
+ _id: id,
267
+ status: 'cancelled',
268
+ enclaveCancelPendingAt: { $exists: true },
269
+ enclaveCancelConfirmedAt: { $exists: false },
270
+ },
271
+ {
272
+ $set: { enclaveCancelConfirmedAt: now, updatedAt: now },
273
+ $unset: { enclaveCancelPendingAt: '', enclaveEnvelope: '' },
274
+ }
275
+ );
276
+ };
277
+
278
+ export const cancelEscrowHold = async (
279
+ id: string,
280
+ cancelledBy: 'did' | 'system',
281
+ cancelReason?: EscrowHold['cancelReason']
282
+ ): Promise<EscrowHold | null> => {
283
+ const now = new Date();
284
+ return getEscrowHoldsCollection().findOneAndUpdate(
285
+ { _id: id, status: 'pending' },
286
+ {
287
+ $set: {
288
+ status: 'cancelled',
289
+ cancelledBy,
290
+ cancelledAt: now,
291
+ enclaveCancelPendingAt: now,
292
+ enclaveCancelAttempts: 0,
293
+ updatedAt: now,
294
+ ...(cancelReason ? { cancelReason } : {}),
295
+ },
296
+ },
297
+ { returnDocument: 'after' }
298
+ );
299
+ };
300
+ /**
301
+ * Cancels a hold via its single-use cancel-link token. The status transition
302
+ * and burning the token happen in one conditional update — matching on the
303
+ * exact `cancelTokenHash` plus `cancelTokenUsedAt` being unset — so two
304
+ * concurrent clicks on the same link can never both succeed; only the first
305
+ * `findOneAndUpdate` observes the pre-burn state and wins the CAS.
306
+ */
307
+ export const cancelEscrowHoldByCancelToken = async (
308
+ id: string,
309
+ cancelTokenHash: string
310
+ ): Promise<EscrowHold | null> => {
311
+ const now = new Date();
312
+ return getEscrowHoldsCollection().findOneAndUpdate(
313
+ {
314
+ _id: id,
315
+ status: 'pending',
316
+ $or: [{ cancelTokenHash }, { cancelTokenHashes: cancelTokenHash }],
317
+ cancelTokenUsedAt: { $exists: false },
318
+ },
319
+ {
320
+ $set: {
321
+ status: 'cancelled',
322
+ cancelledBy: 'link',
323
+ cancelledAt: now,
324
+ enclaveCancelPendingAt: now,
325
+ enclaveCancelAttempts: 0,
326
+ cancelTokenUsedAt: now,
327
+ updatedAt: now,
328
+ },
329
+ },
330
+ { returnDocument: 'after' }
331
+ );
332
+ };
333
+ export const completeEscrowHold = async (id: string): Promise<EscrowHold | null> => {
334
+ const now = new Date();
335
+ return getEscrowHoldsCollection().findOneAndUpdate(
336
+ {
337
+ _id: id,
338
+ status: 'pending',
339
+ releaseAfter: { $gte: new Date(now.getTime() - ESCROW_HOLD_STALE_WINDOW_MS) },
340
+ },
341
+ {
342
+ $set: { status: 'completed', completedAt: now, updatedAt: now },
343
+ },
344
+ { returnDocument: 'after' }
345
+ );
346
+ };
347
+ /** Whether escrow material at this share version has already been released. */
348
+ export const hasCompletedEscrowHoldForVersion = async (
349
+ authProvider: AuthProviderMapping,
350
+ shareVersion: number
351
+ ): Promise<boolean> =>
352
+ (await getEscrowHoldsCollection().countDocuments(
353
+ {
354
+ 'authProvider.type': authProvider.type,
355
+ 'authProvider.id': authProvider.id,
356
+ shareVersion,
357
+ status: 'completed',
358
+ },
359
+ { limit: 1 }
360
+ )) > 0;
361
+ export const expireStaleEscrowHolds = async (now: Date): Promise<number> => {
362
+ const result = await getEscrowHoldsCollection().updateMany(
363
+ {
364
+ status: 'pending',
365
+ releaseAfter: { $lt: new Date(now.getTime() - ESCROW_HOLD_STALE_WINDOW_MS) },
366
+ },
367
+ { $set: { status: 'expired', updatedAt: now } }
368
+ );
369
+ return result.modifiedCount;
370
+ };
371
+
372
+ /** Only rewrite the completed row claimed by this request; never reopen a burned hold. */
373
+ export const markClaimedEscrowHoldFailed = async (
374
+ id: string,
375
+ reason: 'pin-mismatch' | 'pin-locked' | 'release-failed',
376
+ completedAt: Date
377
+ ): Promise<void> => {
378
+ const now = new Date();
379
+ await getEscrowHoldsCollection().updateOne(
380
+ { _id: id, status: 'completed', completedAt },
381
+ {
382
+ $set: {
383
+ status: 'cancelled',
384
+ cancelledBy: 'system',
385
+ cancelReason: reason,
386
+ cancelledAt: now,
387
+ enclaveCancelPendingAt: now,
388
+ enclaveCancelAttempts: 0,
389
+ updatedAt: now,
390
+ },
391
+ }
392
+ );
393
+ };
394
+ /** Reopen only our own failed release claim, never another request's terminal row. */
395
+ export const retryClaimedEscrowHold = async (id: string, completedAt: Date): Promise<void> => {
396
+ try {
397
+ await getEscrowHoldsCollection().updateOne(
398
+ { _id: id, status: 'completed', completedAt },
399
+ { $set: { status: 'pending', updatedAt: new Date() }, $unset: { completedAt: '' } }
400
+ );
401
+ } catch (error) {
402
+ // A replacement hold may have won the pending unique index while we released.
403
+ if (!(
404
+ typeof error === 'object' &&
405
+ error !== null &&
406
+ 'code' in error &&
407
+ error.code === 11000
408
+ ))
409
+ throw error;
410
+ await markClaimedEscrowHoldFailed(id, 'release-failed', completedAt);
411
+ }
412
+ };
413
+
414
+ export const findEscrowHoldsPendingStartNotification = async (
415
+ limit: number
416
+ ): Promise<EscrowHold[]> =>
417
+ getEscrowHoldsCollection()
418
+ .find({ status: 'pending', startedNotificationPending: true })
419
+ .sort({ startedNotificationAttemptedAt: 1, requestedAt: 1 })
420
+ .limit(limit)
421
+ .toArray();
422
+
423
+ export const recordEscrowStartNotificationAttempt = async (
424
+ id: string,
425
+ now: Date
426
+ ): Promise<void> => {
427
+ await getEscrowHoldsCollection().updateOne(
428
+ { _id: id, status: 'pending', startedNotificationPending: true },
429
+ { $set: { startedNotificationAttemptedAt: now } }
430
+ );
431
+ };
432
+ /**
433
+ * Pending, hold-policy holds whose waiting period ends within the next 24h
434
+ * (and hasn't ended yet) that have never been reminded. `pin`-policy holds
435
+ * release immediately at creation (`releaseAfter` set to the creation time,
436
+ * not a future date — see `startRecovery`), so `releaseAfter > now` already
437
+ * excludes them in practice within a few ms of job execution; the explicit
438
+ * releasePolicy filter documents that exclusion instead of relying on
439
+ * timing alone. Legacy rows predating `releasePolicy` are treated as `hold`,
440
+ * matching `findPendingEscrowHoldByAuthProvider`'s own `$or`.
441
+ */
442
+ export const findEscrowHoldsDueForReminder = async (
443
+ now: Date,
444
+ limit: number
445
+ ): Promise<EscrowHold[]> =>
446
+ getEscrowHoldsCollection()
447
+ .find({
448
+ status: 'pending',
449
+ $or: [{ releasePolicy: 'hold' as const }, { releasePolicy: { $exists: false } }],
450
+ releaseAfter: {
451
+ $gt: now,
452
+ $lte: new Date(now.getTime() + ESCROW_HOLD_REMINDER_WINDOW_MS),
453
+ },
454
+ 'notifications.kind': { $ne: 'reminder' },
455
+ })
456
+ .sort({ reminderAttemptedAt: 1, releaseAfter: 1 })
457
+ .limit(limit)
458
+ .toArray();
459
+
460
+ /** A short delivery lease avoids concurrent sends without recording false success.
461
+ * A crashed Lambda's lease expires before the next hourly run. */
462
+ export const claimEscrowHoldForReminder = async (
463
+ id: string,
464
+ now: Date
465
+ ): Promise<EscrowHold | null> =>
466
+ getEscrowHoldsCollection().findOneAndUpdate(
467
+ {
468
+ _id: id,
469
+ status: 'pending',
470
+ 'notifications.kind': { $ne: 'reminder' },
471
+ $or: [
472
+ { reminderClaimedUntil: { $exists: false } },
473
+ { reminderClaimedUntil: { $lte: now } },
474
+ ],
475
+ },
476
+ {
477
+ $set: {
478
+ reminderClaimedUntil: new Date(now.getTime() + 5 * 60_000),
479
+ reminderAttemptedAt: now,
480
+ updatedAt: now,
481
+ },
482
+ },
483
+ { returnDocument: 'after' }
484
+ );
485
+
486
+ export const releaseEscrowReminderClaim = async (hold: EscrowHold): Promise<void> => {
487
+ await getEscrowHoldsCollection().updateOne(
488
+ { _id: hold._id, reminderClaimedUntil: hold.reminderClaimedUntil },
489
+ { $unset: { reminderClaimedUntil: '' } }
490
+ );
491
+ };
492
+
493
+ export const hashEscrowResumeToken = (token: string): string =>
494
+ createHash('sha256').update(token).digest('hex');
495
+ export const generateEscrowResumeToken = (): string => randomBytes(32).toString('hex');
496
+
497
+ /** Appends a delivery record; a missing holdId is a no-op rather than a throw. */
498
+ export const recordEscrowHoldNotification = async (
499
+ holdId: string,
500
+ kind: EscrowHold['notifications'][number]['kind']
501
+ ): Promise<void> => {
502
+ const now = new Date();
503
+ await getEscrowHoldsCollection().updateOne(
504
+ { _id: holdId, 'notifications.kind': { $ne: kind } },
505
+ {
506
+ $push: { notifications: { kind, sentAt: now } },
507
+ $set: {
508
+ updatedAt: now,
509
+ ...(kind === 'started' ? { startedNotificationPending: false } : {}),
510
+ },
511
+ }
512
+ );
513
+ };
514
+
515
+ /**
516
+ * Adds a retry-stable, domain-separated token without revoking earlier links.
517
+ * HMAC allows delivery retries without storing plaintext or exhausting the
518
+ * bounded hash set. The original random hash remains valid until terminal.
519
+ * At most four additional hashes (plus the legacy original) are accepted;
520
+ * never evict a previously delivered link, even after a server seed rotation.
521
+ */
522
+ export const rotateEscrowCancelToken = async (holdId: string): Promise<string | null> => {
523
+ const token = createHmac('sha256', environment.SEED)
524
+ .update(`escrow-cancel-notification:v1:${holdId}`)
525
+ .digest('hex');
526
+ const cancelTokenHash = createHash('sha256').update(token).digest('hex');
527
+ const now = new Date();
528
+ const result = await getEscrowHoldsCollection().findOneAndUpdate(
529
+ {
530
+ _id: holdId,
531
+ status: 'pending',
532
+ $or: [
533
+ { cancelTokenHashes: cancelTokenHash },
534
+ { 'cancelTokenHashes.3': { $exists: false } },
535
+ ],
536
+ },
537
+ { $addToSet: { cancelTokenHashes: cancelTokenHash }, $set: { updatedAt: now } },
538
+ { returnDocument: 'after' }
539
+ );
540
+ return result ? token : null;
541
+ };