@learncard/lca-api-service 1.4.0 → 1.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (191) hide show
  1. package/.env.example +73 -0
  2. package/AGENTS.md +106 -10
  3. package/CHANGELOG.md +52 -0
  4. package/bootstrapLambda.test.ts +76 -0
  5. package/bundle.mjs +9 -1
  6. package/config/config.dev.json +23 -0
  7. package/config/config.example.json +22 -0
  8. package/config/config.json +1 -0
  9. package/config/config.production.json +12 -0
  10. package/config/config.scouts.dev.json +11 -0
  11. package/config/config.scouts.production.json +11 -0
  12. package/dist/app.d.ts +67 -3
  13. package/dist/cache/getDel.d.ts +2 -0
  14. package/dist/cache/getDel.test.d.ts +1 -0
  15. package/dist/cache/login-tickets.d.ts +12 -0
  16. package/dist/config/applyDockerStageConfig.d.ts +1 -0
  17. package/dist/config/cacheEnvironment.d.ts +16 -0
  18. package/dist/config/environment.d.ts +101 -0
  19. package/dist/config/escrowBundleMode.test.d.ts +1 -0
  20. package/dist/config/escrowDeployment.test.d.ts +1 -0
  21. package/dist/config/oidcEnvironment.d.ts +74 -0
  22. package/dist/config/serverlessFunctionEnv.test.d.ts +1 -0
  23. package/dist/config/stageConfig.d.ts +62 -0
  24. package/dist/config/stageConfig.test.d.ts +1 -0
  25. package/dist/didkit_wasm_bg.wasm +0 -0
  26. package/dist/docker-entry.d.ts +1 -1
  27. package/dist/helpers/auth.helpers.d.ts +1 -1
  28. package/dist/helpers/escrowCancelLink.d.ts +7 -0
  29. package/dist/helpers/escrowCancelLink.test.d.ts +1 -0
  30. package/dist/helpers/escrowCancelToken.d.ts +4 -0
  31. package/dist/helpers/escrowCancelToken.test.d.ts +1 -0
  32. package/dist/helpers/keycloak.helpers.d.ts +23 -0
  33. package/dist/helpers/login-ticket.helpers.d.ts +31 -0
  34. package/dist/helpers/login-ticket.helpers.test.d.ts +1 -0
  35. package/dist/helpers/oidc.helpers.d.ts +47 -0
  36. package/dist/helpers/rate-limit.helpers.d.ts +6 -0
  37. package/dist/helpers/rate-limit.helpers.test.d.ts +1 -0
  38. package/dist/helpers/social-token.helpers.d.ts +19 -0
  39. package/dist/index.d.ts +1 -0
  40. package/dist/index.js +2 -2
  41. package/dist/jobs/escrowBlobRewrap.d.ts +9 -0
  42. package/dist/jobs/escrowHoldReminders.d.ts +12 -0
  43. package/dist/models/AuthSubject.d.ts +20 -0
  44. package/dist/models/EscrowHold.d.ts +79 -1
  45. package/dist/models/Notification.d.ts +5 -0
  46. package/dist/models/UserKey.d.ts +11 -1
  47. package/dist/models/index.d.ts +1 -0
  48. package/dist/oidc.d.ts +5 -0
  49. package/dist/publish-metafile.json +1 -0
  50. package/dist/routes/auth.d.ts +31 -0
  51. package/dist/routes/escrow.d.ts +18 -4
  52. package/dist/routes/keys.d.ts +1 -0
  53. package/dist/routes/notifications.d.ts +5 -0
  54. package/dist/routes/signing-authority.d.ts +14 -0
  55. package/dist/services/escrow-enclave/blobIdentity.d.ts +10 -0
  56. package/dist/services/escrow-enclave/blobIdentity.test.d.ts +1 -0
  57. package/dist/services/escrow-enclave/cancellation.d.ts +3 -0
  58. package/dist/services/escrow-enclave/enclaveTrustAnchors.d.ts +1 -0
  59. package/dist/services/escrow-enclave/index.d.ts +16 -0
  60. package/dist/services/escrow-enclave/notifications.d.ts +19 -4
  61. package/dist/services/escrow-enclave/notifications.test.d.ts +1 -0
  62. package/dist/services/escrow-enclave/production.test.d.ts +1 -0
  63. package/dist/services/escrow-enclave/remoteEnclave.contract.test.d.ts +1 -0
  64. package/dist/services/escrow-enclave/remoteEnclave.d.ts +20 -0
  65. package/dist/services/escrow-enclave/remoteEnclave.test.d.ts +1 -0
  66. package/dist/services/escrow-enclave/remoteEnclave.tls.test.d.ts +1 -0
  67. package/dist/services/escrow-enclave/softwareEnclave.d.ts +12 -2
  68. package/dist/services/escrow-enclave/types.d.ts +52 -8
  69. package/dist/types/notifications.d.ts +5 -0
  70. package/esbuildPlugins.cjs +1 -0
  71. package/lambda.ts +27 -117
  72. package/lambdaApp.ts +146 -0
  73. package/oidcLambda.test.ts +38 -0
  74. package/oidcLambda.ts +18 -0
  75. package/oidcLambdaApp.ts +11 -0
  76. package/package.json +22 -13
  77. package/project.json +8 -1
  78. package/scripts/README.md +88 -0
  79. package/scripts/check-seed-encryption-infrastructure.mjs +18 -2
  80. package/scripts/escrow-blob-mode-report.test.ts +227 -0
  81. package/scripts/escrow-blob-mode-report.ts +465 -0
  82. package/scripts/keycloak-admin.ts +108 -0
  83. package/scripts/keycloak-provider-links.ts +147 -0
  84. package/scripts/print-seed-rollout-flags.ts +21 -0
  85. package/scripts/provision-keycloak-users.ts +419 -0
  86. package/seedMigrationApp.test.ts +145 -0
  87. package/seedMigrationApp.ts +50 -0
  88. package/seedMigrationLambda.test.ts +30 -135
  89. package/seedMigrationLambda.ts +15 -46
  90. package/serverless-local.yml +25 -1
  91. package/serverless.function-env.cjs +58 -0
  92. package/serverless.yml +174 -37
  93. package/src/accesslayer/notifications/index.ts +9 -2
  94. package/src/accesslayer/signing-authority/index.ts +4 -1
  95. package/src/app.ts +2 -0
  96. package/src/cache/getDel.test.ts +34 -0
  97. package/src/cache/getDel.ts +32 -0
  98. package/src/cache/index.ts +1 -1
  99. package/src/cache/login-tickets.ts +26 -0
  100. package/src/config/applyDockerStageConfig.ts +6 -0
  101. package/src/config/cacheEnvironment.ts +34 -0
  102. package/src/config/environment.test.ts +45 -2
  103. package/src/config/environment.ts +90 -23
  104. package/src/config/escrowBundleMode.test.ts +52 -0
  105. package/src/config/escrowDeployment.test.ts +114 -0
  106. package/src/config/oidcEnvironment.ts +59 -0
  107. package/src/config/serverlessFunctionEnv.test.ts +98 -0
  108. package/src/config/stageConfig.test.ts +26 -0
  109. package/src/config/stageConfig.ts +24 -0
  110. package/src/docker-entry.ts +9 -0
  111. package/src/helpers/auth.helpers.ts +68 -30
  112. package/src/helpers/escrowCancelLink.test.ts +60 -0
  113. package/src/helpers/escrowCancelLink.ts +38 -0
  114. package/src/helpers/escrowCancelToken.test.ts +105 -0
  115. package/src/helpers/escrowCancelToken.ts +47 -0
  116. package/src/helpers/keycloak.helpers.ts +120 -0
  117. package/src/helpers/login-ticket.helpers.test.ts +45 -0
  118. package/src/helpers/login-ticket.helpers.ts +91 -0
  119. package/src/helpers/oidc.helpers.ts +354 -0
  120. package/src/helpers/rate-limit.helpers.test.ts +61 -0
  121. package/src/helpers/rate-limit.helpers.ts +73 -0
  122. package/src/helpers/social-token.helpers.ts +144 -0
  123. package/src/index.ts +1 -0
  124. package/src/jobs/escrowBlobRewrap.ts +121 -0
  125. package/src/jobs/escrowHoldReminders.ts +177 -0
  126. package/src/models/AuthSubject.ts +147 -0
  127. package/src/models/EscrowHold.ts +321 -5
  128. package/src/models/UserKey.ts +91 -6
  129. package/src/models/index.ts +1 -0
  130. package/src/oidc.ts +371 -0
  131. package/src/routes/auth.ts +173 -0
  132. package/src/routes/escrow.ts +281 -34
  133. package/src/routes/keys.ts +5 -0
  134. package/src/routes/signing-authority.ts +39 -1
  135. package/src/services/delivery/adapters/postmark.adapter.ts +43 -1
  136. package/src/services/escrow-enclave/README.md +81 -10
  137. package/src/services/escrow-enclave/__fixtures__/tls/pinned-ca.pem +11 -0
  138. package/src/services/escrow-enclave/__fixtures__/tls/pinned-leaf.key +5 -0
  139. package/src/services/escrow-enclave/__fixtures__/tls/pinned-leaf.pem +12 -0
  140. package/src/services/escrow-enclave/__fixtures__/tls/rogue-ca.pem +11 -0
  141. package/src/services/escrow-enclave/__fixtures__/tls/rogue-leaf.key +5 -0
  142. package/src/services/escrow-enclave/__fixtures__/tls/rogue-leaf.pem +12 -0
  143. package/src/services/escrow-enclave/blobIdentity.test.ts +42 -0
  144. package/src/services/escrow-enclave/blobIdentity.ts +55 -0
  145. package/src/services/escrow-enclave/cancellation.ts +59 -0
  146. package/src/services/escrow-enclave/enclaveTrustAnchors.ts +34 -0
  147. package/src/services/escrow-enclave/index.ts +145 -7
  148. package/src/services/escrow-enclave/notifications.test.ts +377 -0
  149. package/src/services/escrow-enclave/notifications.ts +340 -6
  150. package/src/services/escrow-enclave/production.test.ts +34 -0
  151. package/src/services/escrow-enclave/remoteEnclave.contract.test.ts +344 -0
  152. package/src/services/escrow-enclave/remoteEnclave.test.ts +490 -0
  153. package/src/services/escrow-enclave/remoteEnclave.tls.test.ts +62 -0
  154. package/src/services/escrow-enclave/remoteEnclave.ts +263 -0
  155. package/src/services/escrow-enclave/softwareEnclave.test.ts +231 -30
  156. package/src/services/escrow-enclave/softwareEnclave.ts +97 -11
  157. package/src/services/escrow-enclave/types.ts +63 -17
  158. package/test/auth-tickets.spec.ts +480 -0
  159. package/test/helpers/keycloak-broker.ts +181 -0
  160. package/test/helpers/live-mongo.ts +9 -0
  161. package/test/jobs/escrowBlobRewrap.spec.ts +262 -0
  162. package/test/jobs/escrowHoldReminders.spec.ts +319 -0
  163. package/test/keycloak-admin.spec.ts +58 -0
  164. package/test/keycloak-broker-roundtrip.integration.spec.ts +40 -0
  165. package/test/keycloak-migration.integration.spec.ts +266 -0
  166. package/test/keycloak-provider-links.spec.ts +201 -0
  167. package/test/keycloak-social-broker.integration.spec.ts +228 -0
  168. package/test/keycloak-verify.integration.spec.ts +100 -0
  169. package/test/keycloak-verify.spec.ts +307 -0
  170. package/test/keys-escrow.spec.ts +963 -24
  171. package/test/models/authSubjectIndexes.spec.ts +132 -0
  172. package/test/models/escrow.spec.ts +375 -13
  173. package/test/notifications.spec.ts +4 -0
  174. package/test/oidc-e2e/broker.spec.ts +116 -0
  175. package/test/oidc-e2e/compose.yaml +33 -0
  176. package/test/oidc-e2e/fixtures.ts +245 -0
  177. package/test/oidc-e2e/lock.test.mjs +73 -0
  178. package/test/oidc-e2e/playwright.config.ts +21 -0
  179. package/test/oidc-e2e/run.mjs +312 -0
  180. package/test/oidc-e2e/tsconfig.json +10 -0
  181. package/test/oidc-e2e/types.ts +21 -0
  182. package/test/oidc.integration.spec.ts +54 -0
  183. package/test/oidc.spec.ts +697 -0
  184. package/test/oidcLambdaApp.boot.spec.ts +105 -0
  185. package/test/provision-keycloak-users.spec.ts +299 -0
  186. package/test/signing-authority.spec.ts +50 -0
  187. package/tsconfig.build.json +1 -1
  188. package/tsconfig.json +1 -0
  189. package/vitest.config.ts +24 -1
  190. package/vitest.integration.config.ts +20 -3
  191. package/serverless.escrow-env.cjs +0 -20
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.1",
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,25 @@
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.3",
73
+ "@learncard/types": "5.23.0",
74
+ "@learncard/sss-key-manager": "0.2.1",
67
75
  "@sentry/serverless": "^7.61.0",
