@learncard/lca-api-service 1.6.3 → 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.
- package/.env.example +2 -0
- package/CHANGELOG.md +114 -0
- package/config/config.dev.json +1 -0
- package/config/config.example.json +1 -0
- package/config/config.production.json +1 -0
- package/config/config.scouts.dev.json +1 -0
- package/config/config.scouts.production.json +1 -0
- package/dist/accesslayer/notifications/create.d.ts +2 -1
- package/dist/app.d.ts +70 -8
- package/dist/config/environment.d.ts +6 -0
- package/dist/config/stageConfig.d.ts +4 -0
- package/dist/helpers/analyticsEligibility.d.ts +6 -0
- package/dist/helpers/brainUrl.d.ts +1 -0
- package/dist/helpers/brainUrl.test.d.ts +1 -0
- package/dist/helpers/currentProfile.d.ts +6 -0
- package/dist/helpers/currentProfile.test.d.ts +1 -0
- package/dist/helpers/retryingOnce.d.ts +1 -0
- package/dist/helpers/retryingOnce.test.d.ts +1 -0
- package/dist/index.js +2 -2
- package/dist/models/EscrowHold.d.ts +1 -0
- package/dist/models/EscrowHold.indexes.test.d.ts +1 -0
- package/dist/models/Notification.d.ts +114 -2
- package/dist/models/SigningAuthority.d.ts +1 -1
- package/dist/models/UserKey.d.ts +23 -0
- package/dist/publish-metafile.json +1 -1
- package/dist/routes/credentials.d.ts +1 -1
- package/dist/routes/index.d.ts +5 -0
- package/dist/routes/keys.d.ts +1 -0
- package/dist/routes/notifications.d.ts +65 -4
- package/dist/routes/preferences.test.d.ts +1 -0
- package/dist/routes/signing-authority.d.ts +3 -3
- package/dist/services/escrow-enclave/index.d.ts +2 -0
- package/dist/services/escrow-enclave/logFailure.d.ts +1 -0
- package/dist/types/credentials.d.ts +1 -1
- package/dist/types/notifications.d.ts +118 -2
- package/docs/bedrock-generation.md +7 -6
- package/lambdaApp.test.ts +114 -0
- package/lambdaApp.ts +23 -11
- package/package.json +11 -11
- package/scripts/escrow-lambda-http.ts +74 -0
- package/scripts/verify-escrow-startup.mjs +463 -0
- package/serverless-local.yml +1 -1
- package/serverless.yml +1 -1
- package/src/accesslayer/notifications/create.test.ts +80 -0
- package/src/accesslayer/notifications/create.ts +97 -2
- package/src/config/environment.test.ts +27 -1
- package/src/config/environment.ts +16 -0
- package/src/config/escrowDeployment.test.ts +1 -0
- package/src/docker-entry.ts +1 -2
- package/src/helpers/analyticsEligibility.ts +15 -0
- package/src/helpers/brainUrl.test.ts +13 -0
- package/src/helpers/brainUrl.ts +3 -0
- package/src/helpers/currentProfile.test.ts +55 -0
- package/src/helpers/currentProfile.ts +58 -0
- package/src/helpers/locale.helpers.test.ts +4 -1
- package/src/helpers/locale.helpers.ts +4 -2
- package/src/helpers/retryingOnce.test.ts +33 -0
- package/src/helpers/retryingOnce.ts +18 -0
- package/src/models/EscrowHold.indexes.test.ts +208 -0
- package/src/models/EscrowHold.ts +106 -51
- package/src/models/UserKey.ts +69 -1
- package/src/routes/escrow.ts +18 -11
- package/src/routes/index.ts +5 -8
- package/src/routes/keys.ts +8 -0
- package/src/routes/notifications.ts +34 -1
- package/src/routes/preferences.test.ts +216 -0
- package/src/routes/preferences.ts +54 -4
- package/src/services/escrow-enclave/index.ts +38 -1
- package/src/services/escrow-enclave/logFailure.ts +5 -1
- package/src/services/escrow-enclave/production.test.ts +64 -0
- package/src/services/escrow-enclave/softwareEnclave.test.ts +14 -2
- package/test/jobs/escrowHoldReminders.spec.ts +21 -4
- package/test/keys-escrow.spec.ts +131 -3
- package/test/keys-lifecycle-invariants.spec.ts +235 -0
- package/test/notifications.spec.ts +102 -0
- package/tsconfig.entrypoints.json +1 -1
- package/vitest.config.ts +1 -0
- package/scripts/node24-runtime.cjs +0 -9
- package/scripts/verify-node24-layer.cjs +0 -49
|
@@ -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 {
|
|
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
|
|
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:
|
|
106
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
213
|
-
const
|
|
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()
|
|
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
|
}
|