@learncard/lca-api-service 1.3.2 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (240) hide show
  1. package/.env.example +99 -2
  2. package/AGENTS.md +155 -44
  3. package/CHANGELOG.md +84 -0
  4. package/bootstrapLambda.test.ts +76 -0
  5. package/compose.env +5 -0
  6. package/config/config.dev.json +23 -0
  7. package/config/config.example.json +22 -0
  8. package/config/config.json +1 -0
  9. package/config/config.production.json +12 -0
  10. package/config/config.scouts.dev.json +11 -0
  11. package/config/config.scouts.production.json +11 -0
  12. package/dist/accesslayer/signing-authority/index.d.ts +8 -2
  13. package/dist/app.d.ts +361 -8
  14. package/dist/cache/challenges.d.ts +2 -0
  15. package/dist/cache/getDel.d.ts +2 -0
  16. package/dist/cache/getDel.test.d.ts +1 -0
  17. package/dist/cache/login-tickets.d.ts +12 -0
  18. package/dist/cache/recoverySessions.d.ts +42 -0
  19. package/dist/config/applyDockerStageConfig.d.ts +1 -0
  20. package/dist/config/cacheEnvironment.d.ts +16 -0
  21. package/dist/config/environment.d.ts +253 -2
  22. package/dist/config/environment.test.d.ts +1 -0
  23. package/dist/config/escrowBundleMode.test.d.ts +1 -0
  24. package/dist/config/escrowDeployment.test.d.ts +1 -0
  25. package/dist/config/oidcEnvironment.d.ts +74 -0
  26. package/dist/config/serverlessFunctionEnv.test.d.ts +1 -0
  27. package/dist/config/stageConfig.d.ts +62 -0
  28. package/dist/config/stageConfig.test.d.ts +1 -0
  29. package/dist/didkit_wasm_bg.wasm +0 -0
  30. package/dist/docker-entry.d.ts +1 -1
  31. package/dist/helpers/auth.helpers.d.ts +1 -1
  32. package/dist/helpers/escrowCancelLink.d.ts +7 -0
  33. package/dist/helpers/escrowCancelLink.test.d.ts +1 -0
  34. package/dist/helpers/escrowCancelToken.d.ts +4 -0
  35. package/dist/helpers/escrowCancelToken.test.d.ts +1 -0
  36. package/dist/helpers/keycloak.helpers.d.ts +23 -0
  37. package/dist/helpers/learnCard.signingAuthority.test.d.ts +1 -0
  38. package/dist/helpers/login-ticket.helpers.d.ts +31 -0
  39. package/dist/helpers/login-ticket.helpers.test.d.ts +1 -0
  40. package/dist/helpers/oidc.helpers.d.ts +47 -0
  41. package/dist/helpers/rate-limit.helpers.d.ts +6 -0
  42. package/dist/helpers/rate-limit.helpers.test.d.ts +1 -0
  43. package/dist/helpers/rateLimit.helpers.d.ts +4 -0
  44. package/dist/helpers/seedEncryption.helpers.d.ts +23 -0
  45. package/dist/helpers/seedEncryption.helpers.test.d.ts +1 -0
  46. package/dist/helpers/social-token.helpers.d.ts +19 -0
  47. package/dist/index.d.ts +1 -0
  48. package/dist/index.js +2 -2
  49. package/dist/jobs/escrowBlobRewrap.d.ts +9 -0
  50. package/dist/jobs/escrowHoldReminders.d.ts +12 -0
  51. package/dist/migrations/signingAuthoritySeeds.d.ts +13 -0
  52. package/dist/migrations/signingAuthoritySeedsCli.test.d.ts +1 -0
  53. package/dist/models/AuthSubject.d.ts +20 -0
  54. package/dist/models/EscrowHold.d.ts +153 -0
  55. package/dist/models/Notification.d.ts +5 -0
  56. package/dist/models/SigningAuthority.d.ts +7 -1
  57. package/dist/models/UserKey.d.ts +326 -3
  58. package/dist/models/UserKey.escrowPin.test.d.ts +1 -0
  59. package/dist/models/index.d.ts +2 -0
  60. package/dist/oidc.d.ts +5 -0
  61. package/dist/routes/auth.d.ts +31 -0
  62. package/dist/routes/escrow.d.ts +164 -0
  63. package/dist/routes/index.d.ts +6 -0
  64. package/dist/routes/keys.d.ts +182 -7
  65. package/dist/routes/notifications.d.ts +5 -0
  66. package/dist/routes/qr-login.d.ts +1 -1
  67. package/dist/routes/signing-authority.d.ts +14 -0
  68. package/dist/services/escrow-enclave/blobIdentity.d.ts +10 -0
  69. package/dist/services/escrow-enclave/blobIdentity.test.d.ts +1 -0
  70. package/dist/services/escrow-enclave/cancellation.d.ts +3 -0
  71. package/dist/services/escrow-enclave/enclaveTrustAnchors.d.ts +1 -0
  72. package/dist/services/escrow-enclave/index.d.ts +25 -0
  73. package/dist/services/escrow-enclave/notifications.d.ts +24 -0
  74. package/dist/services/escrow-enclave/notifications.test.d.ts +1 -0
  75. package/dist/services/escrow-enclave/production.test.d.ts +1 -0
  76. package/dist/services/escrow-enclave/remoteEnclave.contract.test.d.ts +1 -0
  77. package/dist/services/escrow-enclave/remoteEnclave.d.ts +20 -0
  78. package/dist/services/escrow-enclave/remoteEnclave.test.d.ts +1 -0
  79. package/dist/services/escrow-enclave/remoteEnclave.tls.test.d.ts +1 -0
  80. package/dist/services/escrow-enclave/softwareEnclave.d.ts +27 -0
  81. package/dist/services/escrow-enclave/softwareEnclave.test.d.ts +1 -0
  82. package/dist/services/escrow-enclave/types.d.ts +111 -0
  83. package/dist/types/notifications.d.ts +5 -0
  84. package/dist/types/seed-encryption.d.ts +31 -0
  85. package/dist/types/seed-migration.d.ts +49 -0
  86. package/esbuildPlugins.cjs +1 -0
  87. package/lambda.ts +27 -112
  88. package/lambdaApp.ts +146 -0
  89. package/oidcLambda.test.ts +38 -0
  90. package/oidcLambda.ts +18 -0
  91. package/oidcLambdaApp.ts +11 -0
  92. package/package.json +30 -14
  93. package/scripts/README.md +88 -0
  94. package/scripts/check-seed-encryption-infrastructure.mjs +139 -0
  95. package/scripts/check-signing-authority-redis.mjs +93 -0
  96. package/scripts/escrow-blob-mode-report.test.ts +227 -0
  97. package/scripts/escrow-blob-mode-report.ts +465 -0
  98. package/scripts/keycloak-admin.ts +108 -0
  99. package/scripts/keycloak-provider-links.ts +147 -0
  100. package/scripts/migrate-signing-authority-seeds-local.sh +51 -0
  101. package/scripts/migrate-signing-authority-seeds.mjs +105 -0
  102. package/scripts/print-seed-rollout-flags.ts +21 -0
  103. package/scripts/provision-keycloak-users.ts +419 -0
  104. package/seedMigrationApp.test.ts +145 -0
  105. package/seedMigrationApp.ts +50 -0
  106. package/seedMigrationLambda.test.ts +40 -0
  107. package/seedMigrationLambda.ts +19 -0
  108. package/serverless-local.yml +28 -1
  109. package/serverless.function-env.cjs +58 -0
  110. package/serverless.yml +294 -26
  111. package/src/accesslayer/notifications/index.ts +9 -2
  112. package/src/accesslayer/signing-authority/create.ts +11 -7
  113. package/src/accesslayer/signing-authority/index.ts +4 -1
  114. package/src/app.ts +4 -0
  115. package/src/cache/challenges.ts +12 -2
  116. package/src/cache/getDel.test.ts +34 -0
  117. package/src/cache/getDel.ts +32 -0
  118. package/src/cache/in-memory-lru.ts +1 -0
  119. package/src/cache/index.ts +1 -1
  120. package/src/cache/login-tickets.ts +26 -0
  121. package/src/cache/recoverySessions.ts +252 -0
  122. package/src/config/applyDockerStageConfig.ts +6 -0
  123. package/src/config/cacheEnvironment.ts +34 -0
  124. package/src/config/environment.test.ts +159 -0
  125. package/src/config/environment.ts +164 -0
  126. package/src/config/escrowBundleMode.test.ts +52 -0
  127. package/src/config/escrowDeployment.test.ts +114 -0
  128. package/src/config/oidcEnvironment.ts +59 -0
  129. package/src/config/serverlessFunctionEnv.test.ts +98 -0
  130. package/src/config/stageConfig.test.ts +26 -0
  131. package/src/config/stageConfig.ts +24 -0
  132. package/src/docker-entry.ts +13 -0
  133. package/src/helpers/auth.helpers.ts +68 -30
  134. package/src/helpers/escrowCancelLink.test.ts +60 -0
  135. package/src/helpers/escrowCancelLink.ts +38 -0
  136. package/src/helpers/escrowCancelToken.test.ts +105 -0
  137. package/src/helpers/escrowCancelToken.ts +47 -0
  138. package/src/helpers/keycloak.helpers.ts +120 -0
  139. package/src/helpers/learnCard.helpers.test.ts +11 -1
  140. package/src/helpers/learnCard.helpers.ts +57 -45
  141. package/src/helpers/learnCard.signingAuthority.test.ts +194 -0
  142. package/src/helpers/login-ticket.helpers.test.ts +45 -0
  143. package/src/helpers/login-ticket.helpers.ts +91 -0
  144. package/src/helpers/oidc.helpers.ts +354 -0
  145. package/src/helpers/rate-limit.helpers.test.ts +61 -0
  146. package/src/helpers/rate-limit.helpers.ts +73 -0
  147. package/src/helpers/rateLimit.helpers.ts +112 -0
  148. package/src/helpers/seedEncryption.helpers.test.ts +427 -0
  149. package/src/helpers/seedEncryption.helpers.ts +372 -0
  150. package/src/helpers/signingAuthority.helpers.ts +4 -1
  151. package/src/helpers/social-token.helpers.ts +144 -0
  152. package/src/index.ts +1 -0
  153. package/src/jobs/escrowBlobRewrap.ts +121 -0
  154. package/src/jobs/escrowHoldReminders.ts +177 -0
  155. package/src/migrations/signingAuthoritySeeds.ts +430 -0
  156. package/src/migrations/signingAuthoritySeedsCli.test.ts +70 -0
  157. package/src/models/AuthSubject.ts +147 -0
  158. package/src/models/EscrowHold.ts +541 -0
  159. package/src/models/SigningAuthority.ts +14 -3
  160. package/src/models/UserKey.escrowPin.test.ts +49 -0
  161. package/src/models/UserKey.ts +1109 -40
  162. package/src/models/index.ts +2 -0
  163. package/src/models/pruneOrphanedRecoveryMethods.ts +3 -1
  164. package/src/oidc.ts +371 -0
  165. package/src/routes/auth.ts +173 -0
  166. package/src/routes/credentials.ts +8 -0
  167. package/src/routes/escrow.ts +1073 -0
  168. package/src/routes/firebase.ts +90 -48
  169. package/src/routes/index.ts +98 -5
  170. package/src/routes/keys.ts +1022 -174
  171. package/src/routes/qr-login.ts +51 -53
  172. package/src/routes/signing-authority.ts +39 -1
  173. package/src/services/delivery/adapters/postmark.adapter.ts +48 -1
  174. package/src/services/escrow-enclave/README.md +122 -0
  175. package/src/services/escrow-enclave/__fixtures__/tls/pinned-ca.pem +11 -0
  176. package/src/services/escrow-enclave/__fixtures__/tls/pinned-leaf.key +5 -0
  177. package/src/services/escrow-enclave/__fixtures__/tls/pinned-leaf.pem +12 -0
  178. package/src/services/escrow-enclave/__fixtures__/tls/rogue-ca.pem +11 -0
  179. package/src/services/escrow-enclave/__fixtures__/tls/rogue-leaf.key +5 -0
  180. package/src/services/escrow-enclave/__fixtures__/tls/rogue-leaf.pem +12 -0
  181. package/src/services/escrow-enclave/blobIdentity.test.ts +42 -0
  182. package/src/services/escrow-enclave/blobIdentity.ts +55 -0
  183. package/src/services/escrow-enclave/cancellation.ts +59 -0
  184. package/src/services/escrow-enclave/enclaveTrustAnchors.ts +34 -0
  185. package/src/services/escrow-enclave/index.ts +174 -0
  186. package/src/services/escrow-enclave/notifications.test.ts +377 -0
  187. package/src/services/escrow-enclave/notifications.ts +348 -0
  188. package/src/services/escrow-enclave/production.test.ts +34 -0
  189. package/src/services/escrow-enclave/remoteEnclave.contract.test.ts +344 -0
  190. package/src/services/escrow-enclave/remoteEnclave.test.ts +490 -0
  191. package/src/services/escrow-enclave/remoteEnclave.tls.test.ts +62 -0
  192. package/src/services/escrow-enclave/remoteEnclave.ts +263 -0
  193. package/src/services/escrow-enclave/softwareEnclave.test.ts +450 -0
  194. package/src/services/escrow-enclave/softwareEnclave.ts +254 -0
  195. package/src/services/escrow-enclave/types.ts +122 -0
  196. package/src/types/seed-encryption.ts +51 -0
  197. package/src/types/seed-migration.ts +61 -0
  198. package/test/auth-tickets.spec.ts +480 -0
  199. package/test/credentials.spec.ts +2 -0
  200. package/test/helpers/keycloak-broker.ts +181 -0
  201. package/test/helpers/live-mongo.ts +9 -0
  202. package/test/jobs/escrowBlobRewrap.spec.ts +262 -0
  203. package/test/jobs/escrowHoldReminders.spec.ts +319 -0
  204. package/test/keycloak-admin.spec.ts +58 -0
  205. package/test/keycloak-broker-roundtrip.integration.spec.ts +40 -0
  206. package/test/keycloak-migration.integration.spec.ts +266 -0
  207. package/test/keycloak-provider-links.spec.ts +201 -0
  208. package/test/keycloak-social-broker.integration.spec.ts +228 -0
  209. package/test/keycloak-verify.integration.spec.ts +100 -0
  210. package/test/keycloak-verify.spec.ts +307 -0
  211. package/test/keys-escrow.spec.ts +2328 -0
  212. package/test/keys-identity-rebind.spec.ts +456 -0
  213. package/test/keys-lifecycle-invariants.spec.ts +785 -0
  214. package/test/keys-recovery-confirmation.spec.ts +586 -0
  215. package/test/keys-recovery-rate-limit.spec.ts +123 -0
  216. package/test/keys-secret-leakage.spec.ts +265 -0
  217. package/test/models/authSubjectIndexes.spec.ts +132 -0
  218. package/test/models/escrow.spec.ts +572 -0
  219. package/test/notifications.spec.ts +4 -0
  220. package/test/oidc-e2e/broker.spec.ts +116 -0
  221. package/test/oidc-e2e/compose.yaml +33 -0
  222. package/test/oidc-e2e/fixtures.ts +245 -0
  223. package/test/oidc-e2e/lock.test.mjs +73 -0
  224. package/test/oidc-e2e/playwright.config.ts +21 -0
  225. package/test/oidc-e2e/run.mjs +312 -0
  226. package/test/oidc-e2e/tsconfig.json +10 -0
  227. package/test/oidc-e2e/types.ts +21 -0
  228. package/test/oidc.integration.spec.ts +54 -0
  229. package/test/oidc.spec.ts +697 -0
  230. package/test/oidcLambdaApp.boot.spec.ts +105 -0
  231. package/test/provision-keycloak-users.spec.ts +299 -0
  232. package/test/signing-authority-http.spec.ts +161 -0
  233. package/test/signing-authority-seed-migration.spec.ts +414 -0
  234. package/test/signing-authority.spec.ts +90 -0
  235. package/tsconfig.build.json +1 -1
  236. package/tsconfig.json +1 -0
  237. package/vitest.config.ts +24 -1
  238. package/vitest.integration.config.ts +25 -3
  239. package/dist/cache/signing-authority.d.ts +0 -6
  240. package/src/cache/signing-authority.ts +0 -37
