@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,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
+ };
@@ -1,145 +1,40 @@
1
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';
2
+ import { beforeEach, expect, it, vi } from 'vitest';
4
3
 
5
- const mocks = vi.hoisted(() => ({
6
- connect: vi.fn(),
7
- close: vi.fn(),
8
- runBatch: vi.fn(),
9
- mongodb: {},
10
- }));
4
+ const mocks = vi.hoisted(() => ({ bootstrap: vi.fn(), getApp: vi.fn(), handler: vi.fn() }));
11
5
 
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 './seedMigrationLambda';
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
- });
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 }));
49
9
 
50
10
  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);
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 });
55
16
  });
56
17
 
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
- });
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),
135
28
  });
29
+ vi.unstubAllEnvs();
30
+ });
136
31
 
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
- });
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);
145
40
  });
@@ -1,50 +1,19 @@
1
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';
2
+ import { bootstrapLambda } from '@learncard/service-config';
3
+
4
+ import { base, stages } from './src/config/stageConfig';
6
5
  import type { SeedMigrationResult } from './src/types/seed-migration';
7
6
 
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();
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
+ });
14
17
 
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
- };
18
+ export const handler = async (event: unknown, context: Context): Promise<SeedMigrationResult> =>
19
+ (await getApplication()).handler(event, context);
@@ -36,7 +36,8 @@ provider:
36
36
  stage: ${opt:stage, "dev"}
37
37
  region: ${opt:region, "us-east-1"}
38
38
  environment:
39
- LAMBDA_STAGE: ${opt:stage, "dev"}
39
+ # serverless-offline sets AWS_LAMBDA_FUNCTION_NAME; stage config stays opt-in locally.
40
+ LAMBDA_STAGE: ${env:CONFIG_STAGE, ''}
40
41
  SEED: ${env:SEED}
41
42
  SA_SEED_LOCAL_KEK: ${env:SA_SEED_LOCAL_KEK, ''}
42
43
  SA_SEED_ENCRYPT_WRITES: ${env:SA_SEED_ENCRYPT_WRITES, 'false'}
@@ -54,6 +55,16 @@ provider:
54
55
  MONGO_DB_NAME: ${env:MONGO_DB_NAME}
55
56
  AUTHORIZED_DIDS: ${env:AUTHORIZED_DIDS}
56
57
  GOOGLE_APPLICATION_CREDENTIAL: ${env:GOOGLE_APPLICATION_CREDENTIAL, ""}
58
+ KEYCLOAK_ISSUERS: ${env:KEYCLOAK_ISSUERS, ""}
59
+ KEYCLOAK_AUDIENCES: ${env:KEYCLOAK_AUDIENCES, ""}
60
+ KEYCLOAK_JWKS_URL_OVERRIDES: ${env:KEYCLOAK_JWKS_URL_OVERRIDES, ""}
61
+ OIDC_ISSUER: ${env:OIDC_ISSUER, ""}
62
+ OIDC_SIGNING_KEY_JWK: ${env:OIDC_SIGNING_KEY_JWK, ""}
63
+ OIDC_CLIENT_ID: ${env:OIDC_CLIENT_ID, ""}
64
+ OIDC_CLIENT_SECRET: ${env:OIDC_CLIENT_SECRET, ""}
65
+ OIDC_REDIRECT_URIS: ${env:OIDC_REDIRECT_URIS, ""}
66
+ GOOGLE_OAUTH_CLIENT_IDS: ${env:GOOGLE_OAUTH_CLIENT_IDS, ""}
67
+ APPLE_OAUTH_CLIENT_IDS: ${env:APPLE_OAUTH_CLIENT_IDS, ""}
57
68
  REDIS_HOST:
58
69
  'Fn::GetAtt': [ElasticCacheCluster, RedisEndpoint.Address]
59
70
  REDIS_PORT:
@@ -101,6 +112,17 @@ functions:
101
112
  url:
102
113
  cors: true
103
114
 
115
+ oidc:
116
+ handler: lambda.oidcHandler
117
+ vpc:
118
+ securityGroupIds:
119
+ - 'Fn::GetAtt': [ServerlessSecurityGroup, GroupId]
120
+ subnetIds:
121
+ - Ref: PrivateSubnetA
122
+ events:
123
+ - httpApi: GET /.well-known/openid-configuration
124
+ - httpApi: ANY /oidc/{proxy+}
125
+
104
126
  swagger:
105
127
  handler: lambda.swaggerUiHandler
106
128
  package:
@@ -226,6 +248,8 @@ resources:
226
248
  Properties:
227
249
  AutoMinorVersionUpgrade: true
228
250
  Engine: redis
251
+ # GETDEL (login codes/tickets/auth codes) needs Redis >= 6.2
252
+ EngineVersion: '7.0'
229
253
  CacheNodeType: ${self:custom.config.CACHE_INSTANCE_SIZE}
230
254
  NumCacheNodes: 1
231
255
  VpcSecurityGroupIds:
@@ -0,0 +1,58 @@
1
+ const { functionEnvironment, pickNonEmpty } = require('@learncard/service-config/function-env.cjs');
2
+
3
+ const PROVIDER_SECRETS = [
4
+ 'SEED',
5
+ 'MONGO_URI',
6
+ 'MONGO_DB_NAME',
7
+ 'AUTHORIZED_DIDS',
8
+ 'METABASE_SECRET_KEY',
9
+ 'OPENAI_API_KEY',
10
+ 'POSTMARK_SERVER_TOKEN',
11
+ 'SCOUTS_SSO_CLIENT_SECRET',
12
+ 'SENTRY_DSN',
13
+ 'LEARN_CLOUD_URL',
14
+ ];
15
+
16
+ const API_SECRETS = [
17
+ 'GOOGLE_APPLICATION_CREDENTIAL',
18
+ 'POSTHOG_API_KEY',
19
+ 'ESCROW_RELAY_URL',
20
+ 'ESCROW_RELAY_AUTH_TOKEN',
21
+ 'ESCROW_ENCLAVE_SOFTWARE_PRIVATE_KEYS_JSON',
22
+ 'ESCROW_ENCLAVE_REMOTE_URL',
23
+ 'ESCROW_ENCLAVE_REMOTE_TOKEN',
24
+ 'KEYCLOAK_JWKS_URL_OVERRIDES',
25
+ ];
26
+
27
+ // Non-secret operator toggles forwarded in bundle mode too, so enabling RUNTIME_SECRETS_ID
28
+ // can never silently change them: escrow stays off unless ESCROW_ENCLAVE_MODE is set, and
29
+ // ESCROW_RELEASE_KILL_SWITCH must be flippable without editing the bundle.
30
+ const API_TOGGLES = ['ESCROW_ENCLAVE_MODE', 'ESCROW_RELEASE_KILL_SWITCH'];
31
+
32
+ // Serverless v3 supplies CLI options to file functions and preserves CF intrinsics.
33
+ // No stage-file values are forwarded from the deployment environment.
34
+ exports.provider = ({ options = {} } = {}) => ({
35
+ LAMBDA_STAGE: options.stage || 'dev',
36
+ // Non-secret product selector for the shared stage files (config.<tenant>.<stage>.json).
37
+ CONFIG_TENANT: process.env.CONFIG_TENANT || 'learncard',
38
+ PORT: String(options.httpPort || '3000'),
39
+ SA_SEED_KMS_KEY_ARN: { 'Fn::GetAtt': ['SigningAuthoritySeedKey', 'Arn'] },
40
+ REDIS_HOST: { 'Fn::GetAtt': ['ElasticCacheCluster', 'RedisEndpoint.Address'] },
41
+ REDIS_PORT: { 'Fn::GetAtt': ['ElasticCacheCluster', 'RedisEndpoint.Port'] },
42
+ ...functionEnvironment({ always: [], fallback: PROVIDER_SECRETS }),
43
+ });
44
+
45
+ // API/migration and the default-role didWeb/swagger functions get the bundle pointer.
46
+ // Reading the bundle does not grant the default role signing-authority KMS access.
47
+ exports.api = () =>
48
+ functionEnvironment({
49
+ always: ['RUNTIME_SECRETS_ID', ...API_TOGGLES],
50
+ fallback: API_SECRETS,
51
+ });
52
+
53
+ // Scheduled escrow jobs load the full app (Mongo, Postmark, seed) through lambda.ts, so they
54
+ // need the same bundle pointer and escrow settings as the API functions.
55
+ exports.escrow = exports.api;
56
+
57
+ // OIDC has its own signing-key secret and cannot read the runtime bundle.
58
+ exports.oidc = () => pickNonEmpty(['OIDC_CLIENT_SECRET', 'OIDC_REDIRECT_URIS']);