@learncard/lca-api-service 1.4.0 → 1.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (191) hide show
  1. package/.env.example +73 -0
  2. package/AGENTS.md +106 -10
  3. package/CHANGELOG.md +52 -0
  4. package/bootstrapLambda.test.ts +76 -0
  5. package/bundle.mjs +9 -1
  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/app.d.ts +67 -3
  13. package/dist/cache/getDel.d.ts +2 -0
  14. package/dist/cache/getDel.test.d.ts +1 -0
  15. package/dist/cache/login-tickets.d.ts +12 -0
  16. package/dist/config/applyDockerStageConfig.d.ts +1 -0
  17. package/dist/config/cacheEnvironment.d.ts +16 -0
  18. package/dist/config/environment.d.ts +101 -0
  19. package/dist/config/escrowBundleMode.test.d.ts +1 -0
  20. package/dist/config/escrowDeployment.test.d.ts +1 -0
  21. package/dist/config/oidcEnvironment.d.ts +74 -0
  22. package/dist/config/serverlessFunctionEnv.test.d.ts +1 -0
  23. package/dist/config/stageConfig.d.ts +62 -0
  24. package/dist/config/stageConfig.test.d.ts +1 -0
  25. package/dist/didkit_wasm_bg.wasm +0 -0
  26. package/dist/docker-entry.d.ts +1 -1
  27. package/dist/helpers/auth.helpers.d.ts +1 -1
  28. package/dist/helpers/escrowCancelLink.d.ts +7 -0
  29. package/dist/helpers/escrowCancelLink.test.d.ts +1 -0
  30. package/dist/helpers/escrowCancelToken.d.ts +4 -0
  31. package/dist/helpers/escrowCancelToken.test.d.ts +1 -0
  32. package/dist/helpers/keycloak.helpers.d.ts +23 -0
  33. package/dist/helpers/login-ticket.helpers.d.ts +31 -0
  34. package/dist/helpers/login-ticket.helpers.test.d.ts +1 -0
  35. package/dist/helpers/oidc.helpers.d.ts +47 -0
  36. package/dist/helpers/rate-limit.helpers.d.ts +6 -0
  37. package/dist/helpers/rate-limit.helpers.test.d.ts +1 -0
  38. package/dist/helpers/social-token.helpers.d.ts +19 -0
  39. package/dist/index.d.ts +1 -0
  40. package/dist/index.js +2 -2
  41. package/dist/jobs/escrowBlobRewrap.d.ts +9 -0
  42. package/dist/jobs/escrowHoldReminders.d.ts +12 -0
  43. package/dist/models/AuthSubject.d.ts +20 -0
  44. package/dist/models/EscrowHold.d.ts +79 -1
  45. package/dist/models/Notification.d.ts +5 -0
  46. package/dist/models/UserKey.d.ts +11 -1
  47. package/dist/models/index.d.ts +1 -0
  48. package/dist/oidc.d.ts +5 -0
  49. package/dist/publish-metafile.json +1 -0
  50. package/dist/routes/auth.d.ts +31 -0
  51. package/dist/routes/escrow.d.ts +18 -4
  52. package/dist/routes/keys.d.ts +1 -0
  53. package/dist/routes/notifications.d.ts +5 -0
  54. package/dist/routes/signing-authority.d.ts +14 -0
  55. package/dist/services/escrow-enclave/blobIdentity.d.ts +10 -0
  56. package/dist/services/escrow-enclave/blobIdentity.test.d.ts +1 -0
  57. package/dist/services/escrow-enclave/cancellation.d.ts +3 -0
  58. package/dist/services/escrow-enclave/enclaveTrustAnchors.d.ts +1 -0
  59. package/dist/services/escrow-enclave/index.d.ts +16 -0
  60. package/dist/services/escrow-enclave/notifications.d.ts +19 -4
  61. package/dist/services/escrow-enclave/notifications.test.d.ts +1 -0
  62. package/dist/services/escrow-enclave/production.test.d.ts +1 -0
  63. package/dist/services/escrow-enclave/remoteEnclave.contract.test.d.ts +1 -0
  64. package/dist/services/escrow-enclave/remoteEnclave.d.ts +20 -0
  65. package/dist/services/escrow-enclave/remoteEnclave.test.d.ts +1 -0
  66. package/dist/services/escrow-enclave/remoteEnclave.tls.test.d.ts +1 -0
  67. package/dist/services/escrow-enclave/softwareEnclave.d.ts +12 -2
  68. package/dist/services/escrow-enclave/types.d.ts +52 -8
  69. package/dist/types/notifications.d.ts +5 -0
  70. package/esbuildPlugins.cjs +1 -0
  71. package/lambda.ts +27 -117
  72. package/lambdaApp.ts +146 -0
  73. package/oidcLambda.test.ts +38 -0
  74. package/oidcLambda.ts +18 -0
  75. package/oidcLambdaApp.ts +11 -0
  76. package/package.json +22 -13
  77. package/project.json +8 -1
  78. package/scripts/README.md +88 -0
  79. package/scripts/check-seed-encryption-infrastructure.mjs +18 -2
  80. package/scripts/escrow-blob-mode-report.test.ts +227 -0
  81. package/scripts/escrow-blob-mode-report.ts +465 -0
  82. package/scripts/keycloak-admin.ts +108 -0
  83. package/scripts/keycloak-provider-links.ts +147 -0
  84. package/scripts/print-seed-rollout-flags.ts +21 -0
  85. package/scripts/provision-keycloak-users.ts +419 -0
  86. package/seedMigrationApp.test.ts +145 -0
  87. package/seedMigrationApp.ts +50 -0
  88. package/seedMigrationLambda.test.ts +30 -135
  89. package/seedMigrationLambda.ts +15 -46
  90. package/serverless-local.yml +25 -1
  91. package/serverless.function-env.cjs +58 -0
  92. package/serverless.yml +174 -37
  93. package/src/accesslayer/notifications/index.ts +9 -2
  94. package/src/accesslayer/signing-authority/index.ts +4 -1
  95. package/src/app.ts +2 -0
  96. package/src/cache/getDel.test.ts +34 -0
  97. package/src/cache/getDel.ts +32 -0
  98. package/src/cache/index.ts +1 -1
  99. package/src/cache/login-tickets.ts +26 -0
  100. package/src/config/applyDockerStageConfig.ts +6 -0
  101. package/src/config/cacheEnvironment.ts +34 -0
  102. package/src/config/environment.test.ts +45 -2
  103. package/src/config/environment.ts +90 -23
  104. package/src/config/escrowBundleMode.test.ts +52 -0
  105. package/src/config/escrowDeployment.test.ts +114 -0
  106. package/src/config/oidcEnvironment.ts +59 -0
  107. package/src/config/serverlessFunctionEnv.test.ts +98 -0
  108. package/src/config/stageConfig.test.ts +26 -0
  109. package/src/config/stageConfig.ts +24 -0
  110. package/src/docker-entry.ts +9 -0
  111. package/src/helpers/auth.helpers.ts +68 -30
  112. package/src/helpers/escrowCancelLink.test.ts +60 -0
  113. package/src/helpers/escrowCancelLink.ts +38 -0
  114. package/src/helpers/escrowCancelToken.test.ts +105 -0
  115. package/src/helpers/escrowCancelToken.ts +47 -0
  116. package/src/helpers/keycloak.helpers.ts +120 -0
  117. package/src/helpers/login-ticket.helpers.test.ts +45 -0
  118. package/src/helpers/login-ticket.helpers.ts +91 -0
  119. package/src/helpers/oidc.helpers.ts +354 -0
  120. package/src/helpers/rate-limit.helpers.test.ts +61 -0
  121. package/src/helpers/rate-limit.helpers.ts +73 -0
  122. package/src/helpers/social-token.helpers.ts +144 -0
  123. package/src/index.ts +1 -0
  124. package/src/jobs/escrowBlobRewrap.ts +121 -0
  125. package/src/jobs/escrowHoldReminders.ts +177 -0
  126. package/src/models/AuthSubject.ts +147 -0
  127. package/src/models/EscrowHold.ts +321 -5
  128. package/src/models/UserKey.ts +91 -6
  129. package/src/models/index.ts +1 -0
  130. package/src/oidc.ts +371 -0
  131. package/src/routes/auth.ts +173 -0
  132. package/src/routes/escrow.ts +281 -34
  133. package/src/routes/keys.ts +5 -0
  134. package/src/routes/signing-authority.ts +39 -1
  135. package/src/services/delivery/adapters/postmark.adapter.ts +43 -1
  136. package/src/services/escrow-enclave/README.md +81 -10
  137. package/src/services/escrow-enclave/__fixtures__/tls/pinned-ca.pem +11 -0
  138. package/src/services/escrow-enclave/__fixtures__/tls/pinned-leaf.key +5 -0
  139. package/src/services/escrow-enclave/__fixtures__/tls/pinned-leaf.pem +12 -0
  140. package/src/services/escrow-enclave/__fixtures__/tls/rogue-ca.pem +11 -0
  141. package/src/services/escrow-enclave/__fixtures__/tls/rogue-leaf.key +5 -0
  142. package/src/services/escrow-enclave/__fixtures__/tls/rogue-leaf.pem +12 -0
  143. package/src/services/escrow-enclave/blobIdentity.test.ts +42 -0
  144. package/src/services/escrow-enclave/blobIdentity.ts +55 -0
  145. package/src/services/escrow-enclave/cancellation.ts +59 -0
  146. package/src/services/escrow-enclave/enclaveTrustAnchors.ts +34 -0
  147. package/src/services/escrow-enclave/index.ts +145 -7
  148. package/src/services/escrow-enclave/notifications.test.ts +377 -0
  149. package/src/services/escrow-enclave/notifications.ts +340 -6
  150. package/src/services/escrow-enclave/production.test.ts +34 -0
  151. package/src/services/escrow-enclave/remoteEnclave.contract.test.ts +344 -0
  152. package/src/services/escrow-enclave/remoteEnclave.test.ts +490 -0
  153. package/src/services/escrow-enclave/remoteEnclave.tls.test.ts +62 -0
  154. package/src/services/escrow-enclave/remoteEnclave.ts +263 -0
  155. package/src/services/escrow-enclave/softwareEnclave.test.ts +231 -30
  156. package/src/services/escrow-enclave/softwareEnclave.ts +97 -11
  157. package/src/services/escrow-enclave/types.ts +63 -17
  158. package/test/auth-tickets.spec.ts +480 -0
  159. package/test/helpers/keycloak-broker.ts +181 -0
  160. package/test/helpers/live-mongo.ts +9 -0
  161. package/test/jobs/escrowBlobRewrap.spec.ts +262 -0
  162. package/test/jobs/escrowHoldReminders.spec.ts +319 -0
  163. package/test/keycloak-admin.spec.ts +58 -0
  164. package/test/keycloak-broker-roundtrip.integration.spec.ts +40 -0
  165. package/test/keycloak-migration.integration.spec.ts +266 -0
  166. package/test/keycloak-provider-links.spec.ts +201 -0
  167. package/test/keycloak-social-broker.integration.spec.ts +228 -0
  168. package/test/keycloak-verify.integration.spec.ts +100 -0
  169. package/test/keycloak-verify.spec.ts +307 -0
  170. package/test/keys-escrow.spec.ts +963 -24
  171. package/test/models/authSubjectIndexes.spec.ts +132 -0
  172. package/test/models/escrow.spec.ts +375 -13
  173. package/test/notifications.spec.ts +4 -0
  174. package/test/oidc-e2e/broker.spec.ts +116 -0
  175. package/test/oidc-e2e/compose.yaml +33 -0
  176. package/test/oidc-e2e/fixtures.ts +245 -0
  177. package/test/oidc-e2e/lock.test.mjs +73 -0
  178. package/test/oidc-e2e/playwright.config.ts +21 -0
  179. package/test/oidc-e2e/run.mjs +312 -0
  180. package/test/oidc-e2e/tsconfig.json +10 -0
  181. package/test/oidc-e2e/types.ts +21 -0
  182. package/test/oidc.integration.spec.ts +54 -0
  183. package/test/oidc.spec.ts +697 -0
  184. package/test/oidcLambdaApp.boot.spec.ts +105 -0
  185. package/test/provision-keycloak-users.spec.ts +299 -0
  186. package/test/signing-authority.spec.ts +50 -0
  187. package/tsconfig.build.json +1 -1
  188. package/tsconfig.json +1 -0
  189. package/vitest.config.ts +24 -1
  190. package/vitest.integration.config.ts +20 -3
  191. package/serverless.escrow-env.cjs +0 -20
