@learncard/lca-api-service 1.4.0 → 1.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +73 -0
- package/AGENTS.md +106 -10
- package/CHANGELOG.md +52 -0
- package/bootstrapLambda.test.ts +76 -0
- package/bundle.mjs +9 -1
- 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/app.d.ts +67 -3
- 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/config/applyDockerStageConfig.d.ts +1 -0
- package/dist/config/cacheEnvironment.d.ts +16 -0
- package/dist/config/environment.d.ts +101 -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/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/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/models/AuthSubject.d.ts +20 -0
- package/dist/models/EscrowHold.d.ts +79 -1
- package/dist/models/Notification.d.ts +5 -0
- package/dist/models/UserKey.d.ts +11 -1
- package/dist/models/index.d.ts +1 -0
- package/dist/oidc.d.ts +5 -0
- package/dist/publish-metafile.json +1 -0
- package/dist/routes/auth.d.ts +31 -0
- package/dist/routes/escrow.d.ts +18 -4
- package/dist/routes/keys.d.ts +1 -0
- package/dist/routes/notifications.d.ts +5 -0
- 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 +16 -0
- package/dist/services/escrow-enclave/notifications.d.ts +19 -4
- 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 +12 -2
- package/dist/services/escrow-enclave/types.d.ts +52 -8
- package/dist/types/notifications.d.ts +5 -0
- package/esbuildPlugins.cjs +1 -0
- package/lambda.ts +27 -117
- package/lambdaApp.ts +146 -0
- package/oidcLambda.test.ts +38 -0
- package/oidcLambda.ts +18 -0
- package/oidcLambdaApp.ts +11 -0
- package/package.json +22 -13
- package/project.json +8 -1
- package/scripts/README.md +88 -0
- package/scripts/check-seed-encryption-infrastructure.mjs +18 -2
- 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/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 +30 -135
- package/seedMigrationLambda.ts +15 -46
- package/serverless-local.yml +25 -1
- package/serverless.function-env.cjs +58 -0
- package/serverless.yml +174 -37
- package/src/accesslayer/notifications/index.ts +9 -2
- package/src/accesslayer/signing-authority/index.ts +4 -1
- package/src/app.ts +2 -0
- package/src/cache/getDel.test.ts +34 -0
- package/src/cache/getDel.ts +32 -0
- package/src/cache/index.ts +1 -1
- package/src/cache/login-tickets.ts +26 -0
- package/src/config/applyDockerStageConfig.ts +6 -0
- package/src/config/cacheEnvironment.ts +34 -0
- package/src/config/environment.test.ts +45 -2
- package/src/config/environment.ts +90 -23
- 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 +9 -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/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/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/models/AuthSubject.ts +147 -0
- package/src/models/EscrowHold.ts +321 -5
- package/src/models/UserKey.ts +91 -6
- package/src/models/index.ts +1 -0
- package/src/oidc.ts +371 -0
- package/src/routes/auth.ts +173 -0
- package/src/routes/escrow.ts +281 -34
- package/src/routes/keys.ts +5 -0
- package/src/routes/signing-authority.ts +39 -1
- package/src/services/delivery/adapters/postmark.adapter.ts +43 -1
- package/src/services/escrow-enclave/README.md +81 -10
- 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 +145 -7
- package/src/services/escrow-enclave/notifications.test.ts +377 -0
- package/src/services/escrow-enclave/notifications.ts +340 -6
- 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 +231 -30
- package/src/services/escrow-enclave/softwareEnclave.ts +97 -11
- package/src/services/escrow-enclave/types.ts +63 -17
- package/test/auth-tickets.spec.ts +480 -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 +963 -24
- package/test/models/authSubjectIndexes.spec.ts +132 -0
- package/test/models/escrow.spec.ts +375 -13
- 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.spec.ts +50 -0
- package/tsconfig.build.json +1 -1
- package/tsconfig.json +1 -0
- package/vitest.config.ts +24 -1
- package/vitest.integration.config.ts +20 -3
- package/serverless.escrow-env.cjs +0 -20
|
@@ -1,14 +1,348 @@
|
|
|
1
|
-
import
|
|
2
|
-
|
|
1
|
+
import * as Sentry from '@sentry/serverless';
|
|
2
|
+
|
|
3
|
+
import type {
|
|
4
|
+
ResolvedTenant,
|
|
5
|
+
TemplateId,
|
|
6
|
+
EscrowHoldCancelledReason,
|
|
7
|
+
} from '@learncard/email-templates';
|
|
8
|
+
import { resolveBranding } from '@learncard/email-templates';
|
|
9
|
+
|
|
10
|
+
import type { EscrowHold, MongoUserKeyType } from '@models';
|
|
11
|
+
import { getDeliveryService, getFrom, type DeliveryService } from '../delivery';
|
|
12
|
+
import { buildEscrowCancelUrl } from '@helpers/escrowCancelLink';
|
|
13
|
+
|
|
14
|
+
export type EscrowHoldEventKind = 'started' | 'reminder' | 'completed' | 'cancelled' | 'pin-locked';
|
|
3
15
|
|
|
4
16
|
export interface EscrowHoldEvent {
|
|
5
|
-
kind:
|
|
17
|
+
kind: EscrowHoldEventKind;
|
|
6
18
|
hold: EscrowHold;
|
|
7
19
|
userKey: MongoUserKeyType;
|
|
8
20
|
reason?: 'superseded';
|
|
21
|
+
/** Plaintext single-use cancel-link token for 'started' events. Never log this. */
|
|
22
|
+
cancelToken?: string;
|
|
23
|
+
/** Tenant for branding/base-URL resolution. Route handlers pass `ctx.tenant`. */
|
|
24
|
+
tenant?: ResolvedTenant;
|
|
25
|
+
/**
|
|
26
|
+
* The account's still-pending waiting-period (`releasePolicy:'hold'`)
|
|
27
|
+
* hold, if one exists. Only read for 'pin-locked': that event's own
|
|
28
|
+
* `hold` (the PIN hold that just got locked) is already terminal by the
|
|
29
|
+
* time it fires, so its cancel link could never work — `activeHold` is
|
|
30
|
+
* the attacker's remaining path, and the one the email copy refers to as
|
|
31
|
+
* "still available".
|
|
32
|
+
*/
|
|
33
|
+
activeHold?: EscrowHold;
|
|
9
34
|
}
|
|
10
35
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
36
|
+
// Referenced only as a type (`typeof import(...)`) so this module never
|
|
37
|
+
// eagerly value-imports `pushNotifications.helpers` — that helper's chain
|
|
38
|
+
// reaches `@accesslayer/pushtokens`, which touches Mongo at module load and
|
|
39
|
+
// would force every unit test in this file onto a live/mocked database.
|
|
40
|
+
type SendPushNotification =
|
|
41
|
+
typeof import('@helpers/pushNotifications.helpers').sendPushNotification;
|
|
42
|
+
|
|
43
|
+
/** Injectable seams for tests. Defaults are the real delivery/push/DB/clock. */
|
|
44
|
+
export interface EscrowHoldNotifierDeps {
|
|
45
|
+
send: DeliveryService['send'];
|
|
46
|
+
sendPush: SendPushNotification;
|
|
47
|
+
now: () => Date;
|
|
48
|
+
rotateCancelToken: (holdId: string) => Promise<string | null>;
|
|
49
|
+
recordNotification: (holdId: string, kind: EscrowHoldEventKind) => Promise<void>;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// The Mongo-backed defaults are imported lazily, inside each closure, for the
|
|
53
|
+
// same reason as the `SendPushNotification` type above: `@models` (like
|
|
54
|
+
// `@helpers/pushNotifications.helpers`) value-imports `@mongo`, which throws
|
|
55
|
+
// at *module load* unless Mongo test globals are initialized — which unit
|
|
56
|
+
// tests here never do. Every test supplies its own `overrides`, so these
|
|
57
|
+
// closures (and their dynamic imports) are never actually invoked; only real
|
|
58
|
+
// callers (route handlers, which pass no overrides) exercise them.
|
|
59
|
+
const defaultDeps: EscrowHoldNotifierDeps = {
|
|
60
|
+
send: notification => getDeliveryService().send(notification),
|
|
61
|
+
sendPush: async notification => {
|
|
62
|
+
const { sendPushNotification } = await import('@helpers/pushNotifications.helpers');
|
|
63
|
+
return sendPushNotification(notification);
|
|
64
|
+
},
|
|
65
|
+
now: () => new Date(),
|
|
66
|
+
rotateCancelToken: async holdId => {
|
|
67
|
+
const { rotateEscrowCancelToken } = await import('@models');
|
|
68
|
+
return rotateEscrowCancelToken(holdId);
|
|
69
|
+
},
|
|
70
|
+
recordNotification: async (holdId, kind) => {
|
|
71
|
+
const { recordEscrowHoldNotification } = await import('@models');
|
|
72
|
+
return recordEscrowHoldNotification(holdId, kind);
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const EMAIL_TEMPLATE_ALIAS: Record<EscrowHoldEventKind, TemplateId> = {
|
|
77
|
+
started: 'escrow-hold-started',
|
|
78
|
+
reminder: 'escrow-hold-reminder',
|
|
79
|
+
completed: 'escrow-hold-released',
|
|
80
|
+
cancelled: 'escrow-hold-cancelled',
|
|
81
|
+
'pin-locked': 'escrow-pin-locked',
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const PUSH_MESSAGES: Record<EscrowHoldEventKind, { title: string; body: string }> = {
|
|
85
|
+
started: {
|
|
86
|
+
title: 'Account recovery started',
|
|
87
|
+
body: "If this wasn't you, open the app to cancel.",
|
|
88
|
+
},
|
|
89
|
+
reminder: {
|
|
90
|
+
title: 'Recovery completes tomorrow',
|
|
91
|
+
body: "If this wasn't you, open the app to cancel.",
|
|
92
|
+
},
|
|
93
|
+
completed: {
|
|
94
|
+
title: 'Your account was recovered',
|
|
95
|
+
body: 'Account recovery is complete.',
|
|
96
|
+
},
|
|
97
|
+
cancelled: {
|
|
98
|
+
title: 'Recovery request cancelled',
|
|
99
|
+
body: 'Your account recovery request was cancelled.',
|
|
100
|
+
},
|
|
101
|
+
'pin-locked': {
|
|
102
|
+
title: 'Recovery PIN locked',
|
|
103
|
+
body: 'Your recovery PIN was locked after too many attempts.',
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Mirrors `isRecoveryMethodConfirmed` from `models/UserKey.ts` exactly
|
|
109
|
+
* (grandfathers pre-protocol methods on already-active records; otherwise
|
|
110
|
+
* requires `confirmedAt`). Duplicated rather than imported because that
|
|
111
|
+
* module value-imports `@mongo`, which — like the push helper above — would
|
|
112
|
+
* force every unit test in this file onto a live/mocked database.
|
|
113
|
+
*/
|
|
114
|
+
const isMethodConfirmed = (
|
|
115
|
+
userKey: MongoUserKeyType,
|
|
116
|
+
method: MongoUserKeyType['recoveryMethods'][number]
|
|
117
|
+
): boolean => {
|
|
118
|
+
if (method.confirmedAt) return true;
|
|
119
|
+
if (method.confirmationStatus) return false;
|
|
120
|
+
return (userKey.sssActivationState ?? 'active') === 'active';
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Every verified email address on the account, deduped case-insensitively.
|
|
125
|
+
* `contactMethod` has no separate "verified" flag: it is the login identity
|
|
126
|
+
* itself (proved via Firebase/OTP at signup, e.g. the `login-verification-code`
|
|
127
|
+
* flow), so possession of it IS the verification. `recoveryEmail` is checked
|
|
128
|
+
* against its own `recoveryEmailVerifiedAt` companion field defensively, even
|
|
129
|
+
* though every writer sets both together. `recoveryMethods[]` entries only
|
|
130
|
+
* count once confirmed, so an attacker-added, unconfirmed method can never
|
|
131
|
+
* receive a cancel link for a recovery it started.
|
|
132
|
+
*/
|
|
133
|
+
const getRecipientEmails = (userKey: MongoUserKeyType): string[] => {
|
|
134
|
+
const candidates: (string | undefined)[] = [
|
|
135
|
+
userKey.contactMethod.type === 'email' ? userKey.contactMethod.value : undefined,
|
|
136
|
+
userKey.recoveryEmail && userKey.recoveryEmailVerifiedAt
|
|
137
|
+
? userKey.recoveryEmail
|
|
138
|
+
: undefined,
|
|
139
|
+
...userKey.recoveryMethods
|
|
140
|
+
.filter(method => method.confirmationEmail && isMethodConfirmed(userKey, method))
|
|
141
|
+
.map(method => method.confirmationEmail),
|
|
142
|
+
];
|
|
143
|
+
|
|
144
|
+
const seen = new Set<string>();
|
|
145
|
+
const recipients: string[] = [];
|
|
146
|
+
for (const candidate of candidates) {
|
|
147
|
+
const normalized = candidate?.trim().toLowerCase();
|
|
148
|
+
if (!normalized || seen.has(normalized)) continue;
|
|
149
|
+
seen.add(normalized);
|
|
150
|
+
recipients.push(normalized);
|
|
151
|
+
}
|
|
152
|
+
return recipients;
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
const mapCancelReason = (hold: EscrowHold): EscrowHoldCancelledReason | undefined => {
|
|
156
|
+
if (hold.cancelReason === 'superseded') return 'superseded';
|
|
157
|
+
if (hold.cancelReason === 'pin-locked') return 'pin-locked';
|
|
158
|
+
if (hold.cancelReason === 'release-failed') return 'release-failed';
|
|
159
|
+
if (hold.cancelledBy === 'did' || hold.cancelledBy === 'link') return 'user';
|
|
160
|
+
return undefined;
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Builds the per-kind email template model, or null when there is nothing
|
|
165
|
+
* sendable (e.g. a reminder whose hold was claimed/cancelled before the
|
|
166
|
+
* scheduler ran, so a fresh cancel token can no longer be issued).
|
|
167
|
+
*
|
|
168
|
+
* 'started' reuses the creation token when available; scheduled retries and
|
|
169
|
+
* 'reminder' add a retry-stable token without invalidating earlier links.
|
|
170
|
+
* 'pin-locked' instead adds a token on `activeHold`
|
|
171
|
+
* (the account's still-open waiting-period hold, if any) — its own `hold` is
|
|
172
|
+
* already terminal by the time it fires, so a token rotated there could
|
|
173
|
+
* never authorize a cancel; `cancelUrl`/`releaseAfter` are omitted together
|
|
174
|
+
* when there is no still-pending `activeHold`.
|
|
175
|
+
*/
|
|
176
|
+
const buildEmailTemplateModel = async (
|
|
177
|
+
event: EscrowHoldEvent,
|
|
178
|
+
deps: EscrowHoldNotifierDeps
|
|
179
|
+
): Promise<Record<string, unknown> | null> => {
|
|
180
|
+
const { hold } = event;
|
|
181
|
+
const baseUrl = resolveBranding(event.tenant?.emailBranding).appUrl;
|
|
182
|
+
|
|
183
|
+
switch (event.kind) {
|
|
184
|
+
case 'started': {
|
|
185
|
+
const token = event.cancelToken ?? (await deps.rotateCancelToken(hold._id));
|
|
186
|
+
if (!token) return null;
|
|
187
|
+
return {
|
|
188
|
+
requestedAt: hold.requestedAt.toISOString(),
|
|
189
|
+
releaseAfter: hold.releaseAfter.toISOString(),
|
|
190
|
+
cancelUrl: buildEscrowCancelUrl({
|
|
191
|
+
baseUrl,
|
|
192
|
+
holdId: hold._id,
|
|
193
|
+
token,
|
|
194
|
+
}),
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
case 'reminder': {
|
|
199
|
+
const token = await deps.rotateCancelToken(hold._id);
|
|
200
|
+
if (!token) return null;
|
|
201
|
+
return {
|
|
202
|
+
releaseAfter: hold.releaseAfter.toISOString(),
|
|
203
|
+
cancelUrl: buildEscrowCancelUrl({ baseUrl, holdId: hold._id, token }),
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
case 'completed':
|
|
208
|
+
return { completedAt: (hold.completedAt ?? deps.now()).toISOString() };
|
|
209
|
+
|
|
210
|
+
case 'cancelled':
|
|
211
|
+
return {
|
|
212
|
+
cancelledAt: (hold.cancelledAt ?? deps.now()).toISOString(),
|
|
213
|
+
reason: mapCancelReason(hold),
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
case 'pin-locked': {
|
|
217
|
+
const { activeHold } = event;
|
|
218
|
+
const token =
|
|
219
|
+
activeHold?.status === 'pending'
|
|
220
|
+
? await deps.rotateCancelToken(activeHold._id)
|
|
221
|
+
: null;
|
|
222
|
+
return {
|
|
223
|
+
lockedAt: deps.now().toISOString(),
|
|
224
|
+
...(token && activeHold
|
|
225
|
+
? {
|
|
226
|
+
releaseAfter: activeHold.releaseAfter.toISOString(),
|
|
227
|
+
cancelUrl: buildEscrowCancelUrl({
|
|
228
|
+
baseUrl,
|
|
229
|
+
holdId: activeHold._id,
|
|
230
|
+
token,
|
|
231
|
+
}),
|
|
232
|
+
}
|
|
233
|
+
: {}),
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Never log the recipient address, DID, cancel token, or cancel URL. The
|
|
241
|
+
* underlying error (e.g. a rejected Postmark/FCM call) can itself embed the
|
|
242
|
+
* recipient in its message, so only hold id + kind + channel are recorded —
|
|
243
|
+
* the original error value/message is deliberately dropped, not forwarded.
|
|
244
|
+
*/
|
|
245
|
+
const logDeliveryFailure = (
|
|
246
|
+
hold: EscrowHold,
|
|
247
|
+
kind: EscrowHoldEventKind,
|
|
248
|
+
channel: 'email' | 'push'
|
|
249
|
+
): void => {
|
|
250
|
+
const meta = { holdId: hold._id, kind, channel };
|
|
251
|
+
try {
|
|
252
|
+
console.error('[escrow-notifications] delivery failed', meta);
|
|
253
|
+
Sentry.captureException(new Error('Escrow hold notification delivery failed'), {
|
|
254
|
+
extra: meta,
|
|
255
|
+
});
|
|
256
|
+
} catch {
|
|
257
|
+
// Observability must not turn best-effort delivery into a request failure.
|
|
258
|
+
}
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
export const ESCROW_NOTIFICATION_TIMEOUT_MS = 3_000;
|
|
262
|
+
|
|
263
|
+
/** Await delivery within a request budget, including template/token preparation
|
|
264
|
+
* and persistence. Rejections (including late ones after timeout) are consumed.
|
|
265
|
+
* Pending start markers and reminder leases make interrupted deliveries retryable. */
|
|
266
|
+
export const notifyEscrowHoldEvent = async (
|
|
267
|
+
event: EscrowHoldEvent,
|
|
268
|
+
overrides: Partial<EscrowHoldNotifierDeps> = {}
|
|
269
|
+
): Promise<boolean> => {
|
|
270
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
271
|
+
try {
|
|
272
|
+
return await Promise.race([
|
|
273
|
+
deliverEscrowHoldEvent(event, overrides),
|
|
274
|
+
new Promise<never>((_, reject) => {
|
|
275
|
+
timer = setTimeout(
|
|
276
|
+
() => reject(new Error('Notification timed out')),
|
|
277
|
+
ESCROW_NOTIFICATION_TIMEOUT_MS
|
|
278
|
+
);
|
|
279
|
+
}),
|
|
280
|
+
]);
|
|
281
|
+
} catch {
|
|
282
|
+
logDeliveryFailure(event.hold, event.kind, 'email');
|
|
283
|
+
return false;
|
|
284
|
+
} finally {
|
|
285
|
+
clearTimeout(timer);
|
|
286
|
+
}
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
const deliverEscrowHoldEvent = async (
|
|
290
|
+
event: EscrowHoldEvent,
|
|
291
|
+
overrides: Partial<EscrowHoldNotifierDeps>
|
|
292
|
+
): Promise<boolean> => {
|
|
293
|
+
const deps: EscrowHoldNotifierDeps = { ...defaultDeps, ...overrides };
|
|
294
|
+
const { hold, userKey, kind } = event;
|
|
295
|
+
let delivered = false;
|
|
296
|
+
let emailDelivered = false;
|
|
297
|
+
|
|
298
|
+
const recipients = getRecipientEmails(userKey);
|
|
299
|
+
if (recipients.length > 0) {
|
|
300
|
+
const templateModel = await buildEmailTemplateModel(event, deps);
|
|
301
|
+
if (templateModel) {
|
|
302
|
+
const branding = event.tenant?.emailBranding;
|
|
303
|
+
const from = getFrom({ mailbox: 'recovery', branding });
|
|
304
|
+
const results = await Promise.allSettled(
|
|
305
|
+
recipients.map(to =>
|
|
306
|
+
deps.send({
|
|
307
|
+
to,
|
|
308
|
+
templateAlias: EMAIL_TEMPLATE_ALIAS[kind],
|
|
309
|
+
templateModel,
|
|
310
|
+
branding,
|
|
311
|
+
from,
|
|
312
|
+
})
|
|
313
|
+
)
|
|
314
|
+
);
|
|
315
|
+
// Retry the full set on partial failure: a successful login-inbox
|
|
316
|
+
// delivery must not suppress the independent recovery-email alert.
|
|
317
|
+
emailDelivered = results.every(result => result.status === 'fulfilled');
|
|
318
|
+
for (const result of results) {
|
|
319
|
+
if (result.status === 'rejected') logDeliveryFailure(hold, kind, 'email');
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const did = userKey.primaryDid || hold.primaryDid;
|
|
325
|
+
if (did) {
|
|
326
|
+
try {
|
|
327
|
+
const result = await deps.sendPush({
|
|
328
|
+
type: 'APP_NOTIFICATION',
|
|
329
|
+
to: { did },
|
|
330
|
+
from: { did },
|
|
331
|
+
message: PUSH_MESSAGES[kind],
|
|
332
|
+
});
|
|
333
|
+
if (result.successCount > 0) delivered = true;
|
|
334
|
+
if (result.failureCount > 0) logDeliveryFailure(hold, kind, 'push');
|
|
335
|
+
} catch {
|
|
336
|
+
logDeliveryFailure(hold, kind, 'push');
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// A push cannot substitute for the security email's cancel link. Keep
|
|
341
|
+
// start/reminder retries pending until email succeeds when addresses exist.
|
|
342
|
+
delivered =
|
|
343
|
+
(kind === 'started' || kind === 'reminder') && recipients.length > 0
|
|
344
|
+
? emailDelivered
|
|
345
|
+
: delivered || emailDelivered;
|
|
346
|
+
if (delivered) await deps.recordNotification(hold._id, kind);
|
|
347
|
+
return delivered;
|
|
14
348
|
};
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
__setEscrowEnclaveForTests,
|
|
4
|
+
getEscrowEnclave,
|
|
5
|
+
isEscrowEnabled,
|
|
6
|
+
EscrowUnavailableError,
|
|
7
|
+
} from './index';
|
|
8
|
+
|
|
9
|
+
afterEach(() => {
|
|
10
|
+
vi.unstubAllEnvs();
|
|
11
|
+
vi.restoreAllMocks();
|
|
12
|
+
__setEscrowEnclaveForTests(undefined);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
describe('software enclave deploy-stage guard', () => {
|
|
16
|
+
it.each(['production', 'prod', 'unknown'])(
|
|
17
|
+
'fails closed for deploy stage %s despite test/offline flags',
|
|
18
|
+
stage => {
|
|
19
|
+
vi.stubEnv('ESCROW_ENCLAVE_MODE', 'software');
|
|
20
|
+
vi.stubEnv(
|
|
21
|
+
'ESCROW_ENCLAVE_SOFTWARE_PRIVATE_KEYS_JSON',
|
|
22
|
+
JSON.stringify({ test: 'private-key' })
|
|
23
|
+
);
|
|
24
|
+
vi.stubEnv('ESCROW_ENCLAVE_ACTIVE_KEY_ID', 'test');
|
|
25
|
+
vi.stubEnv('LAMBDA_STAGE', stage);
|
|
26
|
+
vi.stubEnv('IS_OFFLINE', 'true');
|
|
27
|
+
const log = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
28
|
+
__setEscrowEnclaveForTests(undefined);
|
|
29
|
+
expect(isEscrowEnabled()).toBe(false);
|
|
30
|
+
expect(getEscrowEnclave).toThrow(EscrowUnavailableError);
|
|
31
|
+
expect(log).toHaveBeenCalled();
|
|
32
|
+
}
|
|
33
|
+
);
|
|
34
|
+
});
|