@learncard/lca-api-service 1.6.2 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/.env.example +2 -0
  2. package/AGENTS.md +1 -1
  3. package/CHANGELOG.md +132 -0
  4. package/config/config.dev.json +1 -0
  5. package/config/config.example.json +1 -0
  6. package/config/config.production.json +1 -0
  7. package/config/config.scouts.dev.json +1 -0
  8. package/config/config.scouts.production.json +1 -0
  9. package/dist/accesslayer/notifications/create.d.ts +2 -1
  10. package/dist/app.d.ts +70 -8
  11. package/dist/config/environment.d.ts +6 -0
  12. package/dist/config/stageConfig.d.ts +4 -0
  13. package/dist/helpers/analyticsEligibility.d.ts +6 -0
  14. package/dist/helpers/brainUrl.d.ts +1 -0
  15. package/dist/helpers/brainUrl.test.d.ts +1 -0
  16. package/dist/helpers/currentProfile.d.ts +6 -0
  17. package/dist/helpers/currentProfile.test.d.ts +1 -0
  18. package/dist/helpers/retryingOnce.d.ts +1 -0
  19. package/dist/helpers/retryingOnce.test.d.ts +1 -0
  20. package/dist/index.js +2 -2
  21. package/dist/models/EscrowHold.d.ts +1 -0
  22. package/dist/models/EscrowHold.indexes.test.d.ts +1 -0
  23. package/dist/models/Notification.d.ts +114 -2
  24. package/dist/models/SigningAuthority.d.ts +1 -1
  25. package/dist/models/UserKey.d.ts +23 -0
  26. package/dist/publish-metafile.json +1 -1
  27. package/dist/routes/credentials.d.ts +1 -1
  28. package/dist/routes/index.d.ts +5 -0
  29. package/dist/routes/keys.d.ts +1 -0
  30. package/dist/routes/notifications.d.ts +65 -4
  31. package/dist/routes/preferences.test.d.ts +1 -0
  32. package/dist/routes/signing-authority.d.ts +3 -3
  33. package/dist/services/escrow-enclave/index.d.ts +2 -0
  34. package/dist/services/escrow-enclave/logFailure.d.ts +1 -0
  35. package/dist/types/credentials.d.ts +1 -1
  36. package/dist/types/notifications.d.ts +118 -2
  37. package/docs/bedrock-generation.md +7 -6
  38. package/lambdaApp.test.ts +114 -0
  39. package/lambdaApp.ts +23 -11
  40. package/package.json +11 -11
  41. package/scripts/escrow-lambda-http.ts +74 -0
  42. package/scripts/verify-escrow-startup.mjs +463 -0
  43. package/serverless-local.yml +1 -1
  44. package/serverless.yml +1 -1
  45. package/src/accesslayer/notifications/create.test.ts +80 -0
  46. package/src/accesslayer/notifications/create.ts +97 -2
  47. package/src/config/environment.test.ts +27 -1
  48. package/src/config/environment.ts +16 -0
  49. package/src/config/escrowDeployment.test.ts +1 -0
  50. package/src/docker-entry.ts +1 -2
  51. package/src/helpers/analyticsEligibility.ts +15 -0
  52. package/src/helpers/brainUrl.test.ts +13 -0
  53. package/src/helpers/brainUrl.ts +3 -0
  54. package/src/helpers/currentProfile.test.ts +55 -0
  55. package/src/helpers/currentProfile.ts +58 -0
  56. package/src/helpers/locale.helpers.test.ts +4 -1
  57. package/src/helpers/locale.helpers.ts +4 -2
  58. package/src/helpers/retryingOnce.test.ts +33 -0
  59. package/src/helpers/retryingOnce.ts +18 -0
  60. package/src/models/EscrowHold.indexes.test.ts +208 -0
  61. package/src/models/EscrowHold.ts +106 -51
  62. package/src/models/UserKey.ts +69 -1
  63. package/src/routes/escrow.ts +30 -12
  64. package/src/routes/index.ts +5 -8
  65. package/src/routes/keys.ts +8 -0
  66. package/src/routes/notifications.ts +34 -1
  67. package/src/routes/preferences.test.ts +216 -0
  68. package/src/routes/preferences.ts +54 -4
  69. package/src/services/escrow-enclave/index.ts +38 -1
  70. package/src/services/escrow-enclave/logFailure.ts +5 -1
  71. package/src/services/escrow-enclave/production.test.ts +64 -0
  72. package/src/services/escrow-enclave/softwareEnclave.test.ts +14 -2
  73. package/test/jobs/escrowHoldReminders.spec.ts +21 -4
  74. package/test/keys-escrow.spec.ts +149 -3
  75. package/test/keys-lifecycle-invariants.spec.ts +235 -0
  76. package/test/keys-pin-sign-in.spec.ts +34 -2
  77. package/test/notifications.spec.ts +102 -0
  78. package/tsconfig.entrypoints.json +1 -1
  79. package/vitest.config.ts +1 -0
  80. package/scripts/node24-runtime.cjs +0 -9
  81. package/scripts/verify-node24-layer.cjs +0 -49