@@ -36,8 +36,12 @@ 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}
42
+ SA_SEED_LOCAL_KEK: ${env:SA_SEED_LOCAL_KEK, ''}
43
+ SA_SEED_ENCRYPT_WRITES: ${env:SA_SEED_ENCRYPT_WRITES, 'false'}
44
+ SA_SEED_ALLOW_LEGACY_READ: ${env:SA_SEED_ALLOW_LEGACY_READ, 'true'}
41
45
  DOMAIN_NAME: ${env:DOMAIN_NAME}
42
46
  OPENAI_API_KEY: ${env:OPENAI_API_KEY}
43
47
  METABASE_SECRET_KEY: ${env:METABASE_SECRET_KEY}
@@ -51,6 +55,16 @@ provider:
51
55
  MONGO_DB_NAME: ${env:MONGO_DB_NAME}
52
56
  AUTHORIZED_DIDS: ${env:AUTHORIZED_DIDS}
53
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, ""}
54
68
  REDIS_HOST:
55
69
  'Fn::GetAtt': [ElasticCacheCluster, RedisEndpoint.Address]
56
70
  REDIS_PORT:
@@ -98,6 +112,17 @@ functions:
98
112
  url:
99
113
  cors: true
100
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
+
101
126
  swagger:
102
127
  handler: lambda.swaggerUiHandler
