@learncard/lca-api-service 1.3.1 → 1.4.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 (99) hide show
  1. package/.env.example +26 -2
  2. package/AGENTS.md +59 -44
  3. package/CHANGELOG.md +57 -0
  4. package/compose.env +5 -0
  5. package/dist/accesslayer/signing-authority/index.d.ts +8 -2
  6. package/dist/app.d.ts +297 -8
  7. package/dist/cache/challenges.d.ts +2 -0
  8. package/dist/cache/recoverySessions.d.ts +42 -0
  9. package/dist/config/environment.d.ts +152 -2
  10. package/dist/config/environment.test.d.ts +1 -0
  11. package/dist/helpers/learnCard.signingAuthority.test.d.ts +1 -0
  12. package/dist/helpers/rateLimit.helpers.d.ts +4 -0
  13. package/dist/helpers/seedEncryption.helpers.d.ts +23 -0
  14. package/dist/helpers/seedEncryption.helpers.test.d.ts +1 -0
  15. package/dist/index.js +2 -2
  16. package/dist/migrations/signingAuthoritySeeds.d.ts +13 -0
  17. package/dist/migrations/signingAuthoritySeedsCli.test.d.ts +1 -0
  18. package/dist/models/EscrowHold.d.ts +75 -0
  19. package/dist/models/SigningAuthority.d.ts +7 -1
  20. package/dist/models/UserKey.d.ts +316 -3
  21. package/dist/models/UserKey.escrowPin.test.d.ts +1 -0
  22. package/dist/models/index.d.ts +1 -0
  23. package/dist/routes/escrow.d.ts +150 -0
  24. package/dist/routes/firebase.test.d.ts +1 -0
  25. package/dist/routes/index.d.ts +6 -0
  26. package/dist/routes/keys.d.ts +181 -7
  27. package/dist/routes/qr-login.d.ts +1 -1
  28. package/dist/services/escrow-enclave/index.d.ts +9 -0
  29. package/dist/services/escrow-enclave/notifications.d.ts +9 -0
  30. package/dist/services/escrow-enclave/softwareEnclave.d.ts +17 -0
  31. package/dist/services/escrow-enclave/softwareEnclave.test.d.ts +1 -0
  32. package/dist/services/escrow-enclave/types.d.ts +67 -0
  33. package/dist/types/seed-encryption.d.ts +31 -0
  34. package/dist/types/seed-migration.d.ts +49 -0
  35. package/lambda.ts +8 -3
  36. package/package.json +17 -11
  37. package/scripts/check-seed-encryption-infrastructure.mjs +123 -0
  38. package/scripts/check-signing-authority-redis.mjs +93 -0
  39. package/scripts/migrate-signing-authority-seeds-local.sh +51 -0
  40. package/scripts/migrate-signing-authority-seeds.mjs +105 -0
  41. package/seedMigrationLambda.test.ts +145 -0
  42. package/seedMigrationLambda.ts +50 -0
  43. package/serverless-local.yml +3 -0
  44. package/serverless.escrow-env.cjs +20 -0
  45. package/serverless.yml +133 -0
  46. package/src/accesslayer/signing-authority/create.ts +11 -7
  47. package/src/app.ts +2 -0
  48. package/src/cache/challenges.ts +12 -2
  49. package/src/cache/in-memory-lru.ts +1 -0
  50. package/src/cache/recoverySessions.ts +252 -0
  51. package/src/config/environment.test.ts +116 -0
  52. package/src/config/environment.ts +97 -0
  53. package/src/docker-entry.ts +4 -0
  54. package/src/helpers/learnCard.helpers.test.ts +11 -1
  55. package/src/helpers/learnCard.helpers.ts +57 -45
  56. package/src/helpers/learnCard.signingAuthority.test.ts +194 -0
  57. package/src/helpers/rateLimit.helpers.ts +112 -0
  58. package/src/helpers/seedEncryption.helpers.test.ts +427 -0
  59. package/src/helpers/seedEncryption.helpers.ts +372 -0
  60. package/src/helpers/signingAuthority.helpers.ts +4 -1
  61. package/src/migrations/signingAuthoritySeeds.ts +430 -0
  62. package/src/migrations/signingAuthoritySeedsCli.test.ts +70 -0
  63. package/src/models/EscrowHold.ts +225 -0
  64. package/src/models/SigningAuthority.ts +14 -3
  65. package/src/models/UserKey.escrowPin.test.ts +49 -0
  66. package/src/models/UserKey.ts +1024 -40
  67. package/src/models/index.ts +1 -0
  68. package/src/models/pruneOrphanedRecoveryMethods.ts +3 -1
  69. package/src/routes/credentials.ts +8 -0
  70. package/src/routes/escrow.ts +826 -0
  71. package/src/routes/firebase.test.ts +94 -0
  72. package/src/routes/firebase.ts +99 -56
  73. package/src/routes/index.ts +98 -5
  74. package/src/routes/keys.ts +1017 -174
  75. package/src/routes/qr-login.ts +51 -53
  76. package/src/services/delivery/adapters/postmark.adapter.ts +5 -0
  77. package/src/services/escrow-enclave/README.md +51 -0
  78. package/src/services/escrow-enclave/index.ts +36 -0
  79. package/src/services/escrow-enclave/notifications.ts +14 -0
  80. package/src/services/escrow-enclave/softwareEnclave.test.ts +249 -0
  81. package/src/services/escrow-enclave/softwareEnclave.ts +168 -0
  82. package/src/services/escrow-enclave/types.ts +76 -0
  83. package/src/types/seed-encryption.ts +51 -0
  84. package/src/types/seed-migration.ts +61 -0
  85. package/test/credentials.spec.ts +2 -0
  86. package/test/keys-escrow.spec.ts +1389 -0
  87. package/test/keys-identity-rebind.spec.ts +456 -0
  88. package/test/keys-lifecycle-invariants.spec.ts +785 -0
  89. package/test/keys-recovery-confirmation.spec.ts +586 -0
  90. package/test/keys-recovery-rate-limit.spec.ts +123 -0
  91. package/test/keys-secret-leakage.spec.ts +265 -0
  92. package/test/models/escrow.spec.ts +210 -0
  93. package/test/signing-authority-http.spec.ts +161 -0
  94. package/test/signing-authority-seed-migration.spec.ts +414 -0
  95. package/test/signing-authority.spec.ts +40 -0
  96. package/vitest.config.ts +1 -1
  97. package/vitest.integration.config.ts +5 -0
  98. package/dist/cache/signing-authority.d.ts +0 -6
  99. package/src/cache/signing-authority.ts +0 -37
