@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
package/.env.example CHANGED
@@ -1,9 +1,27 @@
1
1
  # LCA API environment
2
2
  # Copy to .env. Validation runs before database/client initialization.
3
+ #
4
+ # Config resolves lowest→highest: schema defaults < config/config.json <
5
+ # config/config.<stage>.json < runtime AWS secrets bundle (Lambda only) < real env.
6
+ # Empty values are unset. The config/config*.json stage files are PUBLIC BY DESIGN:
7
+ # put only non-secret config there. Secrets (credentials, AUTHORIZED_DIDS allowlist,
8
+ # internal endpoints, signing keys) stay in the runtime bundle or this local .env.
9
+ # Stage = LAMBDA_STAGE on Lambda, else CONFIG_STAGE; unknown stage uses config.json only.
10
+ # Self-hosting: copy config/config.example.json to a stage file, or just use this .env.
11
+ # Keep the entries you need locally even once a stage uses the runtime bundle.
3
12
 
4
13
  NODE_ENV=development
14
+ # Lambda sets LAMBDA_STAGE; elsewhere CONFIG_STAGE selects the config.<stage>.json file.
15
+ LAMBDA_STAGE=
16
+ CONFIG_STAGE=
5
17
  PORT=3000
6
18
  SEED=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
19
+ # Local development keeps plaintext behavior by default. To opt in, enable encrypted
20
+ # writes and set a key from `openssl rand -hex 32`; retain it for the database lifetime.
21
+ SA_SEED_LOCAL_KEK=
22
+ SA_SEED_KMS_KEY_ARN=
23
+ SA_SEED_ENCRYPT_WRITES=false
24
+ SA_SEED_ALLOW_LEGACY_READ=true
7
25
  MONGO_URI=mongodb://localhost:27017
8
26
  MONGO_DB_NAME=learncard
9
27
  DOMAIN_NAME=
@@ -12,10 +30,57 @@ AUTHORIZED_DIDS=
12
30
  LEARN_CLOUD_URL=
13
31
  NETWORK_BRAIN_SERVICE_URL=
14
32
  GOOGLE_APPLICATION_CREDENTIAL=
33
+ # Lambda deploys may instead set RUNTIME_SECRETS_ID=lca-api/<stage>/runtime-secrets.
34
+ # This secret bundle holds secrets only; non-secret per-stage config lives in the
35
+ # checked-in config/config.<stage>.json files, not here and not in the bundle.
36
+ # SecretString: flat env-name -> string JSON (including the Firebase JSON as a string).
37
+ # Loaded before validation; non-empty explicit env wins. Leave unset for local/Docker/CI.
38
+ # Keep all GitHub runtime secret fallbacks until each stage has a complete bundle.
39
+ RUNTIME_SECRETS_ID=
15
40
  OPENAI_API_KEY=
16
41
  METABASE_SECRET_KEY=
17
42
  SCOUTS_SSO_CLIENT_SECRET=
18
43
 
44
+ # Comma-separated exact Keycloak issuers (e.g. http://localhost:8081/realms/learncard).
45
+ KEYCLOAK_ISSUERS=
46
+ # Comma-separated allowed client IDs (e.g. learncard-app); matches aud or azp.
47
+ KEYCLOAK_AUDIENCES=
48
+ # Optional issuer=url pairs for internal JWKS endpoints, separated by commas.
49
+ # Example: http://localhost:8081/realms/learncard=http://keycloak:8080/realms/learncard/protocol/openid-connect/certs
50
+ KEYCLOAK_JWKS_URL_OVERRIDES=
51
+
52
+ # Minimal self-issued OIDC provider (AD-2). Keycloak trusts lca-api as an
53
+ # upstream Identity Provider: the app proves ownership in-app, exchanges the
54
+ # proof for a single-use login ticket, and Keycloak redeems it via the
55
+ # authorization-code flow (/oidc/authorize -> /oidc/token).
56
+ # OIDC_ISSUER: REQUIRED issuer identifier baked into discovery/tokens (no
57
+ # default; unset = 503).
58
+ # OIDC_SIGNING_KEY_JWK: local/Docker/CI override, JSON RSA private JWK with d, kid
59
+ # and alg RS256, SEPARATE from SEED. Takes precedence over the secret id.
60
+ # OIDC_SIGNING_KEY_SECRET_ID: deployed Lambda secret name or ARN, convention
61
+ # lca-api/<stage>/oidc-signing-jwk in the Lambda account/region. SecretString
62
+ # must be a plain string containing the same JWK JSON (no wrapper).
63
+ # Fetched and cached per process; failed loads retry and readiness returns 503.
64
+ # Redeploy after rotation. Neither source set: prod = 503; dev/CI = ephemeral key.
65
+ # OIDC_CLIENT_ID / OIDC_CLIENT_SECRET: credentials of the confidential Keycloak
66
+ # broker client that calls /oidc/token (default id 'keycloak-broker').
67
+ # OIDC_CLIENT_SECRET stays a GitHub-environment secret on the OIDC function and
68
+ # is NEVER placed in the runtime bundle; its signing key uses a separate AWS secret.
69
+ # OIDC_REDIRECT_URIS: optional redirect-uri allowlist override (defaults to
70
+ # each KEYCLOAK_ISSUERS entry plus /broker/lca-api/endpoint). Exact match only;
71
+ # when neither list is set every redirect is rejected.
72
+ OIDC_ISSUER=
73
+ OIDC_SIGNING_KEY_JWK=
74
+ OIDC_SIGNING_KEY_SECRET_ID=
75
+ OIDC_CLIENT_ID=
76
+ OIDC_CLIENT_SECRET=
77
+ OIDC_REDIRECT_URIS=
78
+
79
+ # Native Google/Apple social login: comma-separated allowed client ids.
80
+ # Issuers and JWKS are fixed to the respective provider.
81
+ GOOGLE_OAUTH_CLIENT_IDS=
82
+ APPLE_OAUTH_CLIENT_IDS=
83
+
19
84
  # Configure both Postmark credentials or leave both empty for log delivery.
20
85
  POSTMARK_SERVER_TOKEN=
21
86
  POSTMARK_FROM_EMAIL=
@@ -25,6 +90,38 @@ POSTMARK_ENDORSEMENT_REQUEST_TEMPLATE_ALIAS=
25
90
  POSTMARK_RECOVERY_EMAIL_CODE_TEMPLATE_ALIAS=
26
91
  POSTMARK_RECOVERY_KEY_TEMPLATE_ALIAS=
27
92
 