103
128
  package:
@@ -223,6 +248,8 @@ resources:
223
248
  Properties:
224
249
  AutoMinorVersionUpgrade: true
225
250
  Engine: redis
251
+ # GETDEL (login codes/tickets/auth codes) needs Redis >= 6.2
252
+ EngineVersion: '7.0'
226
253
  CacheNodeType: ${self:custom.config.CACHE_INSTANCE_SIZE}
227
254
  NumCacheNodes: 1
228
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
@@ -129,32 +129,17 @@ provider:
129
129
  timeout: 29
130
130
  stage: ${opt:stage, "dev"}
131
131
  region: ${opt:region, "us-east-1"}
132
- environment:
133
- LAMBDA_STAGE: ${opt:stage, "dev"}
134
- SEED: ${env:SEED, ''}
135
- DOMAIN_NAME: ${env:DOMAIN_NAME, ''}
136
- OPENAI_API_KEY: ${env:OPENAI_API_KEY, ''}
137
- METABASE_SECRET_KEY: ${env:METABASE_SECRET_KEY, ''}
138
- POSTMARK_SERVER_TOKEN: ${env:POSTMARK_SERVER_TOKEN, ''}
139
- POSTMARK_LOGIN_CODE_TEMPLATE_ALIAS: ${env:POSTMARK_LOGIN_CODE_TEMPLATE_ALIAS, ''}
140
- POSTMARK_ENDORSEMENT_REQUEST_TEMPLATE_ALIAS: ${env:POSTMARK_ENDORSEMENT_REQUEST_TEMPLATE_ALIAS, ''}
141
- POSTMARK_RECOVERY_EMAIL_CODE_TEMPLATE_ALIAS: ${env:POSTMARK_RECOVERY_EMAIL_CODE_TEMPLATE_ALIAS, ''}
142
- POSTMARK_RECOVERY_KEY_TEMPLATE_ALIAS: ${env:POSTMARK_RECOVERY_KEY_TEMPLATE_ALIAS, ''}
143
- POSTMARK_FROM_EMAIL: ${env:POSTMARK_FROM_EMAIL, ''}
144
- POSTMARK_BRAND_NAME: ${env:POSTMARK_BRAND_NAME, ''}
145
- PORT: ${opt:httpPort, "3000"}
146
- MONGO_URI: ${env:MONGO_URI, ''}
147
- MONGO_DB_NAME: ${env:MONGO_DB_NAME, ''}
148
- AUTHORIZED_DIDS: ${env:AUTHORIZED_DIDS, ''}
149
- GOOGLE_APPLICATION_CREDENTIAL: ${env:GOOGLE_APPLICATION_CREDENTIAL, ''}
150
- SCOUTS_SSO_CLIENT_SECRET: ${env:SCOUTS_SSO_CLIENT_SECRET, ''}
151
- LEARN_CLOUD_URL: ${env:LEARN_CLOUD_URL, ''}
152
- SENTRY_DSN: ${env:SENTRY_DSN, ''}
153
- SENTRY_ENV: ${env:SENTRY_ENV, ''}
154
- REDIS_HOST:
155
- 'Fn::GetAtt': [ElasticCacheCluster, RedisEndpoint.Address]
156
- REDIS_PORT:
157
- '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-*
158
143
  tracing:
159
144
  apiGateway: true
160
145
  lambda: true
@@ -167,6 +152,9 @@ provider:
167
152
  functions:
168
153
  trpc:
169
154
  handler: lambda.trpcHandler
155
+ role: SigningAuthorityExecutionRole
156
+ dependsOn: [SigningAuthoritySeedKeyPolicy]
157
+ environment: ${file(./serverless.function-env.cjs):api}
170
158
  vpc:
171
159
  securityGroupIds:
172
160
  - 'Fn::GetAtt': [ServerlessSecurityGroup, GroupId]
@@ -177,6 +165,9 @@ functions:
177
165
 
178
166
  api:
179
167
  handler: lambda.openApiHandler
168
+ role: SigningAuthorityExecutionRole
169
+ dependsOn: [SigningAuthoritySeedKeyPolicy]
170
+ environment: ${file(./serverless.function-env.cjs):api}
180
171
  vpc:
181
172
  securityGroupIds:
182
173
  - 'Fn::GetAtt': [ServerlessSecurityGroup, GroupId]
@@ -187,6 +178,7 @@ functions:
187
178
 
188
179
  didWeb:
189
180
  handler: lambda.didWebHandler
181
+ environment: ${file(./serverless.function-env.cjs):api}
190
182
  vpc:
191
183
  securityGroupIds:
192
184
  - 'Fn::GetAtt': [ServerlessSecurityGroup, GroupId]
@@ -198,8 +190,44 @@ functions:
198
190
  url:
199
191
  cors: true
200
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
+
207
+ seedMigration:
208
+ handler: seedMigrationLambda.handler
209
+ role: SigningAuthorityExecutionRole
210
+ timeout: 900
211
+ reservedConcurrency: 1
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}
216
+ # IAM invoke only: intentionally no events, schedule, or function URL.
217
+ vpc:
218
+ securityGroupIds:
219
+ - Fn::GetAtt: [ServerlessSecurityGroup, GroupId]
220
+ subnetIds:
221
+ - Ref: PrivateSubnetA
222
+
201
223
  swagger:
202
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
203
231
  package:
204
232
  patterns:
205
233
  - src/swagger-ui/**
@@ -207,12 +235,248 @@ functions:
207
235
  - httpApi: GET /docs
208
236
  - httpApi: GET /docs/{trpc+}
209
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
+
210
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, ""}', '']
276
+ Outputs:
277
+ SigningAuthoritySeedKeyArn:
278
+ Value:
279
+ Fn::GetAtt: [SigningAuthoritySeedKey, Arn]
280
+ SigningAuthoritySeedMigrationFunctionName:
281
+ Value:
282
+ Ref: SeedMigrationLambdaFunction
211
283
  Resources:
284
+ # Only the two signing entry points and the migration worker assume this role.
285
+ # Keep KMS references out of the role itself to avoid a Role -> Key -> Role cycle.
286
+ SigningAuthorityExecutionRole:
287
+ Type: AWS::IAM::Role
288
+ Properties:
289
+ AssumeRolePolicyDocument:
290
+ Version: '2012-10-17'
291
+ Statement:
292
+ - Effect: Allow
293
+ Principal:
294
+ Service: lambda.amazonaws.com
295
+ Action: sts:AssumeRole
296
+ ManagedPolicyArns:
297
+ - Fn::Sub: arn:${AWS::Partition}:iam::aws:policy/service-role/AWSLambdaVPCAccessExecutionRole
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-*
332
+ SigningAuthoritySeedKey:
333
+ Type: AWS::KMS::Key
334
+ DeletionPolicy: Retain
335
+ UpdateReplacePolicy: Retain
336
+ Properties:
337
+ Description: Envelope encryption for hosted signing-authority seeds
338
+ KeySpec: SYMMETRIC_DEFAULT
339
+ KeyUsage: ENCRYPT_DECRYPT
340
+ EnableKeyRotation: true
341
+ PendingWindowInDays: 30
342
+ KeyPolicy:
343
+ Version: '2012-10-17'
344
+ Statement:
345
+ - Sid: AccountKeyAdministration
346
+ Effect: Allow
347
+ Principal:
348
+ AWS:
349
+ Fn::Sub: arn:${AWS::Partition}:iam::${AWS::AccountId}:root
350
+ Action:
351
+ - kms:DescribeKey
352
+ - kms:GetKeyPolicy
353
+ - kms:PutKeyPolicy
354
+ - kms:ListResourceTags
355
+ - kms:TagResource
356
+ - kms:UntagResource
357
+ - kms:GetKeyRotationStatus
358
+ - kms:EnableKeyRotation
359
+ - kms:DisableKeyRotation
360
+ - kms:UpdateKeyDescription
361
+ - kms:EnableKey
362
+ - kms:DisableKey
363
+ - kms:ScheduleKeyDeletion
364
+ - kms:CancelKeyDeletion
365
+ - kms:CreateAlias
366
+ - kms:UpdateAlias
367
+ - kms:DeleteAlias
368
+ Resource: '*'
369
+ - Sid: LcaApiSeedEncryption
370
+ Effect: Allow
371
+ Principal:
372
+ AWS:
373
+ Fn::GetAtt: [SigningAuthorityExecutionRole, Arn]
374
+ Action: [kms:GenerateDataKey, kms:Decrypt]
375
+ Resource: '*'
376
+ Condition:
377
+ StringEquals:
378
+ kms:EncryptionContext:purpose: lca-signing-authority-seed
379
+ - Sid: DenyOtherCryptographicPrincipals
380
+ Effect: Deny
381
+ Principal: '*'
382
+ Action:
383
+ [kms:Decrypt, 'kms:ReEncrypt*', 'kms:GenerateDataKey*', kms:Encrypt]
384
+ Resource: '*'
385
+ Condition:
386
+ ArnNotEquals:
387
+ aws:PrincipalArn:
388
+ Fn::GetAtt: [SigningAuthorityExecutionRole, Arn]
389
+ SigningAuthoritySeedKeyAlias:
390
+ Type: AWS::KMS::Alias
391
+ Properties:
392
+ AliasName: alias/${self:service}-${self:provider.stage}-sa-seeds
393
+ TargetKeyId:
394
+ Ref: SigningAuthoritySeedKey
395
+ # The direct execution-role grant in the KEY policy authorizes cryptographic use.
396
+ # This matching IAM allow is not required by that grant; it documents the scope and
397
+ # provides a dependsOn deployment barrier. Keeping it separate avoids a Role -> Key
398
+ # -> Role CloudFormation dependency cycle. Swagger/didWeb use the generated role.
399
+ SigningAuthoritySeedKeyPolicy:
400
+ Type: AWS::IAM::Policy
401
+ Properties:
402
+ PolicyName: ${self:service}-${self:provider.stage}-sa-seeds
403
+ Roles:
404
+ - Ref: SigningAuthorityExecutionRole
405
+ PolicyDocument:
406
+ Version: '2012-10-17'
407
+ Statement:
408
+ - Effect: Allow
409
+ Action: [kms:GenerateDataKey, kms:Decrypt]
410
+ Resource:
411
+ Fn::GetAtt: [SigningAuthoritySeedKey, Arn]
412
+ Condition:
413
+ StringEquals:
414
+ kms:EncryptionContext:purpose: lca-signing-authority-seed
212
415
  VPC:
213
416
  Type: AWS::EC2::VPC
214
417
  Properties:
215
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
216
480
  IP:
217
481
  Type: AWS::EC2::EIP
218
482
  Properties:
@@ -323,6 +587,10 @@ resources:
323
587
  Properties:
324
588
  AutoMinorVersionUpgrade: true
325
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).
326
594
  CacheNodeType: ${self:custom.config.CACHE_INSTANCE_SIZE}
327
595
  NumCacheNodes: 1
328
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';
@@ -3,6 +3,7 @@ import { getEphemeralLearnCard } from '@helpers/learnCard.helpers';
3
3
  import { SigningAuthorities } from '.';
4
4
  import crypto from 'node:crypto';
5
5
  import { v4 as uuidv4 } from 'uuid';
6
+ import { logSeedEncryptionFailure, seedEncryption } from '@helpers/seedEncryption.helpers';
6
7
 
7
8
  export const createSigningAuthorityForDID = async (
8
9
  ownerDid: string,
@@ -10,19 +11,22 @@ export const createSigningAuthorityForDID = async (
10
11
  ): Promise<string | false> => {
11
12
  const seed =
12
13
  environment.NODE_ENV === 'test' ? 'e'.repeat(64) : crypto.randomBytes(32).toString('hex');
13
- const did = (await getEphemeralLearnCard(seed)).id.did();
14
+ const _id = uuidv4();
14
15
  try {
16
+ const did = (await getEphemeralLearnCard(seed)).id.did();
17
+ const identity = { _id, ownerDid, did, name };
18
+ // Plaintext writes support the compatibility deployment and offline opt-in defaults.
19
+ const secret = environment.SA_SEED_ENCRYPT_WRITES
20
+ ? await seedEncryption.encrypt(seed, identity)
21
+ : { seed };
15
22
  return (
16
23
  await SigningAuthorities.insertOne({
17
- _id: uuidv4(),
18
- ownerDid,
19
- did,
20
- name,
21
- seed,
24
+ ...identity,
25
+ ...secret,
22
26
  })
23
27
  ).insertedId;
24
28
  } catch (e) {
25
- console.error(e);
29
+ logSeedEncryptionFailure(e, 'create', { _id });
26
30
  return false;
27
31
  }
28
32
  };
@@ -7,4 +7,7 @@ export const getSigningAuthoritiesCollection = () => {
7
7
 
8
8
  export const SigningAuthorities = getSigningAuthoritiesCollection();
9
9
 
10
- SigningAuthorities.createIndex({ ownerDid: 1, name: 1 }, { unique: true });
10
+ // Fire-and-forget at module load; see accesslayer/notifications for why it must not reject.
11
+ SigningAuthorities.createIndex({ ownerDid: 1, name: 1 }, { unique: true }).catch(error => {
12
+ console.error('Signing authority unique index creation failed:', error);
13
+ });
package/src/app.ts CHANGED
@@ -10,7 +10,9 @@ import { analyticsRouter } from '@routes/analytics';
10
10
  export { createContext } from '@routes';
11
11
  import { preferencesRouter } from '@routes/preferences';
12
12
  import { keysRouter } from '@routes/keys';
13
+ import { escrowRouter } from '@routes/escrow';
13
14
  import { qrLoginRouter } from '@routes/qr-login';
15
+ import { authRouter } from '@routes/auth';
14
16
  import { testRouter, type TestRouter } from '@routes/test';
15
17
  import { environment } from '@environment';
16
18
 
@@ -25,7 +27,9 @@ const routes = {
25
27
  analytics: analyticsRouter,
26
28
  preferences: preferencesRouter,
27
29
  keys: keysRouter,
30
+ escrow: escrowRouter,
28
31
  qrLogin: qrLoginRouter,
32
+ auth: authRouter,
29
33
  };
30
34
 
31
35
  export const appRouter = t.router<typeof routes & { test?: TestRouter }>({
@@ -4,6 +4,7 @@ export const getChallengeCacheKey = (did: string, challenge: string): string =>
4
4
  `challenge|${did}|${challenge}`;
5
5
 
6
6
  export const VALID = 'valid';
7
+ export const DID_CHALLENGE_TTL_SECS = 5 * 60;
7
8
 
8
9
  export const isChallengeValidForDid = async (
9
10
  did: string,
@@ -14,8 +15,17 @@ export const isChallengeValidForDid = async (
14
15
  return result === VALID ? result : undefined;
15
16
  };
16
17
 
18
+ /** Atomically validate and consume a challenge so concurrent replays cannot both succeed. */
19
+ export const consumeChallengeForDid = async (did: string, challenge: string): Promise<boolean> => {
20
+ const key = getChallengeCacheKey(did, challenge);
21
+ const redis = cache.redis ?? cache.node;
22
+ const value = await redis.getdel(key);
23
+
24
+ return value === VALID;
25
+ };
26
+
17
27
  export const setValidChallengeForDid = async (did: string, challenge: string) => {
18
- return cache.set(getChallengeCacheKey(did, challenge), VALID);
28
+ return cache.set(getChallengeCacheKey(did, challenge), VALID, DID_CHALLENGE_TTL_SECS);
19
29
  };
