@learncard/lca-api-service 1.4.0 → 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 (188) hide show
  1. package/.env.example +73 -0
  2. package/AGENTS.md +106 -10
  3. package/CHANGELOG.md +43 -0
  4. package/bootstrapLambda.test.ts +76 -0
  5. package/config/config.dev.json +23 -0
  6. package/config/config.example.json +22 -0
  7. package/config/config.json +1 -0
  8. package/config/config.production.json +12 -0
  9. package/config/config.scouts.dev.json +11 -0
  10. package/config/config.scouts.production.json +11 -0
  11. package/dist/app.d.ts +67 -3
  12. package/dist/cache/getDel.d.ts +2 -0
  13. package/dist/cache/getDel.test.d.ts +1 -0
  14. package/dist/cache/login-tickets.d.ts +12 -0
  15. package/dist/config/applyDockerStageConfig.d.ts +1 -0
  16. package/dist/config/cacheEnvironment.d.ts +16 -0
  17. package/dist/config/environment.d.ts +101 -0
  18. package/dist/config/escrowBundleMode.test.d.ts +1 -0
  19. package/dist/config/escrowDeployment.test.d.ts +1 -0
  20. package/dist/config/oidcEnvironment.d.ts +74 -0
  21. package/dist/config/serverlessFunctionEnv.test.d.ts +1 -0
  22. package/dist/config/stageConfig.d.ts +62 -0
  23. package/dist/config/stageConfig.test.d.ts +1 -0
  24. package/dist/didkit_wasm_bg.wasm +0 -0
  25. package/dist/docker-entry.d.ts +1 -1
  26. package/dist/helpers/auth.helpers.d.ts +1 -1
  27. package/dist/helpers/escrowCancelLink.d.ts +7 -0
  28. package/dist/helpers/escrowCancelLink.test.d.ts +1 -0
  29. package/dist/helpers/escrowCancelToken.d.ts +4 -0
  30. package/dist/helpers/escrowCancelToken.test.d.ts +1 -0
  31. package/dist/helpers/keycloak.helpers.d.ts +23 -0
  32. package/dist/helpers/login-ticket.helpers.d.ts +31 -0
  33. package/dist/helpers/login-ticket.helpers.test.d.ts +1 -0
  34. package/dist/helpers/oidc.helpers.d.ts +47 -0
  35. package/dist/helpers/rate-limit.helpers.d.ts +6 -0
  36. package/dist/helpers/rate-limit.helpers.test.d.ts +1 -0
  37. package/dist/helpers/social-token.helpers.d.ts +19 -0
  38. package/dist/index.d.ts +1 -0
  39. package/dist/index.js +2 -2
  40. package/dist/jobs/escrowBlobRewrap.d.ts +9 -0
  41. package/dist/jobs/escrowHoldReminders.d.ts +12 -0
  42. package/dist/models/AuthSubject.d.ts +20 -0
  43. package/dist/models/EscrowHold.d.ts +79 -1
  44. package/dist/models/Notification.d.ts +5 -0
  45. package/dist/models/UserKey.d.ts +11 -1
  46. package/dist/models/index.d.ts +1 -0
  47. package/dist/oidc.d.ts +5 -0
  48. package/dist/routes/auth.d.ts +31 -0
  49. package/dist/routes/escrow.d.ts +18 -4
  50. package/dist/routes/keys.d.ts +1 -0
  51. package/dist/routes/notifications.d.ts +5 -0
  52. package/dist/routes/signing-authority.d.ts +14 -0
  53. package/dist/services/escrow-enclave/blobIdentity.d.ts +10 -0
  54. package/dist/services/escrow-enclave/blobIdentity.test.d.ts +1 -0
  55. package/dist/services/escrow-enclave/cancellation.d.ts +3 -0
  56. package/dist/services/escrow-enclave/enclaveTrustAnchors.d.ts +1 -0
  57. package/dist/services/escrow-enclave/index.d.ts +16 -0
  58. package/dist/services/escrow-enclave/notifications.d.ts +19 -4
  59. package/dist/services/escrow-enclave/notifications.test.d.ts +1 -0
  60. package/dist/services/escrow-enclave/production.test.d.ts +1 -0
  61. package/dist/services/escrow-enclave/remoteEnclave.contract.test.d.ts +1 -0
  62. package/dist/services/escrow-enclave/remoteEnclave.d.ts +20 -0
  63. package/dist/services/escrow-enclave/remoteEnclave.test.d.ts +1 -0
  64. package/dist/services/escrow-enclave/remoteEnclave.tls.test.d.ts +1 -0
  65. package/dist/services/escrow-enclave/softwareEnclave.d.ts +12 -2
  66. package/dist/services/escrow-enclave/types.d.ts +52 -8
  67. package/dist/types/notifications.d.ts +5 -0
  68. package/esbuildPlugins.cjs +1 -0
  69. package/lambda.ts +27 -117
  70. package/lambdaApp.ts +146 -0
  71. package/oidcLambda.test.ts +38 -0
  72. package/oidcLambda.ts +18 -0
  73. package/oidcLambdaApp.ts +11 -0
  74. package/package.json +23 -13
  75. package/scripts/README.md +88 -0
  76. package/scripts/check-seed-encryption-infrastructure.mjs +18 -2
  77. package/scripts/escrow-blob-mode-report.test.ts +227 -0
  78. package/scripts/escrow-blob-mode-report.ts +465 -0
  79. package/scripts/keycloak-admin.ts +108 -0
  80. package/scripts/keycloak-provider-links.ts +147 -0
  81. package/scripts/print-seed-rollout-flags.ts +21 -0
  82. package/scripts/provision-keycloak-users.ts +419 -0
  83. package/seedMigrationApp.test.ts +145 -0
  84. package/seedMigrationApp.ts +50 -0
  85. package/seedMigrationLambda.test.ts +30 -135
  86. package/seedMigrationLambda.ts +15 -46
  87. package/serverless-local.yml +25 -1
  88. package/serverless.function-env.cjs +58 -0
  89. package/serverless.yml +172 -37
  90. package/src/accesslayer/notifications/index.ts +9 -2
  91. package/src/accesslayer/signing-authority/index.ts +4 -1
  92. package/src/app.ts +2 -0
  93. package/src/cache/getDel.test.ts +34 -0
  94. package/src/cache/getDel.ts +32 -0
  95. package/src/cache/index.ts +1 -1
  96. package/src/cache/login-tickets.ts +26 -0
  97. package/src/config/applyDockerStageConfig.ts +6 -0
  98. package/src/config/cacheEnvironment.ts +34 -0
  99. package/src/config/environment.test.ts +45 -2
  100. package/src/config/environment.ts +90 -23
  101. package/src/config/escrowBundleMode.test.ts +52 -0
  102. package/src/config/escrowDeployment.test.ts +114 -0
  103. package/src/config/oidcEnvironment.ts +59 -0
  104. package/src/config/serverlessFunctionEnv.test.ts +98 -0
  105. package/src/config/stageConfig.test.ts +26 -0
  106. package/src/config/stageConfig.ts +24 -0
  107. package/src/docker-entry.ts +9 -0
  108. package/src/helpers/auth.helpers.ts +68 -30
  109. package/src/helpers/escrowCancelLink.test.ts +60 -0
  110. package/src/helpers/escrowCancelLink.ts +38 -0
  111. package/src/helpers/escrowCancelToken.test.ts +105 -0
  112. package/src/helpers/escrowCancelToken.ts +47 -0
  113. package/src/helpers/keycloak.helpers.ts +120 -0
  114. package/src/helpers/login-ticket.helpers.test.ts +45 -0
  115. package/src/helpers/login-ticket.helpers.ts +91 -0
  116. package/src/helpers/oidc.helpers.ts +354 -0
  117. package/src/helpers/rate-limit.helpers.test.ts +61 -0
  118. package/src/helpers/rate-limit.helpers.ts +73 -0
  119. package/src/helpers/social-token.helpers.ts +144 -0
  120. package/src/index.ts +1 -0
  121. package/src/jobs/escrowBlobRewrap.ts +121 -0
  122. package/src/jobs/escrowHoldReminders.ts +177 -0
  123. package/src/models/AuthSubject.ts +147 -0
  124. package/src/models/EscrowHold.ts +321 -5
  125. package/src/models/UserKey.ts +91 -6
  126. package/src/models/index.ts +1 -0
  127. package/src/oidc.ts +371 -0
  128. package/src/routes/auth.ts +173 -0
  129. package/src/routes/escrow.ts +281 -34
  130. package/src/routes/keys.ts +5 -0
  131. package/src/routes/signing-authority.ts +39 -1
  132. package/src/services/delivery/adapters/postmark.adapter.ts +43 -1
  133. package/src/services/escrow-enclave/README.md +81 -10
  134. package/src/services/escrow-enclave/__fixtures__/tls/pinned-ca.pem +11 -0
  135. package/src/services/escrow-enclave/__fixtures__/tls/pinned-leaf.key +5 -0
  136. package/src/services/escrow-enclave/__fixtures__/tls/pinned-leaf.pem +12 -0
  137. package/src/services/escrow-enclave/__fixtures__/tls/rogue-ca.pem +11 -0
  138. package/src/services/escrow-enclave/__fixtures__/tls/rogue-leaf.key +5 -0
  139. package/src/services/escrow-enclave/__fixtures__/tls/rogue-leaf.pem +12 -0
  140. package/src/services/escrow-enclave/blobIdentity.test.ts +42 -0
  141. package/src/services/escrow-enclave/blobIdentity.ts +55 -0
  142. package/src/services/escrow-enclave/cancellation.ts +59 -0
  143. package/src/services/escrow-enclave/enclaveTrustAnchors.ts +34 -0
  144. package/src/services/escrow-enclave/index.ts +145 -7
  145. package/src/services/escrow-enclave/notifications.test.ts +377 -0
  146. package/src/services/escrow-enclave/notifications.ts +340 -6
  147. package/src/services/escrow-enclave/production.test.ts +34 -0
  148. package/src/services/escrow-enclave/remoteEnclave.contract.test.ts +344 -0
  149. package/src/services/escrow-enclave/remoteEnclave.test.ts +490 -0
  150. package/src/services/escrow-enclave/remoteEnclave.tls.test.ts +62 -0
  151. package/src/services/escrow-enclave/remoteEnclave.ts +263 -0
  152. package/src/services/escrow-enclave/softwareEnclave.test.ts +231 -30
  153. package/src/services/escrow-enclave/softwareEnclave.ts +97 -11
  154. package/src/services/escrow-enclave/types.ts +63 -17
  155. package/test/auth-tickets.spec.ts +480 -0
  156. package/test/helpers/keycloak-broker.ts +181 -0
  157. package/test/helpers/live-mongo.ts +9 -0
  158. package/test/jobs/escrowBlobRewrap.spec.ts +262 -0
  159. package/test/jobs/escrowHoldReminders.spec.ts +319 -0
  160. package/test/keycloak-admin.spec.ts +58 -0
  161. package/test/keycloak-broker-roundtrip.integration.spec.ts +40 -0
  162. package/test/keycloak-migration.integration.spec.ts +266 -0
  163. package/test/keycloak-provider-links.spec.ts +201 -0
  164. package/test/keycloak-social-broker.integration.spec.ts +228 -0
  165. package/test/keycloak-verify.integration.spec.ts +100 -0
  166. package/test/keycloak-verify.spec.ts +307 -0
  167. package/test/keys-escrow.spec.ts +963 -24
  168. package/test/models/authSubjectIndexes.spec.ts +132 -0
  169. package/test/models/escrow.spec.ts +375 -13
  170. package/test/notifications.spec.ts +4 -0
  171. package/test/oidc-e2e/broker.spec.ts +116 -0
  172. package/test/oidc-e2e/compose.yaml +33 -0
  173. package/test/oidc-e2e/fixtures.ts +245 -0
  174. package/test/oidc-e2e/lock.test.mjs +73 -0
  175. package/test/oidc-e2e/playwright.config.ts +21 -0
  176. package/test/oidc-e2e/run.mjs +312 -0
  177. package/test/oidc-e2e/tsconfig.json +10 -0
  178. package/test/oidc-e2e/types.ts +21 -0
  179. package/test/oidc.integration.spec.ts +54 -0
  180. package/test/oidc.spec.ts +697 -0
  181. package/test/oidcLambdaApp.boot.spec.ts +105 -0
  182. package/test/provision-keycloak-users.spec.ts +299 -0
  183. package/test/signing-authority.spec.ts +50 -0
  184. package/tsconfig.build.json +1 -1
  185. package/tsconfig.json +1 -0
  186. package/vitest.config.ts +24 -1
  187. package/vitest.integration.config.ts +20 -3
  188. package/serverless.escrow-env.cjs +0 -20
