@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,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Private CAs for enclave-host TLS, pinned per hostname. When a CA is pinned
|
|
3
|
+
* the remote-enclave transport trusts ONLY that CA (not the system roots), and
|
|
4
|
+
* nothing else in lca-api trusts it. Changing a trust anchor is a reviewed
|
|
5
|
+
* code change, never an environment edit.
|
|
6
|
+
*
|
|
7
|
+
* Each CA is name-constrained to its single hostname; see
|
|
8
|
+
* services/escrow-enclave-app/STAGING.md section 4a.
|
|
9
|
+
*/
|
|
10
|
+
const ENCLAVE_TRUST_ANCHORS: Readonly<Record<string, string>> = {
|
|
11
|
+
// Staging CA, valid until 2029-09-30. SHA-256 1BB676B1B969A916497AB7386A81AEBD
|
|
12
|
+
// 903469ED4B124685F43513B5FCB9E010; source: SSM
|
|
13
|
+
// /learncard/escrow-enclave/staging/ca-cert in account 217358003896.
|
|
14
|
+
'escrow-enclave.staging.internal': `-----BEGIN CERTIFICATE-----
|
|
15
|
+
MIICgTCCAgigAwIBAgIUe/8u9Uy/Y9jD4Aw+cZCIeU/yap0wCgYIKoZIzj0EAwMw
|
|
16
|
+
VDEkMCIGA1UECgwbTGVhcm5pbmcgRWNvbm9teSBGb3VuZGF0aW9uMSwwKgYDVQQD
|
|
17
|
+
DCNMZWFybkNhcmQgRXNjcm93IEVuY2xhdmUgU3RhZ2luZyBDQTAeFw0yNjEwMDEy
|
|
18
|
+
MTI1MDJaFw0yOTA5MzAyMTI1MDJaMFQxJDAiBgNVBAoMG0xlYXJuaW5nIEVjb25v
|
|
19
|
+
bXkgRm91bmRhdGlvbjEsMCoGA1UEAwwjTGVhcm5DYXJkIEVzY3JvdyBFbmNsYXZl
|
|
20
|
+
IFN0YWdpbmcgQ0EwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAARSwi8GmnzoqYadKw9n
|
|
21
|
+
gc7kOMMbimXjF5weA8YCOBhiaNHAxRcYJ0iz1nv74wYt/8x9lvwkFP4IK36i+bdH
|
|
22
|
+
fiu6Oo34LXi3AHBbQvL5A2c2IELil6Cea+ClWtJLfWDu7OOjgZowgZcwHwYDVR0j
|
|
23
|
+
BBgwFoAUybGIxOfLHqqbjDFJ1k28v4aKh4IwEgYDVR0TAQH/BAgwBgEB/wIBADAO
|
|
24
|
+
BgNVHQ8BAf8EBAMCAQYwMQYDVR0eAQH/BCcwJaAjMCGCH2VzY3Jvdy1lbmNsYXZl
|
|
25
|
+
LnN0YWdpbmcuaW50ZXJuYWwwHQYDVR0OBBYEFMmxiMTnyx6qm4wxSdZNvL+GioeC
|
|
26
|
+
MAoGCCqGSM49BAMDA2cAMGQCMGp/wTQLmtB6/GK6hMFaFTA27gXZcxrgJ0Uy+8C0
|
|
27
|
+
Ztl5uoRwN99inHNWv1cnKGdNQgIwSoQgKs8uMnBUcfiB9ZR6xb98z/9athp5pDIE
|
|
28
|
+
/KUv6cNmJtuCgPOJxC+4fVKl8xC3
|
|
29
|
+
-----END CERTIFICATE-----
|
|
30
|
+
`,
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export const pinnedEnclaveCa = (baseUrl: string): string | undefined =>
|
|
34
|
+
ENCLAVE_TRUST_ANCHORS[new URL(baseUrl).hostname];
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import {
|
|
2
|
+
environment,
|
|
3
|
+
parseLcaApiEnvironment,
|
|
4
|
+
parseEscrowPrivateKeys,
|
|
5
|
+
type LcaApiEnvironment,
|
|
6
|
+
} from '@environment';
|
|
7
|
+
import type { EscrowBlobStaleReason } from '@learncard/types';
|
|
8
|
+
import { SoftwareEnclave } from './softwareEnclave';
|
|
9
|
+
import { createRemoteEnclave } from './remoteEnclave';
|
|
10
|
+
import { pinnedEnclaveCa } from './enclaveTrustAnchors';
|
|
11
|
+
import { EscrowUnavailableError, type EscrowEnclave } from './types';
|
|
12
|
+
|
|
13
|
+
export * from './types';
|
|
14
|
+
export * from './softwareEnclave';
|
|
15
|
+
export * from './remoteEnclave';
|
|
16
|
+
export * from './notifications';
|
|
17
|
+
|
|
18
|
+
let enclave: EscrowEnclave | undefined;
|
|
19
|
+
let config: LcaApiEnvironment = environment;
|
|
20
|
+
|
|
21
|
+
// 'software' config validity is enforced at environment-parse time (see
|
|
22
|
+
// config/environment.ts), so reaching that branch always means it's ready.
|
|
23
|
+
// 'remote' config (URL/token) is deliberately NOT required at parse time, so
|
|
24
|
+
// its readiness is checked here instead; missing it fails this closed rather
|
|
25
|
+
// than crashing the whole service at boot.
|
|
26
|
+
export const isEscrowEnabled = (): boolean => {
|
|
27
|
+
if (config.ESCROW_ENCLAVE_MODE === 'software') {
|
|
28
|
+
// Serverless injects the actual deploy stage; NODE_ENV often remains development
|
|
29
|
+
// in Lambda. Unknown deployed stages fail closed, regardless of offline flags.
|
|
30
|
+
const allowed = config.LAMBDA_STAGE
|
|
31
|
+
? ['dev', 'development', 'staging', 'test', 'local'].includes(config.LAMBDA_STAGE)
|
|
32
|
+
: config.NODE_ENV !== 'production';
|
|
33
|
+
if (!allowed) console.error('[escrow] Software enclave disabled for this deploy stage');
|
|
34
|
+
return allowed;
|
|
35
|
+
}
|
|
36
|
+
if (config.ESCROW_ENCLAVE_MODE === 'remote') {
|
|
37
|
+
return Boolean(config.ESCROW_ENCLAVE_REMOTE_URL && config.ESCROW_ENCLAVE_REMOTE_TOKEN);
|
|
38
|
+
}
|
|
39
|
+
return false;
|
|
40
|
+
};
|
|
41
|
+
export const isEscrowRemoteMode = (): boolean => config.ESCROW_ENCLAVE_MODE === 'remote';
|
|
42
|
+
// The client-facing attestation mode implied by the server's active backend
|
|
43
|
+
// ('remote' mode always attests as 'nitro' — see remoteEnclave.ts).
|
|
44
|
+
export const activeEscrowClientMode = (): 'software' | 'nitro' =>
|
|
45
|
+
isEscrowRemoteMode() ? 'nitro' : 'software';
|
|
46
|
+
export const getEscrowHoldDurationMs = (): number => config.ESCROW_HOLD_DURATION_MS;
|
|
47
|
+
export const getEscrowHoldRestartMinAgeMs = (): number => config.ESCROW_HOLD_RESTART_MIN_AGE_MS;
|
|
48
|
+
export const getEscrowEnclave = (): EscrowEnclave => {
|
|
49
|
+
if (!isEscrowEnabled()) throw new EscrowUnavailableError();
|
|
50
|
+
if (enclave) return enclave;
|
|
51
|
+
if (config.ESCROW_ENCLAVE_MODE === 'software') {
|
|
52
|
+
enclave = new SoftwareEnclave({
|
|
53
|
+
privateKeys: parseEscrowPrivateKeys(
|
|
54
|
+
config.ESCROW_ENCLAVE_SOFTWARE_PRIVATE_KEYS_JSON ?? ''
|
|
55
|
+
),
|
|
56
|
+
activeKeyId: config.ESCROW_ENCLAVE_ACTIVE_KEY_ID ?? '',
|
|
57
|
+
holdDurationMs: getEscrowHoldDurationMs(),
|
|
58
|
+
});
|
|
59
|
+
return enclave;
|
|
60
|
+
}
|
|
61
|
+
if (config.ESCROW_ENCLAVE_MODE === 'remote') {
|
|
62
|
+
const { ESCROW_ENCLAVE_REMOTE_URL: baseUrl, ESCROW_ENCLAVE_REMOTE_TOKEN: token } = config;
|
|
63
|
+
if (!baseUrl || !token) throw new EscrowUnavailableError();
|
|
64
|
+
enclave = createRemoteEnclave({
|
|
65
|
+
baseUrl,
|
|
66
|
+
token,
|
|
67
|
+
timeoutMs: config.ESCROW_ENCLAVE_REMOTE_TIMEOUT_MS,
|
|
68
|
+
caPem: pinnedEnclaveCa(baseUrl),
|
|
69
|
+
});
|
|
70
|
+
return enclave;
|
|
71
|
+
}
|
|
72
|
+
throw new EscrowUnavailableError();
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
export type { EscrowBlobStaleReason };
|
|
76
|
+
|
|
77
|
+
interface EscrowAttestationIdentity {
|
|
78
|
+
keyId: string;
|
|
79
|
+
/** P9.1: retired keyIds the enclave still accepts for decrypt only. */
|
|
80
|
+
previousKeyIds: string[];
|
|
81
|
+
mode: 'software' | 'nitro';
|
|
82
|
+
}
|
|
83
|
+
interface EscrowAttestationCacheEntry extends EscrowAttestationIdentity {
|
|
84
|
+
fetchedAt: number;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Keeps every login's status check off the enclave's critical path (a remote
|
|
88
|
+
// nitro backend can be slow or briefly unreachable; a normal sign-in must
|
|
89
|
+
// never wait on it). TTL and budget are process-local constants, not env
|
|
90
|
+
// config, since neither is an operator-facing tuning knob.
|
|
91
|
+
const ESCROW_ATTESTATION_CACHE_TTL_MS = 5 * 60 * 1000;
|
|
92
|
+
const ESCROW_ATTESTATION_STATUS_BUDGET_MS = 250;
|
|
93
|
+
|
|
94
|
+
let attestationCache: EscrowAttestationCacheEntry | undefined;
|
|
95
|
+
let attestationRefreshInFlight: Promise<EscrowAttestationIdentity> | undefined;
|
|
96
|
+
|
|
97
|
+
const isAttestationCacheFresh = (): boolean =>
|
|
98
|
+
!!attestationCache && Date.now() - attestationCache.fetchedAt < ESCROW_ATTESTATION_CACHE_TTL_MS;
|
|
99
|
+
|
|
100
|
+
// Single-flight: any number of concurrent cold-cache callers (status checks
|
|
101
|
+
// and release-enforcement checks alike) share one underlying getAttestation()
|
|
102
|
+
// call and its outcome, so a cache-expiry moment never causes a call fan-out
|
|
103
|
+
// to the enclave. Clears the in-flight marker on settle either way so the
|
|
104
|
+
// next cold call starts a fresh fetch; only a successful fetch warms the cache.
|
|
105
|
+
const refreshAttestationCache = (): Promise<EscrowAttestationIdentity> => {
|
|
106
|
+
attestationRefreshInFlight ??= (async () => {
|
|
107
|
+
try {
|
|
108
|
+
const attestation = await getEscrowEnclave().getAttestation();
|
|
109
|
+
const identity: EscrowAttestationIdentity = {
|
|
110
|
+
keyId: attestation.keyId,
|
|
111
|
+
previousKeyIds: attestation.previousKeyIds ?? [],
|
|
112
|
+
mode: attestation.mode,
|
|
113
|
+
};
|
|
114
|
+
attestationCache = { ...identity, fetchedAt: Date.now() };
|
|
115
|
+
return identity;
|
|
116
|
+
} finally {
|
|
117
|
+
attestationRefreshInFlight = undefined;
|
|
118
|
+
}
|
|
119
|
+
})();
|
|
120
|
+
return attestationRefreshInFlight;
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Fail-closed identity lookup for release enforcement (startRecovery /
|
|
125
|
+
* completeRecovery): resolves instantly from a warm cache, otherwise awaits a
|
|
126
|
+
* real getAttestation() call (subject to the enclave's own normal timeout,
|
|
127
|
+
* e.g. `ESCROW_ENCLAVE_REMOTE_TIMEOUT_MS`) and lets a failure propagate — a
|
|
128
|
+
* caller here must never treat an unreachable enclave as "fresh".
|
|
129
|
+
*/
|
|
130
|
+
export const getEnclaveAttestationIdentity = (): Promise<EscrowAttestationIdentity> =>
|
|
131
|
+
isAttestationCacheFresh() ? Promise.resolve(attestationCache!) : refreshAttestationCache();
|
|
132
|
+
|
|
133
|
+
// Best-effort status check (used for the client's read-only enrollment state
|
|
134
|
+
// on every sign-in — see routes/keys.ts's getAuthShare — not for release
|
|
135
|
+
// enforcement; see getEnclaveAttestationIdentity above for the fail-closed
|
|
136
|
+
// version). Never awaits the enclave directly: a cold/failed cache still
|
|
137
|
+
// returns "not stale" within ESCROW_ATTESTATION_STATUS_BUDGET_MS, while a
|
|
138
|
+
// background refresh (already in flight, or started here) continues so a
|
|
139
|
+
// later call can see a warm cache.
|
|
140
|
+
export const getEscrowBlobStaleReason = async (blob: {
|
|
141
|
+
enclaveMode: 'software' | 'nitro';
|
|
142
|
+
enclaveKeyId: string;
|
|
143
|
+
}): Promise<EscrowBlobStaleReason | undefined> => {
|
|
144
|
+
if (!isEscrowEnabled()) return undefined;
|
|
145
|
+
if (blob.enclaveMode !== activeEscrowClientMode()) return 'mode-mismatch';
|
|
146
|
+
if (isAttestationCacheFresh()) {
|
|
147
|
+
return blob.enclaveKeyId !== attestationCache!.keyId ? 'key-rotated' : undefined;
|
|
148
|
+
}
|
|
149
|
+
const identity = await Promise.race([
|
|
150
|
+
refreshAttestationCache().catch(() => undefined),
|
|
151
|
+
new Promise<undefined>(resolve => {
|
|
152
|
+
setTimeout(() => resolve(undefined), ESCROW_ATTESTATION_STATUS_BUDGET_MS);
|
|
153
|
+
}),
|
|
154
|
+
]);
|
|
155
|
+
return identity && blob.enclaveKeyId !== identity.keyId ? 'key-rotated' : undefined;
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
/** Reset both singleton and environment snapshot after test environment changes. */
|
|
159
|
+
export const __setEscrowEnclaveForTests = (replacement: EscrowEnclave | undefined): void => {
|
|
160
|
+
if (environment.NODE_ENV !== 'test') throw new EscrowUnavailableError();
|
|
161
|
+
config = parseLcaApiEnvironment(process.env);
|
|
162
|
+
enclave = replacement;
|
|
163
|
+
attestationCache = undefined;
|
|
164
|
+
attestationRefreshInFlight = undefined;
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
/** Invalidate the attestation cache without replacing the enclave singleton
|
|
168
|
+
* (e.g. to force the next freshness check to re-fetch against the same
|
|
169
|
+
* enclave, or to isolate tests from each other's cached state). */
|
|
170
|
+
export const __resetEscrowAttestationCacheForTests = (): void => {
|
|
171
|
+
if (environment.NODE_ENV !== 'test') throw new EscrowUnavailableError();
|
|
172
|
+
attestationCache = undefined;
|
|
173
|
+
attestationRefreshInFlight = undefined;
|
|
174
|
+
};
|
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import * as Sentry from '@sentry/serverless';
|
|
3
|
+
|
|
4
|
+
import type { EscrowHold, MongoUserKeyType, RecoveryMethod } from '@models';
|
|
5
|
+
import type { TemplateNotification } from '../delivery';
|
|
6
|
+
import {
|
|
7
|
+
notifyEscrowHoldEvent,
|
|
8
|
+
ESCROW_NOTIFICATION_TIMEOUT_MS,
|
|
9
|
+
type EscrowHoldNotifierDeps,
|
|
10
|
+
type EscrowHoldEventKind,
|
|
11
|
+
} from './notifications';
|
|
12
|
+
|
|
13
|
+
const baseUserKey = (overrides: Partial<MongoUserKeyType> = {}): MongoUserKeyType => {
|
|
14
|
+
const now = new Date();
|
|
15
|
+
return {
|
|
16
|
+
contactMethod: { type: 'email', value: 'owner@example.com' },
|
|
17
|
+
authProviders: [{ type: 'firebase', id: 'user-1' }],
|
|
18
|
+
primaryDid: 'did:key:owner',
|
|
19
|
+
linkedDids: [],
|
|
20
|
+
keyProvider: 'sss',
|
|
21
|
+
shareVersion: 1,
|
|
22
|
+
previousAuthShares: [],
|
|
23
|
+
securityLevel: 'basic',
|
|
24
|
+
recoveryMethods: [],
|
|
25
|
+
migratedFromWeb3Auth: false,
|
|
26
|
+
createdAt: now,
|
|
27
|
+
updatedAt: now,
|
|
28
|
+
...overrides,
|
|
29
|
+
};
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const baseHold = (overrides: Partial<EscrowHold> = {}): EscrowHold => {
|
|
33
|
+
const now = new Date();
|
|
34
|
+
return {
|
|
35
|
+
holdRecord: {
|
|
36
|
+
hold: {
|
|
37
|
+
holdId: 'hold-1',
|
|
38
|
+
did: 'did:key:owner',
|
|
39
|
+
shareVersion: 1,
|
|
40
|
+
blobHash: 'ab'.repeat(32),
|
|
41
|
+
enrollmentEpoch: 1,
|
|
42
|
+
releasePolicy: 'hold',
|
|
43
|
+
clientEphemeralPublicKey: 'public-key',
|
|
44
|
+
createdLo: 0,
|
|
45
|
+
createdHi: 0,
|
|
46
|
+
policyVersion: 1,
|
|
47
|
+
signature: 'test-signature',
|
|
48
|
+
},
|
|
49
|
+
holdDurationMs: 0,
|
|
50
|
+
ledgerSeq: 0,
|
|
51
|
+
},
|
|
52
|
+
_id: 'hold-1',
|
|
53
|
+
authProvider: { type: 'firebase', id: 'user-1' },
|
|
54
|
+
primaryDid: 'did:key:owner',
|
|
55
|
+
shareVersion: 1,
|
|
56
|
+
status: 'pending',
|
|
57
|
+
identityProofType: 'auth-token',
|
|
58
|
+
requestedAt: now,
|
|
59
|
+
releaseAfter: now,
|
|
60
|
+
releasePolicy: 'hold',
|
|
61
|
+
clientEphemeralPublicKey: 'client-pub',
|
|
62
|
+
resumeTokenHash: 'a'.repeat(64),
|
|
63
|
+
notifications: [],
|
|
64
|
+
createdAt: now,
|
|
65
|
+
updatedAt: now,
|
|
66
|
+
...overrides,
|
|
67
|
+
};
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const confirmedRecoveryMethod = (confirmationEmail: string): RecoveryMethod => ({
|
|
71
|
+
type: 'email',
|
|
72
|
+
createdAt: new Date(),
|
|
73
|
+
confirmationStatus: 'confirmed',
|
|
74
|
+
confirmedAt: new Date(),
|
|
75
|
+
confirmationEmail,
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
const pendingRecoveryMethod = (confirmationEmail: string): RecoveryMethod => ({
|
|
79
|
+
type: 'email',
|
|
80
|
+
createdAt: new Date(),
|
|
81
|
+
confirmationStatus: 'pending',
|
|
82
|
+
confirmationEmail,
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
const makeDeps = (overrides: Partial<EscrowHoldNotifierDeps> = {}) => {
|
|
86
|
+
const sent: TemplateNotification[] = [];
|
|
87
|
+
const pushCalls: unknown[] = [];
|
|
88
|
+
const recorded: { holdId: string; kind: EscrowHoldEventKind }[] = [];
|
|
89
|
+
const deps: EscrowHoldNotifierDeps = {
|
|
90
|
+
send: async notification => {
|
|
91
|
+
sent.push(notification as TemplateNotification);
|
|
92
|
+
},
|
|
93
|
+
sendPush: async (notification): ReturnType<EscrowHoldNotifierDeps['sendPush']> => {
|
|
94
|
+
pushCalls.push(notification);
|
|
95
|
+
return { successCount: 1, failureCount: 0, failedTokens: [] };
|
|
96
|
+
},
|
|
97
|
+
now: () => new Date('2026-01-01T00:00:00.000Z'),
|
|
98
|
+
rotateCancelToken: async () => 'b'.repeat(64),
|
|
99
|
+
recordNotification: async (holdId, kind) => {
|
|
100
|
+
recorded.push({ holdId, kind });
|
|
101
|
+
},
|
|
102
|
+
...overrides,
|
|
103
|
+
};
|
|
104
|
+
return { deps, sent, pushCalls, recorded };
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
describe('notifyEscrowHoldEvent', () => {
|
|
108
|
+
it('emails every verified address deduped case-insensitively, excluding unconfirmed methods', async () => {
|
|
109
|
+
const userKey = baseUserKey({
|
|
110
|
+
contactMethod: { type: 'email', value: 'Owner@Example.com' },
|
|
111
|
+
recoveryEmail: 'owner@example.com',
|
|
112
|
+
recoveryEmailVerifiedAt: new Date(),
|
|
113
|
+
recoveryMethods: [
|
|
114
|
+
confirmedRecoveryMethod('secondary@example.com'),
|
|
115
|
+
pendingRecoveryMethod('attacker@example.com'),
|
|
116
|
+
],
|
|
117
|
+
});
|
|
118
|
+
const hold = baseHold({ completedAt: new Date() });
|
|
119
|
+
const { deps, sent } = makeDeps();
|
|
120
|
+
|
|
121
|
+
await notifyEscrowHoldEvent({ kind: 'completed', hold, userKey }, deps);
|
|
122
|
+
|
|
123
|
+
const recipients = sent.map(n => n.to).sort();
|
|
124
|
+
expect(recipients).toEqual(['owner@example.com', 'secondary@example.com']);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('excludes a recoveryEmail that lacks recoveryEmailVerifiedAt', async () => {
|
|
128
|
+
const userKey = baseUserKey({
|
|
129
|
+
contactMethod: { type: 'phone', value: '+15555550100' },
|
|
130
|
+
recoveryEmail: 'unverified@example.com',
|
|
131
|
+
});
|
|
132
|
+
const hold = baseHold({ completedAt: new Date() });
|
|
133
|
+
const { deps, sent } = makeDeps();
|
|
134
|
+
|
|
135
|
+
await notifyEscrowHoldEvent({ kind: 'completed', hold, userKey }, deps);
|
|
136
|
+
|
|
137
|
+
expect(sent).toHaveLength(0);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('sends the started email with a cancel link built from the plaintext cancelToken, never rotating', async () => {
|
|
141
|
+
const userKey = baseUserKey();
|
|
142
|
+
const hold = baseHold();
|
|
143
|
+
const rotateCancelToken = vi.fn(async () => {
|
|
144
|
+
throw new Error('must not rotate for started');
|
|
145
|
+
});
|
|
146
|
+
const { deps, sent } = makeDeps({ rotateCancelToken });
|
|
147
|
+
const cancelToken = 'a'.repeat(64);
|
|
148
|
+
|
|
149
|
+
await notifyEscrowHoldEvent({ kind: 'started', hold, userKey, cancelToken }, deps);
|
|
150
|
+
|
|
151
|
+
expect(rotateCancelToken).not.toHaveBeenCalled();
|
|
152
|
+
expect(sent).toHaveLength(1);
|
|
153
|
+
expect(sent[0]!.templateAlias).toBe('escrow-hold-started');
|
|
154
|
+
expect(sent[0]!.templateModel.cancelUrl).toContain(cancelToken);
|
|
155
|
+
expect(sent[0]!.templateModel.cancelUrl).toContain(hold._id);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('rotates a fresh cancel token for reminder emails', async () => {
|
|
159
|
+
const userKey = baseUserKey();
|
|
160
|
+
const hold = baseHold();
|
|
161
|
+
const rotateCancelToken = vi.fn(async () => 'c'.repeat(64));
|
|
162
|
+
const { deps, sent } = makeDeps({ rotateCancelToken });
|
|
163
|
+
|
|
164
|
+
await notifyEscrowHoldEvent({ kind: 'reminder', hold, userKey }, deps);
|
|
165
|
+
|
|
166
|
+
expect(rotateCancelToken).toHaveBeenCalledWith(hold._id);
|
|
167
|
+
expect(sent[0]!.templateAlias).toBe('escrow-hold-reminder');
|
|
168
|
+
expect(sent[0]!.templateModel.cancelUrl).toContain('c'.repeat(64));
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('pin-locked with an active waiting-period hold rotates that hold (not the PIN hold) and links to it', async () => {
|
|
172
|
+
const userKey = baseUserKey();
|
|
173
|
+
// The PIN hold itself is already terminal by the time 'pin-locked' fires.
|
|
174
|
+
const hold = baseHold({
|
|
175
|
+
_id: 'pin-hold',
|
|
176
|
+
releasePolicy: 'pin',
|
|
177
|
+
status: 'cancelled',
|
|
178
|
+
cancelledBy: 'system',
|
|
179
|
+
cancelReason: 'pin-locked',
|
|
180
|
+
});
|
|
181
|
+
const activeHold = baseHold({
|
|
182
|
+
_id: 'active-hold',
|
|
183
|
+
releasePolicy: 'hold',
|
|
184
|
+
status: 'pending',
|
|
185
|
+
});
|
|
186
|
+
const rotateCancelToken = vi.fn(async () => 'd'.repeat(64));
|
|
187
|
+
const { deps, sent } = makeDeps({ rotateCancelToken });
|
|
188
|
+
|
|
189
|
+
await notifyEscrowHoldEvent({ kind: 'pin-locked', hold, userKey, activeHold }, deps);
|
|
190
|
+
|
|
191
|
+
expect(rotateCancelToken).toHaveBeenCalledExactlyOnceWith(activeHold._id);
|
|
192
|
+
expect(sent[0]!.templateAlias).toBe('escrow-pin-locked');
|
|
193
|
+
expect(sent[0]!.templateModel.cancelUrl).toContain('d'.repeat(64));
|
|
194
|
+
expect(sent[0]!.templateModel.cancelUrl).toContain(activeHold._id);
|
|
195
|
+
expect(sent[0]!.templateModel.releaseAfter).toBe(activeHold.releaseAfter.toISOString());
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it('pin-locked without an active hold sends no cancelUrl/releaseAfter and never rotates the PIN hold', async () => {
|
|
199
|
+
const userKey = baseUserKey();
|
|
200
|
+
const hold = baseHold({
|
|
201
|
+
_id: 'pin-hold',
|
|
202
|
+
releasePolicy: 'pin',
|
|
203
|
+
status: 'cancelled',
|
|
204
|
+
cancelledBy: 'system',
|
|
205
|
+
cancelReason: 'pin-locked',
|
|
206
|
+
});
|
|
207
|
+
const rotateCancelToken = vi.fn(async () => 'e'.repeat(64));
|
|
208
|
+
const { deps, sent } = makeDeps({ rotateCancelToken });
|
|
209
|
+
|
|
210
|
+
await notifyEscrowHoldEvent({ kind: 'pin-locked', hold, userKey }, deps);
|
|
211
|
+
|
|
212
|
+
expect(rotateCancelToken).not.toHaveBeenCalled();
|
|
213
|
+
expect(sent[0]!.templateAlias).toBe('escrow-pin-locked');
|
|
214
|
+
expect(sent[0]!.templateModel).not.toHaveProperty('cancelUrl');
|
|
215
|
+
expect(sent[0]!.templateModel).not.toHaveProperty('releaseAfter');
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it('pin-locked treats a non-pending activeHold the same as no active hold', async () => {
|
|
219
|
+
const userKey = baseUserKey();
|
|
220
|
+
const hold = baseHold({ _id: 'pin-hold', releasePolicy: 'pin', status: 'cancelled' });
|
|
221
|
+
const activeHold = baseHold({ _id: 'stale-hold', status: 'completed' });
|
|
222
|
+
const rotateCancelToken = vi.fn(async () => 'f'.repeat(64));
|
|
223
|
+
const { deps, sent } = makeDeps({ rotateCancelToken });
|
|
224
|
+
|
|
225
|
+
await notifyEscrowHoldEvent({ kind: 'pin-locked', hold, userKey, activeHold }, deps);
|
|
226
|
+
|
|
227
|
+
expect(rotateCancelToken).not.toHaveBeenCalled();
|
|
228
|
+
expect(sent[0]!.templateModel).not.toHaveProperty('cancelUrl');
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it('skips the reminder email entirely when a fresh token cannot be issued', async () => {
|
|
232
|
+
const userKey = baseUserKey();
|
|
233
|
+
const hold = baseHold();
|
|
234
|
+
const { deps, sent } = makeDeps({ rotateCancelToken: async () => null });
|
|
235
|
+
|
|
236
|
+
await notifyEscrowHoldEvent({ kind: 'reminder', hold, userKey }, deps);
|
|
237
|
+
|
|
238
|
+
expect(sent).toHaveLength(0);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it('sends a push notification to the account DID for every kind', async () => {
|
|
242
|
+
const userKey = baseUserKey({ contactMethod: { type: 'phone', value: '+15555550100' } });
|
|
243
|
+
const hold = baseHold();
|
|
244
|
+
const { deps, sent, pushCalls } = makeDeps();
|
|
245
|
+
|
|
246
|
+
await notifyEscrowHoldEvent(
|
|
247
|
+
{ kind: 'started', hold, userKey, cancelToken: 'a'.repeat(64) },
|
|
248
|
+
deps
|
|
249
|
+
);
|
|
250
|
+
|
|
251
|
+
expect(sent).toHaveLength(0);
|
|
252
|
+
expect(pushCalls).toEqual([
|
|
253
|
+
expect.objectContaining({ type: 'APP_NOTIFICATION', to: { did: userKey.primaryDid } }),
|
|
254
|
+
]);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it('keeps the security email pending even when push succeeds and logs only safe metadata', async () => {
|
|
258
|
+
const userKey = baseUserKey();
|
|
259
|
+
const hold = baseHold();
|
|
260
|
+
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
261
|
+
const sentrySpy = vi.spyOn(Sentry, 'captureException').mockImplementation(() => 'event-id');
|
|
262
|
+
const { deps, recorded } = makeDeps({
|
|
263
|
+
send: async () => {
|
|
264
|
+
throw new Error('Postmark rejected owner@example.com');
|
|
265
|
+
},
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
await notifyEscrowHoldEvent(
|
|
269
|
+
{ kind: 'started', hold, userKey, cancelToken: 'a'.repeat(64) },
|
|
270
|
+
deps
|
|
271
|
+
);
|
|
272
|
+
|
|
273
|
+
expect(recorded).toEqual([]);
|
|
274
|
+
expect(errorSpy).toHaveBeenCalled();
|
|
275
|
+
expect(sentrySpy).toHaveBeenCalled();
|
|
276
|
+
|
|
277
|
+
const loggedText = JSON.stringify([...errorSpy.mock.calls, ...sentrySpy.mock.calls]);
|
|
278
|
+
expect(loggedText).not.toContain('owner@example.com');
|
|
279
|
+
expect(loggedText).not.toContain(userKey.primaryDid);
|
|
280
|
+
expect(loggedText).not.toContain('a'.repeat(64));
|
|
281
|
+
|
|
282
|
+
errorSpy.mockRestore();
|
|
283
|
+
sentrySpy.mockRestore();
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it('never throws and records nothing when every channel fails', async () => {
|
|
287
|
+
const userKey = baseUserKey();
|
|
288
|
+
const hold = baseHold();
|
|
289
|
+
vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
290
|
+
vi.spyOn(Sentry, 'captureException').mockImplementation(() => 'event-id');
|
|
291
|
+
const { deps, recorded } = makeDeps({
|
|
292
|
+
send: async () => {
|
|
293
|
+
throw new Error('email down');
|
|
294
|
+
},
|
|
295
|
+
sendPush: async () => {
|
|
296
|
+
throw new Error('push down');
|
|
297
|
+
},
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
await expect(
|
|
301
|
+
notifyEscrowHoldEvent(
|
|
302
|
+
{ kind: 'started', hold, userKey, cancelToken: 'a'.repeat(64) },
|
|
303
|
+
deps
|
|
304
|
+
)
|
|
305
|
+
).resolves.toBe(false);
|
|
306
|
+
expect(recorded).toHaveLength(0);
|
|
307
|
+
|
|
308
|
+
vi.restoreAllMocks();
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
it('contains template token and persistence rejections', async () => {
|
|
312
|
+
for (const overrides of [
|
|
313
|
+
{
|
|
314
|
+
rotateCancelToken: async () => {
|
|
315
|
+
throw new Error('token DB failed');
|
|
316
|
+
},
|
|
317
|
+
},
|
|
318
|
+
{
|
|
319
|
+
recordNotification: async () => {
|
|
320
|
+
throw new Error('record DB failed');
|
|
321
|
+
},
|
|
322
|
+
},
|
|
323
|
+
]) {
|
|
324
|
+
const { deps } = makeDeps(overrides);
|
|
325
|
+
await expect(
|
|
326
|
+
notifyEscrowHoldEvent(
|
|
327
|
+
{ kind: 'reminder', hold: baseHold(), userKey: baseUserKey() },
|
|
328
|
+
deps
|
|
329
|
+
)
|
|
330
|
+
).resolves.toBe(false);
|
|
331
|
+
}
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
it.each(['started', 'reminder'] as const)(
|
|
335
|
+
'retries %s when only one verified inbox receives its email',
|
|
336
|
+
async kind => {
|
|
337
|
+
const { deps, recorded } = makeDeps({
|
|
338
|
+
send: async notification => {
|
|
339
|
+
if (notification.to === 'recovery@example.com')
|
|
340
|
+
throw new Error('delivery failed');
|
|
341
|
+
},
|
|
342
|
+
});
|
|
343
|
+
const userKey = baseUserKey({
|
|
344
|
+
recoveryEmail: 'recovery@example.com',
|
|
345
|
+
recoveryEmailVerifiedAt: new Date(),
|
|
346
|
+
});
|
|
347
|
+
expect(await notifyEscrowHoldEvent({ kind, hold: baseHold(), userKey }, deps)).toBe(
|
|
348
|
+
false
|
|
349
|
+
);
|
|
350
|
+
expect(recorded).toEqual([]);
|
|
351
|
+
}
|
|
352
|
+
);
|
|
353
|
+
|
|
354
|
+
it('bounds hanging delivery and consumes its late rejection', async () => {
|
|
355
|
+
vi.useFakeTimers();
|
|
356
|
+
try {
|
|
357
|
+
let rejectSend!: (error: Error) => void;
|
|
358
|
+
const { deps, recorded } = makeDeps({
|
|
359
|
+
send: () =>
|
|
360
|
+
new Promise((_, reject) => {
|
|
361
|
+
rejectSend = reject;
|
|
362
|
+
}),
|
|
363
|
+
});
|
|
364
|
+
const pending = notifyEscrowHoldEvent(
|
|
365
|
+
{ kind: 'reminder', hold: baseHold(), userKey: baseUserKey() },
|
|
366
|
+
deps
|
|
367
|
+
);
|
|
368
|
+
await vi.advanceTimersByTimeAsync(ESCROW_NOTIFICATION_TIMEOUT_MS);
|
|
369
|
+
expect(await pending).toBe(false);
|
|
370
|
+
rejectSend(new Error('late failure'));
|
|
371
|
+
await vi.runAllTimersAsync();
|
|
372
|
+
expect(recorded).toEqual([]);
|
|
373
|
+
} finally {
|
|
374
|
+
vi.useRealTimers();
|
|
375
|
+
}
|
|
376
|
+
});
|
|
377
|
+
});
|