@learncard/lca-api-service 1.4.0 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (188) hide show
  1. package/.env.example +73 -0
  2. package/AGENTS.md +106 -10
  3. package/CHANGELOG.md +43 -0
  4. package/bootstrapLambda.test.ts +76 -0
  5. package/config/config.dev.json +23 -0
  6. package/config/config.example.json +22 -0
  7. package/config/config.json +1 -0
  8. package/config/config.production.json +12 -0
  9. package/config/config.scouts.dev.json +11 -0
  10. package/config/config.scouts.production.json +11 -0
  11. package/dist/app.d.ts +67 -3
  12. package/dist/cache/getDel.d.ts +2 -0
  13. package/dist/cache/getDel.test.d.ts +1 -0
  14. package/dist/cache/login-tickets.d.ts +12 -0
  15. package/dist/config/applyDockerStageConfig.d.ts +1 -0
  16. package/dist/config/cacheEnvironment.d.ts +16 -0
  17. package/dist/config/environment.d.ts +101 -0
  18. package/dist/config/escrowBundleMode.test.d.ts +1 -0
  19. package/dist/config/escrowDeployment.test.d.ts +1 -0
  20. package/dist/config/oidcEnvironment.d.ts +74 -0
  21. package/dist/config/serverlessFunctionEnv.test.d.ts +1 -0
  22. package/dist/config/stageConfig.d.ts +62 -0
  23. package/dist/config/stageConfig.test.d.ts +1 -0
  24. package/dist/didkit_wasm_bg.wasm +0 -0
  25. package/dist/docker-entry.d.ts +1 -1
  26. package/dist/helpers/auth.helpers.d.ts +1 -1
  27. package/dist/helpers/escrowCancelLink.d.ts +7 -0
  28. package/dist/helpers/escrowCancelLink.test.d.ts +1 -0
  29. package/dist/helpers/escrowCancelToken.d.ts +4 -0
  30. package/dist/helpers/escrowCancelToken.test.d.ts +1 -0
  31. package/dist/helpers/keycloak.helpers.d.ts +23 -0
  32. package/dist/helpers/login-ticket.helpers.d.ts +31 -0
  33. package/dist/helpers/login-ticket.helpers.test.d.ts +1 -0
  34. package/dist/helpers/oidc.helpers.d.ts +47 -0
  35. package/dist/helpers/rate-limit.helpers.d.ts +6 -0
  36. package/dist/helpers/rate-limit.helpers.test.d.ts +1 -0
  37. package/dist/helpers/social-token.helpers.d.ts +19 -0
  38. package/dist/index.d.ts +1 -0
  39. package/dist/index.js +2 -2
  40. package/dist/jobs/escrowBlobRewrap.d.ts +9 -0
  41. package/dist/jobs/escrowHoldReminders.d.ts +12 -0
  42. package/dist/models/AuthSubject.d.ts +20 -0
  43. package/dist/models/EscrowHold.d.ts +79 -1
  44. package/dist/models/Notification.d.ts +5 -0
  45. package/dist/models/UserKey.d.ts +11 -1
  46. package/dist/models/index.d.ts +1 -0
  47. package/dist/oidc.d.ts +5 -0
  48. package/dist/routes/auth.d.ts +31 -0
  49. package/dist/routes/escrow.d.ts +18 -4
  50. package/dist/routes/keys.d.ts +1 -0
  51. package/dist/routes/notifications.d.ts +5 -0
  52. package/dist/routes/signing-authority.d.ts +14 -0
  53. package/dist/services/escrow-enclave/blobIdentity.d.ts +10 -0
  54. package/dist/services/escrow-enclave/blobIdentity.test.d.ts +1 -0
  55. package/dist/services/escrow-enclave/cancellation.d.ts +3 -0
  56. package/dist/services/escrow-enclave/enclaveTrustAnchors.d.ts +1 -0
  57. package/dist/services/escrow-enclave/index.d.ts +16 -0
  58. package/dist/services/escrow-enclave/notifications.d.ts +19 -4
  59. package/dist/services/escrow-enclave/notifications.test.d.ts +1 -0
  60. package/dist/services/escrow-enclave/production.test.d.ts +1 -0
  61. package/dist/services/escrow-enclave/remoteEnclave.contract.test.d.ts +1 -0
  62. package/dist/services/escrow-enclave/remoteEnclave.d.ts +20 -0
  63. package/dist/services/escrow-enclave/remoteEnclave.test.d.ts +1 -0
  64. package/dist/services/escrow-enclave/remoteEnclave.tls.test.d.ts +1 -0
  65. package/dist/services/escrow-enclave/softwareEnclave.d.ts +12 -2
  66. package/dist/services/escrow-enclave/types.d.ts +52 -8
  67. package/dist/types/notifications.d.ts +5 -0
  68. package/esbuildPlugins.cjs +1 -0
  69. package/lambda.ts +27 -117
  70. package/lambdaApp.ts +146 -0
  71. package/oidcLambda.test.ts +38 -0
  72. package/oidcLambda.ts +18 -0
  73. package/oidcLambdaApp.ts +11 -0
  74. package/package.json +23 -13
  75. package/scripts/README.md +88 -0
  76. package/scripts/check-seed-encryption-infrastructure.mjs +18 -2
  77. package/scripts/escrow-blob-mode-report.test.ts +227 -0
  78. package/scripts/escrow-blob-mode-report.ts +465 -0
  79. package/scripts/keycloak-admin.ts +108 -0
  80. package/scripts/keycloak-provider-links.ts +147 -0
  81. package/scripts/print-seed-rollout-flags.ts +21 -0
  82. package/scripts/provision-keycloak-users.ts +419 -0
  83. package/seedMigrationApp.test.ts +145 -0
  84. package/seedMigrationApp.ts +50 -0
  85. package/seedMigrationLambda.test.ts +30 -135
  86. package/seedMigrationLambda.ts +15 -46
  87. package/serverless-local.yml +25 -1
  88. package/serverless.function-env.cjs +58 -0
  89. package/serverless.yml +172 -37
  90. package/src/accesslayer/notifications/index.ts +9 -2
  91. package/src/accesslayer/signing-authority/index.ts +4 -1
  92. package/src/app.ts +2 -0
  93. package/src/cache/getDel.test.ts +34 -0
  94. package/src/cache/getDel.ts +32 -0
  95. package/src/cache/index.ts +1 -1
  96. package/src/cache/login-tickets.ts +26 -0
  97. package/src/config/applyDockerStageConfig.ts +6 -0
  98. package/src/config/cacheEnvironment.ts +34 -0
  99. package/src/config/environment.test.ts +45 -2
  100. package/src/config/environment.ts +90 -23
  101. package/src/config/escrowBundleMode.test.ts +52 -0
  102. package/src/config/escrowDeployment.test.ts +114 -0
  103. package/src/config/oidcEnvironment.ts +59 -0
  104. package/src/config/serverlessFunctionEnv.test.ts +98 -0
  105. package/src/config/stageConfig.test.ts +26 -0
  106. package/src/config/stageConfig.ts +24 -0
  107. package/src/docker-entry.ts +9 -0
  108. package/src/helpers/auth.helpers.ts +68 -30
  109. package/src/helpers/escrowCancelLink.test.ts +60 -0
  110. package/src/helpers/escrowCancelLink.ts +38 -0
  111. package/src/helpers/escrowCancelToken.test.ts +105 -0
  112. package/src/helpers/escrowCancelToken.ts +47 -0
  113. package/src/helpers/keycloak.helpers.ts +120 -0
  114. package/src/helpers/login-ticket.helpers.test.ts +45 -0
  115. package/src/helpers/login-ticket.helpers.ts +91 -0
  116. package/src/helpers/oidc.helpers.ts +354 -0
  117. package/src/helpers/rate-limit.helpers.test.ts +61 -0
  118. package/src/helpers/rate-limit.helpers.ts +73 -0
  119. package/src/helpers/social-token.helpers.ts +144 -0
  120. package/src/index.ts +1 -0
  121. package/src/jobs/escrowBlobRewrap.ts +121 -0
  122. package/src/jobs/escrowHoldReminders.ts +177 -0
  123. package/src/models/AuthSubject.ts +147 -0
  124. package/src/models/EscrowHold.ts +321 -5
  125. package/src/models/UserKey.ts +91 -6
  126. package/src/models/index.ts +1 -0
  127. package/src/oidc.ts +371 -0
  128. package/src/routes/auth.ts +173 -0
  129. package/src/routes/escrow.ts +281 -34
  130. package/src/routes/keys.ts +5 -0
  131. package/src/routes/signing-authority.ts +39 -1
  132. package/src/services/delivery/adapters/postmark.adapter.ts +43 -1
  133. package/src/services/escrow-enclave/README.md +81 -10
  134. package/src/services/escrow-enclave/__fixtures__/tls/pinned-ca.pem +11 -0
  135. package/src/services/escrow-enclave/__fixtures__/tls/pinned-leaf.key +5 -0
  136. package/src/services/escrow-enclave/__fixtures__/tls/pinned-leaf.pem +12 -0
  137. package/src/services/escrow-enclave/__fixtures__/tls/rogue-ca.pem +11 -0
  138. package/src/services/escrow-enclave/__fixtures__/tls/rogue-leaf.key +5 -0
  139. package/src/services/escrow-enclave/__fixtures__/tls/rogue-leaf.pem +12 -0
  140. package/src/services/escrow-enclave/blobIdentity.test.ts +42 -0
  141. package/src/services/escrow-enclave/blobIdentity.ts +55 -0
  142. package/src/services/escrow-enclave/cancellation.ts +59 -0
  143. package/src/services/escrow-enclave/enclaveTrustAnchors.ts +34 -0
  144. package/src/services/escrow-enclave/index.ts +145 -7
  145. package/src/services/escrow-enclave/notifications.test.ts +377 -0
  146. package/src/services/escrow-enclave/notifications.ts +340 -6
  147. package/src/services/escrow-enclave/production.test.ts +34 -0
  148. package/src/services/escrow-enclave/remoteEnclave.contract.test.ts +344 -0
  149. package/src/services/escrow-enclave/remoteEnclave.test.ts +490 -0
  150. package/src/services/escrow-enclave/remoteEnclave.tls.test.ts +62 -0
  151. package/src/services/escrow-enclave/remoteEnclave.ts +263 -0
  152. package/src/services/escrow-enclave/softwareEnclave.test.ts +231 -30
  153. package/src/services/escrow-enclave/softwareEnclave.ts +97 -11
  154. package/src/services/escrow-enclave/types.ts +63 -17
  155. package/test/auth-tickets.spec.ts +480 -0
  156. package/test/helpers/keycloak-broker.ts +181 -0
  157. package/test/helpers/live-mongo.ts +9 -0
  158. package/test/jobs/escrowBlobRewrap.spec.ts +262 -0
  159. package/test/jobs/escrowHoldReminders.spec.ts +319 -0
  160. package/test/keycloak-admin.spec.ts +58 -0
  161. package/test/keycloak-broker-roundtrip.integration.spec.ts +40 -0
  162. package/test/keycloak-migration.integration.spec.ts +266 -0
  163. package/test/keycloak-provider-links.spec.ts +201 -0
  164. package/test/keycloak-social-broker.integration.spec.ts +228 -0
  165. package/test/keycloak-verify.integration.spec.ts +100 -0
  166. package/test/keycloak-verify.spec.ts +307 -0
  167. package/test/keys-escrow.spec.ts +963 -24
  168. package/test/models/authSubjectIndexes.spec.ts +132 -0
  169. package/test/models/escrow.spec.ts +375 -13
  170. package/test/notifications.spec.ts +4 -0
  171. package/test/oidc-e2e/broker.spec.ts +116 -0
  172. package/test/oidc-e2e/compose.yaml +33 -0
  173. package/test/oidc-e2e/fixtures.ts +245 -0
  174. package/test/oidc-e2e/lock.test.mjs +73 -0
  175. package/test/oidc-e2e/playwright.config.ts +21 -0
  176. package/test/oidc-e2e/run.mjs +312 -0
  177. package/test/oidc-e2e/tsconfig.json +10 -0
  178. package/test/oidc-e2e/types.ts +21 -0
  179. package/test/oidc.integration.spec.ts +54 -0
  180. package/test/oidc.spec.ts +697 -0
  181. package/test/oidcLambdaApp.boot.spec.ts +105 -0
  182. package/test/provision-keycloak-users.spec.ts +299 -0
  183. package/test/signing-authority.spec.ts +50 -0
  184. package/tsconfig.build.json +1 -1
  185. package/tsconfig.json +1 -0
  186. package/vitest.config.ts +24 -1
  187. package/vitest.integration.config.ts +20 -3
  188. package/serverless.escrow-env.cjs +0 -20