20
30
 
21
31
  export const setValidChallengesForDid = async (did: string, challenges: string[]) => {
@@ -25,7 +35,7 @@ export const setValidChallengesForDid = async (did: string, challenges: string[]
25
35
  return acc;
26
36
  }, {});
27
37
 
28
- return cache.mset(values);
38
+ return cache.mset(values, DID_CHALLENGE_TTL_SECS);
29
39
  };
30
40
 
31
41
  export const invalidateChallengeForDid = async (did: string, challenge: string) => {
@@ -0,0 +1,34 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest';
2
+ import cache from '@cache';
3
+ import { consumeMatchingCode } from './getDel';
4
+
5
+ vi.mock('@cache', async () => {
6
+ const { default: Redis } = await import('ioredis-mock');
7
+ return { default: { node: new Redis() } };
8
+ });
9
+
10
+ afterEach(async () => {
11
+ await cache.node.flushall();
12
+ });
13
+
14
+ describe('consumeMatchingCode', () => {
15
+ it('preserves a valid code after a wrong guess', async () => {
16
+ await cache.node.set('login-code:test@example.com', '123456');
17
+ expect(await consumeMatchingCode('login-code:test@example.com', '000000')).toBe(false);
18
+ expect(await cache.node.get('login-code:test@example.com')).toBe('123456');
19
+ });
20
+
21
+ it('allows exactly one concurrent redemption and rejects replay', async () => {
22
+ await cache.node.set('login-code:test@example.com', '123456');
23
+ const results = await Promise.all([
24
+ consumeMatchingCode('login-code:test@example.com', '123456'),
25
+ consumeMatchingCode('login-code:test@example.com', '123456'),
26
+ ]);
27
+ expect(results.filter(Boolean)).toHaveLength(1);
28
+ expect(await consumeMatchingCode('login-code:test@example.com', '123456')).toBe(false);
29
+ });
30
+
31
+ it('rejects missing codes', async () => {
32
+ expect(await consumeMatchingCode('login-code:missing@example.com', '123456')).toBe(false);
33
+ });
34
+ });