@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
@@ -13,6 +13,9 @@ import {
13
13
 
14
14
  export const lcaApiEnvironmentShape = {
15
15
  NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
16
+ LAMBDA_STAGE: optionalEnvironmentString,
17
+ CONFIG_STAGE: optionalEnvironmentString,
18
+ RUNTIME_SECRETS_ID: optionalEnvironmentString,
16
19
  PORT: environmentPort.default(3000),
17
20
  SEED: requiredEnvironmentString,
18
21
  SA_SEED_KMS_KEY_ARN: optionalEnvironmentString,
@@ -30,6 +33,17 @@ export const lcaApiEnvironmentShape = {
30
33
  OPENAI_API_KEY: optionalEnvironmentString,
31
34
  METABASE_SECRET_KEY: optionalEnvironmentString,
32
35
  SCOUTS_SSO_CLIENT_SECRET: optionalEnvironmentString,
36
+ KEYCLOAK_ISSUERS: optionalEnvironmentString,
37
+ KEYCLOAK_AUDIENCES: optionalEnvironmentString,
38
+ KEYCLOAK_JWKS_URL_OVERRIDES: optionalEnvironmentString,
39
+ OIDC_ISSUER: optionalEnvironmentString,
40
+ OIDC_SIGNING_KEY_JWK: optionalEnvironmentString,
41
+ OIDC_SIGNING_KEY_SECRET_ID: optionalEnvironmentString,
42
+ OIDC_CLIENT_ID: optionalEnvironmentString,
43
+ OIDC_CLIENT_SECRET: optionalEnvironmentString,
44
+ OIDC_REDIRECT_URIS: optionalEnvironmentString,
45
+ GOOGLE_OAUTH_CLIENT_IDS: optionalEnvironmentString,
46
+ APPLE_OAUTH_CLIENT_IDS: optionalEnvironmentString,
33
47
  POSTMARK_SERVER_TOKEN: optionalEnvironmentString,
34
48
  POSTMARK_FROM_EMAIL: optionalEnvironmentString,
35
49
  POSTMARK_BRAND_NAME: optionalEnvironmentString,
@@ -42,6 +56,16 @@ export const lcaApiEnvironmentShape = {
42
56
  ESCROW_ENCLAVE_MODE: optionalEnvironmentString.pipe(z.enum(['software', 'remote']).optional()),
43
57
  ESCROW_ENCLAVE_SOFTWARE_PRIVATE_KEYS_JSON: optionalEnvironmentString,
44
58
  ESCROW_ENCLAVE_ACTIVE_KEY_ID: optionalEnvironmentString,
59
+ ESCROW_ENCLAVE_REMOTE_URL: optionalEnvironmentUrl,
60
+ ESCROW_ENCLAVE_REMOTE_TOKEN: optionalEnvironmentString,
61
+ /** Operator-flipped, manual-only kill switch (P7.1). When true, startRecovery/completeRecovery
62
+ * refuse with a friendly message; hold cancellation and notifications keep working. Never
63
+ * auto-flipped by the escrow-ledger-monitor — a compromised monitor must not be able to
64
+ * silently DoS recovery. See services/escrow-ledger-monitor/README.md. */
65
+ ESCROW_RELEASE_KILL_SWITCH: optionalEnvironmentBoolean,
66
+ ESCROW_ENCLAVE_REMOTE_TIMEOUT_MS: optionalEnvironmentString
67
+ .transform(value => (value === undefined ? 10_000 : Number(value)))
68
+ .pipe(z.number().int().positive().max(30_000)),
45
69
  ESCROW_HOLD_DURATION_MS: optionalEnvironmentString
46
70
  .transform(value => (value === undefined ? 604_800_000 : Number(value)))
47
71
  .pipe(z.number().int().positive().max(Number.MAX_SAFE_INTEGER)),
@@ -74,29 +98,15 @@ export const lcaApiEnvironmentSchema = z
74
98
  };
75
99
  })
76
100
  .superRefine((environment, context) => {
77
- if (environment.ESCROW_ENCLAVE_MODE === 'software') {
78
- try {
79
- const keys = parseEscrowPrivateKeys(
80
- environment.ESCROW_ENCLAVE_SOFTWARE_PRIVATE_KEYS_JSON ?? ''
81
- );
82
- if (
83
- !environment.ESCROW_ENCLAVE_ACTIVE_KEY_ID ||
84
- !Object.hasOwn(keys, environment.ESCROW_ENCLAVE_ACTIVE_KEY_ID)
85
- ) {
86
- context.addIssue({
87
- code: 'custom',
88
- path: ['ESCROW_ENCLAVE_ACTIVE_KEY_ID'],
89
- message: 'Software escrow requires an active key ID present in the key map',
90
- });
91
- }
92
- } catch {
93
- context.addIssue({
94
- code: 'custom',
95
- path: ['ESCROW_ENCLAVE_SOFTWARE_PRIVATE_KEYS_JSON'],
96
- message:
97
- 'Software escrow requires a valid nonempty key ID to private key JSON map',
98
- });
99
- }
101
+ if (
102
+ environment.KEYCLOAK_ISSUERS?.split(',').some(value => value.trim()) &&
103
+ !environment.KEYCLOAK_AUDIENCES?.split(',').some(value => value.trim())
104
+ ) {
105
+ context.addIssue({
106
+ code: 'custom',
107
+ path: ['KEYCLOAK_AUDIENCES'],
108
+ message: 'Required when KEYCLOAK_ISSUERS is configured',
109
+ });
100
110
  }
101
111
  if (environment.SA_SEED_KMS_KEY_ARN) {
102
112
  if (
@@ -133,6 +143,56 @@ export const lcaApiEnvironmentSchema = z
133
143
  message: 'Plaintext writes require the compatibility reader',
134
144
  });
135
145
  }
146
+
147
+ if (environment.ESCROW_ENCLAVE_MODE === 'software') {
148
+ try {
149
+ const keys = parseEscrowPrivateKeys(
150
+ environment.ESCROW_ENCLAVE_SOFTWARE_PRIVATE_KEYS_JSON ?? ''
151
+ );
152
+ if (
153
+ !environment.ESCROW_ENCLAVE_ACTIVE_KEY_ID ||
154
+ !Object.hasOwn(keys, environment.ESCROW_ENCLAVE_ACTIVE_KEY_ID)
155
+ ) {
156
+ context.addIssue({
157
+ code: 'custom',
158
+ path: ['ESCROW_ENCLAVE_ACTIVE_KEY_ID'],
159
+ message: 'Software escrow requires an active key ID present in the key map',
160
+ });
161
+ }
162
+ } catch {
163
+ context.addIssue({
164
+ code: 'custom',
165
+ path: ['ESCROW_ENCLAVE_SOFTWARE_PRIVATE_KEYS_JSON'],
166
+ message:
167
+ 'Software escrow requires a valid nonempty key ID to private key JSON map',
168
+ });
169
+ }
170
+ }
171
+ // Format-only: absence at 'remote' mode is intentionally NOT a parse-time
172
+ // failure. getEscrowEnclave() fails closed per-request instead, so a
173
+ // misconfigured remote backend degrades that one feature rather than
174
+ // crashing the whole service at boot (see escrow-enclave/index.ts).
175
+ if (environment.ESCROW_ENCLAVE_REMOTE_URL) {
176
+ const { protocol, hostname } = new URL(environment.ESCROW_ENCLAVE_REMOTE_URL);
177
+ const isLocalhost = ['localhost', '127.0.0.1', '::1'].includes(hostname);
178
+ if (protocol !== 'https:' && environment.NODE_ENV !== 'test' && !isLocalhost) {
179
+ context.addIssue({
180
+ code: 'custom',
181
+ path: ['ESCROW_ENCLAVE_REMOTE_URL'],
182
+ message: 'Must use https:// outside tests or localhost',
183
+ });
184
+ }
185
+ }
186
+ if (
187
+ environment.ESCROW_ENCLAVE_REMOTE_TOKEN &&
188
+ environment.ESCROW_ENCLAVE_REMOTE_TOKEN.length < 32
189
+ ) {
190
+ context.addIssue({
191
+ code: 'custom',
192
+ path: ['ESCROW_ENCLAVE_REMOTE_TOKEN'],
193
+ message: 'Must be at least 32 characters',
194
+ });
195
+ }
136
196
  if (
137
197
  environment.NODE_ENV === 'production' &&
138
198
  !environment.IS_OFFLINE &&
@@ -185,6 +245,13 @@ export const parseLcaApiEnvironment = (
185
245
  );
186
246
  };
187
247
 
248
+ // Read live from process.env (not the frozen `environment` snapshot below) so an
249
+ // operator's flip takes effect on the next request without a full restart, and so
250
+ // tests can toggle it per-case. The escrow-ledger-monitor must never set this itself
251
+ // — see services/escrow-ledger-monitor/README.md.
252
+ export const isEscrowReleaseKillSwitchEnabled = (): boolean =>
253
+ optionalEnvironmentBoolean.parse(process.env.ESCROW_RELEASE_KILL_SWITCH) ?? false;
254
+
188
255
  /** Parse configuration without exposing secret values in validation errors. */
189
256
  export const parseEscrowPrivateKeys = (serialized: string): Record<string, string> => {
190
257
  try {
@@ -0,0 +1,52 @@
1
+ import { createRequire } from 'node:module';
2
+ import { afterEach, describe, expect, it, vi } from 'vitest';
3
+ import { mergeRuntimeSecrets, resolveStageDefaults } from '@learncard/service-config';
4
+
5
+ import { parseLcaApiEnvironment } from './environment';
6
+ import { base, stages } from './stageConfig';
7
+
8
+ const require = createRequire(import.meta.url);
9
+ const functions: {
10
+ api: () => Record<string, string>;
11
+ provider: (context?: { options: { stage: string } }) => Record<string, unknown>;
12
+ } = require('../../serverless.function-env.cjs');
13
+
14
+ // Deployed precedence for an api function in bundle mode: real Lambda env > bundle > stage file.
15
+ const deployedEnvironment = (
16
+ bundle: Record<string, string>
17
+ ): Record<string, string | undefined> => {
18
+ const lambdaEnv = Object.fromEntries(
19
+ Object.entries({ ...functions.provider({ options: { stage: 'dev' } }), ...functions.api() })
20
+ // CloudFormation intrinsics (KMS ARN, Redis endpoint) resolve only at deploy time.
21
+ .filter((entry): entry is [string, string] => typeof entry[1] === 'string')
22
+ );
23
+ const stageDefaults = resolveStageDefaults({ base, stages, stage: 'dev', env: lambdaEnv });
24
+ const resolvedIntrinsics = {
25
+ SA_SEED_KMS_KEY_ARN: 'arn:aws:kms:us-east-1:000000000000:key/test',
26
+ };
27
+ return mergeRuntimeSecrets({ ...stageDefaults, ...lambdaEnv, ...resolvedIntrinsics }, bundle);
28
+ };
29
+
30
+ afterEach(() => vi.unstubAllEnvs());
31
+
32
+ describe('escrow in runtime-secrets bundle mode', () => {
33
+ it('keeps software escrow enabled with keys supplied only by the bundle', () => {
34
+ vi.stubEnv('RUNTIME_SECRETS_ID', 'lca-api/dev/runtime-secrets');
35
+ vi.stubEnv('ESCROW_ENCLAVE_MODE', 'software');
36
+ vi.stubEnv('SEED', '');
37
+
38
+ const activeKeyId = stages.dev.ESCROW_ENCLAVE_ACTIVE_KEY_ID;
39
+ const env = parseLcaApiEnvironment(
40
+ deployedEnvironment({
41
+ SEED: 'a'.repeat(64),
42
+ MONGO_URI: 'mongodb://localhost:27017',
43
+ MONGO_DB_NAME: 'test',
44
+ POSTMARK_SERVER_TOKEN: 'postmark',
45
+ ESCROW_ENCLAVE_SOFTWARE_PRIVATE_KEYS_JSON: JSON.stringify({ [activeKeyId]: 'k' }),
46
+ })
47
+ );
48
+
49
+ expect(env.ESCROW_ENCLAVE_MODE).toBe('software');
50
+ expect(env.ESCROW_ENCLAVE_ACTIVE_KEY_ID).toBe(activeKeyId);
51
+ });
52
+ });
@@ -0,0 +1,114 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { createRequire } from 'node:module';
3
+ import { afterEach, describe, expect, it, vi } from 'vitest';
4
+
5
+ import { lcaApiEnvironmentShape } from './environment';
6
+ import { stages } from './stageConfig';
7
+
8
+ const require = createRequire(import.meta.url);
9
+ const serverlessRequire = createRequire(require.resolve('serverless/package.json'));
10
+ const yaml: { load: (source: string) => unknown } = serverlessRequire('js-yaml');
11
+
12
+ const functionEnvironment = require('../../serverless.function-env.cjs') as Record<
13
+ 'api' | 'escrow',
14
+ () => Record<string, string>
15
+ >;
16
+
17
+ // Every schema ESCROW_* setting has exactly one source under the config model.
18
+ const SOURCES = {
19
+ // Credentials and the endpoints bound to them: runtime bundle, GitHub fallback.
20
+ bundle: [
21
+ 'ESCROW_RELAY_URL',
22
+ 'ESCROW_RELAY_AUTH_TOKEN',
23
+ 'ESCROW_ENCLAVE_SOFTWARE_PRIVATE_KEYS_JSON',
24
+ 'ESCROW_ENCLAVE_REMOTE_URL',
25
+ 'ESCROW_ENCLAVE_REMOTE_TOKEN',
26
+ ],
27
+ // Operator toggles: GitHub vars forwarded in every mode.
28
+ toggle: ['ESCROW_ENCLAVE_MODE', 'ESCROW_RELEASE_KILL_SWITCH'],
29
+ // Non-secret tuning: checked-in config.<stage>.json (or schema default).
30
+ stage: [
31
+ 'ESCROW_ENCLAVE_ACTIVE_KEY_ID',
32
+ 'ESCROW_HOLD_DURATION_MS',
33
+ 'ESCROW_HOLD_RESTART_MIN_AGE_MS',
34
+ 'ESCROW_ENCLAVE_REMOTE_TIMEOUT_MS',
35
+ ],
36
+ };
37
+ const SECRETS = new Set([
38
+ 'ESCROW_RELAY_AUTH_TOKEN',
39
+ 'ESCROW_ENCLAVE_SOFTWARE_PRIVATE_KEYS_JSON',
40
+ 'ESCROW_ENCLAVE_REMOTE_TOKEN',
41
+ ]);
42
+ // serverless.yml PrivateLink inputs; read at deploy time, never by the Lambda runtime.
43
+ const DEPLOY_TIME = ['ESCROW_ENCLAVE_ENDPOINT_SERVICE_NAME', 'ESCROW_ENCLAVE_HOSTNAME'];
44
+
45
+ const settings = Object.keys(lcaApiEnvironmentShape).filter(name => name.startsWith('ESCROW_'));
46
+
47
+ afterEach(() => vi.unstubAllEnvs());
48
+
49
+ describe('escrow deployment wiring', () => {
50
+ it('classifies every schema ESCROW_* setting exactly once', () => {
51
+ const classified = Object.values(SOURCES).flat();
52
+ expect([...classified].sort()).toEqual([...settings].sort());
53
+ expect(new Set(classified).size).toBe(classified.length);
54
+ });
55
+
56
+ it('passes only bundle fallbacks, toggles and deploy-time inputs through GitHub', () => {
57
+ const workflow = yaml.load(
58
+ readFileSync(
59
+ new URL('../../../../../.github/workflows/deploy.yml', import.meta.url),
60
+ 'utf8'
61
+ )
62
+ ) as {
63
+ jobs: Record<string, { steps: { name: string; env?: Record<string, string> }[] }>;
64
+ };
65
+ const env = workflow.jobs['deploy-lca-api']!.steps.find(
66
+ step => step.name === 'Deploy LCA API Service Lambda'
67
+ )!.env!;
68
+
69
+ for (const name of [...SOURCES.bundle, ...SOURCES.toggle, ...DEPLOY_TIME]) {
70
+ expect(env[name], name).toBe(
71
+ `\${{ ${SECRETS.has(name) ? 'secrets' : 'vars'}.${name} }}`
72
+ );
73
+ }
74
+ for (const name of SOURCES.stage) expect(env[name], name).toBeUndefined();
75
+ });
76
+
77
+ it('runs the scheduled escrow jobs with the API environment (bundle + toggles)', () => {
78
+ const serverless = yaml.load(
79
+ readFileSync(new URL('../../serverless.yml', import.meta.url), 'utf8')
80
+ ) as { functions: Record<string, { environment?: string; role?: string }> };
81
+ for (const name of ['escrowHoldReminders', 'escrowBlobRewrap']) {
82
+ expect(serverless.functions[name]?.environment, name).toBe(
83
+ '${file(./serverless.function-env.cjs):escrow}'
84
+ );
85
+ }
86
+ expect(functionEnvironment.escrow).toBe(functionEnvironment.api);
87
+ });
88
+
89
+ it('keeps escrow toggles and drops credential fallbacks in bundle mode', () => {
90
+ for (const name of [...SOURCES.bundle, ...SOURCES.toggle]) vi.stubEnv(name, name);
91
+ vi.stubEnv('RUNTIME_SECRETS_ID', 'lca-api/dev/runtime-secrets');
92
+ expect(functionEnvironment.escrow()).toEqual({
93
+ RUNTIME_SECRETS_ID: 'lca-api/dev/runtime-secrets',
94
+ ESCROW_ENCLAVE_MODE: 'ESCROW_ENCLAVE_MODE',
95
+ ESCROW_RELEASE_KILL_SWITCH: 'ESCROW_RELEASE_KILL_SWITCH',
96
+ });
97
+
98
+ vi.stubEnv('RUNTIME_SECRETS_ID', '');
99
+ expect(Object.keys(functionEnvironment.escrow()).sort()).toEqual(
100
+ [...SOURCES.bundle, ...SOURCES.toggle].sort()
101
+ );
102
+ });
103
+
104
+ it('never checks in escrow credentials', () => {
105
+ for (const [stage, config] of Object.entries(stages)) {
106
+ for (const name of [...SOURCES.bundle, ...SOURCES.toggle]) {
107
+ expect(
108
+ (config as Record<string, string>)[name],
109
+ `${stage}/${name}`
110
+ ).toBeUndefined();
111
+ }
112
+ }
113
+ });
114
+ });
@@ -0,0 +1,59 @@
1
+ import 'dotenv/config';
2
+
3
+ import { z } from 'zod';
4
+ import {
5
+ optionalEnvironmentBoolean,
6
+ optionalEnvironmentString,
7
+ parseEnvironment,
8
+ } from '@learncard/helpers';
9
+
10
+ /**
11
+ * Focused environment contract for the OIDC provider surface so the OIDC Lambda
12
+ * can boot without SEED/MONGO/Postmark/Firebase/bundle. Validates ONLY the
13
+ * fields read by src/oidc.ts and src/helpers/oidc.helpers.ts, reproducing the
14
+ * full schema's parsers and its KEYCLOAK_ISSUERS->KEYCLOAK_AUDIENCES superRefine
15
+ * verbatim so no constraint is weakened.
16
+ */
17
+ export const oidcEnvironmentShape = {
18
+ NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
19
+ LAMBDA_STAGE: optionalEnvironmentString,
20
+ IS_OFFLINE: optionalEnvironmentBoolean.default(false),
21
+ IS_E2E_TEST: optionalEnvironmentBoolean.default(false),
22
+ KEYCLOAK_ISSUERS: optionalEnvironmentString,
23
+ KEYCLOAK_AUDIENCES: optionalEnvironmentString,
24
+ OIDC_ISSUER: optionalEnvironmentString,
25
+ OIDC_SIGNING_KEY_JWK: optionalEnvironmentString,
26
+ OIDC_SIGNING_KEY_SECRET_ID: optionalEnvironmentString,
27
+ OIDC_CLIENT_ID: optionalEnvironmentString,
28
+ OIDC_CLIENT_SECRET: optionalEnvironmentString,
29
+ OIDC_REDIRECT_URIS: optionalEnvironmentString,
30
+ } satisfies z.ZodRawShape;
31
+
32
+ export const oidcEnvironmentSchema = z
33
+ .object(oidcEnvironmentShape)
34
+ .superRefine((environment, context) => {
35
+ if (
36
+ environment.KEYCLOAK_ISSUERS?.split(',').some(value => value.trim()) &&
37
+ !environment.KEYCLOAK_AUDIENCES?.split(',').some(value => value.trim())
38
+ ) {
39
+ context.addIssue({
40
+ code: 'custom',
41
+ path: ['KEYCLOAK_AUDIENCES'],
42
+ message: 'Required when KEYCLOAK_ISSUERS is configured',
43
+ });
44
+ }
45
+ });
46
+
47
+ export type OidcEnvironment = z.output<typeof oidcEnvironmentSchema>;
48
+
49
+ export const parseOidcEnvironment = (
50
+ raw: Record<string, unknown>,
51
+ source = 'process environment'
52
+ ): OidcEnvironment =>
53
+ parseEnvironment(oidcEnvironmentSchema, raw, {
54
+ project: 'lca-api (oidc)',
55
+ source,
56
+ examplePath: 'services/learn-card-network/lca-api/.env.example',
57
+ });
58
+
59
+ export const environment = parseOidcEnvironment(process.env);
@@ -0,0 +1,98 @@
1
+ import { createRequire } from 'node:module';
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
3
+
4
+ const require = createRequire(import.meta.url);
5
+ const functions: {
6
+ api: () => Record<string, string>;
7
+ oidc: () => Record<string, string>;
8
+ provider: (context?: {
9
+ options: { stage: string; httpPort?: string };
10
+ }) => Record<string, unknown>;
11
+ } = require('../../serverless.function-env.cjs');
12
+
13
+ beforeEach(() => {
14
+ vi.stubEnv('RUNTIME_SECRETS_ID', '');
15
+ vi.stubEnv('SEED', 'seed');
16
+ vi.stubEnv('GOOGLE_APPLICATION_CREDENTIAL', 'credential');
17
+ vi.stubEnv('OIDC_CLIENT_SECRET', 'broker-secret');
18
+ vi.stubEnv('OIDC_REDIRECT_URIS', 'https://example.com/callback');
19
+ });
20
+ afterEach(() => vi.unstubAllEnvs());
21
+
22
+ describe('lca-api serverless function environments', () => {
23
+ it('omits all fallback credentials in bundle mode', () => {
24
+ vi.stubEnv('RUNTIME_SECRETS_ID', 'lca-api/dev/runtime-secrets');
25
+ expect(functions.api()).toEqual({ RUNTIME_SECRETS_ID: 'lca-api/dev/runtime-secrets' });
26
+ expect(Object.keys(functions.provider()).sort()).toEqual([
27
+ 'CONFIG_TENANT',
28
+ 'LAMBDA_STAGE',
29
+ 'PORT',
30
+ 'REDIS_HOST',
31
+ 'REDIS_PORT',
32
+ 'SA_SEED_KMS_KEY_ARN',
33
+ ]);
34
+ });
35
+
36
+ it('keeps the non-secret escrow toggle in bundle mode', () => {
37
+ vi.stubEnv('RUNTIME_SECRETS_ID', 'lca-api/dev/runtime-secrets');
38
+ vi.stubEnv('ESCROW_ENCLAVE_MODE', 'software');
39
+ vi.stubEnv('ESCROW_ENCLAVE_SOFTWARE_PRIVATE_KEYS_JSON', '{"k":"secret"}');
40
+ expect(functions.api()).toEqual({
41
+ RUNTIME_SECRETS_ID: 'lca-api/dev/runtime-secrets',
42
+ ESCROW_ENCLAVE_MODE: 'software',
43
+ });
44
+ });
45
+
46
+ it('bakes the tenant selector into every function, defaulting to learncard', () => {
47
+ expect(functions.provider().CONFIG_TENANT).toBe('learncard');
48
+ vi.stubEnv('CONFIG_TENANT', 'scouts');
49
+ expect(functions.provider().CONFIG_TENANT).toBe('scouts');
50
+ });
51
+
52
+ it('retains the existing provider and API credentials only in fallback mode', () => {
53
+ expect(functions.provider().SEED).toBe('seed');
54
+ expect(functions.api().GOOGLE_APPLICATION_CREDENTIAL).toBe('credential');
55
+ expect(functions.api().RUNTIME_SECRETS_ID).toBeUndefined();
56
+ });
57
+
58
+ it('keeps the OIDC exception scoped to its separate role', () => {
59
+ vi.stubEnv('RUNTIME_SECRETS_ID', 'bundle');
60
+ expect(functions.oidc()).toEqual({
61
+ OIDC_CLIENT_SECRET: 'broker-secret',
62
+ OIDC_REDIRECT_URIS: 'https://example.com/callback',
63
+ });
64
+ expect(functions.provider().OIDC_CLIENT_SECRET).toBeUndefined();
65
+ expect(functions.api().OIDC_CLIENT_SECRET).toBeUndefined();
66
+ });
67
+
68
+ it.each(['', 'bundle'])(
69
+ 'resolves the full provider environment through Serverless v3 (%s)',
70
+ async bundle => {
71
+ const source = require('serverless/lib/configuration/variables/sources/file');
72
+ vi.stubEnv('RUNTIME_SECRETS_ID', bundle);
73
+ const options = { stage: 'production', httpPort: '5100' };
74
+ for (const name of ['provider', 'api', 'oidc'] as const) {
75
+ const result = await source.resolve({
76
+ serviceDir: new URL('../../', import.meta.url).pathname,
77
+ params: [
78
+ new URL('../../serverless.function-env.cjs', import.meta.url).pathname,
79
+ ],
80
+ address: name,
81
+ options,
82
+ resolveConfigurationProperty: vi.fn(),
83
+ resolveVariable: vi.fn(),
84
+ });
85
+ const expected =
86
+ name === 'provider' ? functions.provider({ options }) : functions[name]();
87
+ expect(result.value).toEqual(expected);
88
+ if (name === 'provider') {
89
+ expect(result.value.LAMBDA_STAGE).toBe('production');
90
+ expect(result.value.PORT).toBe('5100');
91
+ expect(result.value.SA_SEED_KMS_KEY_ARN).toEqual({
92
+ 'Fn::GetAtt': ['SigningAuthoritySeedKey', 'Arn'],
93
+ });
94
+ }
95
+ }
96
+ }
97
+ );
98
+ });
@@ -0,0 +1,26 @@
1
+ import { afterEach, expect, it, vi } from 'vitest';
2
+
3
+ // Model a non-empty .env value arriving before the stage defaults are applied.
4
+ vi.mock('dotenv/config', () => {
5
+ vi.stubEnv('SENTRY_ENV', 'self-hosted');
6
+ return {};
7
+ });
8
+
9
+ afterEach(() => vi.unstubAllEnvs());
10
+
11
+ it('loads local env before applying statically imported stage defaults', async () => {
12
+ const { applyLcaApiStageConfig, stages } = await import('./stageConfig');
13
+ for (const key of Object.keys(stages.dev)) {
14
+ if (key !== 'SENTRY_ENV') vi.stubEnv(key, '');
15
+ }
16
+ applyLcaApiStageConfig('dev');
17
+ expect(process.env.SENTRY_ENV).toBe('self-hosted');
18
+ expect(process.env.DOMAIN_NAME).toBe(stages.dev.DOMAIN_NAME);
19
+ });
20
+
21
+ it('does not select a deployed stage for an unknown local stage', async () => {
22
+ const { applyLcaApiStageConfig } = await import('./stageConfig');
23
+ vi.stubEnv('DOMAIN_NAME', '');
24
+ applyLcaApiStageConfig('unregistered-self-hosted-stage');
25
+ expect(process.env.DOMAIN_NAME).toBe('');
26
+ });
@@ -0,0 +1,24 @@
1
+ import 'dotenv/config';
2
+ import { applyStageConfig } from '@learncard/service-config';
3
+
4
+ import baseConfig from '../../config/config.json';
5
+ import devConfig from '../../config/config.dev.json';
6
+ import productionConfig from '../../config/config.production.json';
7
+ import scoutsDevConfig from '../../config/config.scouts.dev.json';
8
+ import scoutsProductionConfig from '../../config/config.scouts.production.json';
9
+
10
+ export const base: Record<string, string> = baseConfig;
11
+
12
+ export const stages = {
13
+ dev: devConfig,
14
+ production: productionConfig,
15
+ // ScoutPass deploys the same services and stage names; CONFIG_TENANT=scouts selects these.
16
+ 'scouts.dev': scoutsDevConfig,
17
+ 'scouts.production': scoutsProductionConfig,
18
+ } satisfies Record<string, Record<string, string>>;
19
+
20
+ // Must run before any module that reads configuration on import (e.g. @environment):
21
+ // merges base + the selected stage into process.env; explicit non-empty env still wins.
22
+ export const applyLcaApiStageConfig = (stage: string | undefined): void => {
23
+ applyStageConfig({ base, stages, stage });
24
+ };
@@ -1,3 +1,5 @@
1
+ import './config/applyDockerStageConfig';
2
+
1
3
  import { environment } from '@environment';
2
4
  import path from 'path';
3
5
 
@@ -10,6 +12,8 @@ import { appRouter, type AppRouter, createContext } from './app';
10
12
  import { openApiDocument } from './openapi';
11
13
  import { didFastifyPlugin } from './dids';
12
14
  import { ensureUserKeysIndexes, createEscrowHoldsIndexes } from './models';
15
+ import { oidcFastifyPlugin } from './oidc';
16
+ import { ensureAuthSubjectIndexes } from './models/AuthSubject';
13
17
 
14
18
  const server = Fastify({ maxParamLength: 5000 });
15
19
 
@@ -102,9 +106,14 @@ server.register(fastifyStatic, {
102
106
  });
103
107
 
104
108
  server.register(didFastifyPlugin);
109
+ server.register(oidcFastifyPlugin);
105
110
 
106
111
  (async () => {
107
112
  try {
113
+ await ensureAuthSubjectIndexes().catch(error => {
114
+ // Login requests retry index creation and must succeed before writing subjects.
115
+ console.error('AuthSubject indexes unavailable at startup; login will retry:', error);
116
+ });
108
117
  console.log('Server starting on port ', environment.PORT || 3000);
109
118
  await ensureUserKeysIndexes();
110
119
  await createEscrowHoldsIndexes();