package/lambda.ts CHANGED
@@ -1,128 +1,38 @@
1
- import serverlessHttp from 'serverless-http';
2
- import type { Context, APIGatewayProxyResultV2, APIGatewayProxyEventV2 } from 'aws-lambda';
3
- import { awsLambdaRequestHandler } from '@trpc/server/adapters/aws-lambda';
4
- import * as Sentry from '@sentry/serverless';
1
+ import { bootstrapLambda } from '@learncard/service-config';
5
2
 
6
- import app from './src/openapi';
7
- import didWebApp from './src/dids';
8
- import { appRouter, createContext } from './src/app';
9
- import { getEmptyLearnCard } from './src/helpers/learnCard.helpers';
10
- import { createOpenApiAwsLambdaHandler } from './src/helpers/shim';
11
- import {
12
- handleTrpcError,
13
- sentryBeforeSend,
14
- getTracesSampleRate,
15
- } from './src/helpers/sentry.helpers';
16
- import { environment } from './src/config/environment';
17
- import { toServerlessApplication } from './src/helpers/serverlessApplication';
18
- import { ensureUserKeysIndexes, createEscrowHoldsIndexes } from './src/models';
3
+ import { base, stages } from './src/config/stageConfig';
19
4
 
20
- const startupPromise = Promise.all([
21
- getEmptyLearnCard(), // Load WASM in for better cold starts
22
- ensureUserKeysIndexes(),
23
- createEscrowHoldsIndexes(),
24
- ]);
5
+ type LambdaApp = typeof import('./lambdaApp');
25
6
 
