@learncard/network-brain-service 4.0.2 → 4.0.4
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/CHANGELOG.md +49 -0
- package/dist/accesslayer/boost/read.d.ts.map +1 -1
- package/dist/accesslayer/boost/relationships/read.d.ts.map +1 -1
- package/dist/accesslayer/consentflowcontract/relationships/create.d.ts.map +1 -1
- package/dist/accesslayer/consentflowcontract/relationships/update.d.ts.map +1 -1
- package/dist/accesslayer/inbox-batch/schema.d.ts +4 -0
- package/dist/accesslayer/inbox-batch/schema.d.ts.map +1 -0
- package/dist/accesslayer/inbox-batch/schema.test.d.ts +2 -0
- package/dist/accesslayer/inbox-batch/schema.test.d.ts.map +1 -0
- package/dist/accesslayer/inbox-batch/store.d.ts +1 -1
- package/dist/accesslayer/inbox-batch/store.d.ts.map +1 -1
- package/dist/accesslayer/profile/create.d.ts.map +1 -1
- package/dist/accesslayer/profile/read.d.ts.map +1 -1
- package/dist/accesslayer/profile/search-fields.d.ts +16 -0
- package/dist/accesslayer/profile/search-fields.d.ts.map +1 -0
- package/dist/accesslayer/profile/update.d.ts.map +1 -1
- package/dist/helpers/boost.helpers.d.ts +1 -0
- package/dist/helpers/boost.helpers.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/models/credential-refresh-constraints.d.ts.map +1 -1
- package/dist/models/index.d.ts.map +1 -1
- package/dist/publish-metafile.json +1 -1
- package/dist/routes/workflows.d.ts.map +1 -1
- package/dist/seed/seedSkillFrameworks.d.ts.map +1 -1
- package/esbuildPlugins.cjs +9 -6
- package/package.json +19 -16
- package/project.json +18 -0
- package/scripts/backfill-profile-search.ts +79 -0
- package/scripts/seed-demo-persona.ts +6 -1
- package/src/accesslayer/boost/read.ts +171 -34
- package/src/accesslayer/boost/relationships/read.ts +163 -126
- package/src/accesslayer/consentflowcontract/relationships/create.ts +3 -1
- package/src/accesslayer/consentflowcontract/relationships/update.ts +5 -1
- package/src/accesslayer/inbox-batch/schema.test.ts +91 -0
- package/src/accesslayer/inbox-batch/schema.ts +54 -0
- package/src/accesslayer/inbox-batch/store.ts +2 -38
- package/src/accesslayer/profile/create.ts +2 -1
- package/src/accesslayer/profile/read.ts +23 -3
- package/src/accesslayer/profile/search-fields.ts +69 -0
- package/src/accesslayer/profile/update.ts +7 -1
- package/src/helpers/boost-storage.helpers.test.ts +155 -1
- package/src/helpers/boost.helpers.ts +52 -10
- package/src/models/credential-refresh-constraints.test.ts +44 -0
- package/src/models/credential-refresh-constraints.ts +29 -3
- package/src/models/index.ts +15 -4
- package/src/routes/workflows.ts +8 -1
- package/src/seed/seedSkillFrameworks.ts +4 -1
- package/test/boost.familial-equivalence.spec.ts +482 -0
- package/test/boost.recipientsJoin.spec.ts +508 -0
- package/test/consentflow.spec.ts +101 -0
- package/test/inbox-batch.spec.ts +189 -1
- package/test/profile-search.spec.ts +307 -0
- package/vitest.boost-recipients-neo4j.config.ts +21 -0
- package/vitest.config.ts +1 -0
- package/vitest.profile-search-neo4j.config.ts +20 -0
|
@@ -3,6 +3,7 @@ import { Profile } from '@models';
|
|
|
3
3
|
import { ProfileType, FlatProfileType } from 'types/profile';
|
|
4
4
|
import { transformProfileId } from '@helpers/profile.helpers';
|
|
5
5
|
import { inflateObject } from '@helpers/objects.helpers';
|
|
6
|
+
import { PROFILE_DISPLAY_NAME_LOWER_FIELD, normalizeProfileSearchText } from './search-fields';
|
|
6
7
|
|
|
7
8
|
export const getProfileByProfileId = async (profileId: string): Promise<ProfileType | null> => {
|
|
8
9
|
const result = await new QueryBuilder()
|
|
@@ -86,6 +87,15 @@ export const checkIfProfileExists = async ({
|
|
|
86
87
|
return false;
|
|
87
88
|
};
|
|
88
89
|
|
|
90
|
+
/**
|
|
91
|
+
* Case-insensitive substring search over `profileId` and `displayName`.
|
|
92
|
+
*
|
|
93
|
+
* Each branch of the `UNION` is a single `CONTAINS` predicate so the planner can seek a TEXT
|
|
94
|
+
* index for it (`profileId_text_idx` and `profile_displayname_lower_text_idx`) instead of
|
|
95
|
+
* scanning every `Profile`. `UNION` (not `UNION ALL`) dedupes a profile that matches both.
|
|
96
|
+
* The visibility/private/service/blacklist filters, ordering and limit are applied to the
|
|
97
|
+
* merged set, same as before.
|
|
98
|
+
*/
|
|
89
99
|
export const searchProfiles = async (
|
|
90
100
|
searchInput: string,
|
|
91
101
|
{
|
|
@@ -100,15 +110,25 @@ export const searchProfiles = async (
|
|
|
100
110
|
): Promise<ProfileType[]> => {
|
|
101
111
|
const result = await new QueryBuilder(
|
|
102
112
|
new BindParam({
|
|
113
|
+
// profileId is stored lowercased by transformProfileId
|
|
103
114
|
input: searchInput.toLowerCase(),
|
|
104
|
-
|
|
115
|
+
// normalized in Cypher below with the same expression displayNameLower is written with
|
|
116
|
+
displayNameInput: searchInput,
|
|
105
117
|
blacklist,
|
|
106
118
|
})
|
|
107
119
|
)
|
|
108
|
-
.
|
|
120
|
+
.raw(
|
|
121
|
+
`CALL {
|
|
122
|
+
MATCH (profile:Profile) WHERE profile.profileId CONTAINS $input RETURN profile
|
|
123
|
+
UNION
|
|
124
|
+
MATCH (profile:Profile) WHERE profile.${PROFILE_DISPLAY_NAME_LOWER_FIELD} CONTAINS ${normalizeProfileSearchText(
|
|
125
|
+
'$displayNameInput'
|
|
126
|
+
)} RETURN profile
|
|
127
|
+
}`
|
|
128
|
+
)
|
|
129
|
+
.with('profile')
|
|
109
130
|
.where(
|
|
110
131
|
`
|
|
111
|
-
(profile.profileId CONTAINS $input OR profile.displayName =~ $inputRegex) AND
|
|
112
132
|
(
|
|
113
133
|
profile.profileVisibility IS NULL OR
|
|
114
134
|
profile.profileVisibility = "public"
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Profile search runs `CONTAINS` against TEXT indexes, and TEXT index `CONTAINS` is case-sensitive.
|
|
3
|
+
* `profileId` is already stored lowercased (`transformProfileId`), so it can be searched as-is.
|
|
4
|
+
* `displayName` keeps the casing people typed, so we keep a normalized copy of it in
|
|
5
|
+
* `displayNameLower` (TEXT-indexed by `profile_displayname_lower_text_idx`) and search that.
|
|
6
|
+
*
|
|
7
|
+
* Every write that sets `displayName` must also set `displayNameLower`, and writes, the backfill
|
|
8
|
+
* and the search input all go through the same Cypher expression, `normalizeProfileSearchText`.
|
|
9
|
+
*/
|
|
10
|
+
export const PROFILE_DISPLAY_NAME_LOWER_FIELD = 'displayNameLower';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The one search normalization for display names: Cypher `toLower`, then final sigma (ς, U+03C2)
|
|
14
|
+
* folded to σ (U+03C3).
|
|
15
|
+
*
|
|
16
|
+
* `toLower` alone isn't safe for substring matching because it's context-sensitive for Greek:
|
|
17
|
+
* a capital Σ becomes ς at the end of a word and σ everywhere else. So `ΑΝΑΣΤΑΣΙΑ` is stored as
|
|
18
|
+
* `αναστασια`, but the search input `ΑΝΑΣ` lowercases to `ανας` and no longer matches. Folding ς to
|
|
19
|
+
* σ afterwards makes the result the same for a letter wherever it sits in the string, so a
|
|
20
|
+
* substring of a name always normalizes to a substring of the normalized name.
|
|
21
|
+
*
|
|
22
|
+
* Use this for writes, the backfill and queries alike. Changing it means re-running the backfill.
|
|
23
|
+
*/
|
|
24
|
+
export const normalizeProfileSearchText = (expression: string): string =>
|
|
25
|
+
`replace(toLower(${expression}), '\u03C2', '\u03C3')`;
|
|
26
|
+
|
|
27
|
+
/** Cypher fragment that keeps `displayNameLower` in sync with `displayName` for `identifier`. */
|
|
28
|
+
export const syncProfileDisplayNameLower = (identifier: string): string =>
|
|
29
|
+
`${identifier}.${PROFILE_DISPLAY_NAME_LOWER_FIELD} = ${normalizeProfileSearchText(
|
|
30
|
+
`${identifier}.displayName`
|
|
31
|
+
)}`;
|
|
32
|
+
|
|
33
|
+
/** Cypher predicate: true for a profile whose `displayNameLower` is missing or stale. */
|
|
34
|
+
export const profileDisplayNameLowerIsStale = (identifier: string): string =>
|
|
35
|
+
`${identifier}.displayName IS NOT NULL
|
|
36
|
+
AND (${identifier}.${PROFILE_DISPLAY_NAME_LOWER_FIELD} IS NULL
|
|
37
|
+
OR ${identifier}.${PROFILE_DISPLAY_NAME_LOWER_FIELD} <> ${normalizeProfileSearchText(
|
|
38
|
+
`${identifier}.displayName`
|
|
39
|
+
)})`;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* One-pass, idempotent backfill for profiles written before `displayNameLower` existed (or whose
|
|
43
|
+
* copy has drifted). Uses `CALL { ... } IN TRANSACTIONS`, so it has to run in an auto-commit
|
|
44
|
+
* transaction (e.g. `session.run`), not inside an explicit transaction.
|
|
45
|
+
*/
|
|
46
|
+
export const buildBackfillProfileDisplayNameLowerQuery = (batchSize = 1000): string => `
|
|
47
|
+
MATCH (profile:Profile)
|
|
48
|
+
WHERE ${profileDisplayNameLowerIsStale('profile')}
|
|
49
|
+
CALL {
|
|
50
|
+
WITH profile
|
|
51
|
+
SET ${syncProfileDisplayNameLower('profile')}
|
|
52
|
+
} IN TRANSACTIONS OF ${Number.isFinite(batchSize) ? Math.max(1, Math.floor(batchSize)) : 1000} ROWS
|
|
53
|
+
`;
|
|
54
|
+
|
|
55
|
+
/** Anything that runs a query in an auto-commit transaction, e.g. `query => session.run(query)`. */
|
|
56
|
+
export type AutoCommitRunner = (
|
|
57
|
+
query: string
|
|
58
|
+
) => PromiseLike<{ summary: { counters: { updates: () => Record<string, number> } } }>;
|
|
59
|
+
|
|
60
|
+
/** Runs the backfill and returns how many profiles were updated. */
|
|
61
|
+
export const backfillProfileDisplayNameLower = async (
|
|
62
|
+
run: AutoCommitRunner,
|
|
63
|
+
{ batchSize = 1000 }: { batchSize?: number } = {}
|
|
64
|
+
): Promise<number> => {
|
|
65
|
+
// The batch size is inlined: OF ... ROWS needs an integer and JS numbers bind as floats.
|
|
66
|
+
const result = await run(buildBackfillProfileDisplayNameLowerQuery(batchSize));
|
|
67
|
+
|
|
68
|
+
return result.summary.counters.updates().propertiesSet ?? 0;
|
|
69
|
+
};
|
|
@@ -2,6 +2,7 @@ import { QueryBuilder, BindParam } from 'neogma';
|
|
|
2
2
|
import { flattenObject } from '@helpers/objects.helpers';
|
|
3
3
|
import { Profile } from '@models';
|
|
4
4
|
import { ProfileType } from 'types/profile';
|
|
5
|
+
import { syncProfileDisplayNameLower } from './search-fields';
|
|
5
6
|
|
|
6
7
|
export const updateProfile = async (profile: ProfileType, updates: Partial<ProfileType>) => {
|
|
7
8
|
const currentDisplay = profile.display;
|
|
@@ -14,7 +15,12 @@ export const updateProfile = async (profile: ProfileType, updates: Partial<Profi
|
|
|
14
15
|
|
|
15
16
|
const query = new QueryBuilder(new BindParam({ params: newUpdates }))
|
|
16
17
|
.match({ model: Profile, where: { profileId: profile.profileId }, identifier: 'profile' })
|
|
17
|
-
.set(
|
|
18
|
+
.set(
|
|
19
|
+
// Keep the lowercased search copy in sync whenever displayName changes
|
|
20
|
+
'displayName' in newUpdates
|
|
21
|
+
? `profile += $params, ${syncProfileDisplayNameLower('profile')}`
|
|
22
|
+
: 'profile += $params'
|
|
23
|
+
);
|
|
18
24
|
|
|
19
25
|
const result = await query.run();
|
|
20
26
|
|
|
@@ -65,7 +65,12 @@ vi.mock('@tracing', () => ({
|
|
|
65
65
|
traceInternal: async (_name: string, action: () => unknown) => action(),
|
|
66
66
|
}));
|
|
67
67
|
|
|
68
|
-
import {
|
|
68
|
+
import {
|
|
69
|
+
issueClaimLinkBoost,
|
|
70
|
+
prepareCredentialFromBoost,
|
|
71
|
+
sendBoost,
|
|
72
|
+
stampIssuanceDate,
|
|
73
|
+
} from './boost.helpers';
|
|
69
74
|
|
|
70
75
|
const vc = {
|
|
71
76
|
'@context': ['https://www.w3.org/2018/credentials/v1'],
|
|
@@ -223,6 +228,155 @@ describe('boost payload storage', () => {
|
|
|
223
228
|
}
|
|
224
229
|
);
|
|
225
230
|
|
|
231
|
+
describe('malformed template issuance dates', () => {
|
|
232
|
+
// Seen on a production Boost template; it is not RFC 3339, so DIDKit refuses to sign it.
|
|
233
|
+
const MALFORMED_DATE = '2025-10-19+053008:18:16';
|
|
234
|
+
// The reused JOTA-JOTI "Health and Well-being" Boost that carried it.
|
|
235
|
+
const BOOST_ID = 'e073d183-8304-419f-b4f6-ef91fda6524c';
|
|
236
|
+
const V1 = ['https://www.w3.org/2018/credentials/v1'];
|
|
237
|
+
const V2 = ['https://www.w3.org/ns/credentials/v2'];
|
|
238
|
+
const authority = { relationship: { did: 'did:example:sa' } } as Parameters<
|
|
239
|
+
typeof issueClaimLinkBoost
|
|
240
|
+
>[4];
|
|
241
|
+
const boostWith = (template: Record<string, unknown>) =>
|
|
242
|
+
({
|
|
243
|
+
...options.boost,
|
|
244
|
+
dataValues: {
|
|
245
|
+
id: BOOST_ID,
|
|
246
|
+
boost: JSON.stringify({ ...vc, boostId: undefined, ...template }),
|
|
247
|
+
},
|
|
248
|
+
}) as Parameters<typeof issueClaimLinkBoost>[0];
|
|
249
|
+
const expectFreshDate = (value: unknown, before: number) => {
|
|
250
|
+
expect(typeof value).toBe('string');
|
|
251
|
+
expect(value).toBe(new Date(value as string).toISOString());
|
|
252
|
+
expect(Date.parse(value as string)).toBeGreaterThanOrEqual(before);
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
beforeEach(() => {
|
|
256
|
+
mocks.signingAuthority.mockResolvedValue({
|
|
257
|
+
kind: 'issued-credential',
|
|
258
|
+
credential: jwe,
|
|
259
|
+
statusEntries: [],
|
|
260
|
+
});
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it.each([
|
|
264
|
+
[
|
|
265
|
+
'VC 1.1 issuanceDate',
|
|
266
|
+
{ '@context': V1, issuanceDate: MALFORMED_DATE },
|
|
267
|
+
'issuanceDate',
|
|
268
|
+
],
|
|
269
|
+
[
|
|
270
|
+
'VC 2.0 validFrom',
|
|
271
|
+
{ '@context': V2, issuanceDate: undefined, validFrom: MALFORMED_DATE },
|
|
272
|
+
'validFrom',
|
|
273
|
+
],
|
|
274
|
+
] as const)(
|
|
275
|
+
'restamps a malformed %s when a claim link is claimed',
|
|
276
|
+
async (_kind, template, field) => {
|
|
277
|
+
const before = Date.now();
|
|
278
|
+
await issueClaimLinkBoost(
|
|
279
|
+
boostWith(template),
|
|
280
|
+
options.domain,
|
|
281
|
+
options.from,
|
|
282
|
+
options.to,
|
|
283
|
+
authority
|
|
284
|
+
);
|
|
285
|
+
const unsigned = mocks.signingAuthority.mock.calls[0]![1];
|
|
286
|
+
expectFreshDate(unsigned[field], before);
|
|
287
|
+
}
|
|
288
|
+
);
|
|
289
|
+
|
|
290
|
+
it('drops a stale issuanceDate left on a VC 2.0 template', async () => {
|
|
291
|
+
const before = Date.now();
|
|
292
|
+
await issueClaimLinkBoost(
|
|
293
|
+
boostWith({ '@context': V2, issuanceDate: MALFORMED_DATE }),
|
|
294
|
+
options.domain,
|
|
295
|
+
options.from,
|
|
296
|
+
options.to,
|
|
297
|
+
authority
|
|
298
|
+
);
|
|
299
|
+
const unsigned = mocks.signingAuthority.mock.calls[0]![1];
|
|
300
|
+
expectFreshDate(unsigned.validFrom, before);
|
|
301
|
+
expect(unsigned).not.toHaveProperty('issuanceDate');
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
it('drops a stale issuanceDate left on a VC 2.0 template for direct sends', async () => {
|
|
305
|
+
const before = Date.now();
|
|
306
|
+
const credential = await prepareCredentialFromBoost(
|
|
307
|
+
boostWith({ '@context': V2, issuanceDate: MALFORMED_DATE }),
|
|
308
|
+
`lc:network:network.example/boost:${BOOST_ID}`,
|
|
309
|
+
options.domain
|
|
310
|
+
);
|
|
311
|
+
expectFreshDate(credential.validFrom, before);
|
|
312
|
+
expect(credential).not.toHaveProperty('issuanceDate');
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
it.each([
|
|
316
|
+
{ credentialSubject: { creditsEarned: 'not a number' } },
|
|
317
|
+
{ awardedDate: 'bad-date' },
|
|
318
|
+
])('reports invalid standard fields as BAD_REQUEST', async fields => {
|
|
319
|
+
await expect(
|
|
320
|
+
prepareCredentialFromBoost(
|
|
321
|
+
boostWith({
|
|
322
|
+
'@context': V2,
|
|
323
|
+
type: ['VerifiableCredential', 'OpenBadgeCredential'],
|
|
324
|
+
proof: undefined,
|
|
325
|
+
...fields,
|
|
326
|
+
}),
|
|
327
|
+
`lc:network:network.example/boost:${BOOST_ID}`,
|
|
328
|
+
options.domain
|
|
329
|
+
)
|
|
330
|
+
).rejects.toMatchObject({ code: 'BAD_REQUEST' });
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
it.each(['', ' '])(
|
|
334
|
+
'omits blank awardedDate after template substitution %j',
|
|
335
|
+
async completed => {
|
|
336
|
+
const credential = await prepareCredentialFromBoost(
|
|
337
|
+
boostWith({
|
|
338
|
+
'@context': V2,
|
|
339
|
+
type: ['VerifiableCredential', 'OpenBadgeCredential'],
|
|
340
|
+
proof: undefined,
|
|
341
|
+
awardedDate: '{{completed}}',
|
|
342
|
+
}),
|
|
343
|
+
`lc:network:network.example/boost:${BOOST_ID}`,
|
|
344
|
+
options.domain,
|
|
345
|
+
{ templateData: { completed } }
|
|
346
|
+
);
|
|
347
|
+
expect(credential).not.toHaveProperty('awardedDate');
|
|
348
|
+
}
|
|
349
|
+
);
|
|
350
|
+
|
|
351
|
+
describe('stampIssuanceDate', () => {
|
|
352
|
+
const NOW = '2026-10-18T05:00:00.000Z';
|
|
353
|
+
|
|
354
|
+
it('sets validFrom and removes issuanceDate on VC 2.0', () => {
|
|
355
|
+
const stamped = stampIssuanceDate(
|
|
356
|
+
{
|
|
357
|
+
...vc,
|
|
358
|
+
'@context': V2,
|
|
359
|
+
validFrom: MALFORMED_DATE,
|
|
360
|
+
issuanceDate: MALFORMED_DATE,
|
|
361
|
+
},
|
|
362
|
+
NOW
|
|
363
|
+
);
|
|
364
|
+
|
|
365
|
+
expect(stamped.validFrom).toBe(NOW);
|
|
366
|
+
expect(stamped).not.toHaveProperty('issuanceDate');
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
it('only sets issuanceDate on VC 1.1', () => {
|
|
370
|
+
const template = { ...vc, '@context': V1, issuanceDate: MALFORMED_DATE };
|
|
371
|
+
|
|
372
|
+
expect(stampIssuanceDate({ ...template }, NOW)).toEqual({
|
|
373
|
+
...template,
|
|
374
|
+
issuanceDate: NOW,
|
|
375
|
+
});
|
|
376
|
+
});
|
|
377
|
+
});
|
|
378
|
+
});
|
|
379
|
+
|
|
226
380
|
it('preserves explicit notification and acceptance opt-outs', async () => {
|
|
227
381
|
await sendBoost({
|
|
228
382
|
...options,
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import cloneDeep from 'lodash/cloneDeep';
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
CredentialFieldError,
|
|
4
|
+
isEncrypted,
|
|
5
|
+
isVC2Format,
|
|
6
|
+
materializeCredentialFields,
|
|
7
|
+
} from '@learncard/helpers';
|
|
3
8
|
import { TRPCError } from '@trpc/server';
|
|
4
9
|
import {
|
|
5
10
|
VC,
|
|
@@ -87,6 +92,29 @@ export const isBoostViewableByClaimLink = async (boost: BoostInstance): Promise<
|
|
|
87
92
|
return true;
|
|
88
93
|
};
|
|
89
94
|
|
|
95
|
+
/**
|
|
96
|
+
* Stamps the issuance time onto a credential built from a Boost template.
|
|
97
|
+
*
|
|
98
|
+
* A template's own issuanceDate/validFrom is when the template was written, not when this
|
|
99
|
+
* credential is issued, and templates created through the API are not date-validated: a
|
|
100
|
+
* malformed value (e.g. "2025-10-19+053008:18:16") makes DIDKit refuse to sign. VC 2.0
|
|
101
|
+
* templates get validFrom and lose any leftover issuanceDate, which the VC 2.0 context doesn't
|
|
102
|
+
* define (DIDKit rejects it as an undefined JSON-LD term). VC 1.1 templates get issuanceDate.
|
|
103
|
+
*/
|
|
104
|
+
export const stampIssuanceDate = <T extends UnsignedVC | VC>(
|
|
105
|
+
credential: T,
|
|
106
|
+
now: string = new Date().toISOString()
|
|
107
|
+
): T => {
|
|
108
|
+
if (isVC2Format(credential)) {
|
|
109
|
+
credential.validFrom = now;
|
|
110
|
+
delete credential.issuanceDate;
|
|
111
|
+
} else {
|
|
112
|
+
credential.issuanceDate = now;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return credential;
|
|
116
|
+
};
|
|
117
|
+
|
|
90
118
|
export const convertCredentialToBoostTemplateJSON = (
|
|
91
119
|
credential: VC | UnsignedVC,
|
|
92
120
|
defaultIssuerDid?: string
|
|
@@ -255,6 +283,8 @@ export const issueClaimLinkBoost = async (
|
|
|
255
283
|
|
|
256
284
|
boostCredential.issuer = signingAuthorityForUser.relationship.did;
|
|
257
285
|
|
|
286
|
+
stampIssuanceDate(boostCredential);
|
|
287
|
+
|
|
258
288
|
setCredentialSubjectIds(boostCredential, getDidWeb(domain, to.profileId));
|
|
259
289
|
|
|
260
290
|
// Embed the boostURI into the boost credential for verification purposes.
|
|
@@ -331,17 +361,20 @@ export const prepareCredentialFromBoost = async (
|
|
|
331
361
|
boostJsonString = renderBoostTemplate(boostJsonString, mergedTemplateData);
|
|
332
362
|
}
|
|
333
363
|
|
|
334
|
-
|
|
364
|
+
let credential: UnsignedVC;
|
|
365
|
+
try {
|
|
366
|
+
credential = materializeCredentialFields(
|
|
367
|
+
parseRenderedTemplate<UnsignedVC>(boostJsonString)
|
|
368
|
+
) as UnsignedVC;
|
|
369
|
+
} catch (error) {
|
|
370
|
+
if (error instanceof CredentialFieldError)
|
|
371
|
+
throw new TRPCError({ code: 'BAD_REQUEST', message: error.message, cause: error });
|
|
372
|
+
throw error;
|
|
373
|
+
}
|
|
335
374
|
|
|
336
375
|
appendTemplateEvidenceToCredential(credential, mergedTemplateData, allowAutoAppendEvidence);
|
|
337
376
|
|
|
338
|
-
|
|
339
|
-
const now = new Date().toISOString();
|
|
340
|
-
if (isVC2Format(credential)) {
|
|
341
|
-
credential.validFrom = now;
|
|
342
|
-
} else {
|
|
343
|
-
credential.issuanceDate = now;
|
|
344
|
-
}
|
|
377
|
+
stampIssuanceDate(credential);
|
|
345
378
|
|
|
346
379
|
// Set issuer if provided
|
|
347
380
|
if (issuerDid) {
|
|
@@ -432,7 +465,16 @@ export const appendTemplateEvidenceToCredential = (
|
|
|
432
465
|
// EvidenceFile subterms — otherwise the signer fails later with an opaque
|
|
433
466
|
// JSON-LD key-expansion error. Surface a clear message instead so the
|
|
434
467
|
// caller can prompt the user to republish the boost.
|
|
435
|
-
|
|
468
|
+
const usesEvidenceExtensions = normalizedTemplateEvidence.some(item => {
|
|
469
|
+
if (!item || typeof item !== 'object') return false;
|
|
470
|
+
const evidence = item as Record<string, unknown>;
|
|
471
|
+
const types = Array.isArray(evidence.type) ? evidence.type : [evidence.type];
|
|
472
|
+
return (
|
|
473
|
+
types.includes('EvidenceFile') ||
|
|
474
|
+
['fileName', 'fileType', 'fileSize'].some(key => key in evidence)
|
|
475
|
+
);
|
|
476
|
+
});
|
|
477
|
+
if (usesEvidenceExtensions && !credentialContextSupportsDynamicEvidence(credential)) {
|
|
436
478
|
throw new TRPCError({
|
|
437
479
|
code: 'PRECONDITION_FAILED',
|
|
438
480
|
message:
|
|
@@ -77,4 +77,48 @@ describe('credential refresh constraint readiness', () => {
|
|
|
77
77
|
|
|
78
78
|
expect(mocks.run).toHaveBeenCalledTimes(9);
|
|
79
79
|
});
|
|
80
|
+
|
|
81
|
+
it('runs the constraint setup once per process across many calls', async () => {
|
|
82
|
+
mocks.run.mockResolvedValue(undefined);
|
|
83
|
+
|
|
84
|
+
const { ensureCredentialRefreshConstraints } =
|
|
85
|
+
await import('./credential-refresh-constraints');
|
|
86
|
+
|
|
87
|
+
// A burst of concurrent callers (cold start plus the first requests) ...
|
|
88
|
+
await Promise.all(Array.from({ length: 50 }, () => ensureCredentialRefreshConstraints()));
|
|
89
|
+
// ... and every later caller in the same process.
|
|
90
|
+
for (let i = 0; i < 200; i++) await ensureCredentialRefreshConstraints();
|
|
91
|
+
|
|
92
|
+
expect(mocks.run).toHaveBeenCalledTimes(8);
|
|
93
|
+
expect(new Set(mocks.run.mock.calls.map(([query]) => query)).size).toBe(8);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('retries transient errors and deadlocks before giving up on a statement', async () => {
|
|
97
|
+
mocks.run
|
|
98
|
+
.mockRejectedValueOnce({ code: 'Neo.TransientError.Transaction.DeadlockDetected' })
|
|
99
|
+
.mockRejectedValueOnce({
|
|
100
|
+
code: 'Neo.TransientError.Transaction.LockClientStopped',
|
|
101
|
+
retriable: true,
|
|
102
|
+
})
|
|
103
|
+
.mockResolvedValue(undefined);
|
|
104
|
+
|
|
105
|
+
const { ensureCredentialRefreshConstraints } =
|
|
106
|
+
await import('./credential-refresh-constraints');
|
|
107
|
+
|
|
108
|
+
await expect(ensureCredentialRefreshConstraints()).resolves.toBeUndefined();
|
|
109
|
+
// The first statement took three attempts, the other seven one each
|
|
110
|
+
expect(mocks.run).toHaveBeenCalledTimes(10);
|
|
111
|
+
expect(mocks.run.mock.calls[0]![0]).toBe(mocks.run.mock.calls[2]![0]);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('does not retry a non-transient failure', async () => {
|
|
115
|
+
const setupFailure = { code: 'Neo.ClientError.Security.Forbidden' };
|
|
116
|
+
mocks.run.mockRejectedValueOnce(setupFailure).mockResolvedValue(undefined);
|
|
117
|
+
|
|
118
|
+
const { ensureCredentialRefreshConstraints } =
|
|
119
|
+
await import('./credential-refresh-constraints');
|
|
120
|
+
|
|
121
|
+
await expect(ensureCredentialRefreshConstraints()).rejects.toBe(setupFailure);
|
|
122
|
+
expect(mocks.run).toHaveBeenCalledTimes(1);
|
|
123
|
+
});
|
|
80
124
|
});
|
|
@@ -13,24 +13,50 @@ const CREDENTIAL_REFRESH_CONSTRAINT_QUERIES = [
|
|
|
13
13
|
|
|
14
14
|
type Neo4jSchemaError = {
|
|
15
15
|
code?: string;
|
|
16
|
+
retriable?: boolean;
|
|
16
17
|
};
|
|
17
18
|
|
|
19
|
+
const MAX_ATTEMPTS = 5;
|
|
20
|
+
|
|
21
|
+
const wait = (ms: number): Promise<void> => new Promise(resolve => setTimeout(resolve, ms));
|
|
22
|
+
|
|
18
23
|
const isEquivalentSchemaRuleRace = (error: unknown): boolean =>
|
|
19
24
|
(error as Neo4jSchemaError | undefined)?.code ===
|
|
20
25
|
'Neo.ClientError.Schema.EquivalentSchemaRuleAlreadyExists';
|
|
21
26
|
|
|
22
|
-
|
|
23
|
-
|
|
27
|
+
// Same retry rule as the index setup in models/index.ts: transient errors and deadlocks
|
|
28
|
+
// (schema locks contended by other cold starts) are retried with a short backoff.
|
|
29
|
+
const isRetriable = (error: unknown): boolean => {
|
|
30
|
+
const neo4jError = error as Neo4jSchemaError | undefined;
|
|
31
|
+
|
|
32
|
+
return (
|
|
33
|
+
neo4jError?.retriable === true ||
|
|
34
|
+
neo4jError?.code === 'Neo.TransientError.Transaction.DeadlockDetected'
|
|
35
|
+
);
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const runConstraintQuery = async (query: string): Promise<void> => {
|
|
39
|
+
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
|
|
24
40
|
try {
|
|
25
41
|
await neogma.queryRunner.run(query);
|
|
42
|
+
return;
|
|
26
43
|
} catch (error) {
|
|
27
44
|
// Concurrent service starts can both observe a missing rule before one wins
|
|
28
45
|
// creation. The losing CREATE is equivalent to the desired ready state.
|
|
29
|
-
if (
|
|
46
|
+
if (isEquivalentSchemaRuleRace(error)) return;
|
|
47
|
+
if (!isRetriable(error) || attempt === MAX_ATTEMPTS) throw error;
|
|
48
|
+
|
|
49
|
+
await wait(100 * attempt);
|
|
30
50
|
}
|
|
31
51
|
}
|
|
32
52
|
};
|
|
33
53
|
|
|
54
|
+
const createCredentialRefreshConstraints = async (): Promise<void> => {
|
|
55
|
+
for (const query of CREDENTIAL_REFRESH_CONSTRAINT_QUERIES) {
|
|
56
|
+
await runConstraintQuery(query);
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
|
|
34
60
|
let constraintReadiness: Promise<void> | undefined;
|
|
35
61
|
|
|
36
62
|
/**
|
package/src/models/index.ts
CHANGED
|
@@ -150,6 +150,8 @@ const indexQueries = [
|
|
|
150
150
|
'CREATE INDEX profile_did_idx IF NOT EXISTS FOR (p:Profile) ON (p.did)',
|
|
151
151
|
'CREATE TEXT INDEX profileId_text_idx IF NOT EXISTS FOR (n:Profile) ON (n.profileId)',
|
|
152
152
|
'CREATE TEXT INDEX profile_displayname_text_idx IF NOT EXISTS FOR (n:Profile) ON (n.displayName)',
|
|
153
|
+
// Backs case-insensitive profile search (see accesslayer/profile/search-fields.ts)
|
|
154
|
+
'CREATE TEXT INDEX profile_displayname_lower_text_idx IF NOT EXISTS FOR (n:Profile) ON (n.displayNameLower)',
|
|
153
155
|
'CREATE INDEX boost_id_idx IF NOT EXISTS FOR (b:Boost) ON (b.id)',
|
|
154
156
|
'CREATE INDEX boost_category_idx IF NOT EXISTS FOR (b:Boost) ON (b.category)',
|
|
155
157
|
'CREATE INDEX boost_autoconnect_idx IF NOT EXISTS FOR (b:Boost) ON (b.autoConnectRecipients)',
|
|
@@ -210,6 +212,10 @@ const indexQueries = [
|
|
|
210
212
|
'CREATE INDEX contact_method_primary_idx IF NOT EXISTS FOR (c:ContactMethod) ON (c.isPrimary)',
|
|
211
213
|
'CREATE INDEX credential_refresh_credential_id_idx IF NOT EXISTS FOR (r:CredentialRefresh) ON (r.credentialId)',
|
|
212
214
|
'CREATE INDEX credential_refresh_version_refresh_id_idx IF NOT EXISTS FOR (c:Credential) ON (c.refreshId)',
|
|
215
|
+
// Credential activity reads join each CredentialActivity to its credential through
|
|
216
|
+
// (sender)-[:CREDENTIAL_SENT { activityId }]->(credential). Without this index every
|
|
217
|
+
// activity row scans all CREDENTIAL_SENT relationships.
|
|
218
|
+
'CREATE INDEX credential_sent_activity_id_idx IF NOT EXISTS FOR ()-[r:CREDENTIAL_SENT]-() ON (r.activityId)',
|
|
213
219
|
];
|
|
214
220
|
|
|
215
221
|
const wait = (ms: number): Promise<void> => new Promise(resolve => setTimeout(resolve, ms));
|
|
@@ -240,11 +246,16 @@ const runIndexQuery = async (query: string): Promise<void> => {
|
|
|
240
246
|
};
|
|
241
247
|
|
|
242
248
|
if (shouldCreateIndices) {
|
|
243
|
-
void ensureCredentialRefreshConstraints().catch(err => {
|
|
244
|
-
console.error('Error creating credential refresh constraints:', err);
|
|
245
|
-
});
|
|
246
|
-
|
|
247
249
|
(async function createIndices() {
|
|
250
|
+
// Constraints first, then indexes, one schema statement at a time. Running the two
|
|
251
|
+
// lists concurrently made every cold start contend with itself for schema locks.
|
|
252
|
+
// The readiness promise is memoized, so refresh routes awaiting it later reuse this run.
|
|
253
|
+
try {
|
|
254
|
+
await ensureCredentialRefreshConstraints();
|
|
255
|
+
} catch (err) {
|
|
256
|
+
console.error('Error creating credential refresh constraints:', err);
|
|
257
|
+
}
|
|
258
|
+
|
|
248
259
|
try {
|
|
249
260
|
for (const query of indexQueries) {
|
|
250
261
|
await runIndexQuery(query);
|
package/src/routes/workflows.ts
CHANGED
|
@@ -37,7 +37,12 @@ import { createClaimedRelationship } from '@accesslayer/inbox-credential/relatio
|
|
|
37
37
|
import { getContactMethodById, getProfileByContactMethod } from '@accesslayer/contact-method/read';
|
|
38
38
|
import { getProfileByDid, getProfileByProfileId } from '@accesslayer/profile/read';
|
|
39
39
|
|
|
40
|
-
import {
|
|
40
|
+
import {
|
|
41
|
+
getBoostUri,
|
|
42
|
+
isBoostViewableByClaimLink,
|
|
43
|
+
isDraftBoost,
|
|
44
|
+
stampIssuanceDate,
|
|
45
|
+
} from '@helpers/boost.helpers';
|
|
41
46
|
import { getBoostOwnerProfile } from 'types/boost';
|
|
42
47
|
import { getEmptyLearnCard, getLearnCard } from '@helpers/learnCard.helpers';
|
|
43
48
|
import { issueCredentialWithSigningAuthority } from '@helpers/signingAuthority.helpers';
|
|
@@ -392,6 +397,8 @@ async function handlePresentationForClaim(
|
|
|
392
397
|
|
|
393
398
|
boostCredential.issuer = signingAuthorityForUser.relationship.did;
|
|
394
399
|
|
|
400
|
+
stampIssuanceDate(boostCredential);
|
|
401
|
+
|
|
395
402
|
if (Array.isArray(boostCredential.credentialSubject)) {
|
|
396
403
|
boostCredential.credentialSubject = boostCredential.credentialSubject.map(subject => ({
|
|
397
404
|
...subject,
|
|
@@ -13,6 +13,8 @@ import {
|
|
|
13
13
|
upsertSkillEmbeddings,
|
|
14
14
|
type UpdateSkillEmbeddingFn,
|
|
15
15
|
} from '@helpers/skill-embedding.helpers';
|
|
16
|
+
import { syncProfileDisplayNameLower } from '../accesslayer/profile/search-fields';
|
|
17
|
+
|
|
16
18
|
// Inlined from @helpers/profile.helpers to avoid importing the access/model layer
|
|
17
19
|
// (it transitively pulls in @models, which hits a circular-dependency TDZ under tsx).
|
|
18
20
|
const transformProfileId = (rawInput: string): string =>
|
|
@@ -519,7 +521,8 @@ export const ensureSeedProfile = async (
|
|
|
519
521
|
p.shortBio = coalesce(p.shortBio, $shortBio),
|
|
520
522
|
p.profileVisibility = coalesce(p.profileVisibility, $profileVisibility),
|
|
521
523
|
p.approved = coalesce(p.approved, $approved),
|
|
522
|
-
p.isServiceProfile = coalesce(p.isServiceProfile, $isServiceProfile)
|
|
524
|
+
p.isServiceProfile = coalesce(p.isServiceProfile, $isServiceProfile)
|
|
525
|
+
SET ${syncProfileDisplayNameLower('p')}`,
|
|
523
526
|
{
|
|
524
527
|
profileId: normalizedProfileId,
|
|
525
528
|
displayName: DEFAULT_OWNER_DISPLAY_NAME,
|