@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
@@ -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 { Notifications, ensureManagedCredentialRefreshNotificationIndex } from '.';
2
- import { LCNNotification } from '@learncard/types';
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 { isEscrowReleaseKillSwitchEnabled, parseLcaApiEnvironment } from './environment';
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
@@ -32,6 +32,7 @@ const SOURCES = {
32
32
  'ESCROW_HOLD_DURATION_MS',
33
33
  'ESCROW_HOLD_RESTART_MIN_AGE_MS',
34
34
  'ESCROW_ENCLAVE_REMOTE_TIMEOUT_MS',
35
+ 'ESCROW_AUTHORITY_TENANT',
35
36
  ],
36
37
  };
37
38
  const SECRETS = new Set([
@@ -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, createEscrowHoldsIndexes } from './models';
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,3 @@
1
+ /** The shared Brain endpoint may name tRPC; OpenAPI is served alongside it. */
2
+ export const getBrainOpenApiBase = (serviceUrl: string): string =>
3
+ `${serviceUrl.replace(/\/(?:trpc|api)\/?$/, '').replace(/\/$/, '')}/api`;
@@ -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
- expect.stringContaining('/utilities/resolve-email-locale'),
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
+ };