26
- const isWarmupEvent = (event: APIGatewayProxyEventV2): boolean =>
27
- 'source' in event && event.source === 'serverless-plugin-warmup';
28
-
29
- Sentry.AWSLambda.init({
30
- dsn: environment.SENTRY_DSN,
31
- environment: environment.SENTRY_ENV,
32
- enabled: Boolean(environment.SENTRY_DSN),
33
- tracesSampleRate: getTracesSampleRate(),
34
- beforeSend: sentryBeforeSend,
35
- integrations: [
36
- new Sentry.Integrations.Console(),
37
- new Sentry.Integrations.Http({ tracing: true }),
38
- new Sentry.Integrations.ContextLines(),
39
- new Sentry.Integrations.Mongo(),
40
- ],
41
- });
42
-
43
- export const swaggerUiHandler = serverlessHttp(toServerlessApplication(app), {
44
- basePath: '/docs',
45
- });
46
- export const didWebHandler = serverlessHttp(toServerlessApplication(didWebApp));
47
-
48
- export const _openApiHandler = createOpenApiAwsLambdaHandler({
49
- router: appRouter,
50
- responseMeta: () => {
51
- return {
52
- headers: {
53
- 'Access-Control-Allow-Origin': '*',
54
- 'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
55
- 'Access-Control-Allow-Headers': 'Authorization, Content-Type',
56
- },
57
- };
58
- },
59
- createContext,
60
- onError: handleTrpcError,
61
- });
62
-
63
- export const _trpcHandler = awsLambdaRequestHandler({
64
- router: appRouter,
65
- createContext,
66
- onError: handleTrpcError,
67
- responseMeta: () => {
68
- return {
69
- headers: {
70
- 'Access-Control-Allow-Origin': '*',
71
- 'Access-Control-Allow-Methods': '*',
72
- 'Access-Control-Allow-Headers': 'authorization',
73
- },
74
- };
75
- },
7
+ const getApplication = bootstrapLambda<LambdaApp>({
8
+ base,
9
+ stages,
10
+ stage: process.env.AWS_LAMBDA_FUNCTION_NAME
11
+ ? process.env.LAMBDA_STAGE
12
+ : process.env.CONFIG_STAGE,
13
+ importApp: () => import('./lambdaApp'),
76
14
  });
77
15
 
78
- export const openApiHandler = Sentry.AWSLambda.wrapHandler(
79
- async (event: APIGatewayProxyEventV2, context: Context): Promise<APIGatewayProxyResultV2> => {
80
- await startupPromise;
81
-
82
- if (isWarmupEvent(event)) {
83
- console.log('[Warmup] Initializing LearnCard...');
84
- await getEmptyLearnCard();
85
- console.log('[Warmup] Done!');
86
- return 'All done! 😄';
87
- }
16
+ export const trpcHandler = async (...args: Parameters<LambdaApp['trpcHandler']>) =>
17
+ (await getApplication()).trpcHandler(...args);
88
18
 
89
- if (event.requestContext.http.method === 'OPTIONS') {
90
- return {
91
- statusCode: 200,
92
- headers: {
93
- 'Access-Control-Allow-Origin': '*',
94
- 'Access-Control-Allow-Headers': '*',
95
- 'Access-Control-Allow-Methods': '*',
96
- },
97
- };
98
- }
19
+ export const openApiHandler = async (...args: Parameters<LambdaApp['openApiHandler']>) =>
20
+ (await getApplication()).openApiHandler(...args);
99
21
 
100
- return _openApiHandler(event, context);
101
- }
102
- );
22
+ export const swaggerUiHandler = async (...args: Parameters<LambdaApp['swaggerUiHandler']>) =>
23
+ (await getApplication()).swaggerUiHandler(...args);
103
24
 
104
- export const trpcHandler = Sentry.AWSLambda.wrapHandler(
105
- async (event: APIGatewayProxyEventV2, context: Context): Promise<APIGatewayProxyResultV2> => {
106
- await startupPromise;
25
+ export const didWebHandler = async (...args: Parameters<LambdaApp['didWebHandler']>) =>
26
+ (await getApplication()).didWebHandler(...args);
107
27
 
108
- if (isWarmupEvent(event)) {
109
- console.log('[Warmup] Initializing LearnCard...');
110
- await getEmptyLearnCard();
111
- console.log('[Warmup] Done!');
112
- return 'All done! 😄';
113
- }
28
+ // Preserve the local/offline handler name without importing the full API for OIDC.
29
+ export { handler as oidcHandler } from './oidcLambda';
114
30
 
115
- if (event.requestContext.http.method === 'OPTIONS') {
116
- return {
117
- statusCode: 200,
118
- headers: {
119
- 'Access-Control-Allow-Origin': '*',
120
- 'Access-Control-Allow-Headers': '*',
121
- 'Access-Control-Allow-Methods': '*',
122
- },
123
- };
124
- }
31
+ // Scheduled (EventBridge) escrow jobs share the API bootstrap: they need Mongo/Postmark from the bundle.
32
+ export const escrowHoldRemindersHandler = async (
33
+ ...args: Parameters<LambdaApp['escrowHoldRemindersHandler']>
34
+ ) => (await getApplication()).escrowHoldRemindersHandler(...args);
125
35
 
126
- return _trpcHandler(event, context);
127
- }
128
- );
36
+ export const escrowBlobRewrapHandler = async (
37
+ ...args: Parameters<LambdaApp['escrowBlobRewrapHandler']>
38
+ ) => (await getApplication()).escrowBlobRewrapHandler(...args);
package/lambdaApp.ts ADDED
@@ -0,0 +1,146 @@
1
+ import serverlessHttp from 'serverless-http';
2
+ import type { Context, APIGatewayProxyResultV2, APIGatewayProxyEventV2 } from 'aws-lambda';
3
+ import { awsLambdaRequestHandler } from '@trpc/server/adapters/aws-lambda';
4
+ import * as Sentry from '@sentry/serverless';
5
+
6
+ import app from './src/openapi';
7
+ import didWebApp from './src/dids';
8
+ import { appRouter, createContext } from './src/app';
9
+ import { getEmptyLearnCard } from './src/helpers/learnCard.helpers';
10
+ import { createOpenApiAwsLambdaHandler } from './src/helpers/shim';
11
+ import {
12
+ handleTrpcError,
13
+ sentryBeforeSend,
14
+ getTracesSampleRate,
15
+ } from './src/helpers/sentry.helpers';
16
+ import { environment } from './src/config/environment';
17
+ import { toServerlessApplication } from './src/helpers/serverlessApplication';
18
+ import { ensureUserKeysIndexes, createEscrowHoldsIndexes } from './src/models';
19
+ import { runEscrowHoldReminders } from './src/jobs/escrowHoldReminders';
20
+ import { runEscrowBlobRewrap } from './src/jobs/escrowBlobRewrap';
21
+
22
+ const startupPromise = Promise.all([
23
+ getEmptyLearnCard(), // Load WASM in for better cold starts
24
+ ensureUserKeysIndexes(),
25
+ createEscrowHoldsIndexes(),
26
+ ]);
27
+
28
+ const isWarmupEvent = (event: APIGatewayProxyEventV2): boolean =>
29
+ 'source' in event && event.source === 'serverless-plugin-warmup';
30
+
31
+ Sentry.AWSLambda.init({
32
+ dsn: environment.SENTRY_DSN,
33
+ environment: environment.SENTRY_ENV,
34
+ enabled: Boolean(environment.SENTRY_DSN),
35
+ tracesSampleRate: getTracesSampleRate(),
36
+ beforeSend: sentryBeforeSend,
37
+ integrations: [
38
+ new Sentry.Integrations.Console(),
39
+ new Sentry.Integrations.Http({ tracing: true }),
40
+ new Sentry.Integrations.ContextLines(),
41
+ new Sentry.Integrations.Mongo(),
42
+ ],
43
+ });
44
+
45
+ export const swaggerUiHandler = serverlessHttp(toServerlessApplication(app), {
46
+ basePath: '/docs',
47
+ });
48
+ export const didWebHandler = serverlessHttp(toServerlessApplication(didWebApp));
49
+
50
+ export const _openApiHandler = createOpenApiAwsLambdaHandler({
51
+ router: appRouter,
52
+ responseMeta: () => {
53
+ return {
54
+ headers: {
55
+ 'Access-Control-Allow-Origin': '*',
56
+ 'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
57
+ 'Access-Control-Allow-Headers': 'Authorization, Content-Type',
58
+ },
59
+ };
60
+ },
61
+ createContext,
62
+ onError: handleTrpcError,
63
+ });
64
+
65
+ export const _trpcHandler = awsLambdaRequestHandler({
66
+ router: appRouter,
67
+ createContext,
68
+ onError: handleTrpcError,
69
+ responseMeta: () => {
70
+ return {
71
+ headers: {
72
+ 'Access-Control-Allow-Origin': '*',
73
+ 'Access-Control-Allow-Methods': '*',
74
+ 'Access-Control-Allow-Headers': 'authorization',
75
+ },
76
+ };
77
+ },
78
+ });
79
+
80
+ export const openApiHandler = Sentry.AWSLambda.wrapHandler(
81
+ async (event: APIGatewayProxyEventV2, context: Context): Promise<APIGatewayProxyResultV2> => {
82
+ await startupPromise;
83
+
84
+ if (isWarmupEvent(event)) {
85
+ console.log('[Warmup] Initializing LearnCard...');
86
+ await getEmptyLearnCard();
87
+ console.log('[Warmup] Done!');
88
+ return 'All done! 😄';
89
+ }
90
+
91
+ if (event.requestContext.http.method === 'OPTIONS') {
92
+ return {
93
+ statusCode: 200,
94
+ headers: {
95
+ 'Access-Control-Allow-Origin': '*',
96
+ 'Access-Control-Allow-Headers': '*',
97
+ 'Access-Control-Allow-Methods': '*',
98
+ },
99
+ };
100
+ }
101
+
102
+ return _openApiHandler(event, context);
103
+ }
104
+ );
105
+
106
+ export const trpcHandler = Sentry.AWSLambda.wrapHandler(
107
+ async (event: APIGatewayProxyEventV2, context: Context): Promise<APIGatewayProxyResultV2> => {
108
+ await startupPromise;
109
+
110
+ if (isWarmupEvent(event)) {
111
+ console.log('[Warmup] Initializing LearnCard...');
112
+ await getEmptyLearnCard();
113
+ console.log('[Warmup] Done!');
114
+ return 'All done! 😄';
115
+ }
116
+
117
+ if (event.requestContext.http.method === 'OPTIONS') {
118
+ return {
119
+ statusCode: 200,
120
+ headers: {
121
+ 'Access-Control-Allow-Origin': '*',
122
+ 'Access-Control-Allow-Headers': '*',
123
+ 'Access-Control-Allow-Methods': '*',
124
+ },
125
+ };
126
+ }
127
+
128
+ return _trpcHandler(event, context);
129
+ }
130
+ );
131
+
132
+ // Scheduled (EventBridge) functions, not API-Gateway-triggered: they share
133
+ // startupPromise with the handlers above; warmup/OPTIONS handling doesn't apply.
134
+ export const escrowHoldRemindersHandler = Sentry.AWSLambda.wrapHandler(async (): Promise<void> => {
135
+ await startupPromise;
136
+ const counts = await runEscrowHoldReminders();
137
+ console.log('[escrow-hold-reminders] completed', counts);
138
+ });
139
+
140
+ // P9.3: migrates escrow blobs off a retiring enclave key for accounts that
141
+ // never sign in again (P9.2 already covers accounts that do, on next login).
142
+ export const escrowBlobRewrapHandler = Sentry.AWSLambda.wrapHandler(async (): Promise<void> => {
143
+ await startupPromise;
144
+ const counts = await runEscrowBlobRewrap();
145
+ console.log('[escrow-blob-rewrap] completed', counts);
146
+ });
@@ -0,0 +1,38 @@
1
+ import type { APIGatewayProxyEventV2, Context } from 'aws-lambda';
2
+ import { afterEach, beforeEach, expect, it, vi } from 'vitest';
3
+
4
+ const mocks = vi.hoisted(() => ({ applyStage: vi.fn(), handler: vi.fn(), importApp: vi.fn() }));
5
+ vi.mock('./src/config/stageConfig', () => ({ applyLcaApiStageConfig: mocks.applyStage }));
6
+
7
+ beforeEach(() => {
8
+ vi.resetModules();
9
+ vi.clearAllMocks();
10
+ mocks.handler.mockResolvedValue({ statusCode: 200 });
11
+ vi.doMock('./oidcLambdaApp', () => {
12
+ mocks.importApp();
13
+ expect(mocks.applyStage).toHaveBeenCalledTimes(1);
14
+ return { oidcHandler: mocks.handler };
15
+ });
16
+ });
17
+ afterEach(() => vi.unstubAllEnvs());
18
+
19
+ it.each([
20
+ ['lca-api-dev-oidc', 'dev'],
21
+ ['', 'local'],
22
+ ])(
23
+ 'applies the appropriate stage before the isolated OIDC import (%s)',
24
+ async (functionName, expectedStage) => {
25
+ vi.stubEnv('AWS_LAMBDA_FUNCTION_NAME', functionName);
26
+ vi.stubEnv('LAMBDA_STAGE', 'dev');
27
+ vi.stubEnv('CONFIG_STAGE', 'local');
28
+ const { handler } = await import('./oidcLambda');
29
+ const event = {} as APIGatewayProxyEventV2;
30
+ const context = {} as Context;
31
+ const callback = vi.fn();
32
+ await Promise.all([handler(event, context, callback), handler(event, context, callback)]);
33
+ expect(mocks.applyStage).toHaveBeenCalledExactlyOnceWith(expectedStage);
34
+ expect(mocks.importApp).toHaveBeenCalledTimes(1);
35
+ expect(mocks.handler).toHaveBeenCalledTimes(2);
36
+ expect(mocks.handler).toHaveBeenLastCalledWith(event, context, callback);
37
+ }
38
+ );
package/oidcLambda.ts ADDED
@@ -0,0 +1,18 @@
1
+ import { applyLcaApiStageConfig } from './src/config/stageConfig';
2
+
3
+ type OidcApp = typeof import('./oidcLambdaApp');
4
+
5
+ let application: Promise<OidcApp> | undefined;
6
+
7
+ export const handler = async (...args: Parameters<OidcApp['oidcHandler']>) => {
8
+ if (!application) {
9
+ applyLcaApiStageConfig(
10
+ process.env.AWS_LAMBDA_FUNCTION_NAME
11
+ ? process.env.LAMBDA_STAGE
12
+ : process.env.CONFIG_STAGE
13
+ );
14
+ // This role cannot read the runtime bundle. Cache imports, including failures.
15
+ application = import('./oidcLambdaApp');
16
+ }
17
+ return (await application).oidcHandler(...args);
18
+ };
@@ -0,0 +1,11 @@
1
+ import serverlessHttp from 'serverless-http';
2
+
3
+ import { app as oidcApp } from './src/oidc';
4
+ import { toServerlessApplication } from './src/helpers/serverlessApplication';
5
+
6
+ /**
7
+ * Dedicated OIDC Lambda entrypoint. Unlike `lambdaApp.ts`, this imports only the
8
+ * Fastify OIDC surface and its focused environment, so the OIDC function boots
9
+ * without SEED/MONGO/Postmark/Firebase or a runtime-secrets bundle.
10
+ */
11
+ export const oidcHandler = serverlessHttp(toServerlessApplication(oidcApp));
package/package.json CHANGED
@@ -1,18 +1,20 @@
1
1
  {
2
2
  "name": "@learncard/lca-api-service",
3
- "version": "1.4.0",
3
+ "version": "1.5.0",
4
4
  "description": "",
5
5
  "engines": {
6
6
  "node": ">=24.12 <25"
7
7
  },
8
- "packageManager": "bun@1.3.14",
8
+ "packageManager": "bun@1.4.2",
9
9
  "main": "dist/index.js",
10
10
  "types": "dist/index.d.ts",
11
11
  "scripts": {
12
12
  "migrate:sa-seeds:local": "bash scripts/migrate-signing-authority-seeds-local.sh",
13
13
  "migrate:sa-seeds": "node scripts/migrate-signing-authority-seeds.mjs",
14
14
  "check:sa-infra": "node scripts/check-seed-encryption-infrastructure.mjs",
15
+ "sa-seed:flags": "bun --conditions=development scripts/print-seed-rollout-flags.ts",
15
16
  "check:sa-redis": "node scripts/check-signing-authority-redis.mjs",
17
+ "provision:keycloak": "bun --conditions=development scripts/provision-keycloak-users.ts",
16
18
  "test": "vitest run --config vitest.config.ts",
17
19
  "test:watch": "vitest --config vitest.config.ts",
18
20
  "test:coverage": "vitest run --coverage --config vitest.config.ts",
@@ -27,7 +29,9 @@
27
29
  "dev": "bun --conditions=development --watch src/docker-entry.ts",
28
30
  "start:docker": "bun --conditions=development src/docker-entry.ts",
29
31
  "serverless-deploy": "node scripts/check-seed-encryption-infrastructure.mjs && node ../../../scripts/prepare-didkit-native-layer.cjs && serverless deploy",
30
- "serverless-deploy-scouts": "serverless deploy --config serverless-scouts.yml"
32
+ "serverless-deploy-scouts": "serverless deploy --config serverless-scouts.yml",
33
+ "test:oidc:e2e": "node test/oidc-e2e/run.mjs",
34
+ "typecheck:oidc:e2e": "tsc --noEmit -p test/oidc-e2e/tsconfig.json"
31
35
  },
32
36
  "keywords": [],
33
37
  "author": "WeLibrary, LLC (www.welibrary.io)",
@@ -49,21 +53,26 @@
49
53
  "serverless-http": "3.2.0",
50
54
  "serverless-offline": "^14.4.0",
51
55
  "serverless-prune-plugin": "^2.1.0",
52
- "shx": "^0.3.4"
56
+ "shx": "^0.3.4",
57
+ "@playwright/test": "^1.42.0"
53
58
  },
54
59
  "dependencies": {
55
60
  "@aws-sdk/client-kms": "^3.1139.0",
61
+ "@aws-sdk/client-secrets-manager": "^3.1139.0",
56
62
  "@fastify/cors": "^11.2.0",
63
+ "@fastify/formbody": "^8.0.2",
64
+ "@fastify/rate-limit": "^10.2.0",
57
65
  "@fastify/static": "^10.1.1",
58
- "@learncard/core": "9.4.37",
59
- "@learncard/did-web-plugin": "1.1.37",
60
- "@learncard/didkit-plugin": "1.10.2",
61
- "@learncard/didkit-plugin-node": "0.3.2",
62
- "@learncard/email-templates": "1.2.0",
63
- "@learncard/helpers": "1.6.1",
64
- "@learncard/init": "2.5.1",
65
- "@learncard/types": "5.22.0",
66
- "@learncard/sss-key-manager": "0.2.0",
66
+ "@learncard/core": "9.4.38",
67
+ "@learncard/did-web-plugin": "1.1.38",
68
+ "@learncard/didkit-plugin": "1.10.3",
69
+ "@learncard/didkit-plugin-node": "0.3.3",
70
+ "@learncard/email-templates": "1.2.1",
71
+ "@learncard/helpers": "1.6.2",
72
+ "@learncard/init": "2.5.2",
73
+ "@learncard/service-config": "0.1.1",
74
+ "@learncard/types": "5.23.0",
75
+ "@learncard/sss-key-manager": "0.2.1",
67
76
  "@sentry/serverless": "^7.61.0",
68
77
  "@trpc/server": "11.8.0",
69
78
  "@types/jsonwebtoken": "^9.0.8",
@@ -79,6 +88,7 @@
79
88
  "firebase-admin": "13.4.0",
80
89
  "ioredis": "^5.2.2",
81
90
  "ioredis-mock": "^8.2.2",
91
+ "jose": "^6.2.12",
82
92
  "jsonwebtoken": "^9.0.2",
83
93
  "jwt-decode": "^3.1.2",
84
94
  "libsodium-wrappers": "^0.7.11",
@@ -0,0 +1,88 @@
1
+ # lca-api scripts
2
+
3
+ Operational scripts run directly with `bun scripts/<name>.ts` from
4
+ `services/learn-card-network/lca-api/`. These are not part of the deployed
5
+ service and are not built by `bun run build`; they import `src/` files by
6
+ relative path (not the `@alias` path mappings used inside `src/`, since
7
+ `scripts/` is intentionally outside this package's `tsconfig.json` `include`
8
+ and those aliases don't resolve from here — see `escrow-blob-mode-report.ts`
9
+ if you need the full explanation).
10
+
11
+ ## `strip-email.ts`
12
+
13
+ Removes the email attribute from a Firebase user by UID, via the Identity
14
+ Toolkit REST API (the Admin SDK can't clear an email, only replace it).
15
+
16
+ ```bash
17
+ bun scripts/strip-email.ts <uid>
18
+ ```
19
+
20
+ Requires `GOOGLE_APPLICATION_CREDENTIAL`.
21
+
22
+ ## `escrow-blob-mode-report.ts` (P6.2)
23
+
24
+ Read-only counts report over the `userkeys` and `escrowholds` collections:
25
+ escrow blob counts by `enclaveMode` / `enclaveKeyId`, enabled-PIN count,
26
+ pending-hold counts by `releasePolicy`, opted-out count, and a software→nitro
27
+ "re-seal" breakdown. Never writes to the database and never prints PII (counts
28
+ only — no DIDs, emails, or Mongo `_id`s).
29
+
30
+ ```bash
31
+ # Human-readable table
32
+ bun scripts/escrow-blob-mode-report.ts
33
+
34
+ # Machine-readable JSON
35
+ bun scripts/escrow-blob-mode-report.ts --json
36
+
37
+ # Also print the CloudWatch PutMetricData payload for the counts
38
+ bun scripts/escrow-blob-mode-report.ts --json --publish-metrics
39
+ ```
40
+
41
+ Requires the same env as the lca-api server: `SEED`, `MONGO_URI`,
42
+ `MONGO_DB_NAME` (point `MONGO_URI` at whichever environment you want to report
43
+ on). Full flag/limitation reference lives in the script's own header comment.
44
+
45
+ **CloudWatch:** `--publish-metrics` does not call AWS itself — lca-api has no
46
+ `@aws-sdk/client-cloudwatch` dependency (only
47
+ `services/learn-card-network/ai-agent` does elsewhere in this monorepo, and it
48
+ isn't shared with lca-api). The flag prints the exact `MetricData` payload
49
+ (namespace `LearnCard/Escrow`, metrics `EscrowBlobs` per `EnclaveMode`
50
+ dimension and `EscrowBlobsStale`) for the P7.1 monitor Lambda or an ops job
51
+ that already has AWS SDK access to forward.
52
+
53
+ ### Running it locally against a throwaway Mongo
54
+
55
+ There's no persistent local Mongo fixture for scripts (unlike `test/*.spec.ts`,
56
+ which get one via `vitest-setup.ts`'s `mongodb-memory-server` global setup).
57
+ To try the report against real data without touching a real environment, spin
58
+ up your own `mongodb-memory-server` instance and point the script at it:
59
+
60
+ ```ts
61
+ // tmp-run-report.mjs (delete after use — do not commit)
62
+ import { MongoMemoryServer } from 'mongodb-memory-server';
63
+ import { spawnSync } from 'node:child_process';
64
+
65
+ const mongod = await MongoMemoryServer.create();
66
+ // ...seed `userkeys` / `escrowholds` via a MongoClient here...
67
+
68
+ spawnSync('bun', ['scripts/escrow-blob-mode-report.ts', '--json'], {
69
+ env: {
70
+ ...process.env,
71
+ NODE_ENV: 'development',
72
+ SEED: 'a'.repeat(64),
73
+ MONGO_URI: mongod.getUri(),
74
+ MONGO_DB_NAME: 'report-harness',
75
+ },
76
+ stdio: 'inherit',
77
+ });
78
+
79
+ await mongod.stop();
80
+ ```
81
+
82
+ ### Testing
83
+
84
+ `escrow-blob-mode-report.test.ts` covers the pure aggregation → report
85
+ formatting logic (zero-count modes, an unknown `enclaveMode`/`releasePolicy`
86
+ value, the CloudWatch payload shape) without touching Mongo. It's picked up by
87
+ the same `bun run test` as `src/**/*.test.ts` — `vitest.config.ts`'s `include`
88
+ was extended to also match `scripts/**/*.test.ts`.
@@ -67,8 +67,24 @@ for (const name of ['swagger', 'didWeb']) {
67
67
  assert.equal(config.functions[name].role, undefined);
68
68
  }
69
69
  assert.equal(resources.SigningAuthorityExecutionRole.Type, 'AWS::IAM::Role');
70
- assert.deepEqual(config.provider.environment.SA_SEED_KMS_KEY_ARN, keyArn);
71
- assert.equal(config.provider.environment.SA_SEED_LOCAL_KEK, undefined);
70
+ // provider.environment is a Serverless file variable; resolve it like Serverless does, in
71
+ // both runtime-secrets modes, so the KMS ARN and the no-local-KEK invariant are still checked.
72
+ assert.equal(config.provider.environment, '${file(./serverless.function-env.cjs):provider}');
73
+ const functionEnv = require('../serverless.function-env.cjs');
74
+ const savedEnv = { ...process.env };
75
+ try {
76
+ for (const bundle of ['', 'lca-api/dev/runtime-secrets']) {
77
+ process.env.RUNTIME_SECRETS_ID = bundle;
78
+ process.env.SA_SEED_LOCAL_KEK = 'must-not-reach-lambda';
79
+ const provider = functionEnv.provider({ options: { stage: 'dev' } });
80
+ assert.deepEqual(provider.SA_SEED_KMS_KEY_ARN, keyArn);
81
+ // Deployed functions use KMS only; the local KEK is for offline development.
82
+ for (const env of [provider, functionEnv.api(), functionEnv.escrow()])
83
+ assert.equal(env.SA_SEED_LOCAL_KEK, undefined);
84
+ }
85
+ } finally {
86
+ process.env = savedEnv;
87
+ }
72
88
  if (packaged) {
73
89
  const lambda = resources.SeedMigrationLambdaFunction;
74
90
  assert.equal(lambda.Properties.Timeout, 900);