93
+ ESCROW_RELAY_URL=
94
+ ESCROW_RELAY_AUTH_TOKEN=
95
+
96
+ # Automatic (escrow) recovery. Unset disables all /keys/escrow routes.
97
+ # Local dev: set ESCROW_ENCLAVE_MODE=software with the disposable fixture below, which
98
+ # pairs with the public key in apps/learn-card-app/environments/learncard/config.local.json.
99
+ # The fixture is NOT a secret and must never be used outside local development.
100
+ # See docs/how-to-guides/deploy-infrastructure/sss-key-management-config.md.
101
+ ESCROW_ENCLAVE_MODE=
102
+ ESCROW_ENCLAVE_ACTIVE_KEY_ID=
103
+ ESCROW_ENCLAVE_SOFTWARE_PRIVATE_KEYS_JSON=
104
+ # ESCROW_ENCLAVE_MODE=software
105
+ # ESCROW_ENCLAVE_ACTIVE_KEY_ID=local-dev
106
+ # ESCROW_ENCLAVE_SOFTWARE_PRIVATE_KEYS_JSON={"local-dev":"MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgv1vJWrZWg8ydq4ibGe6/IqEw/BEIcgI+Y3c6hnoeF2ehRANCAAQ08rGhw2reTThF/ecfafLzjphjmqvySHJZwqKfqSn8YPK/1GMWODPWounlJ2MZb/iS/D69K0k9durGsGjaSb0I"}
107
+ # Waiting period in ms (default 7 days). Local QA only: shorten to one minute.
108
+ ESCROW_HOLD_DURATION_MS=
109
+ ESCROW_HOLD_RESTART_MIN_AGE_MS=
110
+ # ESCROW_HOLD_DURATION_MS=60000
111
+
112
+ # ESCROW_ENCLAVE_MODE=remote: HTTP client to the enclave-host parent (P3.3).
113
+ # URL must be https:// unless NODE_ENV=test or the host is localhost. Token
114
+ # is a bearer credential, minimum 32 characters. Timeout defaults to 10000ms
115
+ # (max 30000ms). Missing/invalid config fails escrow closed at request time
116
+ # rather than crashing the whole service at boot.
117
+ ESCROW_ENCLAVE_REMOTE_URL=
118
+ ESCROW_ENCLAVE_REMOTE_TOKEN=
119
+ ESCROW_ENCLAVE_REMOTE_TIMEOUT_MS=
120
+
121
+ # Emergency stop: true blocks every escrow release (read per request, no restart needed).
122
+ # Deploy toggle, forwarded even in runtime-secrets bundle mode.
123
+ ESCROW_RELEASE_KILL_SWITCH=
124
+
28
125
  ANDROID_PUSH_ICON=
29
126
  REDIS_HOST=
30
127
  REDIS_PORT=
@@ -34,8 +131,8 @@ TROOP_DASHBOARD_ID=
34
131
 
35
132
  SENTRY_DSN=
36
133
  SENTRY_ENV=development
37
- IS_OFFLINE=false
134
+ IS_OFFLINE=true
38
135
  IS_CI=false
39
136
  IS_E2E_TEST=false
40
137
  SKIP_DIDKIT_NAPI=false
41
- CI=false
138
+ CI=false
package/AGENTS.md CHANGED
@@ -4,22 +4,80 @@ The LearnCard Application API (`lca-api`) is the tRPC service backing client-sid
4
4
 
5
5
  ## Build & Development Commands
6
6
 