68
76
  "@trpc/server": "11.8.0",
69
77
  "@types/jsonwebtoken": "^9.0.8",
@@ -79,6 +87,7 @@
79
87
  "firebase-admin": "13.4.0",
80
88
  "ioredis": "^5.2.2",
81
89
  "ioredis-mock": "^8.2.2",
90
+ "jose": "^6.2.12",
82
91
  "jsonwebtoken": "^9.0.2",
83
92
  "jwt-decode": "^3.1.2",
84
93
  "libsodium-wrappers": "^0.7.11",
package/project.json CHANGED
@@ -14,7 +14,14 @@
14
14
  "targets": {
15
15
  "build": {
16
16
  "executor": "nx:run-script",
17
- "inputs": ["source"],
17
+ "inputs": [
18
+ "source",
19
+ "{projectRoot}/bundle.mjs",
20
+ "{projectRoot}/esbuildPlugins.cjs",
21
+ "{projectRoot}/package.json",
22
+ "{projectRoot}/tsconfig*.json",
23
+ "{workspaceRoot}/packages/service-config/src/**/*"
24
+ ],
18
25
  "options": {
19
26
  "script": "build"
20
27
  }
@@ -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);
@@ -0,0 +1,227 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+
3
+ // Pure-logic test only: the script module (and, transitively, src/models/UserKey.ts)
4
+ // imports the real Mongo connection module for the CLI's live-query path, which
5
+ // requires a real Mongo global outside test-mode integration setup. Stub it so
6
+ // importing the module here never needs one — same pattern as
7
+ // src/models/UserKey.escrowPin.test.ts, but by relative path: `scripts/` sits
8
+ // outside this package's tsconfig `include`, so path-alias mocks (`@mongo`)
9
+ // resolved from here don't reliably match the alias-resolved import inside
10
+ // src/models/UserKey.ts, even though both point at the same file.
11
+ vi.mock('../src/mongo', () => ({ default: {} }));
12
+
13
+ import {
14
+ buildCloudWatchMetricPayload,
15
+ buildReport,
16
+ activeModeFromEnv,
17
+ classifyEnclaveKeyIds,
18
+ CLOUDWATCH_NAMESPACE,
19
+ type RawEscrowCounts,
20
+ } from './escrow-blob-mode-report';
21
+
22
+ const emptyRaw: RawEscrowCounts = {
23
+ totalUserKeys: 0,
24
+ enclaveModeCounts: [],
25
+ enclaveKeyIdCounts: [],
26
+ escrowPinEnabledCount: 0,
27
+ optedOutCount: 0,
28
+ pendingHoldsByPolicy: [],
29
+ };
30
+
31
+ describe('activeModeFromEnv', () => {
32
+ it('maps remote to nitro', () => {
33
+ expect(activeModeFromEnv('remote')).toBe('nitro');
34
+ });
35
+
36
+ it('maps software, undefined, and anything else to software', () => {
37
+ expect(activeModeFromEnv('software')).toBe('software');
38
+ expect(activeModeFromEnv(undefined)).toBe('software');
39
+ expect(activeModeFromEnv('bogus')).toBe('software');
40
+ });
41
+ });
42
+
43
+ describe('buildReport', () => {
44
+ const now = new Date('2026-09-25T00:00:00.000Z');
45
+
46
+ it('reports zero counts for known modes/policies with no data', () => {
47
+ const report = buildReport(emptyRaw, 'nitro', now);
48
+
49
+ expect(report.generatedAt).toBe(now.toISOString());
50
+ expect(report.activeEnclaveMode).toBe('nitro');
51
+ expect(report.byEnclaveMode).toEqual({ software: 0, nitro: 0 });
52
+ expect(report.pendingHoldsByReleasePolicy).toEqual({ hold: 0, pin: 0 });
53
+ expect(report.totals).toEqual({
54
+ userKeys: 0,
55
+ escrowBlobs: 0,
56
+ escrowPinEnabled: 0,
57
+ optedOut: 0,
58
+ pendingHolds: 0,
59
+ });
60
+ expect(report.reseal).toEqual({ activeCount: 0, staleCount: 0, staleRatePercent: 0 });
61
+ });
62
+
63
+ it('computes the re-seal split for a normal software/nitro mix', () => {
64
+ const raw: RawEscrowCounts = {
65
+ ...emptyRaw,
66
+ totalUserKeys: 20,
67
+ enclaveModeCounts: [
68
+ { mode: 'software', count: 7 },
69
+ { mode: 'nitro', count: 3 },
70
+ ],
71
+ escrowPinEnabledCount: 2,
72
+ optedOutCount: 1,
73
+ };
74
+
75
+ const report = buildReport(raw, 'nitro', now);
76
+
77
+ expect(report.byEnclaveMode).toEqual({ software: 7, nitro: 3 });
78
+ expect(report.totals.escrowBlobs).toBe(10);
79
+ expect(report.reseal).toEqual({ activeCount: 3, staleCount: 7, staleRatePercent: 70 });
80
+ });
81
+
82
+ it('surfaces an unknown enclaveMode value instead of dropping it, and counts it as stale', () => {
83
+ const raw: RawEscrowCounts = {
84
+ ...emptyRaw,
85
+ enclaveModeCounts: [
86
+ { mode: 'software', count: 1 },
87
+ { mode: 'nitro', count: 1 },
88
+ { mode: 'legacy-v0', count: 2 },
89
+ ],
90
+ };
91
+
92
+ const report = buildReport(raw, 'nitro', now);
93
+
94
+ expect(report.byEnclaveMode).toEqual({ software: 1, nitro: 1, 'legacy-v0': 2 });
95
+ expect(report.totals.escrowBlobs).toBe(4);
96
+ // activeCount only counts the active mode bucket; the unknown mode is stale too.
97
+ expect(report.reseal).toEqual({ activeCount: 1, staleCount: 3, staleRatePercent: 75 });
98
+ });
99
+
100
+ it('rounds the stale rate to 2 decimal places', () => {
101
+ const raw: RawEscrowCounts = {
102
+ ...emptyRaw,
103
+ enclaveModeCounts: [
104
+ { mode: 'software', count: 1 },
105
+ { mode: 'nitro', count: 2 },
106
+ ],
107
+ };
108
+
109
+ const report = buildReport(raw, 'nitro', now);
110
+
111
+ // stale = 1, total = 3 -> 33.333...% rounds to 33.33
112
+ expect(report.reseal.staleRatePercent).toBe(33.33);
113
+ });
114
+
115
+ it('passes through enclaveKeyId counts and an unknown release policy value', () => {
116
+ const raw: RawEscrowCounts = {
117
+ ...emptyRaw,
118
+ enclaveKeyIdCounts: [
119
+ { keyId: 'key-a', count: 4 },
120
+ { keyId: 'key-b', count: 1 },
121
+ ],
122
+ pendingHoldsByPolicy: [
123
+ { policy: 'hold', count: 3 },
124
+ { policy: 'weird-future-policy', count: 1 },
125
+ ],
126
+ };
127
+
128
+ const report = buildReport(raw, 'software', now);
129
+
130
+ expect(report.byEnclaveKeyId).toEqual({ 'key-a': 4, 'key-b': 1 });
131
+ expect(report.pendingHoldsByReleasePolicy).toEqual({
132
+ hold: 3,
133
+ pin: 0,
134
+ 'weird-future-policy': 1,
135
+ });
136
+ expect(report.totals.pendingHolds).toBe(4);
137
+ });
138
+ });
139
+
140
+ describe('buildCloudWatchMetricPayload', () => {
141
+ it('emits EscrowBlobs per mode dimension plus a single EscrowBlobsStale datum', () => {
142
+ const report = buildReport(
143
+ {
144
+ ...emptyRaw,
145
+ enclaveModeCounts: [
146
+ { mode: 'software', count: 5 },
147
+ { mode: 'nitro', count: 2 },
148
+ ],
149
+ },
150
+ 'nitro',
151
+ new Date('2026-09-25T00:00:00.000Z')
152
+ );
153
+
154
+ const payload = buildCloudWatchMetricPayload(report);
155
+
156
+ expect(payload.Namespace).toBe(CLOUDWATCH_NAMESPACE);
157
+ expect(payload.MetricData).toHaveLength(3);
158
+ expect(payload.MetricData).toEqual(
159
+ expect.arrayContaining([
160
+ {
161
+ MetricName: 'EscrowBlobs',
162
+ Value: 5,
163
+ Unit: 'Count',
164
+ Timestamp: report.generatedAt,
165
+ Dimensions: [{ Name: 'EnclaveMode', Value: 'software' }],
166
+ },
167
+ {
168
+ MetricName: 'EscrowBlobs',
169
+ Value: 2,
170
+ Unit: 'Count',
171
+ Timestamp: report.generatedAt,
172
+ Dimensions: [{ Name: 'EnclaveMode', Value: 'nitro' }],
173
+ },
174
+ {
175
+ MetricName: 'EscrowBlobsStale',
176
+ Value: 5,
177
+ Unit: 'Count',
178
+ Timestamp: report.generatedAt,
179
+ },
180
+ ])
181
+ );
182
+ });
183
+
184
+ it('still emits a zero-value EscrowBlobsStale datum when there is no data at all', () => {
185
+ const report = buildReport(emptyRaw, 'software', new Date('2026-09-25T00:00:00.000Z'));
186
+
187
+ const payload = buildCloudWatchMetricPayload(report);
188
+
189
+ const stale = payload.MetricData.find(datum => datum.MetricName === 'EscrowBlobsStale');
190
+ expect(stale?.Value).toBe(0);
191
+ });
192
+ });
193
+
194
+ describe('classifyEnclaveKeyIds (P9.3)', () => {
195
+ it('splits counts into current/previous/unknown, listing unknown keyIds sorted', () => {
196
+ const classification = classifyEnclaveKeyIds(
197
+ { 'key-2': 10, 'key-1': 4, 'key-0': 1, unknown: 1 },
198
+ 'key-2',
199
+ ['key-1', 'key-0']
200
+ );
201
+
202
+ expect(classification).toEqual({
203
+ current: 10,
204
+ previous: 5,
205
+ unknown: 1,
206
+ unknownKeyIds: ['unknown'],
207
+ });
208
+ });
209
+
210
+ it('reports zero previous/unknown when every blob is already on the current key', () => {
211
+ const classification = classifyEnclaveKeyIds({ 'key-2': 3 }, 'key-2', ['key-1']);
212
+
213
+ expect(classification).toEqual({ current: 3, previous: 0, unknown: 0, unknownKeyIds: [] });
214
+ });
215
+
216
+ it('treats an empty previous-key list as everything non-current being unknown', () => {
217
+ const classification = classifyEnclaveKeyIds(
218
+ { 'key-2': 2, 'retired-1': 1, 'retired-2': 1 },
219
+ 'key-2',
220
+ []
221
+ );
222
+
223
+ expect(classification.previous).toBe(0);
224
+ expect(classification.unknown).toBe(2);
225
+ expect(classification.unknownKeyIds).toEqual(['retired-1', 'retired-2']);
226
+ });
227
+ });