@learncard/lca-api-service 1.3.2 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +99 -2
- package/AGENTS.md +155 -44
- package/CHANGELOG.md +84 -0
- package/bootstrapLambda.test.ts +76 -0
- package/compose.env +5 -0
- package/config/config.dev.json +23 -0
- package/config/config.example.json +22 -0
- package/config/config.json +1 -0
- package/config/config.production.json +12 -0
- package/config/config.scouts.dev.json +11 -0
- package/config/config.scouts.production.json +11 -0
- package/dist/accesslayer/signing-authority/index.d.ts +8 -2
- package/dist/app.d.ts +361 -8
- package/dist/cache/challenges.d.ts +2 -0
- package/dist/cache/getDel.d.ts +2 -0
- package/dist/cache/getDel.test.d.ts +1 -0
- package/dist/cache/login-tickets.d.ts +12 -0
- package/dist/cache/recoverySessions.d.ts +42 -0
- package/dist/config/applyDockerStageConfig.d.ts +1 -0
- package/dist/config/cacheEnvironment.d.ts +16 -0
- package/dist/config/environment.d.ts +253 -2
- package/dist/config/environment.test.d.ts +1 -0
- package/dist/config/escrowBundleMode.test.d.ts +1 -0
- package/dist/config/escrowDeployment.test.d.ts +1 -0
- package/dist/config/oidcEnvironment.d.ts +74 -0
- package/dist/config/serverlessFunctionEnv.test.d.ts +1 -0
- package/dist/config/stageConfig.d.ts +62 -0
- package/dist/config/stageConfig.test.d.ts +1 -0
- package/dist/didkit_wasm_bg.wasm +0 -0
- package/dist/docker-entry.d.ts +1 -1
- package/dist/helpers/auth.helpers.d.ts +1 -1
- package/dist/helpers/escrowCancelLink.d.ts +7 -0
- package/dist/helpers/escrowCancelLink.test.d.ts +1 -0
- package/dist/helpers/escrowCancelToken.d.ts +4 -0
- package/dist/helpers/escrowCancelToken.test.d.ts +1 -0
- package/dist/helpers/keycloak.helpers.d.ts +23 -0
- package/dist/helpers/learnCard.signingAuthority.test.d.ts +1 -0
- package/dist/helpers/login-ticket.helpers.d.ts +31 -0
- package/dist/helpers/login-ticket.helpers.test.d.ts +1 -0
- package/dist/helpers/oidc.helpers.d.ts +47 -0
- package/dist/helpers/rate-limit.helpers.d.ts +6 -0
- package/dist/helpers/rate-limit.helpers.test.d.ts +1 -0
- package/dist/helpers/rateLimit.helpers.d.ts +4 -0
- package/dist/helpers/seedEncryption.helpers.d.ts +23 -0
- package/dist/helpers/seedEncryption.helpers.test.d.ts +1 -0
- package/dist/helpers/social-token.helpers.d.ts +19 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +2 -2
- package/dist/jobs/escrowBlobRewrap.d.ts +9 -0
- package/dist/jobs/escrowHoldReminders.d.ts +12 -0
- package/dist/migrations/signingAuthoritySeeds.d.ts +13 -0
- package/dist/migrations/signingAuthoritySeedsCli.test.d.ts +1 -0
- package/dist/models/AuthSubject.d.ts +20 -0
- package/dist/models/EscrowHold.d.ts +153 -0
- package/dist/models/Notification.d.ts +5 -0
- package/dist/models/SigningAuthority.d.ts +7 -1
- package/dist/models/UserKey.d.ts +326 -3
- package/dist/models/UserKey.escrowPin.test.d.ts +1 -0
- package/dist/models/index.d.ts +2 -0
- package/dist/oidc.d.ts +5 -0
- package/dist/routes/auth.d.ts +31 -0
- package/dist/routes/escrow.d.ts +164 -0
- package/dist/routes/index.d.ts +6 -0
- package/dist/routes/keys.d.ts +182 -7
- package/dist/routes/notifications.d.ts +5 -0
- package/dist/routes/qr-login.d.ts +1 -1
- package/dist/routes/signing-authority.d.ts +14 -0
- package/dist/services/escrow-enclave/blobIdentity.d.ts +10 -0
- package/dist/services/escrow-enclave/blobIdentity.test.d.ts +1 -0
- package/dist/services/escrow-enclave/cancellation.d.ts +3 -0
- package/dist/services/escrow-enclave/enclaveTrustAnchors.d.ts +1 -0
- package/dist/services/escrow-enclave/index.d.ts +25 -0
- package/dist/services/escrow-enclave/notifications.d.ts +24 -0
- package/dist/services/escrow-enclave/notifications.test.d.ts +1 -0
- package/dist/services/escrow-enclave/production.test.d.ts +1 -0
- package/dist/services/escrow-enclave/remoteEnclave.contract.test.d.ts +1 -0
- package/dist/services/escrow-enclave/remoteEnclave.d.ts +20 -0
- package/dist/services/escrow-enclave/remoteEnclave.test.d.ts +1 -0
- package/dist/services/escrow-enclave/remoteEnclave.tls.test.d.ts +1 -0
- package/dist/services/escrow-enclave/softwareEnclave.d.ts +27 -0
- package/dist/services/escrow-enclave/softwareEnclave.test.d.ts +1 -0
- package/dist/services/escrow-enclave/types.d.ts +111 -0
- package/dist/types/notifications.d.ts +5 -0
- package/dist/types/seed-encryption.d.ts +31 -0
- package/dist/types/seed-migration.d.ts +49 -0
- package/esbuildPlugins.cjs +1 -0
- package/lambda.ts +27 -112
- package/lambdaApp.ts +146 -0
- package/oidcLambda.test.ts +38 -0
- package/oidcLambda.ts +18 -0
- package/oidcLambdaApp.ts +11 -0
- package/package.json +30 -14
- package/scripts/README.md +88 -0
- package/scripts/check-seed-encryption-infrastructure.mjs +139 -0
- package/scripts/check-signing-authority-redis.mjs +93 -0
- package/scripts/escrow-blob-mode-report.test.ts +227 -0
- package/scripts/escrow-blob-mode-report.ts +465 -0
- package/scripts/keycloak-admin.ts +108 -0
- package/scripts/keycloak-provider-links.ts +147 -0
- package/scripts/migrate-signing-authority-seeds-local.sh +51 -0
- package/scripts/migrate-signing-authority-seeds.mjs +105 -0
- package/scripts/print-seed-rollout-flags.ts +21 -0
- package/scripts/provision-keycloak-users.ts +419 -0
- package/seedMigrationApp.test.ts +145 -0
- package/seedMigrationApp.ts +50 -0
- package/seedMigrationLambda.test.ts +40 -0
- package/seedMigrationLambda.ts +19 -0
- package/serverless-local.yml +28 -1
- package/serverless.function-env.cjs +58 -0
- package/serverless.yml +294 -26
- package/src/accesslayer/notifications/index.ts +9 -2
- package/src/accesslayer/signing-authority/create.ts +11 -7
- package/src/accesslayer/signing-authority/index.ts +4 -1
- package/src/app.ts +4 -0
- package/src/cache/challenges.ts +12 -2
- package/src/cache/getDel.test.ts +34 -0
- package/src/cache/getDel.ts +32 -0
- package/src/cache/in-memory-lru.ts +1 -0
- package/src/cache/index.ts +1 -1
- package/src/cache/login-tickets.ts +26 -0
- package/src/cache/recoverySessions.ts +252 -0
- package/src/config/applyDockerStageConfig.ts +6 -0
- package/src/config/cacheEnvironment.ts +34 -0
- package/src/config/environment.test.ts +159 -0
- package/src/config/environment.ts +164 -0
- package/src/config/escrowBundleMode.test.ts +52 -0
- package/src/config/escrowDeployment.test.ts +114 -0
- package/src/config/oidcEnvironment.ts +59 -0
- package/src/config/serverlessFunctionEnv.test.ts +98 -0
- package/src/config/stageConfig.test.ts +26 -0
- package/src/config/stageConfig.ts +24 -0
- package/src/docker-entry.ts +13 -0
- package/src/helpers/auth.helpers.ts +68 -30
- package/src/helpers/escrowCancelLink.test.ts +60 -0
- package/src/helpers/escrowCancelLink.ts +38 -0
- package/src/helpers/escrowCancelToken.test.ts +105 -0
- package/src/helpers/escrowCancelToken.ts +47 -0
- package/src/helpers/keycloak.helpers.ts +120 -0
- package/src/helpers/learnCard.helpers.test.ts +11 -1
- package/src/helpers/learnCard.helpers.ts +57 -45
- package/src/helpers/learnCard.signingAuthority.test.ts +194 -0
- package/src/helpers/login-ticket.helpers.test.ts +45 -0
- package/src/helpers/login-ticket.helpers.ts +91 -0
- package/src/helpers/oidc.helpers.ts +354 -0
- package/src/helpers/rate-limit.helpers.test.ts +61 -0
- package/src/helpers/rate-limit.helpers.ts +73 -0
- package/src/helpers/rateLimit.helpers.ts +112 -0
- package/src/helpers/seedEncryption.helpers.test.ts +427 -0
- package/src/helpers/seedEncryption.helpers.ts +372 -0
- package/src/helpers/signingAuthority.helpers.ts +4 -1
- package/src/helpers/social-token.helpers.ts +144 -0
- package/src/index.ts +1 -0
- package/src/jobs/escrowBlobRewrap.ts +121 -0
- package/src/jobs/escrowHoldReminders.ts +177 -0
- package/src/migrations/signingAuthoritySeeds.ts +430 -0
- package/src/migrations/signingAuthoritySeedsCli.test.ts +70 -0
- package/src/models/AuthSubject.ts +147 -0
- package/src/models/EscrowHold.ts +541 -0
- package/src/models/SigningAuthority.ts +14 -3
- package/src/models/UserKey.escrowPin.test.ts +49 -0
- package/src/models/UserKey.ts +1109 -40
- package/src/models/index.ts +2 -0
- package/src/models/pruneOrphanedRecoveryMethods.ts +3 -1
- package/src/oidc.ts +371 -0
- package/src/routes/auth.ts +173 -0
- package/src/routes/credentials.ts +8 -0
- package/src/routes/escrow.ts +1073 -0
- package/src/routes/firebase.ts +90 -48
- package/src/routes/index.ts +98 -5
- package/src/routes/keys.ts +1022 -174
- package/src/routes/qr-login.ts +51 -53
- package/src/routes/signing-authority.ts +39 -1
- package/src/services/delivery/adapters/postmark.adapter.ts +48 -1
- package/src/services/escrow-enclave/README.md +122 -0
- package/src/services/escrow-enclave/__fixtures__/tls/pinned-ca.pem +11 -0
- package/src/services/escrow-enclave/__fixtures__/tls/pinned-leaf.key +5 -0
- package/src/services/escrow-enclave/__fixtures__/tls/pinned-leaf.pem +12 -0
- package/src/services/escrow-enclave/__fixtures__/tls/rogue-ca.pem +11 -0
- package/src/services/escrow-enclave/__fixtures__/tls/rogue-leaf.key +5 -0
- package/src/services/escrow-enclave/__fixtures__/tls/rogue-leaf.pem +12 -0
- package/src/services/escrow-enclave/blobIdentity.test.ts +42 -0
- package/src/services/escrow-enclave/blobIdentity.ts +55 -0
- package/src/services/escrow-enclave/cancellation.ts +59 -0
- package/src/services/escrow-enclave/enclaveTrustAnchors.ts +34 -0
- package/src/services/escrow-enclave/index.ts +174 -0
- package/src/services/escrow-enclave/notifications.test.ts +377 -0
- package/src/services/escrow-enclave/notifications.ts +348 -0
- package/src/services/escrow-enclave/production.test.ts +34 -0
- package/src/services/escrow-enclave/remoteEnclave.contract.test.ts +344 -0
- package/src/services/escrow-enclave/remoteEnclave.test.ts +490 -0
- package/src/services/escrow-enclave/remoteEnclave.tls.test.ts +62 -0
- package/src/services/escrow-enclave/remoteEnclave.ts +263 -0
- package/src/services/escrow-enclave/softwareEnclave.test.ts +450 -0
- package/src/services/escrow-enclave/softwareEnclave.ts +254 -0
- package/src/services/escrow-enclave/types.ts +122 -0
- package/src/types/seed-encryption.ts +51 -0
- package/src/types/seed-migration.ts +61 -0
- package/test/auth-tickets.spec.ts +480 -0
- package/test/credentials.spec.ts +2 -0
- package/test/helpers/keycloak-broker.ts +181 -0
- package/test/helpers/live-mongo.ts +9 -0
- package/test/jobs/escrowBlobRewrap.spec.ts +262 -0
- package/test/jobs/escrowHoldReminders.spec.ts +319 -0
- package/test/keycloak-admin.spec.ts +58 -0
- package/test/keycloak-broker-roundtrip.integration.spec.ts +40 -0
- package/test/keycloak-migration.integration.spec.ts +266 -0
- package/test/keycloak-provider-links.spec.ts +201 -0
- package/test/keycloak-social-broker.integration.spec.ts +228 -0
- package/test/keycloak-verify.integration.spec.ts +100 -0
- package/test/keycloak-verify.spec.ts +307 -0
- package/test/keys-escrow.spec.ts +2328 -0
- package/test/keys-identity-rebind.spec.ts +456 -0
- package/test/keys-lifecycle-invariants.spec.ts +785 -0
- package/test/keys-recovery-confirmation.spec.ts +586 -0
- package/test/keys-recovery-rate-limit.spec.ts +123 -0
- package/test/keys-secret-leakage.spec.ts +265 -0
- package/test/models/authSubjectIndexes.spec.ts +132 -0
- package/test/models/escrow.spec.ts +572 -0
- package/test/notifications.spec.ts +4 -0
- package/test/oidc-e2e/broker.spec.ts +116 -0
- package/test/oidc-e2e/compose.yaml +33 -0
- package/test/oidc-e2e/fixtures.ts +245 -0
- package/test/oidc-e2e/lock.test.mjs +73 -0
- package/test/oidc-e2e/playwright.config.ts +21 -0
- package/test/oidc-e2e/run.mjs +312 -0
- package/test/oidc-e2e/tsconfig.json +10 -0
- package/test/oidc-e2e/types.ts +21 -0
- package/test/oidc.integration.spec.ts +54 -0
- package/test/oidc.spec.ts +697 -0
- package/test/oidcLambdaApp.boot.spec.ts +105 -0
- package/test/provision-keycloak-users.spec.ts +299 -0
- package/test/signing-authority-http.spec.ts +161 -0
- package/test/signing-authority-seed-migration.spec.ts +414 -0
- package/test/signing-authority.spec.ts +90 -0
- package/tsconfig.build.json +1 -1
- package/tsconfig.json +1 -0
- package/vitest.config.ts +24 -1
- package/vitest.integration.config.ts +25 -3
- package/dist/cache/signing-authority.d.ts +0 -6
- package/src/cache/signing-authority.ts +0 -37
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { resolveTenantFromRequest, type ResolvedTenant } from '@learncard/email-templates';
|
|
2
|
+
import {
|
|
3
|
+
expireStaleEscrowHolds,
|
|
4
|
+
findEscrowHoldsPendingEnclaveCancellation,
|
|
5
|
+
countEscrowCancellationsNearRelease,
|
|
6
|
+
findEscrowHoldsDueForReminder,
|
|
7
|
+
claimEscrowHoldForReminder,
|
|
8
|
+
findUserKeyByAuthProvider,
|
|
9
|
+
findEscrowHoldsPendingStartNotification,
|
|
10
|
+
releaseEscrowReminderClaim,
|
|
11
|
+
recordEscrowStartNotificationAttempt,
|
|
12
|
+
} from '@models';
|
|
13
|
+
import { environment } from '@environment';
|
|
14
|
+
import {
|
|
15
|
+
cancelHoldInEnclave,
|
|
16
|
+
logEscrowCancellationPending,
|
|
17
|
+
} from '../services/escrow-enclave/cancellation';
|
|
18
|
+
import { isEscrowEnabled, notifyEscrowHoldEvent } from '../services/escrow-enclave';
|
|
19
|
+
|
|
20
|
+
// Two queues of ten 3-second deliveries leave half the 120-second Lambda
|
|
21
|
+
// budget for database work. Least-recently-attempted ordering prevents starvation.
|
|
22
|
+
const DEFAULT_REMINDER_BATCH_LIMIT = 10;
|
|
23
|
+
|
|
24
|
+
export interface EscrowHoldReminderResult {
|
|
25
|
+
reminded: number;
|
|
26
|
+
expired: number;
|
|
27
|
+
failed: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface RunEscrowHoldRemindersOptions {
|
|
31
|
+
now?: Date;
|
|
32
|
+
limit?: number;
|
|
33
|
+
/**
|
|
34
|
+
* Test-only seam; defaults to the real `notifyEscrowHoldEvent`. Lets
|
|
35
|
+
* tests force a deterministic delivery failure without touching
|
|
36
|
+
* Postmark/FCM. Real callers (the Lambda handler) never pass this, so
|
|
37
|
+
* the effective public signature stays `{ now?, limit? }`.
|
|
38
|
+
*/
|
|
39
|
+
notify?: typeof notifyEscrowHoldEvent;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Given a hold's stored `tenantId`, resolves the same `ResolvedTenant` shape
|
|
44
|
+
* route handlers get from `ctx.tenant`. A scheduled job has no request to
|
|
45
|
+
* read headers from, so this reuses `resolveTenantFromRequest`'s third
|
|
46
|
+
* resolution step directly (deploy-level id, same as `DEFAULT_TENANT_ID`)
|
|
47
|
+
* rather than duplicating the id → branding lookup it already does. Holds
|
|
48
|
+
* created before `tenantId` existed fall back to `DEFAULT_TENANT_ID` / the
|
|
49
|
+
* package's own 'learncard' default, exactly like any other unheadered call.
|
|
50
|
+
*/
|
|
51
|
+
const resolveTenantById = (tenantId: string | undefined): ResolvedTenant =>
|
|
52
|
+
resolveTenantFromRequest({}, tenantId);
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Deliberately drops the caught error's message, not just its stack: a real
|
|
56
|
+
* delivery-channel rejection (Postmark/FCM) can embed the recipient address
|
|
57
|
+
* in its own message text, so forwarding it here would leak PII through the
|
|
58
|
+
* one logging path meant to report the failure. Mirrors
|
|
59
|
+
* `notifications.ts`'s `logDeliveryFailure`, which makes the same trade-off
|
|
60
|
+
* for the same reason. The error's name/constructor is still useful for
|
|
61
|
+
* triage (e.g. "MongoServerError" vs "TypeError") without that risk.
|
|
62
|
+
*/
|
|
63
|
+
const logJobError = (holdId: string, error: unknown): void => {
|
|
64
|
+
console.error('[escrow-hold-reminders] failed to process hold', {
|
|
65
|
+
holdId,
|
|
66
|
+
errorType: error instanceof Error ? error.name : typeof error,
|
|
67
|
+
});
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Hourly maintenance job: expires stale pending holds, then sends a T-24h
|
|
72
|
+
* `reminder` notification to every pending, hold-policy escrow hold whose
|
|
73
|
+
* waiting period ends within 24h and that has never been reminded.
|
|
74
|
+
*
|
|
75
|
+
* Escrow can be entirely disabled (no `ESCROW_ENCLAVE_MODE`) on a given
|
|
76
|
+
* stage/tenant deploy. Rather than a Serverless-level `schedule.enabled`
|
|
77
|
+
* conditional (its value must be a literal boolean, not an interpolated env
|
|
78
|
+
* string, so gating it from `${env:ESCROW_ENCLAVE_MODE}` is fragile), the
|
|
79
|
+
* schedule stays always-enabled in serverless.yml and this job no-ops
|
|
80
|
+
* instead — the same disabled/enabled signal the escrow router itself uses
|
|
81
|
+
* via `getEscrowEnclave()` throwing when `isEscrowEnabled()` is false.
|
|
82
|
+
*/
|
|
83
|
+
export const runEscrowHoldReminders = async (
|
|
84
|
+
options: RunEscrowHoldRemindersOptions = {}
|
|
85
|
+
): Promise<EscrowHoldReminderResult> => {
|
|
86
|
+
const empty: EscrowHoldReminderResult = { reminded: 0, expired: 0, failed: 0 };
|
|
87
|
+
const now = options.now ?? new Date();
|
|
88
|
+
if (!isEscrowEnabled()) {
|
|
89
|
+
// Disabling recovery must not silence outstanding revocation alarms.
|
|
90
|
+
if (await countEscrowCancellationsNearRelease(now)) logEscrowCancellationPending(true);
|
|
91
|
+
return empty;
|
|
92
|
+
}
|
|
93
|
+
const limit = Math.max(
|
|
94
|
+
1,
|
|
95
|
+
Math.min(options.limit ?? DEFAULT_REMINDER_BATCH_LIMIT, DEFAULT_REMINDER_BATCH_LIMIT)
|
|
96
|
+
);
|
|
97
|
+
const notify = options.notify ?? notifyEscrowHoldEvent;
|
|
98
|
+
|
|
99
|
+
const expired = await expireStaleEscrowHolds(now);
|
|
100
|
+
const candidates = await findEscrowHoldsDueForReminder(now, limit);
|
|
101
|
+
|
|
102
|
+
let reminded = 0;
|
|
103
|
+
let failed = 0;
|
|
104
|
+
|
|
105
|
+
// Budget at most 30s of enclave calls beside the two 30s delivery queues.
|
|
106
|
+
// Attempts ordering ensures a repeatedly unavailable cancellation cannot starve others.
|
|
107
|
+
const cancelLimit = Math.min(
|
|
108
|
+
limit,
|
|
109
|
+
3,
|
|
110
|
+
Math.max(1, Math.floor(30_000 / environment.ESCROW_ENCLAVE_REMOTE_TIMEOUT_MS))
|
|
111
|
+
);
|
|
112
|
+
for (const hold of await findEscrowHoldsPendingEnclaveCancellation(cancelLimit)) {
|
|
113
|
+
try {
|
|
114
|
+
const userKey = hold.enclaveEnvelope
|
|
115
|
+
? undefined
|
|
116
|
+
: await findUserKeyByAuthProvider(hold.authProvider.type, hold.authProvider.id);
|
|
117
|
+
if (!(await cancelHoldInEnclave(hold, userKey, now))) failed += 1;
|
|
118
|
+
} catch (error) {
|
|
119
|
+
failed += 1;
|
|
120
|
+
logJobError(hold._id, error);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
// Check the whole backlog, not only this run's bounded retry batch.
|
|
124
|
+
if (await countEscrowCancellationsNearRelease(now)) logEscrowCancellationPending(true);
|
|
125
|
+
|
|
126
|
+
// A hold is inserted with this marker before the route attempts delivery,
|
|
127
|
+
// so Lambda freezes/timeouts cannot lose the security-critical start email.
|
|
128
|
+
for (const hold of await findEscrowHoldsPendingStartNotification(limit)) {
|
|
129
|
+
try {
|
|
130
|
+
await recordEscrowStartNotificationAttempt(hold._id, now);
|
|
131
|
+
const userKey = await findUserKeyByAuthProvider(
|
|
132
|
+
hold.authProvider.type,
|
|
133
|
+
hold.authProvider.id
|
|
134
|
+
);
|
|
135
|
+
if (
|
|
136
|
+
userKey &&
|
|
137
|
+
!(await notify({
|
|
138
|
+
kind: 'started',
|
|
139
|
+
hold,
|
|
140
|
+
userKey,
|
|
141
|
+
tenant: resolveTenantById(hold.tenantId),
|
|
142
|
+
}))
|
|
143
|
+
)
|
|
144
|
+
failed += 1;
|
|
145
|
+
} catch (error) {
|
|
146
|
+
failed += 1;
|
|
147
|
+
logJobError(hold._id, error);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
for (const candidate of candidates) {
|
|
152
|
+
let claimed: Awaited<ReturnType<typeof claimEscrowHoldForReminder>> = null;
|
|
153
|
+
try {
|
|
154
|
+
// Lease before send; only successful delivery writes the sent marker.
|
|
155
|
+
claimed = await claimEscrowHoldForReminder(candidate._id, now);
|
|
156
|
+
if (!claimed) continue;
|
|
157
|
+
|
|
158
|
+
const userKey = await findUserKeyByAuthProvider(
|
|
159
|
+
claimed.authProvider.type,
|
|
160
|
+
claimed.authProvider.id
|
|
161
|
+
);
|
|
162
|
+
if (!userKey) continue;
|
|
163
|
+
|
|
164
|
+
const tenant = resolveTenantById(claimed.tenantId);
|
|
165
|
+
|
|
166
|
+
if (await notify({ kind: 'reminder', hold: claimed, userKey, tenant })) reminded += 1;
|
|
167
|
+
else failed += 1;
|
|
168
|
+
} catch (error) {
|
|
169
|
+
failed += 1;
|
|
170
|
+
logJobError(candidate._id, error);
|
|
171
|
+
} finally {
|
|
172
|
+
if (claimed) await releaseEscrowReminderClaim(claimed);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return { reminded, expired, failed };
|
|
177
|
+
};
|
|
@@ -0,0 +1,430 @@
|
|
|
1
|
+
import { randomUUID, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
import type { Db, Document, Filter } from 'mongodb';
|
|
3
|
+
|
|
4
|
+
import { SIGNING_AUTHORITIES_COLLECTION } from '@models';
|
|
5
|
+
import {
|
|
6
|
+
getSeedIdentity,
|
|
7
|
+
logSeedEncryptionFailure,
|
|
8
|
+
parseSeedEnvelope,
|
|
9
|
+
SeedEncryptionError,
|
|
10
|
+
seedEncryption,
|
|
11
|
+
} from '@helpers/seedEncryption.helpers';
|
|
12
|
+
import type { EncryptedSigningAuthoritySeed } from 'types/seed-encryption';
|
|
13
|
+
import type {
|
|
14
|
+
MigrationSigningAuthority,
|
|
15
|
+
SeedMigrationCounts,
|
|
16
|
+
SeedMigrationReceipt,
|
|
17
|
+
SeedMigrationRequest,
|
|
18
|
+
SeedMigrationResult,
|
|
19
|
+
SeedMigrationState,
|
|
20
|
+
} from 'types/seed-migration';
|
|
21
|
+
|
|
22
|
+
export const SEED_MIGRATION_STATE_COLLECTION = 'signingauthorityseedmigration';
|
|
23
|
+
export const SEED_MIGRATION_RECEIPTS_COLLECTION = 'signingauthorityseedverification';
|
|
24
|
+
const STATE_ID = 'sa-seeds-v1';
|
|
25
|
+
const LEASE_MS = 16 * 60 * 1000; // Longer than the Lambda's maximum lifetime.
|
|
26
|
+
const ENVELOPE_FIELDS = ['encryptedSeed', 'encryptedDek', 'keyVersion'] as const;
|
|
27
|
+
const RECEIPT_FIELDS = [...ENVELOPE_FIELDS, 'ownerDid', 'name', 'did'] as const;
|
|
28
|
+
|
|
29
|
+
export class SeedMigrationError extends Error {
|
|
30
|
+
constructor(public readonly category: string) {
|
|
31
|
+
super(`Signing authority seed migration stopped: ${category}`);
|
|
32
|
+
this.name = 'SeedMigrationError';
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const hasField = (field: string): Document => ({ $ne: [{ $type: `$${field}` }, 'missing'] });
|
|
37
|
+
const isString = (field: string): Document => ({ $eq: [{ $type: `$${field}` }, 'string'] });
|
|
38
|
+
|
|
39
|
+
/** One aggregation reconciles the collection; cryptographic validity is checked in verify/purge. */
|
|
40
|
+
export const countSeedMigrationDocuments = async (db: Db): Promise<SeedMigrationCounts> => {
|
|
41
|
+
const encrypted = {
|
|
42
|
+
$and: [
|
|
43
|
+
{ $in: ['$keyVersion', ['kms-v1', 'local-v1']] },
|
|
44
|
+
isString('encryptedSeed'),
|
|
45
|
+
isString('encryptedDek'),
|
|
46
|
+
{ $ne: ['$encryptedSeed', ''] },
|
|
47
|
+
{ $ne: ['$encryptedDek', ''] },
|
|
48
|
+
],
|
|
49
|
+
};
|
|
50
|
+
const legacy = {
|
|
51
|
+
$and: [
|
|
52
|
+
...ENVELOPE_FIELDS.map(field => ({ $eq: [{ $type: `$${field}` }, 'missing'] })),
|
|
53
|
+
isString('seed'),
|
|
54
|
+
{ $ne: ['$seed', ''] },
|
|
55
|
+
],
|
|
56
|
+
};
|
|
57
|
+
const result = await db
|
|
58
|
+
.collection(SIGNING_AUTHORITIES_COLLECTION)
|
|
59
|
+
.aggregate<SeedMigrationCounts>([
|
|
60
|
+
{
|
|
61
|
+
$group: {
|
|
62
|
+
_id: null,
|
|
63
|
+
total: { $sum: 1 },
|
|
64
|
+
encrypted: { $sum: { $cond: [encrypted, 1, 0] } },
|
|
65
|
+
legacyOnly: { $sum: { $cond: [legacy, 1, 0] } },
|
|
66
|
+
malformed: { $sum: { $cond: [{ $or: [encrypted, legacy] }, 0, 1] } },
|
|
67
|
+
plaintextRemaining: { $sum: { $cond: [hasField('seed'), 1, 0] } },
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
{ $project: { _id: 0 } },
|
|
71
|
+
])
|
|
72
|
+
.next();
|
|
73
|
+
return result ?? { total: 0, encrypted: 0, legacyOnly: 0, malformed: 0, plaintextRemaining: 0 };
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const snapshotFilter = (record: MigrationSigningAuthority): Filter<MigrationSigningAuthority> => {
|
|
77
|
+
const filter: Document = { _id: record._id };
|
|
78
|
+
for (const field of [...RECEIPT_FIELDS, 'seed']) {
|
|
79
|
+
filter[field] = Object.hasOwn(record, field) ? { $eq: record[field] } : { $exists: false };
|
|
80
|
+
}
|
|
81
|
+
return filter;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const sameSeed = (actual: string, expected: string): boolean => {
|
|
85
|
+
const left = Buffer.from(actual);
|
|
86
|
+
const right = Buffer.from(expected);
|
|
87
|
+
try {
|
|
88
|
+
return left.length === right.length && timingSafeEqual(left, right);
|
|
89
|
+
} finally {
|
|
90
|
+
left.fill(0);
|
|
91
|
+
right.fill(0);
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const verifyRecord = async (
|
|
96
|
+
record: MigrationSigningAuthority
|
|
97
|
+
): Promise<EncryptedSigningAuthoritySeed> => {
|
|
98
|
+
const envelope = parseSeedEnvelope(record);
|
|
99
|
+
if (!envelope) throw new SeedMigrationError('legacy_record_remaining');
|
|
100
|
+
const seed = await seedEncryption.decrypt(envelope, getSeedIdentity(record));
|
|
101
|
+
if (
|
|
102
|
+
Object.hasOwn(record, 'seed') &&
|
|
103
|
+
(typeof record.seed !== 'string' || !sameSeed(seed, record.seed))
|
|
104
|
+
) {
|
|
105
|
+
throw new SeedMigrationError('seed_mismatch');
|
|
106
|
+
}
|
|
107
|
+
return envelope;
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
const receiptFor = (
|
|
111
|
+
record: MigrationSigningAuthority,
|
|
112
|
+
envelope: EncryptedSigningAuthoritySeed,
|
|
113
|
+
epoch: string
|
|
114
|
+
): SeedMigrationReceipt => ({
|
|
115
|
+
_id: record._id,
|
|
116
|
+
epoch,
|
|
117
|
+
...envelope,
|
|
118
|
+
ownerDid: record.ownerDid,
|
|
119
|
+
name: record.name,
|
|
120
|
+
did: record.did ?? null,
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
// Compare receipts with CURRENT documents. The final full sweep catches inserts and changes
|
|
124
|
+
// behind the batch cursor before granting permission to purge.
|
|
125
|
+
const pendingVerification = (epoch: string): Document[] => [
|
|
126
|
+
{
|
|
127
|
+
$lookup: {
|
|
128
|
+
from: SEED_MIGRATION_RECEIPTS_COLLECTION,
|
|
129
|
+
localField: '_id',
|
|
130
|
+
foreignField: '_id',
|
|
131
|
+
as: '_verification',
|
|
132
|
+
},
|
|
133
|
+
},
|
|
134
|
+
{
|
|
135
|
+
$match: {
|
|
136
|
+
$expr: {
|
|
137
|
+
$not: [
|
|
138
|
+
{
|
|
139
|
+
$and: [
|
|
140
|
+
{ $eq: [{ $arrayElemAt: ['$_verification.epoch', 0] }, epoch] },
|
|
141
|
+
...RECEIPT_FIELDS.map(field => ({
|
|
142
|
+
$eq: [
|
|
143
|
+
{
|
|
144
|
+
$ifNull: [
|
|
145
|
+
{ $arrayElemAt: [`$_verification.${field}`, 0] },
|
|
146
|
+
null,
|
|
147
|
+
],
|
|
148
|
+
},
|
|
149
|
+
{ $ifNull: [`$${field}`, null] },
|
|
150
|
+
],
|
|
151
|
+
})),
|
|
152
|
+
],
|
|
153
|
+
},
|
|
154
|
+
],
|
|
155
|
+
},
|
|
156
|
+
},
|
|
157
|
+
},
|
|
158
|
+
{ $project: { _verification: 0 } },
|
|
159
|
+
];
|
|
160
|
+
|
|
161
|
+
/** Mongo comparisons are type-bracketed; strings sort before historical ObjectId IDs. */
|
|
162
|
+
const afterCursor = (
|
|
163
|
+
cursor?: MigrationSigningAuthority['_id']
|
|
164
|
+
): Filter<MigrationSigningAuthority> => {
|
|
165
|
+
if (cursor === undefined) return {};
|
|
166
|
+
if (typeof cursor === 'string') {
|
|
167
|
+
return { $or: [{ _id: { $gt: cursor } }, { _id: { $type: 'objectId' } }] };
|
|
168
|
+
}
|
|
169
|
+
return { _id: { $gt: cursor } };
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Process one idempotent batch. The caller repeats until done. No seed is returned or checkpointed.
|
|
174
|
+
* All writes use majority acknowledgement; purge requires a completed verification epoch.
|
|
175
|
+
*/
|
|
176
|
+
export const runSeedMigrationBatch = async (
|
|
177
|
+
db: Db,
|
|
178
|
+
request: SeedMigrationRequest,
|
|
179
|
+
options: { encryptedWritesEnabled: boolean; remainingTime?: () => number }
|
|
180
|
+
): Promise<SeedMigrationResult> => {
|
|
181
|
+
if (
|
|
182
|
+
!['dry-run', 'prepare', 'verify', 'purge'].includes(request.phase) ||
|
|
183
|
+
(request.batchSize !== undefined &&
|
|
184
|
+
(!Number.isInteger(request.batchSize) ||
|
|
185
|
+
request.batchSize < 1 ||
|
|
186
|
+
request.batchSize > 100))
|
|
187
|
+
) {
|
|
188
|
+
throw new SeedMigrationError('invalid_request');
|
|
189
|
+
}
|
|
190
|
+
const phase = request.phase;
|
|
191
|
+
const batchSize = request.batchSize ?? 50;
|
|
192
|
+
if (phase === 'dry-run') {
|
|
193
|
+
const result = {
|
|
194
|
+
phase,
|
|
195
|
+
done: true,
|
|
196
|
+
processed: 0,
|
|
197
|
+
counts: await countSeedMigrationDocuments(db),
|
|
198
|
+
countsReconciled: true,
|
|
199
|
+
};
|
|
200
|
+
console.info({ event: 'signing_authority_seed_migration', ...result });
|
|
201
|
+
return result;
|
|
202
|
+
}
|
|
203
|
+
if (!options.encryptedWritesEnabled) throw new SeedMigrationError('encrypted_writes_required');
|
|
204
|
+
|
|
205
|
+
const states = db.collection<SeedMigrationState>(SEED_MIGRATION_STATE_COLLECTION);
|
|
206
|
+
const receipts = db.collection<SeedMigrationReceipt>(SEED_MIGRATION_RECEIPTS_COLLECTION);
|
|
207
|
+
const authorities = db.collection<MigrationSigningAuthority>(SIGNING_AUTHORITIES_COLLECTION);
|
|
208
|
+
const writeConcern = { w: 'majority' as const };
|
|
209
|
+
// Initialize separately so a held lease never makes an upsert overwrite/duplicate the singleton.
|
|
210
|
+
try {
|
|
211
|
+
await states.updateOne(
|
|
212
|
+
{ _id: STATE_ID },
|
|
213
|
+
{ $setOnInsert: { _id: STATE_ID } },
|
|
214
|
+
{ upsert: true, writeConcern }
|
|
215
|
+
);
|
|
216
|
+
} catch {
|
|
217
|
+
throw new SeedMigrationError('checkpoint_unavailable');
|
|
218
|
+
}
|
|
219
|
+
const token = randomUUID();
|
|
220
|
+
const state = await states.findOneAndUpdate(
|
|
221
|
+
{
|
|
222
|
+
_id: STATE_ID,
|
|
223
|
+
$or: [{ leaseOwner: { $exists: false } }, { leaseExpiresAt: { $lte: new Date() } }],
|
|
224
|
+
},
|
|
225
|
+
{ $set: { leaseOwner: token, leaseExpiresAt: new Date(Date.now() + LEASE_MS) } },
|
|
226
|
+
{
|
|
227
|
+
returnDocument: 'after',
|
|
228
|
+
writeConcern,
|
|
229
|
+
}
|
|
230
|
+
);
|
|
231
|
+
if (!state) throw new SeedMigrationError('worker_busy');
|
|
232
|
+
const lease = { _id: STATE_ID, leaseOwner: token };
|
|
233
|
+
let current: MigrationSigningAuthority | undefined;
|
|
234
|
+
let processed = 0;
|
|
235
|
+
try {
|
|
236
|
+
const resuming = state.phase === phase && state.status === 'running' && !!state.counts;
|
|
237
|
+
const countsBefore = resuming ? state.counts! : await countSeedMigrationDocuments(db);
|
|
238
|
+
if (countsBefore.malformed) throw new SeedMigrationError('malformed_records');
|
|
239
|
+
if (
|
|
240
|
+
phase === 'purge' &&
|
|
241
|
+
(!state.verifiedEpoch || countsBefore.encrypted !== countsBefore.total)
|
|
242
|
+
) {
|
|
243
|
+
throw new SeedMigrationError('verification_required');
|
|
244
|
+
}
|
|
245
|
+
const epoch =
|
|
246
|
+
phase === 'verify'
|
|
247
|
+
? ((state.phase === 'verify' && state.status !== 'failed'
|
|
248
|
+
? state.epoch
|
|
249
|
+
: undefined) ?? randomUUID())
|
|
250
|
+
: (state.verifiedEpoch ?? '');
|
|
251
|
+
const checkpoint: Partial<SeedMigrationState> = {
|
|
252
|
+
phase,
|
|
253
|
+
status: 'running',
|
|
254
|
+
counts: countsBefore,
|
|
255
|
+
updatedAt: new Date(),
|
|
256
|
+
};
|
|
257
|
+
let cursor = resuming ? state.cursor : undefined;
|
|
258
|
+
if (phase === 'verify') checkpoint.epoch = epoch;
|
|
259
|
+
await states.updateOne(
|
|
260
|
+
lease,
|
|
261
|
+
{
|
|
262
|
+
$set: checkpoint,
|
|
263
|
+
// A killed worker or failed checkpoint write must not leave purge authorized.
|
|
264
|
+
// A successful purge batch restores its epoch so the next batch can resume.
|
|
265
|
+
$unset: { verifiedEpoch: '', ...(!resuming ? { cursor: '' } : {}) },
|
|
266
|
+
},
|
|
267
|
+
{ writeConcern }
|
|
268
|
+
);
|
|
269
|
+
|
|
270
|
+
const records =
|
|
271
|
+
phase === 'verify'
|
|
272
|
+
? await authorities
|
|
273
|
+
.aggregate<MigrationSigningAuthority>([
|
|
274
|
+
{ $match: afterCursor(cursor) },
|
|
275
|
+
{ $sort: { _id: 1 } },
|
|
276
|
+
...pendingVerification(epoch),
|
|
277
|
+
{ $limit: batchSize },
|
|
278
|
+
])
|
|
279
|
+
.toArray()
|
|
280
|
+
: await authorities
|
|
281
|
+
.find({
|
|
282
|
+
...afterCursor(cursor),
|
|
283
|
+
...(phase === 'prepare'
|
|
284
|
+
? { keyVersion: { $exists: false } }
|
|
285
|
+
: { seed: { $exists: true } }),
|
|
286
|
+
})
|
|
287
|
+
.sort({ _id: 1 })
|
|
288
|
+
.limit(batchSize)
|
|
289
|
+
.toArray();
|
|
290
|
+
|
|
291
|
+
for (const record of records) {
|
|
292
|
+
if (options.remainingTime && options.remainingTime() < 15_000) break;
|
|
293
|
+
current = record;
|
|
294
|
+
if (phase === 'prepare') {
|
|
295
|
+
if (parseSeedEnvelope(record) || typeof record.seed !== 'string') {
|
|
296
|
+
throw new SeedMigrationError('invalid_legacy_record');
|
|
297
|
+
}
|
|
298
|
+
const envelope = await seedEncryption.encrypt(record.seed, getSeedIdentity(record));
|
|
299
|
+
const updated = await authorities.updateOne(
|
|
300
|
+
snapshotFilter(record),
|
|
301
|
+
{ $set: envelope },
|
|
302
|
+
{ writeConcern }
|
|
303
|
+
);
|
|
304
|
+
if (updated.matchedCount !== 1) throw new SeedMigrationError('document_changed');
|
|
305
|
+
const persisted = await authorities.findOne(
|
|
306
|
+
snapshotFilter({ ...record, ...envelope })
|
|
307
|
+
);
|
|
308
|
+
if (!persisted) throw new SeedMigrationError('document_changed');
|
|
309
|
+
await verifyRecord(persisted);
|
|
310
|
+
} else if (phase === 'verify') {
|
|
311
|
+
const envelope = await verifyRecord(record);
|
|
312
|
+
if (
|
|
313
|
+
!(await authorities.findOne(snapshotFilter(record), { projection: { _id: 1 } }))
|
|
314
|
+
) {
|
|
315
|
+
throw new SeedMigrationError('document_changed');
|
|
316
|
+
}
|
|
317
|
+
await receipts.replaceOne(
|
|
318
|
+
{ _id: record._id },
|
|
319
|
+
receiptFor(record, envelope, epoch),
|
|
320
|
+
{
|
|
321
|
+
upsert: true,
|
|
322
|
+
writeConcern,
|
|
323
|
+
}
|
|
324
|
+
);
|
|
325
|
+
} else {
|
|
326
|
+
const envelope = parseSeedEnvelope(record);
|
|
327
|
+
if (!envelope || !(await receipts.findOne(receiptFor(record, envelope, epoch)))) {
|
|
328
|
+
throw new SeedMigrationError('reverification_required');
|
|
329
|
+
}
|
|
330
|
+
await verifyRecord(record);
|
|
331
|
+
const updated = await authorities.updateOne(
|
|
332
|
+
snapshotFilter(record),
|
|
333
|
+
{ $unset: { seed: '' } },
|
|
334
|
+
{ writeConcern }
|
|
335
|
+
);
|
|
336
|
+
if (updated.matchedCount !== 1) throw new SeedMigrationError('document_changed');
|
|
337
|
+
}
|
|
338
|
+
processed += 1;
|
|
339
|
+
cursor = record._id;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// Avoid collection-wide reconciliation on every batch. Reconcile after exhausting
|
|
343
|
+
// the ordered scan; an interrupted/time-limited batch continues at its last success.
|
|
344
|
+
const countsReconciled = processed === records.length && records.length < batchSize;
|
|
345
|
+
let counts = countsBefore;
|
|
346
|
+
let done = false;
|
|
347
|
+
let rescanRequired = false;
|
|
348
|
+
if (countsReconciled) {
|
|
349
|
+
counts = await countSeedMigrationDocuments(db);
|
|
350
|
+
if (counts.malformed || (phase !== 'prepare' && counts.encrypted !== counts.total)) {
|
|
351
|
+
throw new SeedMigrationError('reconciliation_failed');
|
|
352
|
+
}
|
|
353
|
+
if (phase === 'verify') {
|
|
354
|
+
rescanRequired = Boolean(
|
|
355
|
+
await authorities
|
|
356
|
+
.aggregate([...pendingVerification(epoch), { $limit: 1 }])
|
|
357
|
+
.next()
|
|
358
|
+
);
|
|
359
|
+
} else {
|
|
360
|
+
rescanRequired =
|
|
361
|
+
phase === 'prepare' ? counts.legacyOnly > 0 : counts.plaintextRemaining > 0;
|
|
362
|
+
}
|
|
363
|
+
done = !rescanRequired;
|
|
364
|
+
cursor = undefined;
|
|
365
|
+
}
|
|
366
|
+
await states.updateOne(
|
|
367
|
+
lease,
|
|
368
|
+
{
|
|
369
|
+
$set: {
|
|
370
|
+
counts,
|
|
371
|
+
status: done ? 'complete' : 'running',
|
|
372
|
+
updatedAt: new Date(),
|
|
373
|
+
...(cursor !== undefined ? { cursor } : {}),
|
|
374
|
+
...((phase === 'verify' && done) || phase === 'purge'
|
|
375
|
+
? { verifiedEpoch: epoch }
|
|
376
|
+
: {}),
|
|
377
|
+
},
|
|
378
|
+
$inc: { processed },
|
|
379
|
+
...(cursor === undefined ? { $unset: { cursor: '' } } : {}),
|
|
380
|
+
},
|
|
381
|
+
{ writeConcern }
|
|
382
|
+
);
|
|
383
|
+
const result = { phase, done, processed, counts, countsReconciled, rescanRequired };
|
|
384
|
+
console.info({ event: 'signing_authority_seed_migration', ...result });
|
|
385
|
+
return result;
|
|
386
|
+
} catch (error) {
|
|
387
|
+
// Clear the purge gate even after partial success; rerun verify before continuing purge.
|
|
388
|
+
const failure = new SeedMigrationError(
|
|
389
|
+
error instanceof SeedMigrationError
|
|
390
|
+
? error.category
|
|
391
|
+
: error instanceof SeedEncryptionError
|
|
392
|
+
? error.category
|
|
393
|
+
: 'operation_failed'
|
|
394
|
+
);
|
|
395
|
+
try {
|
|
396
|
+
await states.updateOne(
|
|
397
|
+
lease,
|
|
398
|
+
{
|
|
399
|
+
$set: { status: 'failed', updatedAt: new Date() },
|
|
400
|
+
$unset: { verifiedEpoch: '', cursor: '' },
|
|
401
|
+
},
|
|
402
|
+
{ writeConcern }
|
|
403
|
+
);
|
|
404
|
+
} catch (checkpointError) {
|
|
405
|
+
logSeedEncryptionFailure(checkpointError, 'migration_failure_checkpoint', current);
|
|
406
|
+
}
|
|
407
|
+
if (error instanceof SeedMigrationError) {
|
|
408
|
+
console.error({
|
|
409
|
+
event: 'signing_authority_seed_migration_failure',
|
|
410
|
+
operation: `migration_${phase}`,
|
|
411
|
+
recordId: current?._id.toString(),
|
|
412
|
+
category: failure.category,
|
|
413
|
+
});
|
|
414
|
+
} else {
|
|
415
|
+
logSeedEncryptionFailure(error, `migration_${phase}`, current);
|
|
416
|
+
}
|
|
417
|
+
throw failure;
|
|
418
|
+
} finally {
|
|
419
|
+
try {
|
|
420
|
+
await states.updateOne(
|
|
421
|
+
lease,
|
|
422
|
+
{ $unset: { leaseOwner: '', leaseExpiresAt: '' } },
|
|
423
|
+
{ writeConcern }
|
|
424
|
+
);
|
|
425
|
+
} catch (releaseError) {
|
|
426
|
+
// Preserve the operation's result/error. The lease expires after 16 minutes.
|
|
427
|
+
logSeedEncryptionFailure(releaseError, 'migration_lease_release', current);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
};
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
7
|
+
|
|
8
|
+
const script = fileURLToPath(
|
|
9
|
+
new URL('../../scripts/migrate-signing-authority-seeds.mjs', import.meta.url)
|
|
10
|
+
);
|
|
11
|
+
let directory: string;
|
|
12
|
+
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
directory = mkdtempSync(join(tmpdir(), 'sa-cli-test-'));
|
|
15
|
+
});
|
|
16
|
+
afterEach(() => rmSync(directory, { recursive: true, force: true }));
|
|
17
|
+
|
|
18
|
+
const invoke = () =>
|
|
19
|
+
spawnSync(
|
|
20
|
+
process.execPath,
|
|
21
|
+
[script, '--function-name', 'test-migration', '--region', 'us-east-1'],
|
|
22
|
+
{
|
|
23
|
+
encoding: 'utf8',
|
|
24
|
+
env: {
|
|
25
|
+
NODE_ENV: 'test',
|
|
26
|
+
PATH: `${directory}:${process.env.PATH}`,
|
|
27
|
+
SA_CLI_TEST_DIRECTORY: directory,
|
|
28
|
+
},
|
|
29
|
+
}
|
|
30
|
+
);
|
|
31
|
+
const mockAws = (body: string): void => {
|
|
32
|
+
writeFileSync(join(directory, 'aws'), `#!/usr/bin/env node\n${body}`, { mode: 0o700 });
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
describe('seed migration operator CLI', () => {
|
|
36
|
+
it.each(['ExpiredTokenException', 'TooManyRequestsException', 'Read timeout on endpoint URL'])(
|
|
37
|
+
'shows AWS CLI stderr for %s without dumping stdout or the exec error',
|
|
38
|
+
diagnostic => {
|
|
39
|
+
mockAws(
|
|
40
|
+
`process.stdout.write('do-not-log-stdout'); process.stderr.write(${JSON.stringify(diagnostic)}); process.exit(1);`
|
|
41
|
+
);
|
|
42
|
+
const result = invoke();
|
|
43
|
+
expect(result.status).toBe(1);
|
|
44
|
+
expect(result.stderr).toContain(diagnostic);
|
|
45
|
+
expect(result.stderr).toContain('rerun to resume');
|
|
46
|
+
expect(result.stderr).not.toContain('do-not-log-stdout');
|
|
47
|
+
expect(result.stdout).toBe('');
|
|
48
|
+
}
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
it('continues a requested rescan even when the boundary batch processed zero records', () => {
|
|
52
|
+
mockAws(`
|
|
53
|
+
const fs = require('node:fs');
|
|
54
|
+
const marker = process.env.SA_CLI_TEST_DIRECTORY + '/invoked';
|
|
55
|
+
const done = fs.existsSync(marker);
|
|
56
|
+
fs.writeFileSync(marker, '1');
|
|
57
|
+
fs.writeFileSync(process.argv.at(-1), JSON.stringify({ done, processed: done ? 1 : 0, rescanRequired: !done }));
|
|
58
|
+
process.stdout.write(JSON.stringify({ StatusCode: 200 }));
|
|
59
|
+
`);
|
|
60
|
+
const result = invoke();
|
|
61
|
+
expect(result.status).toBe(0);
|
|
62
|
+
expect(result.stderr).toBe('');
|
|
63
|
+
const batches = result.stdout
|
|
64
|
+
.trim()
|
|
65
|
+
.split('\n')
|
|
66
|
+
.map(line => JSON.parse(line));
|
|
67
|
+
expect(batches).toHaveLength(2);
|
|
68
|
+
expect(batches[1].done).toBe(true);
|
|
69
|
+
});
|
|
70
|
+
});
|