@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,419 @@
1
+ import { parseArgs } from 'node:util';
2
+ import { pathToFileURL } from 'node:url';
3
+ import { z } from 'zod';
4
+ import { ObjectId, type Collection } from 'mongodb';
5
+ import { client } from '../src/mongo';
6
+ import { getUserKeysCollection, type MongoUserKeyType } from '../src/models/UserKey';
7
+ import { getAuthSubjectsCollection, getOrCreateAuthSubject } from '../src/models/AuthSubject';
8
+ import { maskEmail } from '../src/helpers/maskEmail';
9
+ import { createKeycloakAdmin } from './keycloak-admin';
10
+ import { setTimeout } from 'node:timers/promises';
11
+ import {
12
+ fetchFirebaseUsers,
13
+ findProviderLinkConflicts,
14
+ linkFirebaseProviders,
15
+ type FirebaseReader,
16
+ type FirebaseUser,
17
+ type ProviderLinkSummary,
18
+ } from './keycloak-provider-links';
19
+
20
+ export interface ProvisionOptions {
21
+ apply?: boolean;
22
+ email?: string;
23
+ limit?: number;
24
+ after?: string;
25
+ linkProviders?: boolean;
26
+ }
27
+ export interface ProvisionSummary extends ProviderLinkSummary {
28
+ processed: number;
29
+ skipped: number;
30
+ refused: number;
31
+ mapped: number;
32
+ missingMappings: number;
33
+ }
34
+ const log = (message: string): void => {
35
+ process.stdout.write(`${message}\n`);
36
+ };
37
+ const escapeRegex = (value: string): string => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
38
+
39
+ type ProvisionKey = Omit<MongoUserKeyType, '_id'> & { _id: ObjectId };
40
+
41
+ const loadProvisioningBatch = async (
42
+ source: AsyncIterator<ProvisionKey>,
43
+ firebase?: FirebaseReader
44
+ ): Promise<{ batch: ProvisionKey[]; firebaseUsers?: Map<string, FirebaseUser> }> => {
45
+ const batch: ProvisionKey[] = [];
46
+ for (let index = 0; index < (firebase ? 100 : 1); index++) {
47
+ const item = await source.next();
48
+ if (item.done) break;
49
+ batch.push(item.value);
50
+ }
51
+ const firebaseUsers =
52
+ firebase && batch.length
53
+ ? await fetchFirebaseUsers(
54
+ batch.flatMap(key =>
55
+ key.authProviders
56
+ .filter(provider => provider.type === 'firebase')
57
+ .map(provider => provider.id)
58
+ ),
59
+ firebase
60
+ )
61
+ : undefined;
62
+ return { batch, firebaseUsers };
63
+ };
64
+
65
+ const getFirebaseReader = async (): Promise<FirebaseReader> => {
66
+ const { environment } = await import('../src/config/environment');
67
+ // Validate before importing the legacy initializer, which logs raw JSON parse errors.
68
+ try {
69
+ z.object({})
70
+ .passthrough()
71
+ .parse(JSON.parse(environment.GOOGLE_APPLICATION_CREDENTIAL ?? ''));
72
+ } catch {
73
+ throw new Error('Valid existing Firebase Admin credential JSON is required');
74
+ }
75
+ const { default: app } = await import('../src/firebase');
76
+ if (!app)
77
+ throw new Error('Existing Firebase Admin credentials are required for provider linking');
78
+ return app.auth();
79
+ };
80
+
81
+ /** Offline operator migration, never a runtime email-based identity fallback. */
82
+ export const provisionKeycloakUsers = async (
83
+ options: ProvisionOptions = {},
84
+ dependencies: {
85
+ firebase?: FirebaseReader;
86
+ admin?: Awaited<ReturnType<typeof createKeycloakAdmin>>;
87
+ } = {}
88
+ ): Promise<ProvisionSummary> => {
89
+ const email =
90
+ options.email === undefined
91
+ ? undefined
92
+ : z.email().parse(options.email.trim().toLowerCase());
93
+ const limit =
94
+ options.limit === undefined ? undefined : z.number().int().positive().parse(options.limit);
95
+ const after =
96
+ options.after === undefined
97
+ ? undefined
98
+ : new ObjectId(
99
+ z
100
+ .string()
101
+ .regex(/^[0-9a-fA-F]{24}$/)
102
+ .parse(options.after)
103
+ );
104
+ let lastProcessedId = after?.toHexString();
105
+ const summary: ProvisionSummary = {
106
+ processed: 0,
107
+ skipped: 0,
108
+ refused: 0,
109
+ mapped: 0,
110
+ missingMappings: 0,
111
+ linked: 0,
112
+ alreadyLinked: 0,
113
+ conflicts: 0,
114
+ noSocialProvider: 0,
115
+ wouldLink: 0,
116
+ };
117
+ try {
118
+ const linkProviders = options.linkProviders ?? true;
119
+ const firebase = linkProviders
120
+ ? (dependencies.firebase ?? (await getFirebaseReader()))
121
+ : undefined;
122
+ const admin = dependencies.admin ?? (await createKeycloakAdmin());
123
+ // Mongo assigns ObjectIds on insert; the model's optional string _id describes serialized data.
124
+ const keys = getUserKeysCollection() as unknown as Collection<
125
+ Omit<MongoUserKeyType, '_id'>
126
+ >;
127
+ const subjects = getAuthSubjectsCollection();
128
+ const cursor = keys
129
+ .find({
130
+ 'authProviders.type': 'firebase',
131
+ ...(after ? { _id: { $gt: after } } : {}),
132
+ ...(email
133
+ ? {
134
+ 'contactMethod.type': 'email',
135
+ 'contactMethod.value': {
136
+ $regex: `^${escapeRegex(email)}$`,
137
+ $options: 'i',
138
+ },
139
+ }
140
+ : {}),
141
+ })
142
+ .sort({ _id: 1 });
143
+ if (limit) cursor.limit(limit);
144
+ log(`Mode: ${options.apply ? 'APPLY' : 'DRY RUN (no resource writes)'}\nContact\tResult`);
145
+ const iterator = cursor[Symbol.asyncIterator]();
146
+ try {
147
+ while (true) {
148
+ const { batch, firebaseUsers } = await loadProvisioningBatch(iterator, firebase);
149
+ if (!batch.length) break;
150
+ for (const key of batch) {
151
+ let failed = false;
152
+ try {
153
+ summary.processed++;
154
+ if (key.contactMethod.type !== 'email') {
155
+ summary.skipped++;
156
+ log('[phone-only]\tSKIP: phone OTP deferred');
157
+ continue;
158
+ }
159
+ const normalized = key.contactMethod.value.trim().toLowerCase();
160
+ const label = maskEmail(normalized);
161
+ const refuse = (reason: string): void => {
162
+ summary.refused++;
163
+ log(`${label}\tREFUSED: ${reason}`);
164
+ };
165
+ if (!z.email().safeParse(normalized).success) {
166
+ refuse('invalid email');
167
+ continue;
168
+ }
169
+ const firebaseIds = key.authProviders
170
+ .filter(provider => provider.type === 'firebase')
171
+ .map(provider => provider.id);
172
+ const firebaseRecords = firebaseIds.map(id => firebaseUsers?.get(id));
173
+ if (
174
+ linkProviders &&
175
+ (!firebaseIds.length ||
176
+ firebaseRecords.some(
177
+ record =>
178
+ !record ||
179
+ record.disabled ||
180
+ !record.emailVerified ||
181
+ record.email?.trim().toLowerCase() !== normalized
182
+ ))
183
+ ) {
184
+ refuse(
185
+ 'Firebase UID missing, disabled, unverified, or email ownership differs'
186
+ );
187
+ continue;
188
+ }
189
+ const linkSocial = async (userId?: string): Promise<void> => {
190
+ if (!linkProviders) return;
191
+ await linkFirebaseProviders({
192
+ userId,
193
+ providers: firebaseRecords.flatMap(
194
+ record => record?.providerData ?? []
195
+ ),
196
+ apply: options.apply ?? false,
197
+ admin,
198
+ log: message => log(`${label}\t${message}`),
199
+ summary,
200
+ });
201
+ };
202
+ // Contact indexes are deliberately nonunique; recycled/ambiguous addresses need manual review.
203
+ const peers = await keys.countDocuments({
204
+ 'contactMethod.type': 'email',
205
+ 'contactMethod.value': {
206
+ $regex: `^\\s*${escapeRegex(normalized)}\\s*$`,
207
+ $options: 'i',
208
+ },
209
+ });
210
+ if (peers !== 1) {
211
+ refuse('ambiguous UserKey email; manual identity review required');
212
+ continue;
213
+ }
214
+ const identityKey = `email:${normalized}`;
215
+ let subject = await subjects.findOne({ identityKey });
216
+ const matches = await admin.findUsers(normalized);
217
+ if (matches.length > 1) {
218
+ refuse('ambiguous Keycloak email');
219
+ continue;
220
+ }
221
+ let user = matches[0];
222
+ if (
223
+ user &&
224
+ (user.email?.toLowerCase() !== normalized ||
225
+ !user.enabled ||
226
+ !user.emailVerified)
227
+ ) {
228
+ refuse('existing Keycloak user is disabled or email is not verified');
229
+ continue;
230
+ }
231
+ const links = user ? await admin.links(user.id) : [];
232
+ if (linkProviders) {
233
+ const conflicts = findProviderLinkConflicts(
234
+ firebaseRecords.flatMap(record => record?.providerData ?? []),
235
+ links
236
+ );
237
+ if (conflicts.length) {
238
+ summary.conflicts += conflicts.length;
239
+ refuse(
240
+ `${conflicts.join(', ')} identity differs; manual review required`
241
+ );
242
+ continue;
243
+ }
244
+ }
245
+ const link = links.find(item => item.identityProvider === 'lca-api');
246
+ if (link && link.userId !== subject?.subject) {
247
+ refuse('existing lca-api link differs');
248
+ continue;
249
+ }
250
+ const mappings = key.authProviders.filter(
251
+ provider => provider.type === 'keycloak'
252
+ );
253
+ if (mappings.some(mapping => mapping.id !== user?.id)) {
254
+ refuse('existing keycloak mapping differs');
255
+ continue;
256
+ }
257
+ if (
258
+ user &&
259
+ (await keys.findOne({
260
+ _id: { $ne: key._id },
261
+ authProviders: { $elemMatch: { type: 'keycloak', id: user.id } },
262
+ }))
263
+ ) {
264
+ refuse('Keycloak identity already belongs to another UserKey');
265
+ continue;
266
+ }
267
+ const actions = [
268
+ !subject && 'create subject',
269
+ !user && 'create user',
270
+ !link && 'link lca-api',
271
+ !mappings.length && 'append mapping',
272
+ ].filter(Boolean);
273
+ if (!options.apply) {
274
+ log(
275
+ `${label}\t${actions.length ? `PLAN: ${actions.join(', ')}` : 'unchanged'}`
276
+ );
277
+ await linkSocial(user?.id);
278
+ continue;
279
+ }
280
+ // Do not call getOrCreate for an existing subject: it updates lastLoginAt.
281
+ subject ??= await getOrCreateAuthSubject(identityKey, {
282
+ email: normalized,
283
+ emailVerified: true,
284
+ });
285
+ if (!user) {
286
+ const phone = z
287
+ .object({
288
+ phone: z.string().optional(),
289
+ phoneNumber: z.string().optional(),
290
+ })
291
+ .parse(key);
292
+ const number = phone.phone ?? phone.phoneNumber;
293
+ await admin.request('/users', {
294
+ method: 'POST',
295
+ body: JSON.stringify({
296
+ username: normalized,
297
+ email: normalized,
298
+ emailVerified: true,
299
+ enabled: true,
300
+ ...(number
301
+ ? {
302
+ attributes: {
303
+ phone_number: [number],
304
+ // UserKey has no canonical secondary-phone verification proof.
305
+ // Never promote a legacy string into a verified identity claim.
306
+ phone_number_verified: ['false'],
307
+ },
308
+ }
309
+ : {}),
310
+ }),
311
+ });
312
+ const created = await admin.findUsers(normalized);
313
+ if (created.length !== 1)
314
+ throw new Error('Cannot resolve newly provisioned user');
315
+ user = created[0]!;
316
+ }
317
+ if (!link)
318
+ await admin.request(
319
+ `/users/${encodeURIComponent(user.id)}/federated-identity/lca-api`,
320
+ {
321
+ method: 'POST',
322
+ body: JSON.stringify({
323
+ identityProvider: 'lca-api',
324
+ userId: subject.subject,
325
+ userName: subject.subject,
326
+ }),
327
+ }
328
+ );
329
+ if (!mappings.length) {
330
+ const result = await keys.updateOne(
331
+ {
332
+ _id: key._id,
333
+ authProviders: { $not: { $elemMatch: { type: 'keycloak' } } },
334
+ },
335
+ {
336
+ $addToSet: { authProviders: { type: 'keycloak', id: user.id } },
337
+ }
338
+ );
339
+ if (result.modifiedCount !== 1)
340
+ throw new Error('Concurrent mapping change; rerun after review');
341
+ summary.mapped++;
342
+ }
343
+ await linkSocial(user.id);
344
+ log(`${label}\t${actions.length ? actions.join(', ') : 'unchanged'}`);
345
+ } catch (error) {
346
+ failed = true;
347
+ throw error;
348
+ } finally {
349
+ if (!failed) {
350
+ lastProcessedId = key._id.toHexString();
351
+ if (summary.processed % 100 === 0)
352
+ log(`Last processed id: ${lastProcessedId}`);
353
+ }
354
+ }
355
+ }
356
+ if (firebase && batch.length === 100) await setTimeout(1000);
357
+ }
358
+ } finally {
359
+ await iterator.return?.();
360
+ }
361
+ summary.missingMappings = await keys.countDocuments({
362
+ 'contactMethod.type': 'email',
363
+ 'authProviders.type': { $ne: 'keycloak' },
364
+ });
365
+ log(
366
+ `Mapping-presence assertion: ${summary.missingMappings === 0 && summary.refused === 0 ? 'PASS' : 'FAIL'} — ${summary.missingMappings} email UserKeys lack a keycloak mapping (global, not limited by --email/--limit).`
367
+ );
368
+ log(
369
+ 'Cutover approval: NOT EVALUATED. Presence is necessary, not sufficient; validate all existing mappings and authoritative Firebase UID ownership before cutover.'
370
+ );
371
+ return summary;
372
+ } finally {
373
+ log(`Summary: ${JSON.stringify(summary)}`);
374
+ log(
375
+ `Last processed id: ${lastProcessedId ?? '(none)'}${lastProcessedId ? `; resume with --after ${lastProcessedId}` : ''}`
376
+ );
377
+ }
378
+ };
379
+
380
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
381
+ try {
382
+ const { values } = parseArgs({
383
+ args: process.argv.slice(2).filter(arg => arg !== '--'),
384
+ options: {
385
+ apply: { type: 'boolean', default: false },
386
+ email: { type: 'string' },
387
+ limit: { type: 'string' },
388
+ after: { type: 'string' },
389
+ 'link-providers': { type: 'boolean', default: true },
390
+ },
391
+ allowNegative: true,
392
+ strict: true,
393
+ });
394
+ const summary = await provisionKeycloakUsers({
395
+ apply: values.apply,
396
+ email: values.email,
397
+ after: values.after,
398
+ linkProviders: values['link-providers'],
399
+ limit: values.limit === undefined ? undefined : Number(values.limit),
400
+ });
401
+ if (summary.refused || summary.conflicts) process.exitCode = 1;
402
+ } catch (error) {
403
+ // Argument errors are safe to echo; driver/HTTP errors can embed connection
404
+ // strings or credentials, so only their type is shown.
405
+ const code = (error as { code?: unknown }).code;
406
+ const detail =
407
+ typeof code === 'string' && code.startsWith('ERR_PARSE_ARGS') && error instanceof Error
408
+ ? error.message
409
+ : `${error instanceof Error ? error.name : typeof error}${
410
+ typeof code === 'string' ? ` (${code})` : ''
411
+ }`;
412
+ process.stderr.write(
413
+ `Provisioning failed: ${detail}\nCheck arguments, service connectivity and admin permissions; rerun the dry-run before applying.\n`
414
+ );
415
+ process.exitCode = 1;
416
+ } finally {
417
+ await client.close();
418
+ }
419
+ }
@@ -0,0 +1,145 @@
1
+ import type { Context } from 'aws-lambda';
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
3
+ import type { SeedMigrationResult } from './src/types/seed-migration';
4
+
5
+ const mocks = vi.hoisted(() => ({
6
+ connect: vi.fn(),
7
+ close: vi.fn(),
8
+ runBatch: vi.fn(),
9
+ mongodb: {},
10
+ }));
11
+
12
+ vi.mock('./src/mongo', () => ({
13
+ client: { connect: mocks.connect, close: mocks.close },
14
+ mongodb: mocks.mongodb,
15
+ }));
16
+ vi.mock('./src/config/environment', () => ({
17
+ environment: { SA_SEED_ENCRYPT_WRITES: true },
18
+ }));
19
+ vi.mock('./src/migrations/signingAuthoritySeeds', async importOriginal => ({
20
+ ...(await importOriginal<typeof import('./src/migrations/signingAuthoritySeeds')>()),
21
+ runSeedMigrationBatch: mocks.runBatch,
22
+ }));
23
+
24
+ import { handler } from './seedMigrationApp';
25
+ import { SeedMigrationError } from './src/migrations/signingAuthoritySeeds';
26
+
27
+ const result: SeedMigrationResult = {
28
+ phase: 'prepare',
29
+ done: false,
30
+ processed: 1,
31
+ counts: { total: 2, encrypted: 1, legacyOnly: 1, malformed: 0, plaintextRemaining: 2 },
32
+ countsReconciled: false,
33
+ };
34
+
35
+ const context = (): Context => ({
36
+ callbackWaitsForEmptyEventLoop: true,
37
+ functionName: 'seedMigration',
38
+ functionVersion: '$LATEST',
39
+ invokedFunctionArn: 'arn:aws:lambda:us-east-1:123456789012:function:seedMigration',
40
+ memoryLimitInMB: '1024',
41
+ awsRequestId: 'test-request',
42
+ logGroupName: 'test-group',
43
+ logStreamName: 'test-stream',
44
+ getRemainingTimeInMillis: () => 60_000,
45
+ done: vi.fn(),
46
+ fail: vi.fn(),
47
+ succeed: vi.fn(),
48
+ });
49
+
50
+ beforeEach(() => {
51
+ mocks.connect.mockReset().mockResolvedValue(undefined);
52
+ mocks.close.mockReset().mockResolvedValue(undefined);
53
+ mocks.runBatch.mockReset().mockResolvedValue(result);
54
+ vi.spyOn(console, 'error').mockImplementation(() => undefined);
55
+ });
56
+
57
+ afterEach(() => vi.restoreAllMocks());
58
+
59
+ describe('seed migration Lambda connection lifecycle', () => {
60
+ it('waits for cleanup before returning the batch result', async () => {
61
+ let finishClose!: () => void;
62
+ mocks.close.mockReturnValue(new Promise<void>(resolve => (finishClose = resolve)));
63
+ const resolved = vi.fn();
64
+ const lambdaContext = context();
65
+ const invocation = handler({ phase: 'prepare', batchSize: 1 }, lambdaContext).then(
66
+ resolved
67
+ );
68
+
69
+ await vi.waitFor(() => expect(mocks.close).toHaveBeenCalledOnce());
70
+ expect(resolved).not.toHaveBeenCalled();
71
+ expect(mocks.runBatch).toHaveBeenCalledWith(
72
+ mocks.mongodb,
73
+ { phase: 'prepare', batchSize: 1 },
74
+ { encryptedWritesEnabled: true, remainingTime: expect.any(Function) }
75
+ );
76
+ expect(lambdaContext.callbackWaitsForEmptyEventLoop).toBe(false);
77
+
78
+ finishClose();
79
+ await invocation;
80
+ expect(resolved).toHaveBeenCalledWith(result);
81
+ });
82
+
83
+ it('connects and closes for each sequential warm invocation', async () => {
84
+ await handler({ phase: 'prepare' }, context());
85
+ await handler({ phase: 'prepare' }, context());
86
+ expect(mocks.connect).toHaveBeenCalledTimes(2);
87
+ expect(mocks.runBatch).toHaveBeenCalledTimes(2);
88
+ expect(mocks.close).toHaveBeenCalledTimes(2);
89
+ });
90
+
91
+ it('closes after a connection failure without returning connection details', async () => {
92
+ mocks.connect.mockRejectedValue(new Error('mongodb://user:secret@private-host'));
93
+ await expect(handler({ phase: 'prepare' }, context())).rejects.toMatchObject({
94
+ category: 'operation_failed',
95
+ message: 'Signing authority seed migration stopped: operation_failed',
96
+ });
97
+ expect(mocks.runBatch).not.toHaveBeenCalled();
98
+ expect(mocks.close).toHaveBeenCalledOnce();
99
+ });
100
+
101
+ it('closes after an unexpected batch failure without returning query details', async () => {
102
+ mocks.runBatch.mockRejectedValue(new Error('sensitive seed query'));
103
+ await expect(handler({ phase: 'prepare' }, context())).rejects.toMatchObject({
104
+ category: 'operation_failed',
105
+ message: 'Signing authority seed migration stopped: operation_failed',
106
+ });
107
+ expect(mocks.close).toHaveBeenCalledOnce();
108
+ });
109
+
110
+ it('preserves the migration failure when cleanup also fails', async () => {
111
+ mocks.runBatch.mockRejectedValue(new SeedMigrationError('verification_required'));
112
+ mocks.close.mockRejectedValue(new Error('sensitive cleanup detail'));
113
+ await expect(handler({ phase: 'purge' }, context())).rejects.toMatchObject({
114
+ category: 'verification_required',
115
+ });
116
+ expect(mocks.close).toHaveBeenCalledOnce();
117
+ expect(console.error).toHaveBeenCalledExactlyOnceWith({
118
+ event: 'signing_authority_seed_migration_failure',
119
+ operation: 'migration_connection_cleanup',
120
+ category: 'operation_failed',
121
+ });
122
+ });
123
+
124
+ it('sanitizes cleanup errors after an otherwise successful batch', async () => {
125
+ mocks.close.mockRejectedValue(new Error('mongodb://user:secret@private-host'));
126
+ await expect(handler({ phase: 'prepare' }, context())).rejects.toMatchObject({
127
+ category: 'operation_failed',
128
+ message: 'Signing authority seed migration stopped: operation_failed',
129
+ });
130
+ expect(console.error).toHaveBeenCalledExactlyOnceWith({
131
+ event: 'signing_authority_seed_migration_failure',
132
+ operation: 'migration_connection_cleanup',
133
+ category: 'operation_failed',
134
+ });
135
+ });
136
+
137
+ it('rejects invalid requests before touching the database', async () => {
138
+ await expect(handler({ phase: 'unknown' }, context())).rejects.toMatchObject({
139
+ category: 'invalid_request',
140
+ });
141
+ expect(mocks.connect).not.toHaveBeenCalled();
142
+ expect(mocks.runBatch).not.toHaveBeenCalled();
143
+ expect(mocks.close).not.toHaveBeenCalled();
144
+ });
145
+ });
@@ -0,0 +1,50 @@
1
+ import type { Context } from 'aws-lambda';
2
+ import { z } from 'zod';
3
+ import { environment } from './src/config/environment';
4
+ import { client, mongodb } from './src/mongo';
5
+ import { runSeedMigrationBatch, SeedMigrationError } from './src/migrations/signingAuthoritySeeds';
6
+ import type { SeedMigrationResult } from './src/types/seed-migration';
7
+
8
+ const requestValidator = z
9
+ .object({
10
+ phase: z.enum(['dry-run', 'prepare', 'verify', 'purge']),
11
+ batchSize: z.number().int().min(1).max(100).optional(),
12
+ })
13
+ .strict();
14
+
15
+ /** IAM invocation only. Never accept seeds, database URLs, key ARNs, or arbitrary queries in events. */
16
+ export const handler = async (event: unknown, context: Context): Promise<SeedMigrationResult> => {
17
+ const parsed = requestValidator.safeParse(event);
18
+ if (!parsed.success) throw new SeedMigrationError('invalid_request');
19
+ context.callbackWaitsForEmptyEventLoop = false;
20
+ let result: SeedMigrationResult;
21
+ let cleanupFailed = false;
22
+ try {
23
+ await client.connect();
24
+ result = await runSeedMigrationBatch(mongodb, parsed.data, {
25
+ encryptedWritesEnabled: environment.SA_SEED_ENCRYPT_WRITES,
26
+ remainingTime: () => context.getRemainingTimeInMillis(),
27
+ });
28
+ } catch (error) {
29
+ // Mongo connection/lease errors can contain URLs or query values. Never let Lambda serialize them.
30
+ throw new SeedMigrationError(
31
+ error instanceof SeedMigrationError ? error.category : 'operation_failed'
32
+ );
33
+ } finally {
34
+ // This private worker releases its pool between batches, including failed connections.
35
+ // The module-level client reconnects when a warm invocation starts its next batch.
36
+ try {
37
+ await client.close();
38
+ } catch {
39
+ cleanupFailed = true;
40
+ console.error({
41
+ event: 'signing_authority_seed_migration_failure',
42
+ operation: 'migration_connection_cleanup',
43
+ category: 'operation_failed',
44
+ });
45
+ }
46
+ }
47
+ // Reached only after a successful batch; never mask its original failure in finally.
48
+ if (cleanupFailed) throw new SeedMigrationError('operation_failed');
49
+ return result;
50
+ };
@@ -0,0 +1,40 @@
1
+ import type { Context } from 'aws-lambda';
2
+ import { beforeEach, expect, it, vi } from 'vitest';
3
+
4
+ const mocks = vi.hoisted(() => ({ bootstrap: vi.fn(), getApp: vi.fn(), handler: vi.fn() }));
5
+
6
+ vi.mock('@learncard/service-config', () => ({ bootstrapLambda: mocks.bootstrap }));
7
+ vi.mock('./src/config/stageConfig', () => ({ base: {}, stages: { dev: {}, production: {} } }));
8
+ vi.mock('./seedMigrationApp', () => ({ handler: mocks.handler }));
9
+
10
+ beforeEach(() => {
11
+ vi.resetModules();
12
+ vi.clearAllMocks();
13
+ mocks.bootstrap.mockReturnValue(mocks.getApp);
14
+ mocks.getApp.mockResolvedValue({ handler: mocks.handler });
15
+ mocks.handler.mockResolvedValue({ phase: 'prepare', done: true });
16
+ });
17
+
18
+ it('bootstraps the migration app with checked-in stage config and the deploy stage', async () => {
19
+ vi.stubEnv('AWS_LAMBDA_FUNCTION_NAME', 'lca-api-production-seedMigration');
20
+ vi.stubEnv('LAMBDA_STAGE', 'production');
21
+ await import('./seedMigrationLambda');
22
+ expect(mocks.bootstrap).toHaveBeenCalledTimes(1);
23
+ expect(mocks.bootstrap).toHaveBeenCalledWith({
24
+ base: {},
25
+ stages: { dev: {}, production: {} },
26
+ stage: 'production',
27
+ importApp: expect.any(Function),
28
+ });
29
+ vi.unstubAllEnvs();
30
+ });
31
+
32
+ it('resolves the app once per cold start and forwards every argument', async () => {
33
+ const { handler } = await import('./seedMigrationLambda');
34
+ const event = { phase: 'prepare' };
35
+ const context = {} as Context;
36
+ await expect(handler(event, context)).resolves.toEqual({ phase: 'prepare', done: true });
37
+ await handler(event, context);
38
+ expect(mocks.getApp).toHaveBeenCalledTimes(2);
39
+ expect(mocks.handler).toHaveBeenLastCalledWith(event, context);
40
+ });
@@ -0,0 +1,19 @@
1
+ import type { Context } from 'aws-lambda';
2
+ import { bootstrapLambda } from '@learncard/service-config';
3
+
4
+ import { base, stages } from './src/config/stageConfig';
5
+ import type { SeedMigrationResult } from './src/types/seed-migration';
6
+
7
+ type SeedMigrationApp = typeof import('./seedMigrationApp');
8
+
9
+ const getApplication = bootstrapLambda<SeedMigrationApp>({
10
+ base,
11
+ stages,
12
+ stage: process.env.AWS_LAMBDA_FUNCTION_NAME
13
+ ? process.env.LAMBDA_STAGE
14
+ : process.env.CONFIG_STAGE,
15
+ importApp: () => import('./seedMigrationApp'),
16
+ });
17
+
18
+ export const handler = async (event: unknown, context: Context): Promise<SeedMigrationResult> =>
19
+ (await getApplication()).handler(event, context);