package/lambda.ts CHANGED
@@ -15,8 +15,13 @@ import {
15
15
  } from './src/helpers/sentry.helpers';
16
16
  import { environment } from './src/config/environment';
17
17
  import { toServerlessApplication } from './src/helpers/serverlessApplication';
18
+ import { ensureUserKeysIndexes, createEscrowHoldsIndexes } from './src/models';
18
19
 
19
- const promise = getEmptyLearnCard(); // Load WASM in for better cold starts
20
+ const startupPromise = Promise.all([
21
+ getEmptyLearnCard(), // Load WASM in for better cold starts
22
+ ensureUserKeysIndexes(),
23
+ createEscrowHoldsIndexes(),
24
+ ]);
20
25
 
21
26
  const isWarmupEvent = (event: APIGatewayProxyEventV2): boolean =>
22
27
  'source' in event && event.source === 'serverless-plugin-warmup';
@@ -72,7 +77,7 @@ export const _trpcHandler = awsLambdaRequestHandler({
72
77
 
73
78
  export const openApiHandler = Sentry.AWSLambda.wrapHandler(
74
79
  async (event: APIGatewayProxyEventV2, context: Context): Promise<APIGatewayProxyResultV2> => {
75
- await promise;
80
+ await startupPromise;
76
81
 
77
82
  if (isWarmupEvent(event)) {
78
83
  console.log('[Warmup] Initializing LearnCard...');
@@ -98,7 +103,7 @@ export const openApiHandler = Sentry.AWSLambda.wrapHandler(
98
103
 
99
104
  export const trpcHandler = Sentry.AWSLambda.wrapHandler(
100
105
  async (event: APIGatewayProxyEventV2, context: Context): Promise<APIGatewayProxyResultV2> => {
101
- await promise;
106
+ await startupPromise;
102
107
 
103
108
  if (isWarmupEvent(event)) {
104
109
  console.log('[Warmup] Initializing LearnCard...');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@learncard/lca-api-service",
3
- "version": "1.3.1",
3
+ "version": "1.4.0",
4
4
  "description": "",
5
5
  "engines": {
6
6
  "node": ">=24.12 <25"
@@ -9,6 +9,10 @@
9
9
  "main": "dist/index.js",
10
10
  "types": "dist/index.d.ts",
11
11
  "scripts": {
12
+ "migrate:sa-seeds:local": "bash scripts/migrate-signing-authority-seeds-local.sh",
13
+ "migrate:sa-seeds": "node scripts/migrate-signing-authority-seeds.mjs",
14
+ "check:sa-infra": "node scripts/check-seed-encryption-infrastructure.mjs",
15
+ "check:sa-redis": "node scripts/check-signing-authority-redis.mjs",
12
16
  "test": "vitest run --config vitest.config.ts",
13
17
  "test:watch": "vitest --config vitest.config.ts",
14
18
  "test:coverage": "vitest run --coverage --config vitest.config.ts",
@@ -22,7 +26,7 @@
22
26
  "start-p-5000": "PORT=5000 serverless offline --httpPort 5000 --websocketPort 5001 --lambdaPort 5002 --albPort 5003 --config serverless-local.yml",
23
27
  "dev": "bun --conditions=development --watch src/docker-entry.ts",
24
28
  "start:docker": "bun --conditions=development src/docker-entry.ts",
25
- "serverless-deploy": "node ../../../scripts/prepare-didkit-native-layer.cjs && serverless deploy",
29
+ "serverless-deploy": "node scripts/check-seed-encryption-infrastructure.mjs && node ../../../scripts/prepare-didkit-native-layer.cjs && serverless deploy",
26
30
  "serverless-deploy-scouts": "serverless deploy --config serverless-scouts.yml"
27
31
  },
28
32
  "keywords": [],
@@ -48,25 +52,27 @@
48
52
  "shx": "^0.3.4"
49
53
  },
50
54
  "dependencies": {
55
+ "@aws-sdk/client-kms": "^3.1139.0",
51
56
  "@fastify/cors": "^11.2.0",
52
57
  "@fastify/static": "^10.1.1",
53
- "@learncard/core": "9.4.35",
54
- "@learncard/email-templates": "1.0.6",
55
- "@learncard/did-web-plugin": "1.1.35",
56
- "@learncard/didkit-plugin": "1.10.0",
57
- "@learncard/didkit-plugin-node": "0.3.0",
58
- "@learncard/init": "2.4.16",
59
- "@learncard/helpers": "1.5.1",
60
- "@learncard/types": "5.20.0",
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",
61
67
  "@sentry/serverless": "^7.61.0",
62
68
  "@trpc/server": "11.8.0",
63
69
  "@types/jsonwebtoken": "^9.0.8",
64
70
  "@types/lodash": "^4.14.191",
65
71
  "@types/node-forge": "^1.3.11",
66
72
  "axios": "^0.33.0",
67
- "dotenv": "^16.0.3",
68
73
  "dag-jose-utils": "2.0.0",
69
74
  "did-jwt": "5.9.0",
75
+ "dotenv": "^16.0.3",
70
76
  "express": "^4.18.2",
71
77
  "fastify": "^5.8.5",
72
78
  "filestack-js": "^3.24.0",
@@ -0,0 +1,123 @@
1
+ #!/usr/bin/env node
2
+ import assert from 'node:assert/strict';
3
+ import { readFileSync } from 'node:fs';
4
+ import { createRequire } from 'node:module';
5
+ import { fileURLToPath } from 'node:url';
6
+
7
+ // Use Serverless's own YAML parser. An optional argument validates its packaged JSON template.
8
+ const require = createRequire(import.meta.url);
9
+ const serverlessRequire = createRequire(require.resolve('serverless/package.json'));
10
+ const config = serverlessRequire('js-yaml').load(
11
+ readFileSync(fileURLToPath(new URL('../serverless.yml', import.meta.url)), 'utf8')
12
+ );
13
+ const packaged = process.argv[2] ? JSON.parse(readFileSync(process.argv[2], 'utf8')) : undefined;
14
+ const resources = packaged?.Resources ?? config.resources.Resources;
15
+ const key = resources.SigningAuthoritySeedKey;
16
+ assert.equal(key.Type, 'AWS::KMS::Key');
17
+ assert.equal(key.DeletionPolicy, 'Retain');
18
+ assert.equal(key.UpdateReplacePolicy, 'Retain');
19
+ assert.equal(key.Properties.KeySpec, 'SYMMETRIC_DEFAULT');
20
+ assert.equal(key.Properties.KeyUsage, 'ENCRYPT_DECRYPT');
21
+ assert.equal(key.Properties.EnableKeyRotation, true);
22
+
23
+ const roleArn = { 'Fn::GetAtt': ['SigningAuthorityExecutionRole', 'Arn'] };
24
+ const keyArn = { 'Fn::GetAtt': ['SigningAuthoritySeedKey', 'Arn'] };
25
+ const purpose = { StringEquals: { 'kms:EncryptionContext:purpose': 'lca-signing-authority-seed' } };
26
+ const statements = key.Properties.KeyPolicy.Statement;
27
+ const use = statements.find(statement => statement.Sid === 'LcaApiSeedEncryption');
28
+ assert.deepEqual(use.Principal, { AWS: roleArn });
29
+ assert.deepEqual(use.Action, ['kms:GenerateDataKey', 'kms:Decrypt']);
30
+ assert.deepEqual(use.Condition, purpose);
31
+ const deny = statements.find(statement => statement.Sid === 'DenyOtherCryptographicPrincipals');
32
+ assert.equal(deny.Effect, 'Deny');
33
+ assert.equal(deny.Principal, '*');
34
+ assert.deepEqual(deny.Condition, { ArnNotEquals: { 'aws:PrincipalArn': roleArn } });
35
+ assert(deny.Action.includes('kms:Decrypt'));
36
+ assert(deny.Action.includes('kms:ReEncrypt*'));
37
+ const admin = statements.find(statement => statement.Sid === 'AccountKeyAdministration');
38
+ assert(admin.Action.includes('kms:DeleteAlias'));
39
+ assert(admin.Action.includes('kms:GetKeyPolicy'));
40
+ assert(
41
+ !admin.Action.some(action =>
42
+ ['kms:*', 'kms:Decrypt', 'kms:CreateGrant', 'kms:ReEncrypt*'].includes(action)
43
+ )
44
+ );
45
+ const iam = resources.SigningAuthoritySeedKeyPolicy.Properties;
46
+ assert.deepEqual(iam.Roles, [{ Ref: 'SigningAuthorityExecutionRole' }]);
47
+ assert.deepEqual(iam.PolicyDocument.Statement, [
48
+ {
49
+ Effect: 'Allow',
50
+ Action: ['kms:GenerateDataKey', 'kms:Decrypt'],
51
+ Resource: keyArn,
52
+ Condition: purpose,
53
+ },
54
+ ]);
55
+
56
+ const worker = config.functions.seedMigration;
57
+ assert.equal(worker.timeout, 900);
58
+ assert.equal(worker.reservedConcurrency, 1);
59
+ assert.equal(worker.events, undefined);
60
+ assert.equal(worker.url, undefined);
61
+ assert.deepEqual(worker.vpc, config.functions.api.vpc);
62
+ for (const name of ['api', 'trpc', 'seedMigration']) {
63
+ assert(config.functions[name].dependsOn.includes('SigningAuthoritySeedKeyPolicy'));
64
+ assert.equal(config.functions[name].role, 'SigningAuthorityExecutionRole');
65
+ }
66
+ for (const name of ['swagger', 'didWeb']) {
67
+ assert.equal(config.functions[name].role, undefined);
68
+ }
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);
72
+ if (packaged) {
73
+ const lambda = resources.SeedMigrationLambdaFunction;
74
+ assert.equal(lambda.Properties.Timeout, 900);
75
+ assert.equal(lambda.Properties.ReservedConcurrentExecutions, 1);
76
+ assert.deepEqual(lambda.Properties.Role, roleArn);
77
+ assert.deepEqual(lambda.Properties.Environment.Variables.SA_SEED_KMS_KEY_ARN, keyArn);
78
+ for (const name of ['Api', 'Trpc']) {
79
+ assert.deepEqual(resources[`${name}LambdaFunction`].Properties.Role, roleArn);
80
+ }
81
+ for (const name of ['Swagger', 'DidWeb']) {
82
+ assert.deepEqual(resources[`${name}LambdaFunction`].Properties.Role, {
83
+ 'Fn::GetAtt': ['IamRoleLambdaExecution', 'Arn'],
84
+ });
85
+ }
86
+ for (const resource of Object.values(resources)) {
87
+ if (resource.Type === 'AWS::Lambda::Permission' || resource.Type === 'AWS::Lambda::Url') {
88
+ assert(!JSON.stringify(resource).includes('SeedMigrationLambdaFunction'));
89
+ }
90
+ }
91
+ }
92
+
93
+ // Validate the entire dependency graph, including generated role/function resources when packaged.
94
+ const references = value => {
95
+ const found = new Set();
96
+ const visit = node => {
97
+ if (!node || typeof node !== 'object') return;
98
+ if (typeof node.Ref === 'string') found.add(node.Ref);
99
+ const attribute = node['Fn::GetAtt'];
100
+ if (attribute) found.add(Array.isArray(attribute) ? attribute[0] : attribute.split('.')[0]);
101
+ const sub = node['Fn::Sub'];
102
+ const source = Array.isArray(sub) ? sub[0] : sub;
103
+ if (typeof source === 'string') {
104
+ for (const match of source.matchAll(/\$\{([^}.]+)(?:\.[^}]+)?\}/g)) found.add(match[1]);
105
+ }
106
+ for (const child of Object.values(node)) visit(child);
107
+ };
108
+ visit(value);
109
+ for (const name of [].concat(value.DependsOn ?? [])) found.add(name);
110
+ return [...found].filter(name => name in resources);
111
+ };
112
+ const finished = new Set();
113
+ const visit = (name, active = new Set()) => {
114
+ assert(!active.has(name), `Circular CloudFormation dependency at ${name}`);
115
+ if (finished.has(name)) return;
116
+ const next = new Set(active).add(name);
117
+ for (const dependency of references(resources[name])) visit(dependency, next);
118
+ finished.add(name);
119
+ };
120
+ for (const name of Object.keys(resources)) visit(name);
121
+ console.log(
122
+ `Seed encryption infrastructure checks passed (${packaged ? 'packaged template' : 'source config'}, ${finished.size} resources).`
123
+ );
@@ -0,0 +1,93 @@
1
+ #!/usr/bin/env node
2
+ import { resolve } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import Redis from 'ioredis';
5
+
6
+ const LEGACY_SEED_PATTERN = 'sa|*';
7
+
8
+ /** Read key names only. SCAN can repeat keys, so only a zero count proves absence. */
9
+ export const countLegacySigningAuthorityKeys = async redis => {
10
+ let cursor = '0';
11
+ let matches = 0;
12
+ const deadline = Date.now() + 60_000;
13
+ do {
14
+ if (Date.now() >= deadline) throw new Error('Redis audit timed out.');
15
+ const [nextCursor, keys] = await redis.scan(
16
+ cursor,
17
+ 'MATCH',
18
+ LEGACY_SEED_PATTERN,
19
+ 'COUNT',
20
+ 500
21
+ );
22
+ matches += keys.length;
23
+ cursor = nextCursor;
24
+ } while (cursor !== '0');
25
+ return matches;
26
+ };
27
+
28
+ /** Require an explicit real endpoint; never fall back to an empty in-memory cache. */
29
+ export const getAuditRedisOptions = (env = process.env) => {
30
+ const port = Number(env.REDIS_PORT);
31
+ const db = Number(env.REDIS_DB ?? '0');
32
+ if (
33
+ !env.REDIS_HOST?.trim() ||
34
+ !Number.isInteger(port) ||
35
+ port < 1 ||
36
+ port > 65535 ||
37
+ !Number.isInteger(db) ||
38
+ db < 0 ||
39
+ (env.REDIS_TLS !== undefined && !['true', 'false'].includes(env.REDIS_TLS))
40
+ ) {
41
+ throw new Error(
42
+ 'Set REDIS_HOST and REDIS_PORT; REDIS_DB defaults to 0 and REDIS_TLS accepts true or false.'
43
+ );
44
+ }
45
+ return {
46
+ host: env.REDIS_HOST,
47
+ port,
48
+ db,
49
+ username: env.REDIS_USERNAME,
50
+ password: env.REDIS_PASSWORD,
51
+ ...(env.REDIS_TLS === 'true' ? { tls: {} } : {}),
52
+ lazyConnect: true,
53
+ connectTimeout: 2_000,
54
+ commandTimeout: 5_000,
55
+ maxRetriesPerRequest: 0,
56
+ retryStrategy: () => null,
57
+ };
58
+ };
59
+
60
+ const main = async () => {
61
+ let redis;
62
+ try {
63
+ const options = getAuditRedisOptions();
64
+ redis = new Redis(options);
65
+ // Driver errors can include endpoint/auth details; report only the sanitized failure below.
66
+ redis.on('error', () => undefined);
67
+ await redis.connect();
68
+ const legacyKeyMatches = await countLegacySigningAuthorityKeys(redis);
69
+ console.log(
70
+ JSON.stringify({
71
+ database: options.db,
72
+ pattern: LEGACY_SEED_PATTERN,
73
+ legacyKeyMatches,
74
+ auditedAt: new Date().toISOString(),
75
+ })
76
+ );
77
+ if (legacyKeyMatches > 0) {
78
+ console.error(
79
+ 'Legacy signing-authority cache keys remain. Follow the seed-encryption runbook before completing rollout.'
80
+ );
81
+ process.exitCode = 1;
82
+ }
83
+ } catch {
84
+ console.error(
85
+ 'Redis audit failed. Check REDIS_HOST, REDIS_PORT, optional REDIS_DB/TLS/auth settings, and private network access. No clean result was established.'
86
+ );
87
+ process.exitCode = 1;
88
+ } finally {
89
+ redis?.disconnect();
90
+ }
91
+ };
92
+
93
+ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) await main();
@@ -0,0 +1,51 @@
1
+ #!/usr/bin/env bash
2
+ # Run one local migration phase using the API container's database and keys.
3
+ set -euo pipefail
4
+
5
+ usage() {
6
+ echo 'Usage: bun run migrate:sa-seeds:local --dry-run|--prepare|--verify|--purge'
7
+ echo 'Defaults to read-only dry-run. Runs one phase to completion and streams logs.'
8
+ }
9
+
10
+ if [[ "$#" -gt 1 ]]; then
11
+ usage >&2
12
+ exit 1
13
+ fi
14
+
15
+ case "${1:---dry-run}" in
16
+ --dry-run|--prepare|--verify|--purge) SA_PHASE="${1:---dry-run}"; SA_PHASE="${SA_PHASE#--}" ;;
17
+ --help|-h) usage; exit 0 ;;
18
+ *) usage >&2; exit 1 ;;
19
+ esac
20
+
21
+ SA_REPO_ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../../../.." && pwd)"
22
+ cd "$SA_REPO_ROOT"
23
+ printf 'Running local signing-authority migration: %s\n' "$SA_PHASE"
24
+
25
+ docker compose -f apps/learn-card-app/compose-local.yaml exec -T \
26
+ -e "SA_MIGRATION_PHASE=$SA_PHASE" \
27
+ -w /app/services/learn-card-network/lca-api api bun --conditions=development -e '
28
+ import { environment } from "./src/config/environment";
29
+ import { client, mongodb } from "./src/mongo";
30
+ import { runSeedMigrationBatch, SeedMigrationError } from "./src/migrations/signingAuthoritySeeds";
31
+
32
+ try {
33
+ let result;
34
+ do {
35
+ result = await runSeedMigrationBatch(
36
+ mongodb,
37
+ { phase: process.env.SA_MIGRATION_PHASE, batchSize: 50 },
38
+ { encryptedWritesEnabled: environment.SA_SEED_ENCRYPT_WRITES }
39
+ );
40
+ console.log(JSON.stringify(result, null, 2));
41
+ if (!result.done && result.processed === 0 && !result.rescanRequired) {
42
+ throw new Error("No progress");
43
+ }
44
+ } while (!result.done);
45
+ } catch (error) {
46
+ console.error(error instanceof SeedMigrationError ? error.category : "operation_failed");
47
+ process.exitCode = 1;
48
+ } finally {
49
+ await client.close();
50
+ }
51
+ '
@@ -0,0 +1,105 @@
1
+ #!/usr/bin/env node
2
+ import { execFileSync } from 'node:child_process';
3
+ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import { parseArgs } from 'node:util';
7
+
8
+ const { values } = parseArgs({
9
+ options: {
10
+ 'function-name': { type: 'string' },
11
+ region: { type: 'string' },
12
+ phase: { type: 'string', default: 'dry-run' },
13
+ 'batch-size': { type: 'string', default: '50' },
14
+ 'one-batch': { type: 'boolean', default: false },
15
+ help: { type: 'boolean', default: false },
16
+ },
17
+ });
18
+
19
+ if (values.help) {
20
+ console.log(
21
+ 'Usage: node scripts/migrate-signing-authority-seeds.mjs --function-name NAME --region REGION --phase dry-run|prepare|verify|purge [--batch-size 1..100] [--one-batch]'
22
+ );
23
+ process.exit(0);
24
+ }
25
+ const batchSize = Number(values['batch-size']);
26
+ if (
27
+ !values['function-name'] ||
28
+ !values.region ||
29
+ !['dry-run', 'prepare', 'verify', 'purge'].includes(values.phase) ||
30
+ !Number.isInteger(batchSize) ||
31
+ batchSize < 1 ||
32
+ batchSize > 100
33
+ ) {
34
+ console.error('Provide --function-name, --region, a valid --phase, and --batch-size 1..100.');
35
+ process.exit(1);
36
+ }
37
+
38
+ // No database credentials or key material on the operator host. AWS CLI uses its normal credential chain.
39
+ const directory = mkdtempSync(join(tmpdir(), 'lca-sa-migration-'));
40
+ const payloadFile = join(directory, 'request.json');
41
+ const outputFile = join(directory, 'response.json');
42
+ writeFileSync(payloadFile, JSON.stringify({ phase: values.phase, batchSize }), { mode: 0o600 });
43
+ try {
44
+ let done = false;
45
+ do {
46
+ const invocation = JSON.parse(
47
+ execFileSync(
48
+ 'aws',
49
+ [
50
+ 'lambda',
51
+ 'invoke',
52
+ '--function-name',
53
+ values['function-name'],
54
+ '--region',
55
+ values.region,
56
+ '--invocation-type',
57
+ 'RequestResponse',
58
+ '--payload',
59
+ `fileb://${payloadFile}`,
60
+ '--cli-read-timeout',
61
+ '910',
62
+ '--cli-connect-timeout',
63
+ '10',
64
+ '--output',
65
+ 'json',
66
+ outputFile,
67
+ ],
68
+ {
69
+ encoding: 'utf8',
70
+ stdio: ['ignore', 'pipe', 'pipe'],
71
+ env: { ...process.env, AWS_PAGER: '' },
72
+ }
73
+ )
74
+ );
75
+ const result = JSON.parse(readFileSync(outputFile, 'utf8'));
76
+ if (invocation.FunctionError || invocation.StatusCode !== 200) {
77
+ // The Lambda boundary emits only sanitized migration errors.
78
+ console.error(
79
+ result.errorMessage ?? 'Migration invocation failed. Inspect the function logs.'
80
+ );
81
+ process.exitCode = 1;
82
+ break;
83
+ }
84
+ console.log(JSON.stringify(result));
85
+ done = result.done === true;
86
+ if (!done && result.processed === 0 && !result.rescanRequired) {
87
+ console.error('No progress in this batch. Inspect the function logs before retrying.');
88
+ process.exitCode = 1;
89
+ break;
90
+ }
91
+ } while (!done && !values['one-batch']);
92
+ } catch (error) {
93
+ // CLI diagnostics describe invocation/auth/transport failures. Never print the whole
94
+ // exec error: stdout and the Lambda response are handled separately above.
95
+ if (typeof error.stderr === 'string' || Buffer.isBuffer(error.stderr)) {
96
+ const diagnostic = error.stderr.toString().trim();
97
+ if (diagnostic) console.error(diagnostic);
98
+ }
99
+ console.error(
100
+ 'AWS invocation failed. Check your credentials, region, and function name; rerun to resume.'
101
+ );
102
+ process.exitCode = 1;
103
+ } finally {
104
+ rmSync(directory, { recursive: true, force: true });
105
+ }
@@ -0,0 +1,145 @@
1
+ import type { Context } from 'aws-lambda';
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
3
+ import type { SeedMigrationResult } from './src/types/seed-migration';
4
+
5
+ const mocks = vi.hoisted(() => ({
6
+ connect: vi.fn(),
7
+ close: vi.fn(),
8
+ runBatch: vi.fn(),
9
+ mongodb: {},
10
+ }));
11
+
12
+ vi.mock('./src/mongo', () => ({
13
+ client: { connect: mocks.connect, close: mocks.close },
14
+ mongodb: mocks.mongodb,
15
+ }));
16
+ vi.mock('./src/config/environment', () => ({
17
+ environment: { SA_SEED_ENCRYPT_WRITES: true },
18
+ }));
19
+ vi.mock('./src/migrations/signingAuthoritySeeds', async importOriginal => ({
20
+ ...(await importOriginal<typeof import('./src/migrations/signingAuthoritySeeds')>()),
21
+ runSeedMigrationBatch: mocks.runBatch,
22
+ }));
23
+
24
+ import { handler } from './seedMigrationLambda';
25
+ import { SeedMigrationError } from './src/migrations/signingAuthoritySeeds';
26
+
27
+ const result: SeedMigrationResult = {
28
+ phase: 'prepare',
29
+ done: false,
30
+ processed: 1,
31
+ counts: { total: 2, encrypted: 1, legacyOnly: 1, malformed: 0, plaintextRemaining: 2 },
32
+ countsReconciled: false,
33
+ };
34
+
35
+ const context = (): Context => ({
36
+ callbackWaitsForEmptyEventLoop: true,
37
+ functionName: 'seedMigration',
38
+ functionVersion: '$LATEST',
39
+ invokedFunctionArn: 'arn:aws:lambda:us-east-1:123456789012:function:seedMigration',
40
+ memoryLimitInMB: '1024',
41
+ awsRequestId: 'test-request',
42
+ logGroupName: 'test-group',
43
+ logStreamName: 'test-stream',
44
+ getRemainingTimeInMillis: () => 60_000,
45
+ done: vi.fn(),
46
+ fail: vi.fn(),
47
+ succeed: vi.fn(),
48
+ });
49
+
50
+ beforeEach(() => {
51
+ mocks.connect.mockReset().mockResolvedValue(undefined);
52
+ mocks.close.mockReset().mockResolvedValue(undefined);
53
+ mocks.runBatch.mockReset().mockResolvedValue(result);
54
+ vi.spyOn(console, 'error').mockImplementation(() => undefined);
55
+ });
56
+
57
+ afterEach(() => vi.restoreAllMocks());
58
+
59
+ describe('seed migration Lambda connection lifecycle', () => {
60
+ it('waits for cleanup before returning the batch result', async () => {
61
+ let finishClose!: () => void;
62
+ mocks.close.mockReturnValue(new Promise<void>(resolve => (finishClose = resolve)));
63
+ const resolved = vi.fn();
64
+ const lambdaContext = context();
65
+ const invocation = handler({ phase: 'prepare', batchSize: 1 }, lambdaContext).then(
66
+ resolved
67
+ );
68
+
69
+ await vi.waitFor(() => expect(mocks.close).toHaveBeenCalledOnce());
70
+ expect(resolved).not.toHaveBeenCalled();
71
+ expect(mocks.runBatch).toHaveBeenCalledWith(
72
+ mocks.mongodb,
73
+ { phase: 'prepare', batchSize: 1 },
74
+ { encryptedWritesEnabled: true, remainingTime: expect.any(Function) }
75
+ );
76
+ expect(lambdaContext.callbackWaitsForEmptyEventLoop).toBe(false);
77
+
78
+ finishClose();
79
+ await invocation;
80
+ expect(resolved).toHaveBeenCalledWith(result);
81
+ });
82
+
83
+ it('connects and closes for each sequential warm invocation', async () => {
84
+ await handler({ phase: 'prepare' }, context());
85
+ await handler({ phase: 'prepare' }, context());
86
+ expect(mocks.connect).toHaveBeenCalledTimes(2);
87
+ expect(mocks.runBatch).toHaveBeenCalledTimes(2);
88
+ expect(mocks.close).toHaveBeenCalledTimes(2);
89
+ });
90
+
91
+ it('closes after a connection failure without returning connection details', async () => {
92
+ mocks.connect.mockRejectedValue(new Error('mongodb://user:secret@private-host'));
93
+ await expect(handler({ phase: 'prepare' }, context())).rejects.toMatchObject({
94
+ category: 'operation_failed',
95
+ message: 'Signing authority seed migration stopped: operation_failed',
96
+ });
97
+ expect(mocks.runBatch).not.toHaveBeenCalled();
98
+ expect(mocks.close).toHaveBeenCalledOnce();
99
+ });
100
+
101
+ it('closes after an unexpected batch failure without returning query details', async () => {
102
+ mocks.runBatch.mockRejectedValue(new Error('sensitive seed query'));
103
+ await expect(handler({ phase: 'prepare' }, context())).rejects.toMatchObject({
104
+ category: 'operation_failed',
105
+ message: 'Signing authority seed migration stopped: operation_failed',
106
+ });
107
+ expect(mocks.close).toHaveBeenCalledOnce();
108
+ });
109
+
110
+ it('preserves the migration failure when cleanup also fails', async () => {
111
+ mocks.runBatch.mockRejectedValue(new SeedMigrationError('verification_required'));
112
+ mocks.close.mockRejectedValue(new Error('sensitive cleanup detail'));
113
+ await expect(handler({ phase: 'purge' }, context())).rejects.toMatchObject({
114
+ category: 'verification_required',
115
+ });
116
+ expect(mocks.close).toHaveBeenCalledOnce();
117
+ expect(console.error).toHaveBeenCalledExactlyOnceWith({
118
+ event: 'signing_authority_seed_migration_failure',
119
+ operation: 'migration_connection_cleanup',
120
+ category: 'operation_failed',
121
+ });
122
+ });
123
+
124
+ it('sanitizes cleanup errors after an otherwise successful batch', async () => {
125
+ mocks.close.mockRejectedValue(new Error('mongodb://user:secret@private-host'));
126
+ await expect(handler({ phase: 'prepare' }, context())).rejects.toMatchObject({
127
+ category: 'operation_failed',
128
+ message: 'Signing authority seed migration stopped: operation_failed',
129
+ });
130
+ expect(console.error).toHaveBeenCalledExactlyOnceWith({
131
+ event: 'signing_authority_seed_migration_failure',
132
+ operation: 'migration_connection_cleanup',
133
+ category: 'operation_failed',
134
+ });
135
+ });
136
+
137
+ it('rejects invalid requests before touching the database', async () => {
138
+ await expect(handler({ phase: 'unknown' }, context())).rejects.toMatchObject({
139
+ category: 'invalid_request',
140
+ });
141
+ expect(mocks.connect).not.toHaveBeenCalled();
142
+ expect(mocks.runBatch).not.toHaveBeenCalled();
143
+ expect(mocks.close).not.toHaveBeenCalled();
144
+ });
145
+ });
@@ -0,0 +1,50 @@
1
+ import type { Context } from 'aws-lambda';
2
+ import { z } from 'zod';
3
+ import { environment } from './src/config/environment';
4
+ import { client, mongodb } from './src/mongo';
5
+ import { runSeedMigrationBatch, SeedMigrationError } from './src/migrations/signingAuthoritySeeds';
6
+ import type { SeedMigrationResult } from './src/types/seed-migration';
7
+
8
+ const requestValidator = z
9
+ .object({
10
+ phase: z.enum(['dry-run', 'prepare', 'verify', 'purge']),
11
+ batchSize: z.number().int().min(1).max(100).optional(),
12
+ })
13
+ .strict();
14
+
15
+ /** IAM invocation only. Never accept seeds, database URLs, key ARNs, or arbitrary queries in events. */
16
+ export const handler = async (event: unknown, context: Context): Promise<SeedMigrationResult> => {
17
+ const parsed = requestValidator.safeParse(event);
18
+ if (!parsed.success) throw new SeedMigrationError('invalid_request');
19
+ context.callbackWaitsForEmptyEventLoop = false;
20
+ let result: SeedMigrationResult;
21
+ let cleanupFailed = false;
22
+ try {
23
+ await client.connect();
24
+ result = await runSeedMigrationBatch(mongodb, parsed.data, {
25
+ encryptedWritesEnabled: environment.SA_SEED_ENCRYPT_WRITES,
26
+ remainingTime: () => context.getRemainingTimeInMillis(),
27
+ });
28
+ } catch (error) {
29
+ // Mongo connection/lease errors can contain URLs or query values. Never let Lambda serialize them.
30
+ throw new SeedMigrationError(
31
+ error instanceof SeedMigrationError ? error.category : 'operation_failed'
32
+ );
33
+ } finally {
34
+ // This private worker releases its pool between batches, including failed connections.
35
+ // The module-level client reconnects when a warm invocation starts its next batch.
36
+ try {
37
+ await client.close();
38
+ } catch {
39
+ cleanupFailed = true;
40
+ console.error({
41
+ event: 'signing_authority_seed_migration_failure',
42
+ operation: 'migration_connection_cleanup',
43
+ category: 'operation_failed',
44
+ });
45
+ }
46
+ }
47
+ // Reached only after a successful batch; never mask its original failure in finally.
48
+ if (cleanupFailed) throw new SeedMigrationError('operation_failed');
49
+ return result;
50
+ };