@learncard/lca-api-service 1.4.0 → 1.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (191) hide show
  1. package/.env.example +73 -0
  2. package/AGENTS.md +106 -10
  3. package/CHANGELOG.md +52 -0
  4. package/bootstrapLambda.test.ts +76 -0
  5. package/bundle.mjs +9 -1
  6. package/config/config.dev.json +23 -0
  7. package/config/config.example.json +22 -0
  8. package/config/config.json +1 -0
  9. package/config/config.production.json +12 -0
  10. package/config/config.scouts.dev.json +11 -0
  11. package/config/config.scouts.production.json +11 -0
  12. package/dist/app.d.ts +67 -3
  13. package/dist/cache/getDel.d.ts +2 -0
  14. package/dist/cache/getDel.test.d.ts +1 -0
  15. package/dist/cache/login-tickets.d.ts +12 -0
  16. package/dist/config/applyDockerStageConfig.d.ts +1 -0
  17. package/dist/config/cacheEnvironment.d.ts +16 -0
  18. package/dist/config/environment.d.ts +101 -0
  19. package/dist/config/escrowBundleMode.test.d.ts +1 -0
  20. package/dist/config/escrowDeployment.test.d.ts +1 -0
  21. package/dist/config/oidcEnvironment.d.ts +74 -0
  22. package/dist/config/serverlessFunctionEnv.test.d.ts +1 -0
  23. package/dist/config/stageConfig.d.ts +62 -0
  24. package/dist/config/stageConfig.test.d.ts +1 -0
  25. package/dist/didkit_wasm_bg.wasm +0 -0
  26. package/dist/docker-entry.d.ts +1 -1
  27. package/dist/helpers/auth.helpers.d.ts +1 -1
  28. package/dist/helpers/escrowCancelLink.d.ts +7 -0
  29. package/dist/helpers/escrowCancelLink.test.d.ts +1 -0
  30. package/dist/helpers/escrowCancelToken.d.ts +4 -0
  31. package/dist/helpers/escrowCancelToken.test.d.ts +1 -0
  32. package/dist/helpers/keycloak.helpers.d.ts +23 -0
  33. package/dist/helpers/login-ticket.helpers.d.ts +31 -0
  34. package/dist/helpers/login-ticket.helpers.test.d.ts +1 -0
  35. package/dist/helpers/oidc.helpers.d.ts +47 -0
  36. package/dist/helpers/rate-limit.helpers.d.ts +6 -0
  37. package/dist/helpers/rate-limit.helpers.test.d.ts +1 -0
  38. package/dist/helpers/social-token.helpers.d.ts +19 -0
  39. package/dist/index.d.ts +1 -0
  40. package/dist/index.js +2 -2
  41. package/dist/jobs/escrowBlobRewrap.d.ts +9 -0
  42. package/dist/jobs/escrowHoldReminders.d.ts +12 -0
  43. package/dist/models/AuthSubject.d.ts +20 -0
  44. package/dist/models/EscrowHold.d.ts +79 -1
  45. package/dist/models/Notification.d.ts +5 -0
  46. package/dist/models/UserKey.d.ts +11 -1
  47. package/dist/models/index.d.ts +1 -0
  48. package/dist/oidc.d.ts +5 -0
  49. package/dist/publish-metafile.json +1 -0
  50. package/dist/routes/auth.d.ts +31 -0
  51. package/dist/routes/escrow.d.ts +18 -4
  52. package/dist/routes/keys.d.ts +1 -0
  53. package/dist/routes/notifications.d.ts +5 -0
  54. package/dist/routes/signing-authority.d.ts +14 -0
  55. package/dist/services/escrow-enclave/blobIdentity.d.ts +10 -0
  56. package/dist/services/escrow-enclave/blobIdentity.test.d.ts +1 -0
  57. package/dist/services/escrow-enclave/cancellation.d.ts +3 -0
  58. package/dist/services/escrow-enclave/enclaveTrustAnchors.d.ts +1 -0
  59. package/dist/services/escrow-enclave/index.d.ts +16 -0
  60. package/dist/services/escrow-enclave/notifications.d.ts +19 -4
  61. package/dist/services/escrow-enclave/notifications.test.d.ts +1 -0
  62. package/dist/services/escrow-enclave/production.test.d.ts +1 -0
  63. package/dist/services/escrow-enclave/remoteEnclave.contract.test.d.ts +1 -0
  64. package/dist/services/escrow-enclave/remoteEnclave.d.ts +20 -0
  65. package/dist/services/escrow-enclave/remoteEnclave.test.d.ts +1 -0
  66. package/dist/services/escrow-enclave/remoteEnclave.tls.test.d.ts +1 -0
  67. package/dist/services/escrow-enclave/softwareEnclave.d.ts +12 -2
  68. package/dist/services/escrow-enclave/types.d.ts +52 -8
  69. package/dist/types/notifications.d.ts +5 -0
  70. package/esbuildPlugins.cjs +1 -0
  71. package/lambda.ts +27 -117
  72. package/lambdaApp.ts +146 -0
  73. package/oidcLambda.test.ts +38 -0
  74. package/oidcLambda.ts +18 -0
  75. package/oidcLambdaApp.ts +11 -0
  76. package/package.json +22 -13
  77. package/project.json +8 -1
  78. package/scripts/README.md +88 -0
  79. package/scripts/check-seed-encryption-infrastructure.mjs +18 -2
  80. package/scripts/escrow-blob-mode-report.test.ts +227 -0
  81. package/scripts/escrow-blob-mode-report.ts +465 -0
  82. package/scripts/keycloak-admin.ts +108 -0
  83. package/scripts/keycloak-provider-links.ts +147 -0
  84. package/scripts/print-seed-rollout-flags.ts +21 -0
  85. package/scripts/provision-keycloak-users.ts +419 -0
  86. package/seedMigrationApp.test.ts +145 -0
  87. package/seedMigrationApp.ts +50 -0
  88. package/seedMigrationLambda.test.ts +30 -135
  89. package/seedMigrationLambda.ts +15 -46
  90. package/serverless-local.yml +25 -1
  91. package/serverless.function-env.cjs +58 -0
  92. package/serverless.yml +174 -37
  93. package/src/accesslayer/notifications/index.ts +9 -2
  94. package/src/accesslayer/signing-authority/index.ts +4 -1
  95. package/src/app.ts +2 -0
  96. package/src/cache/getDel.test.ts +34 -0
  97. package/src/cache/getDel.ts +32 -0
  98. package/src/cache/index.ts +1 -1
  99. package/src/cache/login-tickets.ts +26 -0
  100. package/src/config/applyDockerStageConfig.ts +6 -0
  101. package/src/config/cacheEnvironment.ts +34 -0
  102. package/src/config/environment.test.ts +45 -2
  103. package/src/config/environment.ts +90 -23
  104. package/src/config/escrowBundleMode.test.ts +52 -0
  105. package/src/config/escrowDeployment.test.ts +114 -0
  106. package/src/config/oidcEnvironment.ts +59 -0
  107. package/src/config/serverlessFunctionEnv.test.ts +98 -0
  108. package/src/config/stageConfig.test.ts +26 -0
  109. package/src/config/stageConfig.ts +24 -0
  110. package/src/docker-entry.ts +9 -0
  111. package/src/helpers/auth.helpers.ts +68 -30
  112. package/src/helpers/escrowCancelLink.test.ts +60 -0
  113. package/src/helpers/escrowCancelLink.ts +38 -0
  114. package/src/helpers/escrowCancelToken.test.ts +105 -0
  115. package/src/helpers/escrowCancelToken.ts +47 -0
  116. package/src/helpers/keycloak.helpers.ts +120 -0
  117. package/src/helpers/login-ticket.helpers.test.ts +45 -0
  118. package/src/helpers/login-ticket.helpers.ts +91 -0
  119. package/src/helpers/oidc.helpers.ts +354 -0
  120. package/src/helpers/rate-limit.helpers.test.ts +61 -0
  121. package/src/helpers/rate-limit.helpers.ts +73 -0
  122. package/src/helpers/social-token.helpers.ts +144 -0
  123. package/src/index.ts +1 -0
  124. package/src/jobs/escrowBlobRewrap.ts +121 -0
  125. package/src/jobs/escrowHoldReminders.ts +177 -0
  126. package/src/models/AuthSubject.ts +147 -0
  127. package/src/models/EscrowHold.ts +321 -5
  128. package/src/models/UserKey.ts +91 -6
  129. package/src/models/index.ts +1 -0
  130. package/src/oidc.ts +371 -0
  131. package/src/routes/auth.ts +173 -0
  132. package/src/routes/escrow.ts +281 -34
  133. package/src/routes/keys.ts +5 -0
  134. package/src/routes/signing-authority.ts +39 -1
  135. package/src/services/delivery/adapters/postmark.adapter.ts +43 -1
  136. package/src/services/escrow-enclave/README.md +81 -10
  137. package/src/services/escrow-enclave/__fixtures__/tls/pinned-ca.pem +11 -0
  138. package/src/services/escrow-enclave/__fixtures__/tls/pinned-leaf.key +5 -0
  139. package/src/services/escrow-enclave/__fixtures__/tls/pinned-leaf.pem +12 -0
  140. package/src/services/escrow-enclave/__fixtures__/tls/rogue-ca.pem +11 -0
  141. package/src/services/escrow-enclave/__fixtures__/tls/rogue-leaf.key +5 -0
  142. package/src/services/escrow-enclave/__fixtures__/tls/rogue-leaf.pem +12 -0
  143. package/src/services/escrow-enclave/blobIdentity.test.ts +42 -0
  144. package/src/services/escrow-enclave/blobIdentity.ts +55 -0
  145. package/src/services/escrow-enclave/cancellation.ts +59 -0
  146. package/src/services/escrow-enclave/enclaveTrustAnchors.ts +34 -0
  147. package/src/services/escrow-enclave/index.ts +145 -7
  148. package/src/services/escrow-enclave/notifications.test.ts +377 -0
  149. package/src/services/escrow-enclave/notifications.ts +340 -6
  150. package/src/services/escrow-enclave/production.test.ts +34 -0
  151. package/src/services/escrow-enclave/remoteEnclave.contract.test.ts +344 -0
  152. package/src/services/escrow-enclave/remoteEnclave.test.ts +490 -0
  153. package/src/services/escrow-enclave/remoteEnclave.tls.test.ts +62 -0
  154. package/src/services/escrow-enclave/remoteEnclave.ts +263 -0
  155. package/src/services/escrow-enclave/softwareEnclave.test.ts +231 -30
  156. package/src/services/escrow-enclave/softwareEnclave.ts +97 -11
  157. package/src/services/escrow-enclave/types.ts +63 -17
  158. package/test/auth-tickets.spec.ts +480 -0
  159. package/test/helpers/keycloak-broker.ts +181 -0
  160. package/test/helpers/live-mongo.ts +9 -0
  161. package/test/jobs/escrowBlobRewrap.spec.ts +262 -0
  162. package/test/jobs/escrowHoldReminders.spec.ts +319 -0
  163. package/test/keycloak-admin.spec.ts +58 -0
  164. package/test/keycloak-broker-roundtrip.integration.spec.ts +40 -0
  165. package/test/keycloak-migration.integration.spec.ts +266 -0
  166. package/test/keycloak-provider-links.spec.ts +201 -0
  167. package/test/keycloak-social-broker.integration.spec.ts +228 -0
  168. package/test/keycloak-verify.integration.spec.ts +100 -0
  169. package/test/keycloak-verify.spec.ts +307 -0
  170. package/test/keys-escrow.spec.ts +963 -24
  171. package/test/models/authSubjectIndexes.spec.ts +132 -0
  172. package/test/models/escrow.spec.ts +375 -13
  173. package/test/notifications.spec.ts +4 -0
  174. package/test/oidc-e2e/broker.spec.ts +116 -0
  175. package/test/oidc-e2e/compose.yaml +33 -0
  176. package/test/oidc-e2e/fixtures.ts +245 -0
  177. package/test/oidc-e2e/lock.test.mjs +73 -0
  178. package/test/oidc-e2e/playwright.config.ts +21 -0
  179. package/test/oidc-e2e/run.mjs +312 -0
  180. package/test/oidc-e2e/tsconfig.json +10 -0
  181. package/test/oidc-e2e/types.ts +21 -0
  182. package/test/oidc.integration.spec.ts +54 -0
  183. package/test/oidc.spec.ts +697 -0
  184. package/test/oidcLambdaApp.boot.spec.ts +105 -0
  185. package/test/provision-keycloak-users.spec.ts +299 -0
  186. package/test/signing-authority.spec.ts +50 -0
  187. package/tsconfig.build.json +1 -1
  188. package/tsconfig.json +1 -0
  189. package/vitest.config.ts +24 -1
  190. package/vitest.integration.config.ts +20 -3
  191. package/serverless.escrow-env.cjs +0 -20
@@ -1,14 +1,348 @@
1
- import type { EscrowHold } from '../../models/EscrowHold';
2
- import type { MongoUserKeyType } from '../../models/UserKey';
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: 'started' | 'cancelled' | 'completed';
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
- /** Placeholder for notification fan-out. Never log the supplied account or hold. */
12
- export const notifyEscrowHoldEvent = async (event: EscrowHoldEvent): Promise<void> => {
13
- console.info('[Escrow hold notification]', { kind: event.kind, reason: event.reason });
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
+ });