@@ -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']);
package/serverless.yml CHANGED
@@ -18,10 +18,6 @@ custom:
18
18
  number: 3
19
19
  config:
20
20
  CACHE_INSTANCE_SIZE: cache.t2.micro
21
- # Lambda caps each function's environment at 4KB. Escrow config is only read
22
- # by the trpc/api handlers, so it is attached per-function (not provider-wide)
23
- # and unset keys are omitted entirely.
24
- escrowEnvironment: ${file(./serverless.escrow-env.cjs)}
25
21
  esbuild:
26
22
  # serverless-esbuild does not support Bun as a Lambda artifact packager.
27
23
  # Bun remains the repo installer/runner; pnpm is used only for packaging externals.
@@ -133,37 +129,17 @@ provider:
133
129
  timeout: 29
134
130
  stage: ${opt:stage, "dev"}
135
131
  region: ${opt:region, "us-east-1"}
136
- environment:
137
- LAMBDA_STAGE: ${opt:stage, "dev"}
138
- SEED: ${env:SEED, ''}
139
- SA_SEED_KMS_KEY_ARN:
140
- Fn::GetAtt: [SigningAuthoritySeedKey, Arn]
141
- # First deployment upgrades readers only. Enable writes after every reader is compatible.
142
- SA_SEED_ENCRYPT_WRITES: ${param:saSeedEncryptWrites, env:SA_SEED_ENCRYPT_WRITES, 'false'}
143
- SA_SEED_ALLOW_LEGACY_READ: ${param:saSeedAllowLegacyRead, env:SA_SEED_ALLOW_LEGACY_READ, 'true'}
144
- DOMAIN_NAME: ${env:DOMAIN_NAME, ''}
145
- OPENAI_API_KEY: ${env:OPENAI_API_KEY, ''}
146
- METABASE_SECRET_KEY: ${env:METABASE_SECRET_KEY, ''}
147
- POSTMARK_SERVER_TOKEN: ${env:POSTMARK_SERVER_TOKEN, ''}
148
- POSTMARK_LOGIN_CODE_TEMPLATE_ALIAS: ${env:POSTMARK_LOGIN_CODE_TEMPLATE_ALIAS, ''}
149
- POSTMARK_ENDORSEMENT_REQUEST_TEMPLATE_ALIAS: ${env:POSTMARK_ENDORSEMENT_REQUEST_TEMPLATE_ALIAS, ''}
150
- POSTMARK_RECOVERY_EMAIL_CODE_TEMPLATE_ALIAS: ${env:POSTMARK_RECOVERY_EMAIL_CODE_TEMPLATE_ALIAS, ''}
151
- POSTMARK_RECOVERY_KEY_TEMPLATE_ALIAS: ${env:POSTMARK_RECOVERY_KEY_TEMPLATE_ALIAS, ''}
152
- POSTMARK_FROM_EMAIL: ${env:POSTMARK_FROM_EMAIL, ''}
153
- POSTMARK_BRAND_NAME: ${env:POSTMARK_BRAND_NAME, ''}
154
- PORT: ${opt:httpPort, "3000"}
155
- MONGO_URI: ${env:MONGO_URI, ''}
156
- MONGO_DB_NAME: ${env:MONGO_DB_NAME, ''}
157
- AUTHORIZED_DIDS: ${env:AUTHORIZED_DIDS, ''}
158
- GOOGLE_APPLICATION_CREDENTIAL: ${env:GOOGLE_APPLICATION_CREDENTIAL, ''}
159
- SCOUTS_SSO_CLIENT_SECRET: ${env:SCOUTS_SSO_CLIENT_SECRET, ''}
160
- LEARN_CLOUD_URL: ${env:LEARN_CLOUD_URL, ''}
161
- SENTRY_DSN: ${env:SENTRY_DSN, ''}
162
- SENTRY_ENV: ${env:SENTRY_ENV, ''}
163
- REDIS_HOST:
164
- 'Fn::GetAtt': [ElasticCacheCluster, RedisEndpoint.Address]
165
- REDIS_PORT:
166
- 'Fn::GetAtt': [ElasticCacheCluster, RedisEndpoint.Port]
132
+ # Bundle mode emits only infra; fallback mode retains the existing GitHub secrets.
133
+ environment: ${file(./serverless.function-env.cjs):provider}
134
+ # Only didWeb/swagger use this generated role. Explicit function roles do not inherit it.
135
+ # Bundle access must not grant cryptographic use of the hosted signing-authority KMS key.
136
+ iam:
137
+ role:
138
+ statements:
139
+ - Effect: Allow
140
+ Action: secretsmanager:GetSecretValue
141
+ Resource:
142
+ Fn::Sub: arn:${AWS::Partition}:secretsmanager:${AWS::Region}:${AWS::AccountId}:secret:lca-api/${sls:stage}/runtime-secrets-*
167
143
  tracing:
168
144
  apiGateway: true
169
145
  lambda: true
@@ -178,7 +154,7 @@ functions:
178
154
  handler: lambda.trpcHandler
179
155
  role: SigningAuthorityExecutionRole
180
156
  dependsOn: [SigningAuthoritySeedKeyPolicy]
181
- environment: ${self:custom.escrowEnvironment}
157
+ environment: ${file(./serverless.function-env.cjs):api}
182
158
  vpc:
183
159
  securityGroupIds:
184
160
  - 'Fn::GetAtt': [ServerlessSecurityGroup, GroupId]
@@ -191,7 +167,7 @@ functions:
191
167
  handler: lambda.openApiHandler
192
168
  role: SigningAuthorityExecutionRole
193
169
  dependsOn: [SigningAuthoritySeedKeyPolicy]
194
- environment: ${self:custom.escrowEnvironment}
170
+ environment: ${file(./serverless.function-env.cjs):api}
195
171
  vpc:
196
172
  securityGroupIds:
197
173
  - 'Fn::GetAtt': [ServerlessSecurityGroup, GroupId]
@@ -202,6 +178,7 @@ functions:
202
178
 
203
179
  didWeb:
204
180
  handler: lambda.didWebHandler
181
+ environment: ${file(./serverless.function-env.cjs):api}
205
182
  vpc:
206
183
  securityGroupIds:
207
184
  - 'Fn::GetAtt': [ServerlessSecurityGroup, GroupId]
@@ -213,12 +190,29 @@ functions:
213
190
  url:
214
191
  cors: true
215
192
 
193
+ oidc:
194
+ handler: oidcLambda.handler
195
+ # Only this function may read the private signing key (see OidcExecutionRole).
196
+ role: OidcExecutionRole
197
+ environment: ${file(./serverless.function-env.cjs):oidc}
198
+ vpc:
199
+ securityGroupIds:
200
+ - 'Fn::GetAtt': [ServerlessSecurityGroup, GroupId]
201
+ subnetIds:
202
+ - Ref: PrivateSubnetA
203
+ events:
204
+ - httpApi: GET /.well-known/openid-configuration
205
+ - httpApi: ANY /oidc/{proxy+}
206
+
216
207
  seedMigration:
217
208
  handler: seedMigrationLambda.handler
218
209
  role: SigningAuthorityExecutionRole
219
210
  timeout: 900
220
211
  reservedConcurrency: 1
221
212
  dependsOn: [SigningAuthoritySeedKeyPolicy]
213
+ # Shares the SigningAuthority role, so it reads the runtime bundle when present and
214
+ # falls back to the same credentials as trpc/api otherwise (it needs SEED + Mongo).
215
+ environment: ${file(./serverless.function-env.cjs):api}
222
216
  # IAM invoke only: intentionally no events, schedule, or function URL.