7
- - Build: `bun run build`
8
- - Dev: `bun run dev` — watches and rebuilds
9
- - Start: `bun run start` — local server (default port from env, typically 5100)
10
- - Test: `bun run test`
11
- - Run tests once (non-watch): `bun run test -- run` (equivalent to `vitest run`)
12
- - Run single test: `bun run test test/credentials.spec.ts`
13
- - Typecheck: `bunx tsc --noEmit`
7
+ ### Backend configuration model
8
+
9
+ Configuration resolves in layers, lowest to highest precedence:
10
+
11
+ 1. Schema defaults (`src/config/environment.ts` Zod `.default()`s).
12
+ 2. Checked-in **non-secret** base config `config/config.json`.
13
+ 3. Checked-in **non-secret** per-stage config `config/config.<stage>.json`.
14
+ 4. Runtime AWS secrets bundle (Lambda only).
15
+ 5. Real environment variables (always win). Empty strings are unset.
16
+
17
+ The stage is `LAMBDA_STAGE` on Lambda, else `CONFIG_STAGE`; an unknown stage uses
18
+ the base `config/config.json` only (no per-stage file). The config stage files are
19
+ **public by design** — put only non-secret config in them. Credentials,
20
+ authorization allowlists (`AUTHORIZED_DIDS`), and internal service endpoints are
21
+ **secrets** and belong in the runtime bundle or local env, never the stage files.
22
+
23
+ For Lambda `trpc`/`api`, set the deploy environment variable `RUNTIME_SECRETS_ID` to
24
+ `lca-api/<stage>/runtime-secrets` (or its ARN in the Lambda account/region). Provision
25
+ the secret before enabling the variable. Its SecretString is a flat JSON object of
26
+ UPPER_SNAKE_CASE environment names to string values, including **all** required
27
+ runtime credentials. For example, Firebase is encoded as a string:
28
+
29
+ ```json
30
+ { "GOOGLE_APPLICATION_CREDENTIAL": "{\"type\":\"service_account\",\"...\":\"...\"}" }
31
+ ```
32
+
33
+ The Firebase JSON is a **string inside the bundle**, not a nested object. No secret
34
+ values belong in checked-in files. `lambda.ts` loads and validates the bundle once
35
+ before importing `lambdaApp.ts` and the environment schema. Non-empty explicit env
36
+ values win; empty strings are unset. Failed secret loads stop startup with a
37
+ sanitized error and can retry; failed application imports remain memoized until
38
+ the process is recycled. Redeploy/recycle functions after rotating the bundle.
39
+
40
+ When the id is absent, no AWS lookup occurs. Deploys retain the GitHub
41
+ runtime secrets as environment fallbacks; keep those inputs
42
+ until every stage has a bundle. Self-hosters, Docker, local development and CI keep
43
+ using plain environment variables — copy `config/config.example.json` to a stage
44
+ file (register its static import in `src/config/stageConfig.ts` and rebuild) or
45
+ just supply a `.env`. `trpc`, `api`, and `seedMigration` use
46
+ `SigningAuthorityExecutionRole`. `didWeb` and `swagger` use the generated default
47
+ role, with only a scoped runtime-bundle read grant in addition to Serverless's
48
+ execution permissions, not signing-authority KMS access. All five receive the
49
+ bundle id and run in the service VPC. Explicit function roles do not inherit the
50
+ default role's grants. OIDC has an isolated
51
+ `oidcLambda.ts` → `oidcLambdaApp.ts` import path and focused OIDC/cache schemas,
52
+ so it never imports the API schema requiring seed and Mongo credentials. Keep that path free of Mongo, models and DIDKit:
53
+ a Mongo connect left pending when an idle sandbox freezes crashes the next request after thaw. OIDC gets
54
+ its broker settings and keeps its separate signing-key secret and IAM role.
55
+ `OIDC_CLIENT_SECRET` stays a GitHub-environment secret on the OIDC function and is
56
+ **never** placed in the runtime bundle; that function makes no AWS Secrets Manager
57
+ calls for it. Preserve the existing issuer/audience validation.
58
+
59
+ The Infisical → AWS sync and per-stage cutover are documented in the repo-root
60
+ [environment-variables.md](../../../environment-variables.md#infisical--aws-secrets-sync).
61
+ The shared `@learncard/service-config` package owns parsing, merging, runtime
62
+ loading, stage application and Lambda bootstrap. Brain-service and LearnCloud
63
+ adoption remains a separate task.
64
+
65
+ - Build: `bun run build`
66
+ - Dev: `bun run dev` — watches and rebuilds
67
+ - Start: `bun run start` — local server (default port from env, typically 5100)
68
+ - Test: `bun run test`
69
+ - Run tests once (non-watch): `bun run test -- run` (equivalent to `vitest run`)
70
+ - Run single test: `bun run test test/credentials.spec.ts`
71
+ - Typecheck: `bunx tsc --noEmit`
14
72
 
15
73
  ## Code Style Guidelines
16
74
 
17
- - **TypeScript**: Strict typing, interfaces in dedicated files under `src/types/`
18
- - **Imports**: Path aliases (`@helpers/*`, `@routes/*`, `@cache/*`)
19
- - **Routes**: tRPC routers under `src/routes/` — one router per resource
20
- - **Error handling**: `TRPCError` with specific codes (`BAD_REQUEST`, `UNAUTHORIZED`, `INTERNAL_SERVER_ERROR`, `NOT_FOUND`)
21
- - **Named exports only** — no one-line default exports
22
- - **Explicit return types** on all exported functions
75
+ - **TypeScript**: Strict typing, interfaces in dedicated files under `src/types/`
76
+ - **Imports**: Path aliases (`@helpers/*`, `@routes/*`, `@cache/*`)
77
+ - **Routes**: tRPC routers under `src/routes/` — one router per resource
78
+ - **Error handling**: `TRPCError` with specific codes (`BAD_REQUEST`, `UNAUTHORIZED`, `INTERNAL_SERVER_ERROR`, `NOT_FOUND`)
79
+ - **Named exports only** — no one-line default exports
80
+ - **Explicit return types** on all exported functions
23
81
 
24
82
  ## Request Lifecycle & Context
25
83
 
@@ -32,33 +90,86 @@ Every request goes through `createContext` in `src/routes/index.ts`, which:
32
90
 
33
91
  ### Route Procedures
34
92
 
35
- - `openRoute` — unauthenticated, tenant + Sentry tracing attached
36
- - `didRoute` — requires `ctx.user.did` (DID-auth VP verified, challenge may be stale)
37
- - `didAndChallengeRoute` — requires `didRoute` + live challenge
38
- - `authorizedDidRoute` — requires `didRoute` + DID in `AUTHORIZED_DIDS` env allowlist
93
+ - `openRoute` — unauthenticated, tenant + Sentry tracing attached
94
+ - `didRoute` — requires `ctx.user.did` (DID-auth VP verified, challenge may be stale)
95
+ - `didAndChallengeRoute` — requires `didRoute` + live challenge
96
+ - `authorizedDidRoute` — requires `didRoute` + DID in `AUTHORIZED_DIDS` env allowlist
39
97
 
40
98
  ## SSS Key Management Routes (`src/routes/keys.ts`)
41
99
 
42
100
  The `keysRouter` implements the server side of Shamir Secret Sharing key management used by `@learncard/sss-key-manager`:
43
101
 
44
- | Route | Purpose |
45
- | ----------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- |
46
- | `POST /keys/auth-share` | Fetch the encrypted auth share for a user by contact method + provider |
47
- | `PUT /keys/auth-share` | Store / rotate the auth share; manages `shareVersion` for historical lookup |
48
- | `GET /keys/recovery` | Fetch an encrypted recovery share (passkey / phrase / backup) |
49
- | `POST /keys/recovery` | Register a recovery method (passkey / phrase / backup / email) |
50
- | `POST /keys/recovery-email` | Start recovery email verification (sends 6-digit OTP) |
51
- | `POST /keys/recovery-email/verify` | Verify the OTP and persist the recovery email on the UserKey |
52
- | `POST /keys/email-backup` | Relay the emailed recovery share to the user's primary or recovery email. **Share is never persisted** — in-memory for the duration of the request. |
53
- | `POST /keys/upgrade-contact-method` | Upgrade phone-only users to email + phone |
54
- | `POST /keys/migrate` | Mark a UserKey as migrated from the legacy Web3Auth pathway |
102
+ | Route | Purpose |
103
+ | ----------------------------------- | --------------------------------------------------------------------------------------------------------------- |
104
+ | `POST /keys/auth-share` | Fetch the encrypted auth share for a user by contact method + provider |
105
+ | `PUT /keys/auth-share` | Store / rotate the auth share; manages `shareVersion` for historical lookup |
106
+ | `GET /keys/recovery` | Fetch an encrypted recovery share (passkey / phrase / backup) |
107
+ | `POST /keys/recovery` | Register a recovery method (passkey / phrase / backup / email) |
108
+ | `POST /keys/recovery-email` | Start recovery email verification (sends 6-digit OTP) |
109
+ | `POST /keys/recovery-email/verify` | Verify the OTP and persist the recovery email on the UserKey |
110
+ | `POST /keys/email-backup` | Proxy a client-encrypted recovery payload to the isolated email relay. The share is never visible to `lca-api`. |
111
+ | `POST /keys/upgrade-contact-method` | Upgrade phone-only users to email + phone |
112
+ | `POST /keys/migrate` | Mark a UserKey as migrated from the legacy Web3Auth pathway |
113
+
114
+ ### Escrow recovery routes
115
+
116
+ The `escrow` tRPC router is disabled unless `ESCROW_ENCLAVE_MODE` is configured.
117
+
118
+ | Route | Procedure | Purpose |
119
+ | ------------------------------- | ----------------------------- | ------------------------------------------------------------------------- |
120
+ | `GET /keys/escrow/attestation` | `escrow.getAttestation` | Enclave public key and hold duration |
121
+ | `POST /keys/escrow` | `escrow.enroll` | Verify and store automatic recovery material |
122
+ | `DELETE /keys/escrow` | `escrow.remove` | Remove material and optionally opt out |
123
+ | `POST /keys/escrow/opt-in` | `escrow.optIn` | Allow enrollment again |
124
+ | `POST /keys/escrow/recover` | `escrow.startRecovery` | Start a waiting period without resetting an existing hold |
125
+ | `GET /keys/escrow/status` | `escrow.getStatus` | Check status; active devices send provider auth via `X-Auth-Token` |
126
+ | `POST /keys/escrow/cancel` | `escrow.cancelRecovery` | DID owner cancels a pending hold |
127
+ | `POST /keys/escrow/cancel-link` | `escrow.cancelRecoveryByLink` | Cancels a pending hold via the single-use email cancel link (no DID auth) |
128
+ | `POST /keys/escrow/complete` | `escrow.completeRecovery` | Claim an elapsed hold and release sealed recovery material once |
55
129
 
56
130
  ### Important Invariants
57
131
 
58
- - **Auth shares are encrypted at rest** with a KEK derived from `SEED`. Losing `SEED` means every stored auth share is permanently unrecoverable.
59
- - **Recovery shares are encrypted client-side** (except the phrase method, which stores only shareVersion metadata).
60
- - **Email share is never written to the DB.** The `sendEmailBackup` route holds it in memory only for the duration of the request; Postmark receives it but never stores it.
61
- - **`shareVersion` pairs a device share with a matching auth share.** On rotation, previous versions are kept in `previousAuthShares` so users with stale device shares can still recover.
132
+ - **Auth shares are encrypted at rest** with a KEK derived from `SEED`. Losing `SEED` means every stored auth share is permanently unrecoverable.
133
+ - **Recovery shares are encrypted client-side** (except the phrase method, which stores only shareVersion metadata).
134
+ - **Email share is encrypted before leaving the client.** The `sendEmailBackup` route receives only a relay-key ciphertext envelope and a confirmation code, then waits for isolated relay acceptance before recording pending recovery metadata.
135
+ - **`shareVersion` pairs a device share with a matching auth share.** On rotation, previous versions are kept in `previousAuthShares` so users with stale device shares can still recover.
136
+
137
+ ## OIDC identity provider (AD-2)
138
+
139
+ `auth.requestLoginTicket` and `auth.requestSocialLoginTicket` prove ownership in-app and issue 60-second single-use tickets. The ticket travels in URLs (`login_hint` on the Keycloak auth URL and again on the `/oidc/authorize` redirect), so it lands in browser history and API Gateway access logs; single-use + the short TTL is what makes that acceptable. Do not lengthen the TTL without revisiting that.
140
+ `src/oidc.ts` serves discovery, JWKS, authorize, token and userinfo in Docker and Lambda; no CORS or duplicate tRPC endpoints.
141
+ Subjects are random permanent UUIDs in `AuthSubject`, keyed by normalized email or Google/Apple subject.
142
+ `requestSocialLoginTicket` is the one exception to "never auto-linked by email": when `verifySocialIdToken`
143
+ returns a verified email (Apple's private-relay addresses count), a brand-new `<provider>:<sub>` identity
144
+ adopts the existing `email:<address>` identity's `subject` via `getOrCreateAuthSubjectLinkedTo`
145
+ (`$setOnInsert`), so a user doesn't hit "Account already exists" switching from email-code to native
146
+ social sign-in. It never runs the other direction, and an already-existing `<provider>:<sub>` record
147
+ keeps its own `subject` — only a first-time native sign-in can be linked this way.
148
+ Require `OIDC_ISSUER`, an exact redirect allowlist, and a token client secret. Deployed Lambdas use `OIDC_SIGNING_KEY_SECRET_ID` (name or ARN of `lca-api/<stage>/oidc-signing-jwk`) pointing to a plain-string Secrets Manager SecretString containing the RSA private JWK JSON (`kty=RSA`, `d`, `kid`, `alg=RS256`). The key is fetched and cached per process; failures return 503 and retry on the next request. Redeploy after rotation. `OIDC_SIGNING_KEY_JWK` remains supported for local/Docker/CI and takes precedence. Any deployed stage (`NODE_ENV=production` or `LAMBDA_STAGE` set) fails closed without a configured key.
149
+ Login codes, tickets and authorization codes are consumed with `getDel` (`src/cache/getDel.ts`, Redis `GETDEL`, requires Redis >= 6.2).
150
+ Rate limiting is two layers, both keyed on `request.ip` (API Gateway `sourceIp` under `serverless-http`; `x-forwarded-for` is attacker-controlled and never trusted). Both fail open if Redis is unreachable. **Layer 1 — failures only** (`src/helpers/rate-limit.helpers.ts`, Redis counters, 10-minute window): the ticket routes use 5 per email plus a 50-per-IP backstop; `/oidc/authorize` (bad client/redirect, invalid ticket) and `/oidc/token` (`invalid_client` only) each use 50 per IP and answer `429 temporarily_unavailable` + `Retry-After` (or an OAuth error redirect once the `redirect_uri` is validated). `invalid_grant` at the token endpoint is deliberately not counted: only the broker (one NAT IP) can reach that branch, and counting the codes it relays would let anyone lock every user out. **Layer 2 — all requests** (`@fastify/rate-limit`, registered in `oidcFastifyPlugin`, Redis-backed when available): 300/min per IP globally so Keycloak's discovery/JWKS polling is never blocked; 60/min per IP on the browser-facing `/oidc/authorize`; a 3000/min sanity ceiling on the server-to-server `/oidc/token` and `/oidc/userinfo` (every legitimate call shares the broker's egress IP, so a tight cap there is a global login ceiling). Layer 2 exists mainly so CodeQL's `js/missing-rate-limiting` recognises the routes; do not remove either layer.
151
+ Unit coverage is in `test/oidc.spec.ts` and `test/auth-tickets.spec.ts`; broker import coverage is gated by `KEYCLOAK_INTEGRATION`.
152
+ Phone OTP remains deferred. The complete live broker round-trip and Firebase-era mapping proof
153
+ are in `test/keycloak-broker-roundtrip.integration.spec.ts` and
154
+ `test/keycloak-migration.integration.spec.ts`, gated on both `KEYCLOAK_INTEGRATION=true`
155
+ and `KEYCLOAK_ROUNDTRIP=true`. With both flags, `vitest.integration.config.ts` selects
156
+ only the live auth suites and aliases Mongo to the explicitly configured API database;
157
+ without them, existing temporary-Mongo integration suites retain their setup.
158
+ See `infra/keycloak/README.md` at the repository root for the local command.
159
+
160
+ `bun run provision:keycloak` previews the AD-10 mapping migration; `--apply` writes,
161
+ `--email` and `--limit` restrict a batch. Never use this operator-only email join in a
162
+ runtime login route. Dry-run performs no resource writes, including no index creation.
163
+ Existing AuthSubjects are read, not upserted, so repeat apply does not alter lastLoginAt.
164
+ The final global email mapping coverage gate must pass before cutover. Phone-only
165
+ accounts remain a separately reported deferred cohort.
166
+
167
+ `bun run test:oidc:e2e` also covers the complete email-code broker round-trip in Playwright, with real lca-api and disposable Keycloak/Redis/Mongo. Keep its cleanup contract: exact per-test user/subject deletion, private transient stores, and runner teardown on success/failure/signals. Never point it at shared environments.
168
+
169
+ ### Keycloak broker gotchas
170
+
171
+ - **Client secret encoding.** Keycloak's broker sends `client_secret_basic` credentials **raw** (not form-url-encoded, contrary to RFC 6749 §2.3.1). `/oidc/token` therefore compares the raw pair first and only falls back to the URL-decoded pair. Secrets containing `+` or `%` are fine.
172
+ - **Phase 2 migration must create the federated identity link.** The realm uses the default `first broker login` flow with `trustEmail: true`. If the migration script pre-creates a Keycloak user by email **without** also creating the `lca-api` federated identity link (`sub` = the `AuthSubject.subject` UUID), the user's first silent hop stops on Keycloak's "Handle Existing Account" web page ("User with email X already exists. How do you want to continue?"), which breaks the no-web-form guarantee. Pre-seed `AuthSubject` and the Keycloak `federatedIdentities` entry together.
62
173
 
63
174
  ## Email Delivery & Tenant Branding
64
175
 
@@ -90,10 +201,10 @@ The adapter additionally re-throws when local rendering fails for a **sentinel a
90
201
 
91
202
  ### Rules for Route Authors
92
203
 
93
- - **Always pass `branding: ctx.tenant?.emailBranding`** to `getDeliveryService().send()`. Calls without branding render with LearnCard defaults regardless of the caller's tenant.
94
- - **Use `getFrom({ mailbox, branding: ctx.tenant?.emailBranding })`** for the `from` field so the domain matches the tenant (e.g. `recovery@vetpass.app`).
95
- - **Use the local template ID as the `templateAlias`** — e.g. `'recovery-key'`, `'recovery-email-code'`, `'login-verification-code'`, `'endorsement-request'`. These are pre-registered as sentinels in `LOCAL_TEMPLATE_ALIASES`. Env-var overrides (`POSTMARK_*_TEMPLATE_ALIAS`) are optional; `templateAlias: RECOVERY_KEY_TEMPLATE_ALIAS || 'recovery-key'` is the canonical pattern.
96
- - **Do not write plain-text fallbacks.** The adapter renders the React Email template regardless; plain-text branches are dead code and should be deleted on sight.
204
+ - **Always pass `branding: ctx.tenant?.emailBranding`** to `getDeliveryService().send()`. Calls without branding render with LearnCard defaults regardless of the caller's tenant.
205
+ - **Use `getFrom({ mailbox, branding: ctx.tenant?.emailBranding })`** for the `from` field so the domain matches the tenant (e.g. `recovery@vetpass.app`).
206
+ - **Use the local template ID as the `templateAlias`** — e.g. `'recovery-key'`, `'recovery-email-code'`, `'login-verification-code'`, `'endorsement-request'`. These are pre-registered as sentinels in `LOCAL_TEMPLATE_ALIASES`. Env-var overrides (`POSTMARK_*_TEMPLATE_ALIAS`) are optional; `templateAlias: RECOVERY_KEY_TEMPLATE_ALIAS || 'recovery-key'` is the canonical pattern.
207
+ - **Do not write plain-text fallbacks.** The adapter renders the React Email template regardless; plain-text branches are dead code and should be deleted on sight.
97
208
 
98
209
  ### Env Vars
99
210
 
@@ -108,14 +219,14 @@ See [`docs/how-to-guides/deploy-infrastructure/sss-key-management-config.md`](..
108
219
 
109
220
  ## Testing Notes
110
221
 
111
- - Test files in `test/` spin up a Fastify server via `getClient()` and exercise routes with `fetch`.
112
- - `IS_E2E_TEST=true` disables Firebase Admin SDK calls and switches email delivery to the log adapter.
113
- - For tenant-aware tests, set `X-Tenant-Id` on the request; `createContext` will pick it up and `ctx.tenant.emailBranding` will be populated from the registry in `@learncard/email-templates`.
222
+ - Test files using `getClient()` exercise tRPC procedures via `appRouter.createCaller()`, not an HTTP server. Header-context tests inject `providerToken`; they do not verify HTTP header extraction or CORS.
223
+ - `IS_E2E_TEST=true` disables Firebase Admin SDK calls and switches email delivery to the log adapter.
224
+ - For tenant-aware tests, set `X-Tenant-Id` on the request; `createContext` will pick it up and `ctx.tenant.emailBranding` will be populated from the registry in `@learncard/email-templates`.
114
225
 
115
226
  ## Cross-References
116
227
 
117
- - [Tenant-Branded Emails (architecture)](../../../docs/core-concepts/tenant-branded-emails.md) — end-to-end email flow
118
- - [Configure Tenant-Branded Emails](../../../docs/how-to-guides/configure-tenant-branded-emails.md) — operator-facing setup
119
- - [SSS Key Management Config](../../../docs/how-to-guides/deploy-infrastructure/sss-key-management-config.md) — env var reference for deployers
120
- - [`@learncard/email-templates` README](../../../packages/email-templates/README.md) — template catalog, tenant registry, preview server
121
- - [`@learncard/sss-key-manager`](../../../packages/sss-key-manager/README.md) — client side of SSS key management
228
+ - [Tenant-Branded Emails (architecture)](../../../docs/core-concepts/tenant-branded-emails.md) — end-to-end email flow
229
+ - [Configure Tenant-Branded Emails](../../../docs/how-to-guides/configure-tenant-branded-emails.md) — operator-facing setup
230
+ - [SSS Key Management Config](../../../docs/how-to-guides/deploy-infrastructure/sss-key-management-config.md) — env var reference for deployers
231
+ - [`@learncard/email-templates` README](../../../packages/email-templates/README.md) — template catalog, tenant registry, preview server
232
+ - [`@learncard/sss-key-manager`](../../../packages/sss-key-manager/README.md) — client side of SSS key management
package/CHANGELOG.md CHANGED
@@ -1,5 +1,89 @@
1
1
  # @welibraryos/lca-api-service
2
2
 
3
+ ## 1.5.0
4
+
5
+ ### Minor Changes
6
+
7
+ - [#1589](https://github.com/learningeconomy/LearnCard/pull/1589) [`599f048b082d2ee64775f7a0ef972e171b8c2a8a`](https://github.com/learningeconomy/LearnCard/commit/599f048b082d2ee64775f7a0ef972e171b8c2a8a) Thanks [@Custard7](https://github.com/Custard7)! - Add Keycloak token verification (`verifyKeycloakToken`) using `jose`: exact issuer allowlist, `aud`/`azp` client allowlist, ID/Bearer token-type check, and required `email_verified` / `phone_number_verified` claims. Configured with `KEYCLOAK_ISSUERS`, `KEYCLOAK_AUDIENCES`, and optional `KEYCLOAK_JWKS_URL_OVERRIDES`. Disabled (rejects all tokens) until issuers are configured.
8
+
9
+ - [#1429](https://github.com/learningeconomy/LearnCard/pull/1429) [`1387cd0a08fa6b0a706c86a5a0376b6c7d706856`](https://github.com/learningeconomy/LearnCard/commit/1387cd0a08fa6b0a706c86a5a0376b6c7d706856) Thanks [@Custard7](https://github.com/Custard7)! - Add a trusted-service `ensureManagedSigningAuthority` route (`POST /signing-authority/managed`) that returns or creates a named signing authority for an owner DID through the standard encrypted-seed path. Brain service now provisions app signing authorities through this route instead of writing plaintext seeds to the LCA API database.
10
+
11
+ - [#1592](https://github.com/learningeconomy/LearnCard/pull/1592) [`1f1ff54118a5d08c97f57696ca45671f0ee982b8`](https://github.com/learningeconomy/LearnCard/commit/1f1ff54118a5d08c97f57696ca45671f0ee982b8) Thanks [@Custard7](https://github.com/Custard7)! - Add the lca-api OIDC identity provider with RS256 signing, permanent UUID subjects,
12
+ single-use login tickets, authorization codes and opaque access tokens. The
13
+ `auth.requestLoginTicket` and `auth.requestSocialLoginTicket` routes verify email
14
+ codes or native Google/Apple proofs. Keycloak brokers the redirect flow without
15
+ an additional login form. Includes Docker/Lambda wiring, a development broker
16
+ fixture and protocol tests. Phone OTP and full live broker round-trip CI remain deferred.
17
+
18
+ ### Patch Changes
19
+
20
+ - [#1621](https://github.com/learningeconomy/LearnCard/pull/1621) [`d7ca528898d988c65f0dc7084cfdcd41767648c5`](https://github.com/learningeconomy/LearnCard/commit/d7ca528898d988c65f0dc7084cfdcd41767648c5) Thanks [@Custard7](https://github.com/Custard7)! - Add remote enclave backend, hold notifications, cancel links, reminders, and kill switch for escrow recovery.
21
+
22
+ - [#1679](https://github.com/learningeconomy/LearnCard/pull/1679) [`77e5b0681f5567caa446da57a1746d148ffde27a`](https://github.com/learningeconomy/LearnCard/commit/77e5b0681f5567caa446da57a1746d148ffde27a) Thanks [@Custard7](https://github.com/Custard7)! - Catch module-load index creation failures so a Mongo connect that outlives a frozen Lambda invocation no longer crashes the next request (seen as a 500 on `/oidc/authorize` during Keycloak sign-in).
23
+
24
+ - [#1670](https://github.com/learningeconomy/LearnCard/pull/1670) [`7656a57d30c6d80b8a25a8bb14bcd3b08b3648c2`](https://github.com/learningeconomy/LearnCard/commit/7656a57d30c6d80b8a25a8bb14bcd3b08b3648c2) Thanks [@Custard7](https://github.com/Custard7)! - Honor CONFIG_STAGE in serverless-offline without implicitly applying a deployed stage's configuration during local startup.
25
+
26
+ - [#1662](https://github.com/learningeconomy/LearnCard/pull/1662) [`aef0f8cfdef94170104d2179fb04759c8dae7162`](https://github.com/learningeconomy/LearnCard/commit/aef0f8cfdef94170104d2179fb04759c8dae7162) Thanks [@Custard7](https://github.com/Custard7)! - Load deployed OIDC signing keys from Secrets Manager with retryable per-process caching instead of storing private JWKs in Lambda environment variables.
27
+
28
+ - [#1670](https://github.com/learningeconomy/LearnCard/pull/1670) [`7656a57d30c6d80b8a25a8bb14bcd3b08b3648c2`](https://github.com/learningeconomy/LearnCard/commit/7656a57d30c6d80b8a25a8bb14bcd3b08b3648c2) Thanks [@Custard7](https://github.com/Custard7)! - Document the layered backend configuration model: schema defaults, checked-in non-secret per-stage config files, the Lambda runtime AWS secrets bundle, and real environment variables, plus the Infisical → AWS sync and per-stage cutover.
29
+
30
+ - [#1670](https://github.com/learningeconomy/LearnCard/pull/1670) [`7656a57d30c6d80b8a25a8bb14bcd3b08b3648c2`](https://github.com/learningeconomy/LearnCard/commit/7656a57d30c6d80b8a25a8bb14bcd3b08b3648c2) Thanks [@Custard7](https://github.com/Custard7)! - Select backend stage files by tenant as well as stage. `CONFIG_TENANT=scouts` loads `config.scouts.<stage>.json` (generated from the live ScoutPass Lambdas), so ScoutPass deployments no longer inherit LearnCard domains, trust settings or seed-encryption flags; a named tenant without a stage file fails closed. `ESCROW_ENCLAVE_MODE` is now forwarded in runtime-secrets bundle mode too.
31
+
32
+ - [#1664](https://github.com/learningeconomy/LearnCard/pull/1664) [`4a237f41ea4ab0d3bd69f2066039518fdec20984`](https://github.com/learningeconomy/LearnCard/commit/4a237f41ea4ab0d3bd69f2066039518fdec20984) Thanks [@Custard7](https://github.com/Custard7)! - Load optional per-stage runtime secrets before Lambda initialization and scope authentication environment settings by function to avoid Lambda's 4KB limit. Preserve the Firebase environment fallback for stages without a bundle.
33
+
34
+ - Updated dependencies [[`6209da80c52ac056a7644dab14669192740c9dad`](https://github.com/learningeconomy/LearnCard/commit/6209da80c52ac056a7644dab14669192740c9dad), [`5890451789b18afee45dc5ecbab3fa30aa90f085`](https://github.com/learningeconomy/LearnCard/commit/5890451789b18afee45dc5ecbab3fa30aa90f085), [`1387cd0a08fa6b0a706c86a5a0376b6c7d706856`](https://github.com/learningeconomy/LearnCard/commit/1387cd0a08fa6b0a706c86a5a0376b6c7d706856), [`d7ca528898d988c65f0dc7084cfdcd41767648c5`](https://github.com/learningeconomy/LearnCard/commit/d7ca528898d988c65f0dc7084cfdcd41767648c5), [`d7ca528898d988c65f0dc7084cfdcd41767648c5`](https://github.com/learningeconomy/LearnCard/commit/d7ca528898d988c65f0dc7084cfdcd41767648c5), [`d7ca528898d988c65f0dc7084cfdcd41767648c5`](https://github.com/learningeconomy/LearnCard/commit/d7ca528898d988c65f0dc7084cfdcd41767648c5), [`8f3bc66ab3d3e5e1edb3fbf5f362025875a914ba`](https://github.com/learningeconomy/LearnCard/commit/8f3bc66ab3d3e5e1edb3fbf5f362025875a914ba), [`1387cd0a08fa6b0a706c86a5a0376b6c7d706856`](https://github.com/learningeconomy/LearnCard/commit/1387cd0a08fa6b0a706c86a5a0376b6c7d706856), [`267cf40291b3a63caf178857be0e57feaf5e0948`](https://github.com/learningeconomy/LearnCard/commit/267cf40291b3a63caf178857be0e57feaf5e0948), [`7656a57d30c6d80b8a25a8bb14bcd3b08b3648c2`](https://github.com/learningeconomy/LearnCard/commit/7656a57d30c6d80b8a25a8bb14bcd3b08b3648c2), [`c78c982f9f4911f1ca2973380f19d80db775e012`](https://github.com/learningeconomy/LearnCard/commit/c78c982f9f4911f1ca2973380f19d80db775e012), [`590adf48867d6c6fd504e66a11c9827d5102122d`](https://github.com/learningeconomy/LearnCard/commit/590adf48867d6c6fd504e66a11c9827d5102122d)]:
35
+ - @learncard/types@5.23.0
36
+ - @learncard/email-templates@1.2.1
37
+ - @learncard/sss-key-manager@0.2.1
38
+ - @learncard/service-config@0.1.1
39
+ - @learncard/didkit-plugin@1.10.3
40
+ - @learncard/core@9.4.38
41
+ - @learncard/helpers@1.6.2
42
+ - @learncard/init@2.5.2
43
+ - @learncard/did-web-plugin@1.1.38
44
+ - @learncard/didkit-plugin-node@0.3.3
45
+
46
+ ## 1.4.0
47
+
48
+ ### Minor Changes
49
+
50
+ - [#1565](https://github.com/learningeconomy/LearnCard/pull/1565) [`4b83aa9ee802f0d7a54adc5f053a7376fa103cdd`](https://github.com/learningeconomy/LearnCard/commit/4b83aa9ee802f0d7a54adc5f053a7376fa103cdd) Thanks [@Custard7](https://github.com/Custard7)! - Escrow PIN fast release: an optional PIN release policy on the existing escrow-recovery substrate.
51
+
52
+ - The escrow recovery share now supports a second release policy alongside the 7-day hold: `pin`. A user who sets an optional 6-12 digit PIN can recover immediately by presenting it, instead of waiting for the hold. Custodianship is unchanged — this adds a release policy on the already-sealed blob, not a second custodial share.
53
+ - PIN verifier lives inside the enclave-sealed envelope (`EscrowBlobPlaintext.pinVerifier`); a leaked DB alone yields nothing to brute force. Client derives the proof with Argon2id and the enclave constant-time compares.
54
+ - 10 lifetime failed attempts locks the PIN (`EscrowPinLockedError`); the 7-day hold path is always available as a fallback. Setting/changing a PIN rotates the escrow share.
55
+ - Carry the sealed PIN verifier across share rotations while preserving its salt and attempt counters; explicit removal and unavailable carry still enroll without a PIN.
56
+ - New app UI: recovery PIN setup overlay after first setup (skippable), a Recovery PIN row in recovery settings (set/change/remove), and a PIN-first step in the recovery flow with fallback to the existing hold.
57
+
58
+ - [#1529](https://github.com/learningeconomy/LearnCard/pull/1529) [`0e262737aafb8248e88e05039c83a3f30be6750f`](https://github.com/learningeconomy/LearnCard/commit/0e262737aafb8248e88e05039c83a3f30be6750f) Thanks [@Custard7](https://github.com/Custard7)! - SSS prod hardening: confirmed recovery enrollment, lost-login identity rebind, and an isolated email relay.
59
+
60
+ - Recovery methods now carry `confirmedAt` and must be proven before they count (email confirmation code, phrase challenge words, backup re-decrypt, passkey round trip). New SSS accounts are `active` immediately; web3auth migrations stay `provisional` until a method is confirmed.
61
+ - New recovery-session flow lets a user whose sign-in identity is gone recover via a verified personal email and bind a new sign-in.
62
+ - Email recovery shares are encrypted on the client to an isolated relay's public key; `lca-api` never sees plaintext. Provider tokens move from query strings to the `X-Auth-Token` header.
63
+ - Sensitive key routes require a single-use DID challenge; key records are keyed by immutable provider ID.
64
+ - `recovery-key` email template now requires `confirmationCode`.
65
+ - Auth-share initialization requires the provider uniqueness index before inserting, preventing competing first writes from creating duplicate accounts. Existing records with missing or BSON-null auth material retain atomic update protection.
66
+ - Automatic stale-key cleanup preserves unresolved pending shares for delayed commits; explicit device forgetting still removes them. Custom SSS storage adapters now implement `deleteDeviceShare(id?)` to remove only the selected share and its version.
67
+
68
+ ### Patch Changes
69
+
70
+ - [#1601](https://github.com/learningeconomy/LearnCard/pull/1601) [`3850da277fe17d421bc055cef3cac65616b7626d`](https://github.com/learningeconomy/LearnCard/commit/3850da277fe17d421bc055cef3cac65616b7626d) Thanks [@rhen92](https://github.com/rhen92)! - fix: [LC-2207] Limit wrong code attempts on email login verification
71
+
72
+ - [#1636](https://github.com/learningeconomy/LearnCard/pull/1636) [`dab95a1313b10f0339a4284cb5dcdc2f459dcdd5`](https://github.com/learningeconomy/LearnCard/commit/dab95a1313b10f0339a4284cb5dcdc2f459dcdd5) Thanks [@Custard7](https://github.com/Custard7)! - Fix lca-api deploys failing on Lambda's 4KB environment limit: escrow variables are now attached only to the trpc/api functions, omitted when unset, and enclave settings are gated on `ESCROW_ENCLAVE_MODE`. SSS client errors now include the HTTP status code (HTTP/2 responses have an empty `statusText`).
73
+
74
+ - [#1619](https://github.com/learningeconomy/LearnCard/pull/1619) [`5639d80a9d111f8156469e6692c37ffb354d2ec7`](https://github.com/learningeconomy/LearnCard/commit/5639d80a9d111f8156469e6692c37ffb354d2ec7) Thanks [@gerardopar](https://github.com/gerardopar)! - feat: [LC-2161] - Encrypt hosted signing-authority seeds with AWS KMS
75
+
76
+ - Updated dependencies [[`4b83aa9ee802f0d7a54adc5f053a7376fa103cdd`](https://github.com/learningeconomy/LearnCard/commit/4b83aa9ee802f0d7a54adc5f053a7376fa103cdd), [`f7b54c45cf495e14cc7b2824653c8f0a63b61660`](https://github.com/learningeconomy/LearnCard/commit/f7b54c45cf495e14cc7b2824653c8f0a63b61660), [`dab95a1313b10f0339a4284cb5dcdc2f459dcdd5`](https://github.com/learningeconomy/LearnCard/commit/dab95a1313b10f0339a4284cb5dcdc2f459dcdd5), [`b184f5552abf37a11468d435e5cea07fc35e1993`](https://github.com/learningeconomy/LearnCard/commit/b184f5552abf37a11468d435e5cea07fc35e1993), [`0e262737aafb8248e88e05039c83a3f30be6750f`](https://github.com/learningeconomy/LearnCard/commit/0e262737aafb8248e88e05039c83a3f30be6750f), [`d436c39e00994e6d8c77e7b94985a51fe1f42adb`](https://github.com/learningeconomy/LearnCard/commit/d436c39e00994e6d8c77e7b94985a51fe1f42adb)]:
77
+ - @learncard/sss-key-manager@0.2.0
78
+ - @learncard/types@5.22.0
79
+ - @learncard/email-templates@1.2.0
80
+ - @learncard/helpers@1.6.1
81
+ - @learncard/core@9.4.37
82
+ - @learncard/init@2.5.1
83
+ - @learncard/did-web-plugin@1.1.37
84
+ - @learncard/didkit-plugin@1.10.2
85
+ - @learncard/didkit-plugin-node@0.3.2
86
+
3
87
  ## 1.3.2
4
88
 
5
89
  ### Patch Changes
@@ -0,0 +1,76 @@
1
+ import type { APIGatewayProxyEventV2, Context } from 'aws-lambda';
2
+ import { beforeEach, expect, it, vi } from 'vitest';
3
+
4
+ const mocks = vi.hoisted(() => ({ bootstrap: vi.fn(), getApp: vi.fn(), handler: vi.fn() }));
5
+
6
+ vi.mock('@learncard/service-config', () => ({ bootstrapLambda: mocks.bootstrap }));
7
+ vi.mock('./src/config/stageConfig', () => ({ base: {}, stages: { dev: {}, production: {} } }));
8
+ vi.mock('./oidcLambda', () => ({ handler: mocks.handler }));
9
+ vi.mock('./lambdaApp', () =>
10
+ Object.fromEntries(
11
+ ['trpcHandler', 'openApiHandler', 'swaggerUiHandler', 'didWebHandler', 'oidcHandler'].map(
12
+ name => [name, mocks.handler]
13
+ )
14
+ )
15
+ );
16
+
17
+ beforeEach(() => {
18
+ vi.resetModules();
19
+ vi.clearAllMocks();
20
+ mocks.bootstrap.mockReturnValue(mocks.getApp);
21
+ mocks.handler.mockResolvedValue({ statusCode: 200 });
22
+ });
23
+
24
+ it('bootstraps the API once with checked-in stage config', async () => {
25
+ vi.stubEnv('AWS_LAMBDA_FUNCTION_NAME', 'lca-api-dev-api');
26
+ vi.stubEnv('LAMBDA_STAGE', 'dev');
27
+ await import('./lambda');
28
+ expect(mocks.bootstrap).toHaveBeenCalledTimes(1);
29
+ expect(mocks.bootstrap).toHaveBeenCalledWith({
30
+ base: {},
31
+ stages: { dev: {}, production: {} },
32
+ stage: 'dev',
33
+ importApp: expect.any(Function),
34
+ });
35
+ vi.unstubAllEnvs();
36
+ });
37
+
38
+ it('uses CONFIG_STAGE outside Lambda rather than LAMBDA_STAGE', async () => {
39
+ vi.stubEnv('AWS_LAMBDA_FUNCTION_NAME', '');
40
+ vi.stubEnv('LAMBDA_STAGE', 'production');
41
+ vi.stubEnv('CONFIG_STAGE', 'dev');
42
+ await import('./lambda');
43
+ expect(mocks.bootstrap).toHaveBeenCalledWith(expect.objectContaining({ stage: 'dev' }));
44
+ vi.unstubAllEnvs();
45
+ });
46
+
47
+ it('forwards every handler through its bootstrap accessor', async () => {
48
+ mocks.getApp.mockResolvedValue(
49
+ Object.fromEntries(
50
+ [
51
+ 'trpcHandler',
52
+ 'openApiHandler',
53
+ 'swaggerUiHandler',
54
+ 'didWebHandler',
55
+ 'oidcHandler',
56
+ ].map(name => [name, mocks.handler])
57
+ )
58
+ );
59
+ const lambda = await import('./lambda');
60
+ const event = {} as APIGatewayProxyEventV2;
61
+ const context = {} as Context;
62
+ const callback = vi.fn();
63
+ const handlers = [
64
+ lambda.trpcHandler,
65
+ lambda.openApiHandler,
66
+ lambda.swaggerUiHandler,
67
+ lambda.didWebHandler,
68
+ ];
69
+ for (const handler of handlers) await handler(event, context, callback);
70
+ expect(mocks.bootstrap).toHaveBeenCalledTimes(1);
71
+ expect(mocks.getApp).toHaveBeenCalledTimes(handlers.length);
72
+ expect(mocks.handler).toHaveBeenCalledTimes(handlers.length);
73
+ expect(mocks.handler).toHaveBeenCalledWith(event, context, callback);
74
+ await lambda.oidcHandler(event, context, callback);
75
+ expect(mocks.getApp).toHaveBeenCalledTimes(handlers.length);
76
+ });
package/compose.env ADDED
@@ -0,0 +1,5 @@
1
+ # Local Docker defaults only; encryption is opt-in and requires an explicit KEK.
2
+ # Load before optional .env files so existing custom keys and rollout settings win.
3
+ # For persistent data, configure a unique KEK before the first encrypted write and retain it.
4
+ SA_SEED_ENCRYPT_WRITES=false
5
+ SA_SEED_ALLOW_LEGACY_READ=true
@@ -0,0 +1,23 @@
1
+ {
2
+ "APPLE_OAUTH_CLIENT_IDS": "com.learncard.app,app.learncard.www",
3
+ "DOMAIN_NAME": "staging.api.learncard.app",
4
+ "ESCROW_ENCLAVE_ACTIVE_KEY_ID": "escrow-staging-v1",
5
+ "ESCROW_ENCLAVE_REMOTE_TIMEOUT_MS": "10000",
6
+ "ESCROW_HOLD_DURATION_MS": "600000",
7
+ "ESCROW_HOLD_RESTART_MIN_AGE_MS": "300000",
8
+ "GOOGLE_OAUTH_CLIENT_IDS": "776298253175-74or0s6rg2k2mmdks6d45ro0i4te1bvf.apps.googleusercontent.com,776298253175-kkf562eofl541vu0oedfdvrk40bpkbh7.apps.googleusercontent.com",
9
+ "KEYCLOAK_AUDIENCES": "learncard-app",
10
+ "KEYCLOAK_ISSUERS": "https://auth.staging.learncard.app/realms/learncard",
11
+ "OIDC_CLIENT_ID": "keycloak-broker",
12
+ "OIDC_ISSUER": "https://staging.api.learncard.app",
13
+ "OIDC_SIGNING_KEY_SECRET_ID": "lca-api/dev/oidc-signing-jwk",
14
+ "POSTMARK_BRAND_NAME": "LearnCard",
15
+ "POSTMARK_ENDORSEMENT_REQUEST_TEMPLATE_ALIAS": "universal-inbox-claim-1",
16
+ "POSTMARK_FROM_EMAIL": "learncard.com",
17
+ "POSTMARK_LOGIN_CODE_TEMPLATE_ALIAS": "contact-method-verification-1",
18
+ "POSTMARK_RECOVERY_EMAIL_CODE_TEMPLATE_ALIAS": "recovery-email-verification",
19
+ "POSTMARK_RECOVERY_KEY_TEMPLATE_ALIAS": "recovery-key-backup",
20
+ "SA_SEED_ALLOW_LEGACY_READ": "false",
21
+ "SA_SEED_ENCRYPT_WRITES": "true",
22
+ "SENTRY_ENV": "lca-api-staging"
23
+ }
@@ -0,0 +1,22 @@
1
+ {
2
+ "APPLE_OAUTH_CLIENT_IDS": "com.example.app,app.example.www",
3
+ "DOMAIN_NAME": "api.example.com",
4
+ "ESCROW_ENCLAVE_ACTIVE_KEY_ID": "example-2026-01",
5
+ "ESCROW_HOLD_DURATION_MS": "604800000",
6
+ "ESCROW_HOLD_RESTART_MIN_AGE_MS": "86400000",
7
+ "GOOGLE_OAUTH_CLIENT_IDS": "000000000000-exampleexampleexampleexampleexam.apps.googleusercontent.com",
8
+ "KEYCLOAK_AUDIENCES": "example-app",
9
+ "KEYCLOAK_ISSUERS": "https://auth.example.com/realms/example",
10
+ "OIDC_CLIENT_ID": "keycloak-broker",
11
+ "OIDC_ISSUER": "https://api.example.com",
12
+ "OIDC_SIGNING_KEY_SECRET_ID": "lca-api/example/oidc-signing-jwk",
13
+ "POSTMARK_BRAND_NAME": "Example",
14
+ "POSTMARK_ENDORSEMENT_REQUEST_TEMPLATE_ALIAS": "universal-inbox-claim-1",
15
+ "POSTMARK_FROM_EMAIL": "example.com",
16
+ "POSTMARK_LOGIN_CODE_TEMPLATE_ALIAS": "contact-method-verification-1",
17
+ "POSTMARK_RECOVERY_EMAIL_CODE_TEMPLATE_ALIAS": "recovery-email-verification",
18
+ "POSTMARK_RECOVERY_KEY_TEMPLATE_ALIAS": "recovery-key-backup",
19
+ "SA_SEED_ALLOW_LEGACY_READ": "false",
20
+ "SA_SEED_ENCRYPT_WRITES": "true",
21
+ "SENTRY_ENV": "lca-api-example"
22
+ }
@@ -0,0 +1 @@
1
+ {}