@@ -42,6 +42,7 @@ import {
42
42
  setRecoveryEmailByAuthProvider,
43
43
  markUserKeyMigrationProvisionalByAuthProvider,
44
44
  activateUserKeyByAuthProvider,
45
+ isWeb3AuthLogin,
45
46
  purgeExpiredProvisionalMigrationByAuthProvider,
46
47
  getSssActivationState,
47
48
  hasActivatableRecoveryMethod,
@@ -1058,6 +1059,12 @@ export const keysRouter = t.router({
1058
1059
  */
1059
1060
  priorEscrowEnrollment: z.boolean(),
1060
1061
  sssActivationState: z.enum(['provisional', 'active']),
1062
+ /**
1063
+ * Web3Auth, signing in through this login, derives this account's
1064
+ * key (see `isWeb3AuthLogin`). Web3Auth-mode clients stop any
1065
+ * existing account where this is false.
1066
+ */
1067
+ isWeb3AuthLogin: z.boolean(),
1061
1068
  })
1062
1069
  .nullable()
1063
1070
  )
@@ -1133,6 +1140,7 @@ export const keysRouter = t.router({
1133
1140
  ? maskEmail(userKey.recoveryEmail)
1134
1141
  : null,
1135
1142
  sssActivationState: getSssActivationState(userKey),
1143
+ isWeb3AuthLogin: isWeb3AuthLogin(userKey, authProvider),
1136
1144
  escrowOptedOut: Boolean(userKey.escrowOptedOutAt),
1137
1145
  escrowPin: getEscrowPinStatus(userKey),
1138
1146
  escrowStale: userKey.escrowBlob
@@ -8,12 +8,17 @@ import { t, didAndChallengeRoute, authorizedDidRoute } from '@routes';
8
8
  import cache from '@cache';
9
9
  import { createPushNotificationRegistration } from '@accesslayer/pushtokens/create';
10
10
  import { deletePushNotificationRegistration } from '@accesslayer/pushtokens/delete';
11
- import { LCNNotification, LCNNotificationValidator } from '@learncard/types';
11
+ import {
12
+ ConsentFlowContractNotificationDataValidator,
13
+ LCNNotification,
14
+ LCNNotificationValidator,
15
+ } from '@learncard/types';
12
16
  import { sendPushNotification } from '@helpers/pushNotifications.helpers';
13
17
  import { isDidOwnerOfNotification } from '@helpers/notifications.helpers';
14
18
  import {
15
19
  createNotification,
16
20
  isManagedCredentialRefreshNotification,
21
+ upsertConsentFlowContractUpdateNotification,
17
22
  upsertCredentialRefreshNotification,
18
23
  } from '@accesslayer/notifications/create';
19
24
  import {
@@ -316,6 +321,34 @@ export const notificationsRouter = t.router({
316
321
 
317
322
  let sendNotificationResponse: unknown;
318
323
 
324
+ // Quiet revisions belong in the feed as already-read records; access
325
+ // increases are unread and pushed once per recipient and revision.
326
+ if (input.type === 'CONSENT_FLOW_CONTRACT_UPDATED') {
327
+ const updateData = ConsentFlowContractNotificationDataValidator.safeParse(
328
+ input.data
329
+ );
330
+ if (!updateData.success) return false;
331
+
332
+ const updateResult = await upsertConsentFlowContractUpdateNotification(input);
333
+
334
+ if (!updateResult) return false;
335
+
336
+ if (updateResult.created && updateData.data.kind === 'requires_consent') {
337
+ await recordE2ePushAttempt(input);
338
+
339
+ try {
340
+ sendNotificationResponse = await sendPushNotification(input);
341
+ } catch (error) {
342
+ console.error(
343
+ 'Failed to send push notification after durable storage',
344
+ error
345
+ );
346
+ }
347
+ }
348
+
349
+ return true;
350
+ }
351
+
319
352
  // Managed credential refresh deliveries (LC-2117/LC-2136) collapse
320
353
  // atomically per delivery window: persist first, push only when the
321
354
  // upsert inserted a new window record. Other notification types keep
@@ -0,0 +1,216 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import { initTRPC } from '@trpc/server';
3
+
4
+ const state = vi.hoisted(() => ({
5
+ saved: {} as Record<string, unknown>,
6
+ profile: { dob: '1990-01-01', country: 'US', type: 'personal' } as {
7
+ dob?: string;
8
+ country?: string;
9
+ type?: string;
10
+ },
11
+ profileUnavailable: false,
12
+ repairFailure: null as 'throw' | 'return-false' | null,
13
+ }));
14
+ vi.mock('@routes', () => {
15
+ const t = initTRPC.create();
16
+ return { t, didAndChallengeRoute: t.procedure };
17
+ });
18
+ vi.mock('@accesslayer/preferences/create', () => ({
19
+ createPreferences: async (_did: string, theme: string) => {
20
+ state.saved = { theme };
21
+ return true;
22
+ },
23
+ }));
24
+ vi.mock('@accesslayer/preferences/read', () => ({
25
+ getPreferencesForDid: async () => ({ ...state.saved }),
26
+ }));
27
+ vi.mock('@accesslayer/preferences/update', () => ({
28
+ updatePreferences: async (_did: string, input: Record<string, unknown>) => {
29
+ if (state.repairFailure === 'throw') throw new Error('write failed');
30
+ if (state.repairFailure === 'return-false') return false;
31
+ Object.assign(state.saved, input);
32
+ return true;
33
+ },
34
+ }));
35
+ vi.mock('@helpers/currentProfile', () => ({
36
+ getCurrentProfile: async () => {
37
+ if (state.profileUnavailable) return null;
38
+ return { ...state.profile };
39
+ },
40
+ }));
41
+ import { preferencesRouter } from './preferences';
42
+ import { ThemeEnum } from '../types/preferences';
43
+ const caller = preferencesRouter.createCaller({
44
+ user: { did: 'did:example:person', isChallengeValid: true },
45
+ tenant: { id: 'test-tenant' },
46
+ authorizationHeader: 'Bearer signed-vp',
47
+ } as never);
48
+
49
+ beforeEach(() => {
50
+ vi.useFakeTimers({ toFake: ['Date'] });
51
+ vi.setSystemTime(new Date('2026-10-29T12:00:00Z'));
52
+ state.saved = { theme: ThemeEnum.Colorful };
53
+ state.profile = { dob: '1990-01-01', country: 'US', type: 'personal' };
54
+ state.profileUnavailable = false;
55
+ state.repairFailure = null;
56
+ });
57
+ afterEach(() => vi.useRealTimers());
58
+
59
+ describe('backend analytics age authorization', () => {
60
+ it.each(['guardian', 'school', 'personal'])(
61
+ 'rejects forged age flags for an underage %s account',
62
+ async type => {
63
+ state.profile = { dob: '2012-01-01', country: 'US', type };
64
+ await caller.updatePreferences({
65
+ analyticsEnabled: true,
66
+ isMinor: false,
67
+ analyticsAutoDisabled: false,
68
+ });
69
+ expect(state.saved).toMatchObject({
70
+ analyticsEnabled: false,
71
+ isMinor: true,
72
+ analyticsAutoDisabled: true,
73
+ });
74
+ expect(await caller.getPreferencesForDid()).toMatchObject({
75
+ analyticsEnabled: false,
76
+ isMinor: true,
77
+ });
78
+ }
79
+ );
80
+
81
+ it('permits adult opt-in but derives metadata instead of trusting caller flags', async () => {
82
+ await caller.updatePreferences({
83
+ analyticsEnabled: true,
84
+ isMinor: true,
85
+ analyticsAutoDisabled: true,
86
+ });
87
+ expect(state.saved).toMatchObject({
88
+ analyticsEnabled: true,
89
+ isMinor: false,
90
+ analyticsAutoDisabled: false,
91
+ });
92
+ expect((await caller.getPreferencesForDid()).analyticsEnabled).toBe(true);
93
+ });
94
+
95
+ it('repairs incorrect saved consent and keeps it off after the profile becomes eligible', async () => {
96
+ state.saved.analyticsEnabled = true;
97
+ state.saved.isMinor = false;
98
+ state.profile.dob = '2012-01-01';
99
+ expect((await caller.getPreferencesForDid()).analyticsEnabled).toBe(false);
100
+ expect(state.saved).toMatchObject({ analyticsEnabled: false, isMinor: true });
101
+ state.profile.dob = '1990-01-01';
102
+ expect(await caller.getPreferencesForDid()).toMatchObject({
103
+ analyticsEnabled: false,
104
+ isMinor: false,
105
+ });
106
+ expect(state.saved.analyticsEnabled).toBe(false);
107
+ });
108
+
109
+ it('re-evaluates trusted country and DOB on every direct API request', async () => {
110
+ state.profile = { dob: '2010-10-29', country: 'FR' };
111
+ await caller.updatePreferences({ analyticsEnabled: true });
112
+ expect((await caller.getPreferencesForDid()).analyticsEnabled).toBe(true);
113
+ state.profile.country = 'US';
114
+ expect((await caller.getPreferencesForDid()).analyticsEnabled).toBe(false);
115
+ expect(state.saved.analyticsEnabled).toBe(false);
116
+ await caller.updatePreferences({ analyticsEnabled: true });
117
+ expect(state.saved.analyticsEnabled).toBe(false);
118
+ state.profile.dob = '2000-01-01';
119
+ await caller.updatePreferences({ analyticsEnabled: true });
120
+ expect((await caller.getPreferencesForDid()).analyticsEnabled).toBe(true);
121
+ });
122
+
123
+ it.each([
124
+ [{ dob: undefined }, true],
125
+ [{ dob: '' }, true],
126
+ [{ dob: '1990-01-01', type: 'child' }, false],
127
+ [{ dob: '2030-01-01' }, false],
128
+ [{ dob: 'not-a-date' }, false],
129
+ ])(
130
+ 'honors legacy absent DOB and fails closed for child/invalid/future profiles: %j',
131
+ async (profile, allowed) => {
132
+ state.profile = profile;
133
+ await caller.updatePreferences({ analyticsEnabled: true });
134
+ expect((await caller.getPreferencesForDid()).analyticsEnabled).toBe(allowed);
135
+ expect(state.saved.analyticsEnabled).toBe(allowed);
136
+ }
137
+ );
138
+
139
+ it('keeps preferences available and analytics transiently off when age authority is unknown', async () => {
140
+ state.saved = {
141
+ theme: ThemeEnum.Formal,
142
+ aiEnabled: true,
143
+ analyticsEnabled: true,
144
+ isMinor: false,
145
+ };
146
+ state.profileUnavailable = true;
147
+ await expect(caller.getPreferencesForDid()).resolves.toMatchObject({
148
+ theme: ThemeEnum.Formal,
149
+ aiEnabled: true,
150
+ analyticsEnabled: false,
151
+ });
152
+ expect(state.saved.analyticsEnabled).toBe(true);
153
+ await expect(
154
+ caller.updatePreferences({ theme: ThemeEnum.Colorful, aiEnabled: false })
155
+ ).resolves.toBe(true);
156
+ expect(state.saved).toMatchObject({
157
+ theme: ThemeEnum.Colorful,
158
+ aiEnabled: false,
159
+ analyticsEnabled: true,
160
+ });
161
+ await caller.updatePreferences({ analyticsEnabled: false, isMinor: true });
162
+ expect(state.saved).toMatchObject({ analyticsEnabled: false, isMinor: false });
163
+ });
164
+ it('rejects opt-in under unknown authority before applying mixed changes', async () => {
165
+ state.profileUnavailable = true;
166
+ await expect(
167
+ caller.updatePreferences({ theme: ThemeEnum.Formal, analyticsEnabled: true })
168
+ ).rejects.toMatchObject({ code: 'SERVICE_UNAVAILABLE' });
169
+ expect(state.saved).toEqual({ theme: ThemeEnum.Colorful });
170
+ });
171
+
172
+ it.each(['throw', 'return-false'] as const)(
173
+ 'returns fail-closed preferences on %s repair failure, then retries repair',
174
+ async failure => {
175
+ state.profile = { dob: '2012-01-01', country: 'US' };
176
+ state.saved = { theme: ThemeEnum.Formal, analyticsEnabled: true };
177
+ state.repairFailure = failure;
178
+ await expect(caller.getPreferencesForDid()).resolves.toMatchObject({
179
+ theme: ThemeEnum.Formal,
180
+ analyticsEnabled: false,
181
+ isMinor: true,
182
+ });
183
+ expect(state.saved.analyticsEnabled).toBe(true);
184
+ state.repairFailure = null;
185
+ await caller.getPreferencesForDid();
186
+ expect(state.saved).toMatchObject({ analyticsEnabled: false, isMinor: true });
187
+ }
188
+ );
189
+
190
+ it('allows onboarding theme writes without a profile and rejects forged analytics authority', async () => {
191
+ state.profileUnavailable = true;
192
+ await expect(caller.createPreferences({ theme: ThemeEnum.Formal })).resolves.toBe(true);
193
+ await expect(
194
+ caller.updatePreferences({
195
+ analyticsEnabled: true,
196
+ analyticsAutoDisabled: false,
197
+ isMinor: false,
198
+ })
199
+ ).rejects.toMatchObject({ code: 'SERVICE_UNAVAILABLE' });
200
+ expect(state.saved).toEqual({ theme: ThemeEnum.Formal });
201
+ });
202
+
203
+ it('repairs a known minor and restores adult authorization after authority recovers', async () => {
204
+ state.saved = { theme: ThemeEnum.Formal, analyticsEnabled: true };
205
+ state.profile = { dob: '2012-01-01', country: 'US' };
206
+ await caller.getPreferencesForDid();
207
+ expect(state.saved).toMatchObject({ analyticsEnabled: false, isMinor: true });
208
+ state.profileUnavailable = true;
209
+ await caller.getPreferencesForDid();
210
+ expect(state.saved.analyticsEnabled).toBe(false);
211
+ state.profileUnavailable = false;
212
+ state.profile = { dob: '1990-01-01', country: 'US' };
213
+ await caller.updatePreferences({ analyticsEnabled: true });
214
+ expect(state.saved).toMatchObject({ analyticsEnabled: true, isMinor: false });
215
+ });
216
+ });
@@ -1,5 +1,8 @@
1
1
  import { z } from 'zod';
2
2
 
3
+ import { getCurrentProfile } from '@helpers/currentProfile';
4
+ import { isAnalyticsRestricted } from '@helpers/analyticsEligibility';
5
+
3
6
  import { t, didAndChallengeRoute } from '@routes';
4
7
  import { createPreferences } from '@accesslayer/preferences/create';
5
8
  import { TRPCError } from '@trpc/server';
@@ -71,7 +74,27 @@ export const preferencesRouter = t.router({
71
74
  .input(preferencesInputSchema)
72
75
  .output(z.boolean())
73
76
  .mutation(async ({ input, ctx }) => {
74
- const success = await updatePreferences(ctx.user.did, input);
77
+ const profile = await getCurrentProfile(ctx);
78
+ const restricted = profile !== null && isAnalyticsRestricted(profile);
79
+ // Age-derived metadata is server-owned, not a caller assertion.
80
+ const safeInput: typeof input = { ...input };
81
+ if (profile !== null) {
82
+ safeInput.isMinor = restricted;
83
+ safeInput.analyticsAutoDisabled = restricted;
84
+ if (restricted) safeInput.analyticsEnabled = false;
85
+ } else {
86
+ // Unknown authority cannot grant opt-in or persist fabricated age metadata.
87
+ delete safeInput.isMinor;
88
+ delete safeInput.analyticsAutoDisabled;
89
+ if (safeInput.analyticsEnabled === true) {
90
+ throw new TRPCError({
91
+ code: 'SERVICE_UNAVAILABLE',
92
+ message:
93
+ 'Analytics settings are temporarily unavailable. Please try again.',
94
+ });
95
+ }
96
+ }
97
+ const success = await updatePreferences(ctx.user.did, safeInput);
75
98
 
76
99
  if (!success) {
77
100
  throw new TRPCError({
@@ -96,16 +119,43 @@ export const preferencesRouter = t.router({
96
119
  .input(z.void())
97
120
  .output(preferencesOutputSchema)
98
121
  .query(async ({ ctx }) => {
122
+ const profile = await getCurrentProfile(ctx);
123
+ const restricted = profile !== null && isAnalyticsRestricted(profile);
99
124
  const preferences = await getPreferencesForDid(ctx.user.did);
100
125
 
126
+ if (
127
+ preferences &&
128
+ profile !== null &&
129
+ ((restricted && preferences.analyticsEnabled === true) ||
130
+ preferences.isMinor !== restricted ||
131
+ (restricted && preferences.analyticsAutoDisabled !== true))
132
+ ) {
133
+ try {
134
+ const repaired = await updatePreferences(ctx.user.did, {
135
+ ...(restricted ? { analyticsEnabled: false } : {}),
136
+ isMinor: restricted,
137
+ ...(restricted ? { analyticsAutoDisabled: true } : {}),
138
+ });
139
+ if (!repaired) console.error('Failed to persist age-based preference repair');
140
+ } catch {
141
+ console.error('Failed to persist age-based preference repair');
142
+ }
143
+ }
144
+
101
145
  return {
102
146
  theme: preferences?.theme,
103
147
  aiEnabled: preferences?.aiEnabled,
104
148
  aiAutoDisabled: preferences?.aiAutoDisabled,
105
- analyticsEnabled: preferences?.analyticsEnabled,
106
- analyticsAutoDisabled: preferences?.analyticsAutoDisabled,
149
+ analyticsEnabled:
150
+ profile === null || restricted ? false : preferences?.analyticsEnabled,
151
+ analyticsAutoDisabled:
152
+ profile === null
153
+ ? preferences?.analyticsAutoDisabled
154
+ : restricted
155
+ ? true
156
+ : preferences?.analyticsAutoDisabled,
107
157
  bugReportsEnabled: preferences?.bugReportsEnabled,
108
- isMinor: preferences?.isMinor,
158
+ ...(profile === null ? {} : { isMinor: restricted }),
109
159
  };
110
160
  }),
111
161
  });
@@ -8,6 +8,7 @@ import type { EscrowBlobStaleReason } from '@learncard/types';
8
8
  import { SoftwareEnclave } from './softwareEnclave';
9
9
  import { createRemoteEnclave } from './remoteEnclave';
10
10
  import { pinnedEnclaveCa } from './enclaveTrustAnchors';
11
+ import { emitEnclaveFailureMetric } from './logFailure';
11
12
  import { EscrowUnavailableError, type EscrowEnclave } from './types';
12
13
 
13
14
  export * from './types';
@@ -17,6 +18,27 @@ export * from './notifications';
17
18
 
18
19
  let enclave: EscrowEnclave | undefined;
19
20
  let config: LcaApiEnvironment = environment;
21
+ let remoteConfigMissingReported = false;
22
+
23
+ /**
24
+ * Once per container: 'remote' mode without URL/token silently disables escrow
25
+ * (every escrow call answers 412). On 2026-11-04 a runtime-secrets sync dropped
26
+ * both keys and nothing said so; this makes it visible in logs and as
27
+ * `LearnCard/Escrow EnclaveFailure` with `Reason=remote_config_missing`.
28
+ * Never logs the values, only whether each is present.
29
+ */
30
+ const reportRemoteConfigMissing = (): void => {
31
+ if (remoteConfigMissingReported) return;
32
+ remoteConfigMissingReported = true;
33
+ console.error(
34
+ '[escrow] ESCROW_ENCLAVE_MODE=remote but ESCROW_ENCLAVE_REMOTE_URL/TOKEN missing: escrow disabled',
35
+ {
36
+ hasUrl: Boolean(config.ESCROW_ENCLAVE_REMOTE_URL),
37
+ hasToken: Boolean(config.ESCROW_ENCLAVE_REMOTE_TOKEN),
38
+ }
39
+ );
40
+ emitEnclaveFailureMetric('remote_config_missing');
41
+ };
20
42
 
21
43
  // 'software' config validity is enforced at environment-parse time (see
22
44
  // config/environment.ts), so reaching that branch always means it's ready.
@@ -34,15 +56,29 @@ export const isEscrowEnabled = (): boolean => {
34
56
  return allowed;
35
57
  }
36
58
  if (config.ESCROW_ENCLAVE_MODE === 'remote') {
37
- return Boolean(config.ESCROW_ENCLAVE_REMOTE_URL && config.ESCROW_ENCLAVE_REMOTE_TOKEN);
59
+ const ready = Boolean(
60
+ config.ESCROW_ENCLAVE_REMOTE_URL && config.ESCROW_ENCLAVE_REMOTE_TOKEN
61
+ );
62
+ if (!ready) reportRemoteConfigMissing();
63
+ return ready;
38
64
  }
39
65
  return false;
40
66
  };
67
+ /** Cold-start check so a missing remote config is reported before the first escrow request. */
68
+ export const checkEscrowRemoteConfig = (): void => {
69
+ if (isEscrowRemoteMode()) isEscrowEnabled();
70
+ };
41
71
  export const isEscrowRemoteMode = (): boolean => config.ESCROW_ENCLAVE_MODE === 'remote';
42
72
  // The client-facing attestation mode implied by the server's active backend
43
73
  // ('remote' mode always attests as 'nitro' — see remoteEnclave.ts).
44
74
  export const activeEscrowClientMode = (): 'software' | 'nitro' =>
45
75
  isEscrowRemoteMode() ? 'nitro' : 'software';
76
+ /**
77
+ * The tenant label the (single-tenant) remote enclave signs and requires: server config,
78
+ * never the request's X-Tenant-Id. App-tenant concerns (hold `tenantId`, email branding,
79
+ * links) keep using `ctx.tenant`.
80
+ */
81
+ export const getEscrowAuthorityTenant = (): string => config.ESCROW_AUTHORITY_TENANT;
46
82
  export const getEscrowHoldDurationMs = (): number => config.ESCROW_HOLD_DURATION_MS;
47
83
  export const getEscrowHoldRestartMinAgeMs = (): number => config.ESCROW_HOLD_RESTART_MIN_AGE_MS;
48
84
  export const getEscrowEnclave = (): EscrowEnclave => {
@@ -160,6 +196,7 @@ export const __setEscrowEnclaveForTests = (replacement: EscrowEnclave | undefine
160
196
  if (environment.NODE_ENV !== 'test') throw new EscrowUnavailableError();
161
197
  config = parseLcaApiEnvironment(process.env);
162
198
  enclave = replacement;
199
+ remoteConfigMissingReported = false;
163
200
  attestationCache = undefined;
164
201
  attestationRefreshInFlight = undefined;
165
202
  };
@@ -26,7 +26,11 @@ export const logEscrowFailure = (error: unknown): void => {
26
26
  : 'unexpected';
27
27
  // lca-api uses structured console warnings rather than the browser logger.
28
28
  console.warn('[LCA escrow] enclave operation failed', { reason });
29
- // One EMF event alongside the existing warning; no error text, request or identity fields.
29
+ emitEnclaveFailureMetric(reason);
30
+ };
31
+
32
+ /** One EMF event per call; `reason` is an allowlisted label, never error text, request or identity fields. */
33
+ export const emitEnclaveFailureMetric = (reason: string): void => {
30
34
  console.log(
31
35
  JSON.stringify({
32
36
  _aws: {
@@ -1,6 +1,7 @@
1
1
  import { afterEach, describe, expect, it, vi } from 'vitest';
2
2
  import {
3
3
  __setEscrowEnclaveForTests,
4
+ checkEscrowRemoteConfig,
4
5
  getEscrowEnclave,
5
6
  isEscrowEnabled,
6
7
  EscrowUnavailableError,
@@ -32,3 +33,66 @@ describe('software enclave deploy-stage guard', () => {
32
33
  }
33
34
  );
34
35
  });
36
+
37
+ describe('remote enclave config guard', () => {
38
+ const metricEvents = (log: { mock: { calls: unknown[][] } }): Record<string, unknown>[] =>
39
+ log.mock.calls
40
+ .map(([line]) =>
41
+ typeof line === 'string' && line.startsWith('{')
42
+ ? (JSON.parse(line) as Record<string, unknown>)
43
+ : null
44
+ )
45
+ .filter((event): event is Record<string, unknown> => event?.EnclaveFailure === 1);
46
+
47
+ it.each([
48
+ ['URL', { ESCROW_ENCLAVE_REMOTE_TOKEN: 'secret-token-value-0123456789abcdef' }],
49
+ ['token', { ESCROW_ENCLAVE_REMOTE_URL: 'https://escrow.internal:8443' }],
50
+ ['both', {}],
51
+ ])('reports a missing %s once per container, without values', (_name, present) => {
52
+ vi.stubEnv('ESCROW_ENCLAVE_MODE', 'remote');
53
+ vi.stubEnv('ESCROW_ENCLAVE_REMOTE_URL', '');
54
+ vi.stubEnv('ESCROW_ENCLAVE_REMOTE_TOKEN', '');
55
+ for (const [key, value] of Object.entries(present)) vi.stubEnv(key, value);
56
+ const error = vi.spyOn(console, 'error').mockImplementation(() => {});
57
+ const log = vi.spyOn(console, 'log').mockImplementation(() => {});
58
+ __setEscrowEnclaveForTests(undefined);
59
+
60
+ checkEscrowRemoteConfig();
61
+ expect(isEscrowEnabled()).toBe(false);
62
+ expect(getEscrowEnclave).toThrow(EscrowUnavailableError);
63
+
64
+ expect(error).toHaveBeenCalledTimes(1);
65
+ expect(String(error.mock.calls[0]?.[0])).toContain('ESCROW_ENCLAVE_MODE=remote');
66
+ expect(JSON.stringify(error.mock.calls)).not.toContain(
67
+ 'secret-token-value-0123456789abcdef'
68
+ );
69
+ expect(JSON.stringify(error.mock.calls)).not.toContain('escrow.internal');
70
+ expect(metricEvents(log)).toEqual([
71
+ expect.objectContaining({ Reason: 'remote_config_missing', EnclaveFailure: 1 }),
72
+ ]);
73
+ });
74
+
75
+ it('stays quiet when the remote config is complete', () => {
76
+ vi.stubEnv('ESCROW_ENCLAVE_MODE', 'remote');
77
+ vi.stubEnv('ESCROW_ENCLAVE_REMOTE_URL', 'https://escrow.internal:8443');
78
+ vi.stubEnv('ESCROW_ENCLAVE_REMOTE_TOKEN', 'secret-token-value-0123456789abcdef');
79
+ const error = vi.spyOn(console, 'error').mockImplementation(() => {});
80
+ const log = vi.spyOn(console, 'log').mockImplementation(() => {});
81
+ __setEscrowEnclaveForTests(undefined);
82
+
83
+ checkEscrowRemoteConfig();
84
+ expect(isEscrowEnabled()).toBe(true);
85
+ expect(error).not.toHaveBeenCalled();
86
+ expect(metricEvents(log)).toEqual([]);
87
+ });
88
+
89
+ it('does not report when escrow is simply off', () => {
90
+ vi.stubEnv('ESCROW_ENCLAVE_MODE', '');
91
+ const error = vi.spyOn(console, 'error').mockImplementation(() => {});
92
+ __setEscrowEnclaveForTests(undefined);
93
+
94
+ checkEscrowRemoteConfig();
95
+ expect(isEscrowEnabled()).toBe(false);
96
+ expect(error).not.toHaveBeenCalled();
97
+ });
98
+ });
@@ -1,5 +1,6 @@
1
1
  import { describe, it, expect } from 'vitest';
2
2
  import {
3
+ decryptEscrowBlob,
3
4
  encryptEscrowBlob,
4
5
  generateEscrowKeyPair,
5
6
  openEscrowRelease,
@@ -51,7 +52,13 @@ describe('software enclave', () => {
51
52
  shareVersion: targetVersion,
52
53
  ...(scenario === 'target PIN' ? { pinVerifier } : {}),
53
54
  };
54
- const targetEnvelope = await encryptEscrowBlob(target, keys.publicKey, 'test');
55
+ // D21: the owner-sealed email brand must survive the carry re-seal (the
56
+ // shared parser used to drop unknown plaintext fields).
57
+ const targetEnvelope = await encryptEscrowBlob(
58
+ { ...target, recoveryBrand: 'VetPass' },
59
+ keys.publicKey,
60
+ 'test'
61
+ );
55
62
  const result = enclave.carryPinVerifier({
56
63
  sourceEnvelope,
57
64
  targetEnvelope,
@@ -68,6 +75,7 @@ describe('software enclave', () => {
68
75
  }
69
76
  const { envelope } = await result;
70
77
  expect(envelope.ciphertext).not.toBe(targetEnvelope.ciphertext);
78
+ expect((await decryptEscrowBlob(envelope, keys.privateKey)).recoveryBrand).toBe('VetPass');
71
79
  expect(
72
80
  await enclave.verifyEscrowBlob({ envelope, expectedDid: did, expectedShareVersion: 3 })
73
81
  ).toEqual({ ok: true, hasPin: true });
@@ -116,8 +124,9 @@ describe('software enclave', () => {
116
124
  const target = { recoveryShare: 'ef'.repeat(33), did, shareVersion: 1 };
117
125
  const sourceKeys = scenario === 'current key' ? current : previous;
118
126
  const sourceKeyId = scenario === 'current key' ? 'current' : 'previous';
127
+ // D21: the owner-sealed email brand must survive the rewrap.
119
128
  const sourceEnvelope = await encryptEscrowBlob(
120
- { ...target, pinVerifier },
129
+ { ...target, pinVerifier, recoveryBrand: 'VetPass' },
121
130
  sourceKeys.publicKey,
122
131
  sourceKeyId
123
132
  );
@@ -141,6 +150,9 @@ describe('software enclave', () => {
141
150
  }
142
151
  const { envelope } = await result;
143
152
  expect(envelope.ciphertext).not.toBe(sourceEnvelope.ciphertext);
153
+ expect((await decryptEscrowBlob(envelope, current.privateKey)).recoveryBrand).toBe(
154
+ 'VetPass'
155
+ );
144
156
  expect(
145
157
  await enclave.verifyEscrowBlob({
146
158
  envelope,
@@ -209,15 +209,32 @@ describe('runEscrowHoldReminders', () => {
209
209
  await runEscrowHoldReminders({ now, limit: 200 });
210
210
  const firstRun = await Promise.all(holds.map(hold => findEscrowHoldById(hold._id)));
211
211
  expect(firstRun.map(hold => hold?.enclaveCancelAttempts).sort()).toEqual([0, 0, 1, 1]);
212
- // UUIDs are ASCII: default string ordering matches Mongo's byte ordering.
213
- const unattemptedId = firstRun
212
+ // UUIDs are ASCII: default string ordering matches Mongo's _id tie-breaker.
213
+ const unattemptedIds = firstRun
214
214
  .filter(hold => hold?.enclaveCancelAttempts === 0)
215
215
  .map(hold => hold!._id)
216
- .sort()[0]!;
216
+ .sort();
217
+ await runEscrowHoldReminders({ now, limit: 1 });
218
+ const secondRun = await Promise.all(holds.map(hold => findEscrowHoldById(hold._id)));
219
+ expect((await findEscrowHoldById(unattemptedIds[0]!))?.enclaveCancelAttempts).toBe(1);
220
+ // Only the first unattempted hold runs; previously attempted holds must not retry.
221
+ expect(
222
+ secondRun
223
+ .filter(hold => unattemptedIds.includes(hold!._id))
224
+ .map(hold => hold?.enclaveCancelAttempts)
225
+ .sort()
226
+ ).toEqual([0, 1]);
227
+ expect(
228
+ secondRun
229
+ .filter(hold => !unattemptedIds.includes(hold!._id))
230
+ .map(hold => hold?.enclaveCancelAttempts)
231
+ ).toEqual([1, 1]);
232
+
233
+ // The remaining unattempted cancellation must run before any retry, too.
217
234
  await runEscrowHoldReminders({ now, limit: 1 });
218
- expect((await findEscrowHoldById(unattemptedId))?.enclaveCancelAttempts).toBe(1);
219
235
  for (const hold of holds) {
220
236
  const stored = await findEscrowHoldById(hold._id);
237
+ expect(stored?.enclaveCancelAttempts).toBe(1);
221
238
  expect(stored?.enclaveCancelPendingAt).toBeInstanceOf(Date);
222
239
  expect(stored?.enclaveCancelConfirmedAt).toBeUndefined();
223
240
  }