223
217
  vpc:
224
218
  securityGroupIds:
@@ -228,6 +222,12 @@ functions:
228
222
 
229
223
  swagger:
230
224
  handler: lambda.swaggerUiHandler
225
+ environment: ${file(./serverless.function-env.cjs):api}
226
+ vpc:
227
+ securityGroupIds:
228
+ - 'Fn::GetAtt': [ServerlessSecurityGroup, GroupId]
229
+ subnetIds:
230
+ - Ref: PrivateSubnetA
231
231
  package:
232
232
  patterns:
233
233
  - src/swagger-ui/**
@@ -235,7 +235,44 @@ functions:
235
235
  - httpApi: GET /docs
236
236
  - httpApi: GET /docs/{trpc+}
237
237
 
238
+ # Always deployed (no per-stage conditional: `schedule.enabled` needs a
239
+ # literal boolean, not an interpolated ESCROW_ENCLAVE_MODE env string);
240
+ # the handler itself no-ops when escrow is disabled for this stage/tenant.
241
+ escrowHoldReminders:
242
+ handler: lambda.escrowHoldRemindersHandler
243
+ timeout: 120
244
+ environment: ${file(./serverless.function-env.cjs):escrow}
245
+ vpc:
246
+ securityGroupIds:
247
+ - 'Fn::GetAtt': [ServerlessSecurityGroup, GroupId]
248
+ subnetIds:
249
+ - Ref: PrivateSubnetA
250
+ events:
251
+ - schedule: rate(1 hour)
252
+
253
+ # Same always-deployed/no-op-when-disabled shape as escrowHoldReminders
254
+ # above; a bounded batch each run, so an hourly rate is generous relative
255
+ # to how quickly a key rotation's previous-key backlog should drain.
256
+ escrowBlobRewrap:
257
+ handler: lambda.escrowBlobRewrapHandler
258
+ timeout: 120
259
+ environment: ${file(./serverless.function-env.cjs):escrow}
260
+ vpc:
261
+ securityGroupIds:
262
+ - 'Fn::GetAtt': [ServerlessSecurityGroup, GroupId]
263
+ subnetIds:
264
+ - Ref: PrivateSubnetA
265
+ events:
266
+ - schedule: rate(1 hour)
267
+
238
268
  resources:
269
+ # Escrow enclave over PrivateLink: created only when the stage sets
270
+ # ESCROW_ENCLAVE_ENDPOINT_SERVICE_NAME (the enclave account's endpoint
271
+ # service). TLS trust for the hostname is pinned in enclaveTrustAnchors.ts.
272
+ Conditions:
273
+ EscrowEnclavePrivateLink:
274
+ Fn::Not:
275
+ - Fn::Equals: ['${env:ESCROW_ENCLAVE_ENDPOINT_SERVICE_NAME, ""}', '']
239
276
  Outputs:
240
277
  SigningAuthoritySeedKeyArn:
241
278
  Value:
@@ -259,6 +296,39 @@ resources:
259
296
  ManagedPolicyArns:
260
297
  - Fn::Sub: arn:${AWS::Partition}:iam::aws:policy/service-role/AWSLambdaVPCAccessExecutionRole
261
298
  - Fn::Sub: arn:${AWS::Partition}:iam::aws:policy/AWSXRayDaemonWriteAccess
299
+ Policies:
300
+ - PolicyName: runtime-secrets
301
+ PolicyDocument:
302
+ Version: '2012-10-17'
303
+ Statement:
304
+ # seedMigration shares this role; bundle access is acceptable for now.
305
+ - Effect: Allow
306
+ Action: secretsmanager:GetSecretValue
307
+ Resource:
308
+ Fn::Sub: arn:${AWS::Partition}:secretsmanager:${AWS::Region}:${AWS::AccountId}:secret:lca-api/${sls:stage}/runtime-secrets-*
309
+ OidcExecutionRole:
310
+ Type: AWS::IAM::Role
311
+ Properties:
312
+ AssumeRolePolicyDocument:
313
+ Version: '2012-10-17'
314
+ Statement:
315
+ - Effect: Allow
316
+ Principal:
317
+ Service: lambda.amazonaws.com
318
+ Action: sts:AssumeRole
319
+ ManagedPolicyArns:
320
+ - Fn::Sub: arn:${AWS::Partition}:iam::aws:policy/service-role/AWSLambdaVPCAccessExecutionRole
321
+ - Fn::Sub: arn:${AWS::Partition}:iam::aws:policy/AWSXRayDaemonWriteAccess
322
+ Policies:
323
+ - PolicyName: oidc-signing-key
324
+ PolicyDocument:
325
+ Version: '2012-10-17'
326
+ Statement:
327
+ # SecretString is the private JWK JSON; only its id belongs in Lambda env.
328
+ - Effect: Allow
329
+ Action: secretsmanager:GetSecretValue
330
+ Resource:
331
+ Fn::Sub: arn:${AWS::Partition}:secretsmanager:${AWS::Region}:${AWS::AccountId}:secret:lca-api/${sls:stage}/oidc-signing-jwk-*
262
332
  SigningAuthoritySeedKey:
263
333
  Type: AWS::KMS::Key
264
334
  DeletionPolicy: Retain
@@ -346,6 +416,67 @@ resources:
346
416
  Type: AWS::EC2::VPC
347
417
  Properties:
348
418
  CidrBlock: '10.0.0.0/16'
419
+ # A private hosted zone needs DNS hostnames; left at the default elsewhere.
420
+ EnableDnsHostnames:
421
+ Fn::If: [EscrowEnclavePrivateLink, true, Ref: AWS::NoValue]
422
+ EscrowEnclaveEndpointSecurityGroup:
423
+ Type: AWS::EC2::SecurityGroup
424
+ Condition: EscrowEnclavePrivateLink
425
+ Properties:
426
+ GroupDescription: Escrow enclave PrivateLink endpoint, 8443 from lca-api only
427
+ VpcId:
428
+ Ref: VPC
429
+ SecurityGroupIngress:
430
+ - IpProtocol: tcp
431
+ FromPort: 8443
432
+ ToPort: 8443
433
+ SourceSecurityGroupId:
434
+ Ref: ServerlessSecurityGroup
435
+ EscrowEnclaveEndpoint:
436
+ Type: AWS::EC2::VPCEndpoint
437
+ Condition: EscrowEnclavePrivateLink
438
+ Properties:
439
+ VpcEndpointType: Interface
440
+ ServiceName: ${env:ESCROW_ENCLAVE_ENDPOINT_SERVICE_NAME, ''}
441
+ VpcId:
442
+ Ref: VPC
443
+ SubnetIds:
444
+ - Ref: PrivateSubnetA
445
+ SecurityGroupIds:
446
+ - Ref: EscrowEnclaveEndpointSecurityGroup
447
+ PrivateDnsEnabled: false
448
+ EscrowEnclaveHostedZone:
449
+ Type: AWS::Route53::HostedZone
450
+ Condition: EscrowEnclavePrivateLink
451
+ Properties:
452
+ Name: ${env:ESCROW_ENCLAVE_HOSTNAME, ''}
453
+ VPCs:
454
+ - VPCId:
455
+ Ref: VPC
456
+ VPCRegion: ${self:provider.region}
457
+ EscrowEnclaveDnsRecord:
458
+ Type: AWS::Route53::RecordSet
459
+ Condition: EscrowEnclavePrivateLink
460
+ Properties:
461
+ HostedZoneId:
462
+ Ref: EscrowEnclaveHostedZone
463
+ Name: ${env:ESCROW_ENCLAVE_HOSTNAME, ''}
464
+ Type: A
465
+ # DnsEntries[0] is "<hosted zone id>:<regional endpoint DNS name>".
466
+ AliasTarget:
467
+ HostedZoneId:
468
+ Fn::Select:
469
+ - 0
470
+ - Fn::Split:
471
+ - ':'
472
+ - Fn::Select: [0, Fn::GetAtt: [EscrowEnclaveEndpoint, DnsEntries]]
473
+ DNSName:
474
+ Fn::Select:
475
+ - 1
476
+ - Fn::Split:
477
+ - ':'
478
+ - Fn::Select: [0, Fn::GetAtt: [EscrowEnclaveEndpoint, DnsEntries]]
479
+ EvaluateTargetHealth: false
349
480
  IP:
350
481
  Type: AWS::EC2::EIP
351
482
  Properties:
@@ -456,6 +587,10 @@ resources:
456
587
  Properties:
457
588
  AutoMinorVersionUpgrade: true
458
589
  Engine: redis
590
+ # GETDEL (login codes/tickets/auth codes) needs Redis >= 6.2. The
591
+ # version is deliberately NOT pinned here: existing clusters are
592
+ # already on 7.x, and a pin below the running version is rejected
593
+ # by CloudFormation as a downgrade (failing the whole deploy).
459
594
  CacheNodeType: ${self:custom.config.CACHE_INSTANCE_SIZE}
460
595
  NumCacheNodes: 1
461
596
  VpcSecurityGroupIds:
@@ -12,12 +12,19 @@ export const getNotificationsCollection = () => {
12
12
 
13
13
  export const Notifications = getNotificationsCollection();
14
14
 
15
- Notifications.createIndex({ 'to.did': 1, read: 1, sent: -1, _id: 1 });
15
+ // Fire-and-forget at module load: every Lambda imports this, so an uncaught
16
+ // rejection (e.g. a Mongo connect that outlives a frozen invocation) would
17
+ // crash whichever function thaws next.
18
+ const logIndexFailure = (error: unknown): void => {
19
+ console.error('Notifications index creation failed:', error);
20
+ };
21
+
22
+ Notifications.createIndex({ 'to.did': 1, read: 1, sent: -1, _id: 1 }).catch(logIndexFailure);
16
23
  Notifications.createIndex({
17
24
  'to.did': 1,
18
25
  type: 1,
19
26
  'data.metadata.connectionPrompt.promptId': 1,
20
- });
27
+ }).catch(logIndexFailure);
21
28
 
22
29
  const LEGACY_CREDENTIAL_REFRESH_INDEX = 'to.did_1_type_1_data.metadata.deliveryKey_1';
23
30
  const MANAGED_CREDENTIAL_REFRESH_INDEX = 'managed_credential_refresh_delivery_key_unique';