@@ -0,0 +1,147 @@
1
+ import { setTimeout } from 'node:timers/promises';
2
+ import { KeycloakAdminError, type createKeycloakAdmin } from './keycloak-admin';
3
+
4
+ export interface FirebaseProvider {
5
+ providerId: string;
6
+ uid: string;
7
+ email?: string;
8
+ }
9
+
10
+ export interface FirebaseUser {
11
+ uid: string;
12
+ disabled: boolean;
13
+ emailVerified: boolean;
14
+ email?: string;
15
+ providerData: FirebaseProvider[];
16
+ }
17
+
18
+ export interface FirebaseReader {
19
+ getUsers: (identifiers: { uid: string }[]) => Promise<{ users: FirebaseUser[] }>;
20
+ }
21
+
22
+ export interface ProviderLinkSummary {
23
+ linked: number;
24
+ alreadyLinked: number;
25
+ conflicts: number;
26
+ noSocialProvider: number;
27
+ wouldLink: number;
28
+ }
29
+
30
+ /** Firebase accepts at most 100 identifiers per call; keep requests sequential and paced. */
31
+ export const fetchFirebaseUsers = async (
32
+ uids: string[],
33
+ firebase: FirebaseReader,
34
+ sleep: (ms: number) => Promise<void> = setTimeout
35
+ ): Promise<Map<string, FirebaseUser>> => {
36
+ const unique = [...new Set(uids)];
37
+ const users = new Map<string, FirebaseUser>();
38
+ for (let offset = 0; offset < unique.length; offset += 100) {
39
+ if (offset) await sleep(1000);
40
+ const identifiers = unique.slice(offset, offset + 100).map(uid => ({ uid }));
41
+ for (let attempt = 0; ; attempt++) {
42
+ try {
43
+ const result = await firebase.getUsers(identifiers);
44
+ for (const user of result.users) users.set(user.uid, user);
45
+ break;
46
+ } catch (error) {
47
+ const code =
48
+ typeof error === 'object' && error !== null && 'code' in error
49
+ ? error.code
50
+ : undefined;
51
+ if (
52
+ attempt >= 3 ||
53
+ (code !== 'auth/too-many-requests' && code !== 'auth/quota-exceeded')
54
+ ) {
55
+ throw error;
56
+ }
57
+ await sleep(1000 * 2 ** attempt);
58
+ }
59
+ }
60
+ }
61
+ return users;
62
+ };
63
+
64
+ /** Read-only preflight: reject the whole user before adding any provider links. */
65
+ export const findProviderLinkConflicts = (
66
+ providers: FirebaseProvider[],
67
+ links: { identityProvider: string; userId: string }[]
68
+ ): string[] =>
69
+ ['google', 'apple'].filter(alias => {
70
+ const matches = providers.filter(provider => provider.providerId === `${alias}.com`);
71
+ if (!matches.length) return false;
72
+ const existing = links.find(link => link.identityProvider === alias);
73
+ return (
74
+ matches.some(provider => !provider.uid) ||
75
+ new Set(matches.map(provider => provider.uid)).size > 1 ||
76
+ Boolean(existing && existing.userId !== matches[0]!.uid)
77
+ );
78
+ });
79
+
80
+ /** Only add identities; never remove or reassign a provider link, even on conflicts. */
81
+ export const linkFirebaseProviders = async ({
82
+ userId,
83
+ providers,
84
+ apply,
85
+ admin,
86
+ log,
87
+ summary = { linked: 0, alreadyLinked: 0, conflicts: 0, noSocialProvider: 0, wouldLink: 0 },
88
+ }: {
89
+ userId?: string;
90
+ providers: FirebaseProvider[];
91
+ apply: boolean;
92
+ admin: Pick<Awaited<ReturnType<typeof createKeycloakAdmin>>, 'links' | 'request'>;
93
+ log: (message: string) => void;
94
+ summary?: ProviderLinkSummary;
95
+ }): Promise<ProviderLinkSummary> => {
96
+ const social = providers.filter(
97
+ provider => provider.providerId === 'google.com' || provider.providerId === 'apple.com'
98
+ );
99
+ if (!social.length) {
100
+ summary.noSocialProvider++;
101
+ return summary;
102
+ }
103
+ const links = userId ? await admin.links(userId) : [];
104
+ const conflicts = findProviderLinkConflicts(providers, links);
105
+ if (conflicts.length) {
106
+ summary.conflicts += conflicts.length;
107
+ for (const alias of conflicts)
108
+ log(`CONFLICT: ${alias} identity differs; manual review required`);
109
+ return summary;
110
+ }
111
+ for (const alias of ['google', 'apple']) {
112
+ const matches = social.filter(provider => provider.providerId === `${alias}.com`);
113
+ if (!matches.length) continue;
114
+ const provider = matches[0]!;
115
+ const existing = links.find(link => link.identityProvider === alias);
116
+ if (existing) {
117
+ summary.alreadyLinked++;
118
+ continue;
119
+ }
120
+ if (!apply) {
121
+ summary.wouldLink++;
122
+ log(`PLAN: link ${alias} (ownership conflicts checked on apply)`);
123
+ continue;
124
+ }
125
+ if (!userId) throw new Error('A provisioned Keycloak user is required for linking');
126
+ try {
127
+ await admin.request(
128
+ `/users/${encodeURIComponent(userId)}/federated-identity/${alias}`,
129
+ {
130
+ method: 'POST',
131
+ body: JSON.stringify({
132
+ identityProvider: alias,
133
+ userId: provider.uid,
134
+ userName: provider.email || provider.uid,
135
+ }),
136
+ }
137
+ );
138
+ summary.linked++;
139
+ log(`Linked ${alias}`);
140
+ } catch (error) {
141
+ if (!(error instanceof KeycloakAdminError) || error.status !== 409) throw error;
142
+ summary.conflicts++;
143
+ log(`CONFLICT: ${alias} identity already linked; manual review required`);
144
+ }
145
+ }
146
+ return summary;
147
+ };
@@ -0,0 +1,21 @@
1
+ // Prints the signing-authority seed rollout flags a deployment loads from its checked-in
2
+ // config.<tenant>.<stage>.json. Usage: CONFIG_TENANT=scouts bun run sa-seed:flags production
3
+ import { resolveStageDefaults } from '@learncard/service-config';
4
+
5
+ import { base, stages } from '../src/config/stageConfig';
6
+
7
+ const stage = process.argv[2];
8
+ if (!stage)
9
+ throw new Error(
10
+ 'Usage: bun run sa-seed:flags <dev|production> (CONFIG_TENANT selects the tenant)'
11
+ );
12
+
13
+ const resolved = resolveStageDefaults({ base, stages, stage, env: process.env });
14
+ console.log(
15
+ JSON.stringify({
16
+ tenant: process.env.CONFIG_TENANT || 'learncard',
17
+ stage,
18
+ SA_SEED_ENCRYPT_WRITES: resolved.SA_SEED_ENCRYPT_WRITES ?? '(schema default)',
19
+ SA_SEED_ALLOW_LEGACY_READ: resolved.SA_SEED_ALLOW_LEGACY_READ ?? '(schema default)',
20
+ })
21
+ );
@@ -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
+ }