@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
|
@@ -12,6 +12,7 @@ vi.mock('.', () => ({
|
|
|
12
12
|
}));
|
|
13
13
|
|
|
14
14
|
import {
|
|
15
|
+
createNotification,
|
|
15
16
|
isManagedCredentialRefreshNotification,
|
|
16
17
|
upsertCredentialRefreshNotification,
|
|
17
18
|
} from './create';
|
|
@@ -103,3 +104,82 @@ describe('managed credential refresh notification persistence', () => {
|
|
|
103
104
|
expect(update.$setOnInsert).toMatchObject({ archived: false });
|
|
104
105
|
});
|
|
105
106
|
});
|
|
107
|
+
|
|
108
|
+
describe('campaign notification persistence', () => {
|
|
109
|
+
const campaignNotification = {
|
|
110
|
+
type: 'ANNOUNCEMENT' as const,
|
|
111
|
+
to: { did: 'did:web:brain.example:users:learner' },
|
|
112
|
+
from: 'did:web:brain.example',
|
|
113
|
+
message: { title: 'Welcome', body: 'Explore your next step' },
|
|
114
|
+
data: { metadata: { campaignId: 'pilot-clr', campaignCreatedAt: '2026-01-01T00:00:00Z' } },
|
|
115
|
+
};
|
|
116
|
+
beforeEach(() => {
|
|
117
|
+
mocks.updateOne.mockReset();
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('collapses concurrent retries into one insertion without changing read state or copy', async () => {
|
|
121
|
+
const stored = new Map<string, unknown>();
|
|
122
|
+
mocks.updateOne.mockImplementation(async (filter, update) => {
|
|
123
|
+
if (stored.has(filter._id)) return { upsertedCount: 0 };
|
|
124
|
+
stored.set(filter._id, update.$setOnInsert);
|
|
125
|
+
return { upsertedCount: 1 };
|
|
126
|
+
});
|
|
127
|
+
const results = await Promise.all(
|
|
128
|
+
Array.from({ length: 10 }, () => createNotification(campaignNotification))
|
|
129
|
+
);
|
|
130
|
+
expect(results.filter(result => result && result.created)).toHaveLength(1);
|
|
131
|
+
expect(stored.size).toBe(1);
|
|
132
|
+
await createNotification({ ...campaignNotification, message: { title: 'Edited' } });
|
|
133
|
+
expect([...stored.values()][0]).toMatchObject({ message: campaignNotification.message });
|
|
134
|
+
for (const [filter, update, options] of mocks.updateOne.mock.calls) {
|
|
135
|
+
expect(filter._id).toMatch(/^campaign:[a-f0-9]{64}$/);
|
|
136
|
+
expect(update.$set).toBeUndefined();
|
|
137
|
+
expect(options).toEqual({ upsert: true });
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('keeps separate recipients, recreated campaigns and explicit tests independent', async () => {
|
|
142
|
+
mocks.updateOne.mockResolvedValue({ upsertedCount: 1 });
|
|
143
|
+
await createNotification(campaignNotification);
|
|
144
|
+
await createNotification({ ...campaignNotification, to: { did: 'did:key:other' } });
|
|
145
|
+
await createNotification({
|
|
146
|
+
...campaignNotification,
|
|
147
|
+
data: {
|
|
148
|
+
metadata: {
|
|
149
|
+
...campaignNotification.data.metadata,
|
|
150
|
+
campaignCreatedAt: '2026-02-01T00:00:00Z',
|
|
151
|
+
},
|
|
152
|
+
},
|
|
153
|
+
});
|
|
154
|
+
await createNotification({
|
|
155
|
+
...campaignNotification,
|
|
156
|
+
data: {
|
|
157
|
+
metadata: {
|
|
158
|
+
...campaignNotification.data.metadata,
|
|
159
|
+
campaignTestId: 'aaaaaaaa-aaaa-4aaa-aaaa-aaaaaaaaaaaa',
|
|
160
|
+
},
|
|
161
|
+
},
|
|
162
|
+
});
|
|
163
|
+
expect(new Set(mocks.updateOne.mock.calls.map(([filter]) => filter._id)).size).toBe(4);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('does not turn ambiguous storage errors into a successful delivery', async () => {
|
|
167
|
+
const error = Object.assign(new Error('lost response'), { name: 'MongoNetworkError' });
|
|
168
|
+
mocks.updateOne.mockRejectedValue(error);
|
|
169
|
+
await expect(createNotification(campaignNotification)).rejects.toBe(error);
|
|
170
|
+
mocks.updateOne.mockResolvedValue({ upsertedCount: 0 });
|
|
171
|
+
await expect(createNotification(campaignNotification)).resolves.toEqual({ created: false });
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('treats an atomic duplicate-key race as an existing delivery', async () => {
|
|
175
|
+
mocks.updateOne.mockRejectedValue({ code: 11000 });
|
|
176
|
+
await expect(createNotification(campaignNotification)).resolves.toEqual({ created: false });
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it('rejects missing campaign identity before storing or pushing', async () => {
|
|
180
|
+
await expect(
|
|
181
|
+
createNotification({ ...campaignNotification, data: undefined })
|
|
182
|
+
).resolves.toBe(false);
|
|
183
|
+
expect(mocks.updateOne).not.toHaveBeenCalled();
|
|
184
|
+
});
|
|
185
|
+
});
|
|
@@ -1,7 +1,11 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
1
|
+
import {
|
|
2
|
+
ConsentFlowContractNotificationDataValidator,
|
|
3
|
+
type ConsentFlowContractNotificationData,
|
|
4
|
+
type LCNNotification,
|
|
5
|
+
} from '@learncard/types';
|
|
3
6
|
import { createHash } from 'crypto';
|
|
4
7
|
import { v4 as uuidv4 } from 'uuid';
|
|
8
|
+
import { Notifications, ensureManagedCredentialRefreshNotificationIndex } from '.';
|
|
5
9
|
|
|
6
10
|
export type NotificationCreationResult = {
|
|
7
11
|
created: boolean;
|
|
@@ -53,6 +57,66 @@ const getCredentialRefreshNotificationId = (
|
|
|
53
57
|
return `credential-refresh:${digest}`;
|
|
54
58
|
};
|
|
55
59
|
|
|
60
|
+
const getContractUpdateNotificationData = (
|
|
61
|
+
notification: LCNNotification
|
|
62
|
+
): ConsentFlowContractNotificationData | undefined => {
|
|
63
|
+
const parsed = ConsentFlowContractNotificationDataValidator.safeParse(notification.data);
|
|
64
|
+
return parsed.success ? parsed.data : undefined;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const getContractUpdateNotificationId = (
|
|
68
|
+
notification: LCNNotification,
|
|
69
|
+
update: ConsentFlowContractNotificationData
|
|
70
|
+
): string => {
|
|
71
|
+
const digest = createHash('sha256')
|
|
72
|
+
.update(JSON.stringify([notification.to.did, update.contractUri, update.toVersion]))
|
|
73
|
+
.digest('hex');
|
|
74
|
+
|
|
75
|
+
return `consent-flow-contract-update:${digest}`;
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
/** Persist each revision once so queue retries cannot duplicate its push. */
|
|
79
|
+
export const upsertConsentFlowContractUpdateNotification = async (
|
|
80
|
+
notification: LCNNotification
|
|
81
|
+
): Promise<NotificationCreationResult | false> => {
|
|
82
|
+
const update = getContractUpdateNotificationData(notification);
|
|
83
|
+
if (!update) return false;
|
|
84
|
+
|
|
85
|
+
const notificationId = getContractUpdateNotificationId(notification, update);
|
|
86
|
+
|
|
87
|
+
try {
|
|
88
|
+
const result = await Notifications.updateOne(
|
|
89
|
+
{ _id: notificationId },
|
|
90
|
+
{
|
|
91
|
+
$setOnInsert: {
|
|
92
|
+
_id: notificationId,
|
|
93
|
+
read: update.kind !== 'requires_consent',
|
|
94
|
+
archived: false,
|
|
95
|
+
sent: new Date().toISOString(),
|
|
96
|
+
...notification,
|
|
97
|
+
},
|
|
98
|
+
},
|
|
99
|
+
{ upsert: true }
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
return { created: result.upsertedCount === 1 };
|
|
103
|
+
} catch (e) {
|
|
104
|
+
if (
|
|
105
|
+
typeof e === 'object' &&
|
|
106
|
+
e !== null &&
|
|
107
|
+
'code' in e &&
|
|
108
|
+
(e as { code?: unknown }).code === 11000
|
|
109
|
+
) {
|
|
110
|
+
return { created: false };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (isAmbiguousMongoWriteError(e)) throw e;
|
|
114
|
+
|
|
115
|
+
console.error(e);
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
|
|
56
120
|
const isAmbiguousMongoWriteError = (error: unknown): boolean => {
|
|
57
121
|
if (typeof error !== 'object' || error === null) return false;
|
|
58
122
|
|
|
@@ -79,6 +143,37 @@ export const createNotification = async (
|
|
|
79
143
|
notification: LCNNotification
|
|
80
144
|
): Promise<NotificationCreationResult | false> => {
|
|
81
145
|
try {
|
|
146
|
+
if (notification.type === 'ANNOUNCEMENT') {
|
|
147
|
+
const metadata = notification.data?.metadata;
|
|
148
|
+
if (!metadata?.campaignId || !metadata.campaignCreatedAt) return false;
|
|
149
|
+
// The Mongo _id index arbitrates concurrent deliveries. A dispatch/retry
|
|
150
|
+
// never changes this identity; explicit test sends get their own identity.
|
|
151
|
+
const digest = createHash('sha256')
|
|
152
|
+
.update(
|
|
153
|
+
JSON.stringify([
|
|
154
|
+
notification.from,
|
|
155
|
+
notification.to.did,
|
|
156
|
+
metadata.campaignId,
|
|
157
|
+
metadata.campaignCreatedAt,
|
|
158
|
+
metadata.campaignTestId ?? null,
|
|
159
|
+
])
|
|
160
|
+
)
|
|
161
|
+
.digest('hex');
|
|
162
|
+
const result = await Notifications.updateOne(
|
|
163
|
+
{ _id: `campaign:${digest}` },
|
|
164
|
+
{
|
|
165
|
+
$setOnInsert: {
|
|
166
|
+
...notification,
|
|
167
|
+
_id: `campaign:${digest}`,
|
|
168
|
+
read: false,
|
|
169
|
+
archived: false,
|
|
170
|
+
sent: new Date().toISOString(),
|
|
171
|
+
},
|
|
172
|
+
},
|
|
173
|
+
{ upsert: true }
|
|
174
|
+
);
|
|
175
|
+
return { created: result.upsertedCount === 1 };
|
|
176
|
+
}
|
|
82
177
|
const promptId = getConnectionPromptId(notification);
|
|
83
178
|
|
|
84
179
|
if (!promptId) {
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
-
import {
|
|
2
|
+
import { ESCROW_AUTHORITY_TENANT_PATTERN } from '@learncard/sss-key-manager';
|
|
3
|
+
import {
|
|
4
|
+
ESCROW_AUTHORITY_TENANT_LABEL,
|
|
5
|
+
isEscrowReleaseKillSwitchEnabled,
|
|
6
|
+
parseLcaApiEnvironment,
|
|
7
|
+
} from './environment';
|
|
3
8
|
|
|
4
9
|
afterEach(() => vi.unstubAllEnvs());
|
|
5
10
|
|
|
@@ -157,3 +162,24 @@ describe('Keycloak startup configuration', () => {
|
|
|
157
162
|
).not.toThrow();
|
|
158
163
|
});
|
|
159
164
|
});
|
|
165
|
+
|
|
166
|
+
describe('escrow authority tenant', () => {
|
|
167
|
+
it('validates the label exactly like the SDK', () => {
|
|
168
|
+
expect(ESCROW_AUTHORITY_TENANT_LABEL.source).toBe(ESCROW_AUTHORITY_TENANT_PATTERN.source);
|
|
169
|
+
expect(ESCROW_AUTHORITY_TENANT_LABEL.flags).toBe(ESCROW_AUTHORITY_TENANT_PATTERN.flags);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
const offline = { ...production, IS_OFFLINE: 'true' };
|
|
173
|
+
it('defaults to the learncard enclave label (no infra change)', () => {
|
|
174
|
+
expect(parseLcaApiEnvironment(offline).ESCROW_AUTHORITY_TENANT).toBe('learncard');
|
|
175
|
+
});
|
|
176
|
+
it('accepts an explicit label and rejects a malformed one', () => {
|
|
177
|
+
expect(
|
|
178
|
+
parseLcaApiEnvironment({ ...offline, ESCROW_AUTHORITY_TENANT: 'scouts' })
|
|
179
|
+
.ESCROW_AUTHORITY_TENANT
|
|
180
|
+
).toBe('scouts');
|
|
181
|
+
expect(() =>
|
|
182
|
+
parseLcaApiEnvironment({ ...offline, ESCROW_AUTHORITY_TENANT: 'learn card' })
|
|
183
|
+
).toThrow();
|
|
184
|
+
});
|
|
185
|
+
});
|
|
@@ -11,6 +11,12 @@ import {
|
|
|
11
11
|
requiredEnvironmentString,
|
|
12
12
|
} from '@learncard/helpers';
|
|
13
13
|
|
|
14
|
+
/**
|
|
15
|
+
* Same as `ESCROW_AUTHORITY_TENANT_PATTERN` in `@learncard/sss-key-manager`; copied so loading
|
|
16
|
+
* the environment doesn't import the SDK. `environment.test.ts` asserts the two stay identical.
|
|
17
|
+
*/
|
|
18
|
+
export const ESCROW_AUTHORITY_TENANT_LABEL = /^[A-Za-z0-9_-]{1,128}$/;
|
|
19
|
+
|
|
14
20
|
export const lcaApiEnvironmentShape = {
|
|
15
21
|
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
|
|
16
22
|
LAMBDA_STAGE: optionalEnvironmentString,
|
|
@@ -72,6 +78,16 @@ export const lcaApiEnvironmentShape = {
|
|
|
72
78
|
ESCROW_ENCLAVE_ACTIVE_KEY_ID: optionalEnvironmentString,
|
|
73
79
|
ESCROW_ENCLAVE_REMOTE_URL: optionalEnvironmentUrl,
|
|
74
80
|
ESCROW_ENCLAVE_REMOTE_TOKEN: optionalEnvironmentString,
|
|
81
|
+
/** The remote enclave's measured authority tenant label (ESCROW_AUTHORITY_TENANT in its
|
|
82
|
+
* image). Every app tenant's escrow intents and authority state are checked against it,
|
|
83
|
+
* never against the client-supplied X-Tenant-Id: the enclave is single-tenant, so
|
|
84
|
+
* VetPass and LearnCard both use "learncard". Defaults to "learncard" (no infra change).
|
|
85
|
+
* The default also applies to the ScoutPass deployment (CONFIG_TENANT=scouts), whose app
|
|
86
|
+
* has escrow off: enabling ScoutPass escrow on its own enclave must set this in
|
|
87
|
+
* config.scouts.<stage>.json, or its escrow routes fail closed. */
|
|
88
|
+
ESCROW_AUTHORITY_TENANT: optionalEnvironmentString
|
|
89
|
+
.transform(value => value ?? 'learncard')
|
|
90
|
+
.pipe(z.string().regex(ESCROW_AUTHORITY_TENANT_LABEL)),
|
|
75
91
|
/** Operator-flipped, manual-only kill switch (P7.1). When true, startRecovery/completeRecovery
|
|
76
92
|
* refuse with a friendly message; hold cancellation and notifications keep working. Never
|
|
77
93
|
* auto-flipped by the escrow-ledger-monitor — a compromised monitor must not be able to
|
package/src/docker-entry.ts
CHANGED
|
@@ -11,7 +11,7 @@ import { fastifyTRPCOpenApiPlugin, CreateOpenApiFastifyPluginOptions } from 'trp
|
|
|
11
11
|
import { appRouter, type AppRouter, createContext } from './app';
|
|
12
12
|
import { openApiDocument } from './openapi';
|
|
13
13
|
import { didFastifyPlugin } from './dids';
|
|
14
|
-
import { ensureUserKeysIndexes
|
|
14
|
+
import { ensureUserKeysIndexes } from './models';
|
|
15
15
|
import { oidcFastifyPlugin } from './oidc';
|
|
16
16
|
import { ensureAuthSubjectIndexes } from './models/AuthSubject';
|
|
17
17
|
|
|
@@ -116,7 +116,6 @@ server.register(oidcFastifyPlugin);
|
|
|
116
116
|
});
|
|
117
117
|
console.log('Server starting on port ', environment.PORT || 3000);
|
|
118
118
|
await ensureUserKeysIndexes();
|
|
119
|
-
await createEscrowHoldsIndexes();
|
|
120
119
|
await server.listen({ host: '0.0.0.0', port: Number(environment.PORT || 3000) });
|
|
121
120
|
} catch (err) {
|
|
122
121
|
console.error(err);
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { calculateAgeFromISODate, getMinorAgeThreshold } from '@learncard/helpers';
|
|
2
|
+
|
|
3
|
+
export type TrustedProfileAge = {
|
|
4
|
+
dob?: string | null;
|
|
5
|
+
country?: string | null;
|
|
6
|
+
type?: string | null;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
/** Missing DOB preserves legacy adult eligibility; malformed/future DOB fails closed. */
|
|
10
|
+
export const isAnalyticsRestricted = (profile: TrustedProfileAge, now = new Date()): boolean => {
|
|
11
|
+
if (profile.type === 'child') return true;
|
|
12
|
+
if (!profile.dob) return false;
|
|
13
|
+
const age = calculateAgeFromISODate(profile.dob, now);
|
|
14
|
+
return Number.isNaN(age) || age < getMinorAgeThreshold(profile.country ?? undefined);
|
|
15
|
+
};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { getBrainOpenApiBase } from './brainUrl';
|
|
3
|
+
|
|
4
|
+
describe('getBrainOpenApiBase', () => {
|
|
5
|
+
it.each(['', '/', '/api', '/api/', '/trpc', '/trpc/'])(
|
|
6
|
+
'normalizes the endpoint suffix %j',
|
|
7
|
+
suffix => {
|
|
8
|
+
expect(getBrainOpenApiBase(`https://brain.test${suffix}`)).toBe(
|
|
9
|
+
'https://brain.test/api'
|
|
10
|
+
);
|
|
11
|
+
}
|
|
12
|
+
);
|
|
13
|
+
});
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
const mocks = vi.hoisted(() => ({ get: vi.fn() }));
|
|
4
|
+
vi.mock('axios', async importOriginal => {
|
|
5
|
+
const actual = await importOriginal<typeof import('axios')>();
|
|
6
|
+
return { ...actual, default: { ...actual.default, get: mocks.get } };
|
|
7
|
+
});
|
|
8
|
+
vi.mock('@environment', () => ({
|
|
9
|
+
environment: { NETWORK_BRAIN_SERVICE_URL: 'http://brain.test/api' },
|
|
10
|
+
}));
|
|
11
|
+
|
|
12
|
+
import { getCurrentProfile } from './currentProfile';
|
|
13
|
+
import { isAnalyticsRestricted } from './analyticsEligibility';
|
|
14
|
+
|
|
15
|
+
const ctx = {
|
|
16
|
+
authorizationHeader: 'Bearer signed-did-vp',
|
|
17
|
+
tenant: { id: 'tenant-a' },
|
|
18
|
+
user: { did: 'did:example:person' },
|
|
19
|
+
} as never;
|
|
20
|
+
|
|
21
|
+
describe('getCurrentProfile', () => {
|
|
22
|
+
beforeEach(() => {
|
|
23
|
+
vi.clearAllMocks();
|
|
24
|
+
vi.useFakeTimers({ toFake: ['Date'] });
|
|
25
|
+
vi.setSystemTime(new Date('2026-10-29T12:00:00Z'));
|
|
26
|
+
});
|
|
27
|
+
afterEach(() => vi.useRealTimers());
|
|
28
|
+
|
|
29
|
+
it('keeps age authority when Brain rewrites the authenticated profile DID', async () => {
|
|
30
|
+
mocks.get.mockResolvedValueOnce({
|
|
31
|
+
data: { did: 'did:web:network.learncard.com:users:person', dob: '2010-01-01' },
|
|
32
|
+
});
|
|
33
|
+
expect(isAnalyticsRestricted((await getCurrentProfile(ctx))!)).toBe(true);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('treats malformed profile authority as unknown rather than adult authority', async () => {
|
|
37
|
+
mocks.get.mockResolvedValueOnce({ data: { dob: 2010, country: 'US' } });
|
|
38
|
+
await expect(getCurrentProfile(ctx)).resolves.toBeNull();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('fails closed when brain-service cannot resolve the profile without exposing transport details', async () => {
|
|
42
|
+
mocks.get.mockRejectedValueOnce(new Error('private transport detail'));
|
|
43
|
+
await expect(getCurrentProfile(ctx)).resolves.toBeNull();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('accepts a self-profile without a DID field', async () => {
|
|
47
|
+
mocks.get.mockResolvedValueOnce({ data: { dob: '1990-01-01' } });
|
|
48
|
+
expect(isAnalyticsRestricted((await getCurrentProfile(ctx))!)).toBe(false);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('treats an absent network profile as unknown rather than an adult', async () => {
|
|
52
|
+
mocks.get.mockResolvedValueOnce({ data: undefined });
|
|
53
|
+
await expect(getCurrentProfile(ctx)).resolves.toBeNull();
|
|
54
|
+
});
|
|
55
|
+
});
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import axios from 'axios';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
|
|
4
|
+
import { environment } from '@environment';
|
|
5
|
+
import type { Context } from '@routes';
|
|
6
|
+
import { getBrainOpenApiBase } from './brainUrl';
|
|
7
|
+
|
|
8
|
+
const brainServiceUrl = environment.NETWORK_BRAIN_SERVICE_URL;
|
|
9
|
+
// The shared service endpoint is normally the tRPC base. OpenAPI is served
|
|
10
|
+
// alongside it, not beneath /trpc.
|
|
11
|
+
const profileUrl = brainServiceUrl ? `${getBrainOpenApiBase(brainServiceUrl)}/profile` : null;
|
|
12
|
+
|
|
13
|
+
const currentProfileSchema = z.object({
|
|
14
|
+
dob: z.string().nullish(),
|
|
15
|
+
country: z.string().nullish(),
|
|
16
|
+
type: z.string().nullish(),
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
/** Resolve the authenticated holder's persisted profile; null means unknown age authority. */
|
|
20
|
+
// Brain's self-profile uses didRoute and verifies this same signed VP without
|
|
21
|
+
// consuming a second challenge. A challenge-consuming route would need a new
|
|
22
|
+
// service-auth contract. Brain may rewrite profile DIDs; the VP establishes identity.
|
|
23
|
+
export const getCurrentProfile = async (
|
|
24
|
+
ctx: Pick<Context, 'authorizationHeader' | 'tenant' | 'user'>
|
|
25
|
+
) => {
|
|
26
|
+
if (!ctx.authorizationHeader || !ctx.user?.did) return null;
|
|
27
|
+
if (!profileUrl) {
|
|
28
|
+
console.warn('Current profile authority unavailable: endpoint not configured');
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
try {
|
|
33
|
+
const { data } = await axios.get(profileUrl, {
|
|
34
|
+
timeout: 3000,
|
|
35
|
+
maxRedirects: 0,
|
|
36
|
+
headers: {
|
|
37
|
+
Authorization: ctx.authorizationHeader,
|
|
38
|
+
'X-Tenant-Id': ctx.tenant.id,
|
|
39
|
+
},
|
|
40
|
+
});
|
|
41
|
+
// The self-profile endpoint returns an empty body when no profile exists.
|
|
42
|
+
if (data == null || data === '') return null;
|
|
43
|
+
const parsed = currentProfileSchema.safeParse(data);
|
|
44
|
+
if (!parsed.success) {
|
|
45
|
+
console.warn('Current profile authority unavailable: malformed response');
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
return parsed.data;
|
|
49
|
+
} catch (error) {
|
|
50
|
+
const status = axios.isAxiosError(error) ? error.response?.status : undefined;
|
|
51
|
+
console.warn(
|
|
52
|
+
status
|
|
53
|
+
? `Current profile authority unavailable: HTTP ${status}`
|
|
54
|
+
: 'Current profile authority unavailable: request failed'
|
|
55
|
+
);
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
};
|
|
@@ -5,6 +5,9 @@ import cache from '@cache';
|
|
|
5
5
|
|
|
6
6
|
import { resolveLocaleByEmail } from './locale.helpers';
|
|
7
7
|
|
|
8
|
+
vi.mock('@environment', () => ({
|
|
9
|
+
environment: { NETWORK_BRAIN_SERVICE_URL: 'http://brain.test/trpc' },
|
|
10
|
+
}));
|
|
8
11
|
vi.mock('axios');
|
|
9
12
|
vi.mock('@cache', () => ({
|
|
10
13
|
default: {
|
|
@@ -44,7 +47,7 @@ describe('resolveLocaleByEmail', () => {
|
|
|
44
47
|
await expect(resolveLocaleByEmail('Jane@Example.com')).resolves.toBe('es');
|
|
45
48
|
|
|
46
49
|
expect(mockedPost).toHaveBeenCalledWith(
|
|
47
|
-
|
|
50
|
+
'http://brain.test/api/utilities/resolve-email-locale',
|
|
48
51
|
{ email: 'Jane@Example.com' },
|
|
49
52
|
expect.objectContaining({ timeout: expect.any(Number) })
|
|
50
53
|
);
|
|
@@ -2,14 +2,16 @@ import { environment } from '@environment';
|
|
|
2
2
|
import axios from 'axios';
|
|
3
3
|
|
|
4
4
|
import cache from '@cache';
|
|
5
|
+
import { getBrainOpenApiBase } from './brainUrl';
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* Base URL for the LearnCard Network (brain-service) OpenAPI, used to resolve a
|
|
8
9
|
* recipient's saved locale by email before they authenticate. Overridable via
|
|
9
10
|
* `NETWORK_BRAIN_SERVICE_URL`; defaults to production.
|
|
10
11
|
*/
|
|
11
|
-
const BRAIN_SERVICE_URL =
|
|
12
|
-
environment.NETWORK_BRAIN_SERVICE_URL || 'https://network.learncard.com/api'
|
|
12
|
+
const BRAIN_SERVICE_URL = getBrainOpenApiBase(
|
|
13
|
+
environment.NETWORK_BRAIN_SERVICE_URL || 'https://network.learncard.com/api'
|
|
14
|
+
);
|
|
13
15
|
|
|
14
16
|
/** Max time we'll wait on the locale lookup before falling back. Login must not block on it. */
|
|
15
17
|
const LOOKUP_TIMEOUT_MS = 1500;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { createRetryingOnce } from './retryingOnce';
|
|
4
|
+
|
|
5
|
+
describe('createRetryingOnce', () => {
|
|
6
|
+
it('runs the task once and shares the result', async () => {
|
|
7
|
+
const task = vi.fn().mockResolvedValue('ready');
|
|
8
|
+
const ensure = createRetryingOnce(task);
|
|
9
|
+
|
|
10
|
+
await expect(Promise.all([ensure(), ensure()])).resolves.toEqual(['ready', 'ready']);
|
|
11
|
+
await expect(ensure()).resolves.toBe('ready');
|
|
12
|
+
expect(task).toHaveBeenCalledTimes(1);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('shares an in-flight failure, then retries on the next call', async () => {
|
|
16
|
+
const task = vi
|
|
17
|
+
.fn()
|
|
18
|
+
.mockRejectedValueOnce(new Error('Escrow hold index initialization failed'))
|
|
19
|
+
.mockResolvedValue('ready');
|
|
20
|
+
const ensure = createRetryingOnce(task);
|
|
21
|
+
|
|
22
|
+
const first = ensure();
|
|
23
|
+
const concurrent = ensure();
|
|
24
|
+
await expect(first).rejects.toThrow('Escrow hold index initialization failed');
|
|
25
|
+
await expect(concurrent).rejects.toThrow('Escrow hold index initialization failed');
|
|
26
|
+
expect(task).toHaveBeenCalledTimes(1);
|
|
27
|
+
|
|
28
|
+
await expect(ensure()).resolves.toBe('ready');
|
|
29
|
+
expect(task).toHaveBeenCalledTimes(2);
|
|
30
|
+
await expect(ensure()).resolves.toBe('ready');
|
|
31
|
+
expect(task).toHaveBeenCalledTimes(2);
|
|
32
|
+
});
|
|
33
|
+
});
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Run `task` at most once at a time and share its result. A rejection is not
|
|
3
|
+
* cached: the next call starts the task again. Used for Lambda cold-start work,
|
|
4
|
+
* where caching a failure turns one transient error (e.g. a slow Mongo call)
|
|
5
|
+
* into a container that fails every request until it is recycled.
|
|
6
|
+
*/
|
|
7
|
+
export const createRetryingOnce = <T>(task: () => Promise<T>): (() => Promise<T>) => {
|
|
8
|
+
let pending: Promise<T> | undefined;
|
|
9
|
+
|
|
10
|
+
return () => {
|
|
11
|
+
pending ??= task().catch((error: unknown) => {
|
|
12
|
+
pending = undefined;
|
|
13
|
+
throw error;
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
return pending;
|
|
17
|
+
};
|
|
18
|
+
};
|