@learncard/network-brain-service 4.0.3 → 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.
@@ -1 +1 @@
1
- {"version":3,"file":"workflows.d.ts","sourceRoot":"","sources":["../../src/routes/workflows.ts"],"names":[],"mappings":"AAgBA,OAAO,EAAgB,OAAO,EAAE,MAAM,SAAS,CAAC;AAuFhD,eAAO,MAAM,eAAe;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAqF1B,CAAC;AA8uBH,MAAM,MAAM,eAAe,GAAG,OAAO,eAAe,CAAC"}
1
+ {"version":3,"file":"workflows.d.ts","sourceRoot":"","sources":["../../src/routes/workflows.ts"],"names":[],"mappings":"AAgBA,OAAO,EAAgB,OAAO,EAAE,MAAM,SAAS,CAAC;AA4FhD,eAAO,MAAM,eAAe;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAqF1B,CAAC;AAgvBH,MAAM,MAAM,eAAe,GAAG,OAAO,eAAe,CAAC"}
@@ -2,6 +2,7 @@ const path = require('node:path');
2
2
  const fs = require('node:fs');
3
3
 
4
4
  const { sentryEsbuildPlugin } = require('@sentry/esbuild-plugin');
5
+ const { withSentryRateLimitRetry } = require('../../../scripts/sentry-rate-limit-retry.cjs');
5
6
 
6
7
  const forceCjsSodiumWrapperPlugin = {
7
8
  name: 'force-cjs-sodium-wrapper',
@@ -40,10 +41,12 @@ const copyDidkitWasmPlugin = {
40
41
  module.exports = [
41
42
  forceCjsSodiumWrapperPlugin,
42
43
  copyDidkitWasmPlugin,
43
- sentryEsbuildPlugin({
44
- telemetry: process.env.SENTRY_BUILD_TELEMETRY !== 'false',
45
- authToken: process.env.SENTRY_AUTH_TOKEN,
46
- org: process.env.SENTRY_ORG,
47
- project: process.env.SENTRY_PROJECT,
48
- }),
44
+ withSentryRateLimitRetry(
45
+ sentryEsbuildPlugin({
46
+ telemetry: process.env.SENTRY_BUILD_TELEMETRY !== 'false',
47
+ authToken: process.env.SENTRY_AUTH_TOKEN,
48
+ org: process.env.SENTRY_ORG,
49
+ project: process.env.SENTRY_PROJECT,
50
+ })
51
+ ),
49
52
  ];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@learncard/network-brain-service",
3
- "version": "4.0.3",
3
+ "version": "4.0.4",
4
4
  "description": "",
5
5
  "engines": {
6
6
  "node": ">=24.12 <25"
@@ -65,23 +65,23 @@
65
65
  "@digitalcredentials/issuer-registry-client": "^4.1.0",
66
66
  "@fastify/cors": "^11.2.0",
67
67
  "@google/genai": "^1.27.0",
68
- "@learncard/credential-library": "2.1.2",
69
- "@learncard/core": "9.4.39",
70
- "@learncard/crypto-plugin": "1.1.39",
71
- "@learncard/did-web-plugin": "1.1.39",
72
- "@learncard/didkey-plugin": "1.1.39",
73
- "@learncard/didkit-plugin": "1.10.4",
74
- "@learncard/didkit-plugin-node": "0.3.4",
75
- "@learncard/dynamic-loader-plugin": "1.1.39",
76
- "@learncard/encryption-plugin": "1.1.39",
77
- "@learncard/expiration-plugin": "1.2.39",
68
+ "@learncard/credential-library": "2.1.3",
69
+ "@learncard/core": "9.4.40",
70
+ "@learncard/crypto-plugin": "1.1.40",
71
+ "@learncard/did-web-plugin": "1.1.40",
72
+ "@learncard/didkey-plugin": "1.1.40",
73
+ "@learncard/didkit-plugin": "1.10.5",
74
+ "@learncard/didkit-plugin-node": "0.3.5",
75
+ "@learncard/dynamic-loader-plugin": "1.1.40",
76
+ "@learncard/encryption-plugin": "1.1.40",
77
+ "@learncard/expiration-plugin": "1.2.40",
78
78
  "@learncard/email-templates": "1.2.1",
79
- "@learncard/helpers": "1.6.3",
80
- "@learncard/learn-card-plugin": "1.2.39",
79
+ "@learncard/helpers": "1.7.0",
80
+ "@learncard/learn-card-plugin": "1.2.40",
81
81
  "@learncard/partner-connect-core": "0.2.0",
82
- "@learncard/types": "5.23.1",
83
- "@learncard/vc-plugin": "1.6.5",
84
- "@learncard/vc-templates-plugin": "1.1.39",
82
+ "@learncard/types": "5.23.2",
83
+ "@learncard/vc-plugin": "1.6.6",
84
+ "@learncard/vc-templates-plugin": "1.1.40",
85
85
  "@sentry/serverless": "7.61.0",
86
86
  "@trpc/server": "11.8.0",
87
87
  "@types/lodash": "^4.14.191",
@@ -21,7 +21,7 @@ import { BoostType } from 'types/boost';
21
21
  import { flattenObject, inflateObject } from '@helpers/objects.helpers';
22
22
  import { convertQueryResultToPropertiesObjectArray } from '@helpers/neo4j.helpers';
23
23
  import { reconsentTerms } from './update';
24
- import { getBoostUri, sendBoost } from '@helpers/boost.helpers';
24
+ import { getBoostUri, sendBoost, stampIssuanceDate } from '@helpers/boost.helpers';
25
25
  import { setCredentialSubjectIds } from '@helpers/credentialSubject.helpers';
26
26
  import { getDidWeb } from '@helpers/did.helpers';
27
27
  import { getSigningAuthorityForUserByName } from '@accesslayer/signing-authority/relationships/read';
@@ -296,6 +296,8 @@ export const consentToContract = async (
296
296
 
297
297
  boostCredential.issuer = { id: contractOwnerSigningAuthority.relationship.did };
298
298
 
299
+ stampIssuanceDate(boostCredential);
300
+
299
301
  if (boostCredential.type.includes('BoostCredential')) {
300
302
  boostCredential.boostId = getBoostUri(boost.target.id, domain);
301
303
  }
@@ -15,7 +15,7 @@ import { ConsentFlowTerms, ConsentFlowTransaction, ConsentFlowContract } from '@
15
15
  import { neogma } from '@instance';
16
16
  import { flattenObject } from '@helpers/objects.helpers';
17
17
  import { DbContractType, DbTermsType } from 'types/consentflowcontract';
18
- import { getBoostUri, sendBoost } from '@helpers/boost.helpers';
18
+ import { getBoostUri, sendBoost, stampIssuanceDate } from '@helpers/boost.helpers';
19
19
  import { setCredentialSubjectIds } from '@helpers/credentialSubject.helpers';
20
20
  import { getDidWeb } from '@helpers/did.helpers';
21
21
  import { getSigningAuthorityForUserByName } from '@accesslayer/signing-authority/relationships/read';
@@ -201,6 +201,8 @@ export const reconsentTerms = async (
201
201
  // Set the issuer and subject
202
202
  boostCredential.issuer = { id: contractOwnerSigningAuthority.relationship.did };
203
203
 
204
+ stampIssuanceDate(boostCredential);
205
+
204
206
  if (boostCredential.type.includes('BoostCredential')) {
205
207
  boostCredential.boostId = getBoostUri(boost.dataValues.id, domain);
206
208
  }
@@ -439,6 +441,8 @@ export const updateTerms = async (
439
441
  // Set the issuer and subject
440
442
  boostCredential.issuer = { id: contractOwnerSigningAuthority.relationship.did };
441
443
 
444
+ stampIssuanceDate(boostCredential);
445
+
442
446
  if (boostCredential.type.includes('BoostCredential')) {
443
447
  boostCredential.boostId = getBoostUri(boost.target.id, domain);
444
448
  }
@@ -0,0 +1,91 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import type { Driver, ManagedTransaction } from 'neo4j-driver';
3
+
4
+ vi.mock('@instance', () => ({ neogma: { driver: { session: vi.fn() } } }));
5
+ import { createInboxBatchSchemaInitializer } from './schema';
6
+
7
+ const harness = () => {
8
+ const run = vi.fn().mockResolvedValue({});
9
+ const close = vi.fn().mockResolvedValue(undefined);
10
+ const executeWrite = vi.fn(async (callback: (tx: ManagedTransaction) => unknown) =>
11
+ callback({ run } as unknown as ManagedTransaction)
12
+ );
13
+ const driver = { session: vi.fn(() => ({ run: vi.fn(), executeWrite, close })) };
14
+ const ensure = createInboxBatchSchemaInitializer(driver as unknown as Driver);
15
+ return { run, close, executeWrite, driver, ensure };
16
+ };
17
+
18
+ beforeEach(() => vi.clearAllMocks());
19
+
20
+ describe('inbox batch schema readiness', () => {
21
+ it('shares in-flight readiness and runs every constraint AND index in its own managed write', async () => {
22
+ const { ensure, driver, executeWrite, run, close } = harness();
23
+ const first = ensure();
24
+ expect(ensure()).toBe(first);
25
+ await first;
26
+ await ensure();
27
+ expect(driver.session).toHaveBeenCalledTimes(1);
28
+ expect(executeWrite).toHaveBeenCalledTimes(10);
29
+ expect(run).toHaveBeenCalledTimes(10);
30
+ expect(
31
+ run.mock.calls.filter(([query]) => query.startsWith('CREATE CONSTRAINT'))
32
+ ).toHaveLength(4);
33
+ expect(run.mock.calls.filter(([query]) => query.startsWith('CREATE INDEX'))).toHaveLength(
34
+ 6
35
+ );
36
+ expect(close).toHaveBeenCalledTimes(1);
37
+ });
38
+
39
+ it('lets independent API/worker initializers complete through a transient managed-transaction retry', async () => {
40
+ const api = harness();
41
+ const worker = harness();
42
+ // Model the driver re-entering the transaction callback after a DDL deadlock.
43
+ // The real-driver concurrent cold-start regression lives in inbox-batch.spec.ts.
44
+ const deadlock = { code: 'Neo.TransientError.Transaction.DeadlockDetected' };
45
+ api.run.mockRejectedValueOnce(deadlock);
46
+ api.executeWrite.mockImplementationOnce(async callback => {
47
+ await expect(callback({ run: api.run } as unknown as ManagedTransaction)).rejects.toBe(
48
+ deadlock
49
+ );
50
+ return callback({ run: api.run } as unknown as ManagedTransaction);
51
+ });
52
+ await expect(Promise.all([api.ensure(), worker.ensure()])).resolves.toEqual([
53
+ undefined,
54
+ undefined,
55
+ ]);
56
+ const firstQuery =
57
+ 'CREATE CONSTRAINT inboxbatch_id IF NOT EXISTS FOR (n:InboxBatch) REQUIRE n.id IS UNIQUE';
58
+ expect(api.run.mock.calls.slice(0, 2)).toEqual([[firstQuery], [firstQuery]]);
59
+ expect(api.run).toHaveBeenCalledTimes(11);
60
+ expect(worker.run).toHaveBeenCalledTimes(10);
61
+ });
62
+
63
+ it.each([0, 4])('accepts equivalent-schema races for statement %i', async position => {
64
+ const { ensure, run } = harness();
65
+ for (let index = 0; index < position; index++) run.mockResolvedValueOnce({});
66
+ run.mockRejectedValueOnce({
67
+ code: 'Neo.ClientError.Schema.EquivalentSchemaRuleAlreadyExists',
68
+ });
69
+ await expect(ensure()).resolves.toBeUndefined();
70
+ expect(run).toHaveBeenCalledTimes(10);
71
+ });
72
+
73
+ it.each([
74
+ 'Neo.ClientError.Security.Forbidden',
75
+ 'Neo.TransientError.Transaction.DeadlockDetected',
76
+ ])(
77
+ 'propagates %s after the managed write fails and allows a later readiness attempt',
78
+ async code => {
79
+ const { ensure, executeWrite, run, close, driver } = harness();
80
+ const failure = { code };
81
+ executeWrite.mockRejectedValueOnce(failure);
82
+ await expect(ensure()).rejects.toBe(failure);
83
+ expect(run).not.toHaveBeenCalled();
84
+ expect(close).toHaveBeenCalledTimes(1);
85
+ await expect(ensure()).resolves.toBeUndefined();
86
+ expect(driver.session).toHaveBeenCalledTimes(2);
87
+ expect(run).toHaveBeenCalledTimes(10);
88
+ expect(close).toHaveBeenCalledTimes(2);
89
+ }
90
+ );
91
+ });
@@ -0,0 +1,54 @@
1
+ import type { Driver } from 'neo4j-driver';
2
+ import { neogma } from '@instance';
3
+
4
+ const SCHEMA_QUERIES = [
5
+ ...['InboxBatch', 'InboxBatchItem', 'InboxBatchIssuer', 'InboxBatchReplay'].map(
6
+ label =>
7
+ `CREATE CONSTRAINT ${label.toLowerCase()}_id IF NOT EXISTS FOR (n:${label}) REQUIRE n.id IS UNIQUE`
8
+ ),
9
+ 'CREATE INDEX inbox_batch_request IF NOT EXISTS FOR (b:InboxBatch) ON (b.issuer, b.requestId)',
10
+ 'CREATE INDEX inbox_batch_dispatch IF NOT EXISTS FOR (i:InboxBatchItem) ON (i.state, i.dispatchAt)',
11
+ 'CREATE INDEX inbox_batch_recovery IF NOT EXISTS FOR (i:InboxBatchItem) ON (i.state, i.leaseUntil)',
12
+ 'CREATE INDEX inbox_batch_replay_owner IF NOT EXISTS FOR (r:InboxBatchReplay) ON (r.itemId)',
13
+ 'CREATE INDEX inbox_batch_completed IF NOT EXISTS FOR (b:InboxBatch) ON (b.completedAt)',
14
+ 'CREATE INDEX inbox_batch_replay_expiry IF NOT EXISTS FOR (r:InboxBatchReplay) ON (r.expiresAt)',
15
+ ];
16
+
17
+ /** Each API/worker process shares readiness locally; the driver retries cross-process DDL deadlocks. */
18
+ export const createInboxBatchSchemaInitializer = (
19
+ driver: Pick<Driver, 'session'>
20
+ ): (() => Promise<void>) => {
21
+ let readiness: Promise<void> | undefined;
22
+ return (): Promise<void> => {
23
+ if (!readiness) {
24
+ const pending = (async (): Promise<void> => {
25
+ const session = driver.session();
26
+ try {
27
+ for (const query of SCHEMA_QUERIES) {
28
+ try {
29
+ // One schema statement per managed transaction. IF NOT EXISTS is
30
+ // idempotent, but competing cold processes can still deadlock while
31
+ // acquiring label locks. Auto-commit queryRunner.run does not retry.
32
+ await session.executeWrite(tx => tx.run(query));
33
+ } catch (error) {
34
+ if (
35
+ (error as { code?: string } | null)?.code !==
36
+ 'Neo.ClientError.Schema.EquivalentSchemaRuleAlreadyExists'
37
+ )
38
+ throw error;
39
+ }
40
+ }
41
+ } finally {
42
+ await session.close();
43
+ }
44
+ })();
45
+ readiness = pending;
46
+ void pending.catch(() => {
47
+ if (readiness === pending) readiness = undefined;
48
+ });
49
+ }
50
+ return readiness;
51
+ };
52
+ };
53
+
54
+ export const ensureInboxBatchConstraints = createInboxBatchSchemaInitializer(neogma.driver);
@@ -6,49 +6,13 @@ import { neogma } from '@instance';
6
6
  import { encryptInboxCredential, decryptInboxCredential } from '@helpers/inbox-encryption.helpers';
7
7
  import type { BatchItem, BatchJob, BatchReplayStore, InboxDispatchLease } from 'types/inbox-batch';
8
8
  import { getInboxBatchState } from '@helpers/inbox-batch-status.helpers';
9
+ import { ensureInboxBatchConstraints } from './schema';
9
10
 
10
11
  export const DAY = 86_400_000;
11
12
  export const LEASE_MS = 360_000;
12
13
  const CLEANUP_MARGIN_MS = 5_000;
13
14
 
14
- let readiness: Promise<void> | undefined;
15
- export const ensureInboxBatchConstraints = (): Promise<void> => {
16
- if (!readiness) {
17
- readiness = (async () => {
18
- for (const label of [
19
- 'InboxBatch',
20
- 'InboxBatchItem',
21
- 'InboxBatchIssuer',
22
- 'InboxBatchReplay',
23
- ]) {
24
- try {
25
- await neogma.queryRunner.run(
26
- `CREATE CONSTRAINT ${label.toLowerCase()}_id IF NOT EXISTS FOR (n:${label}) REQUIRE n.id IS UNIQUE`
27
- );
28
- } catch (error) {
29
- if (
30
- (error as { code?: string }).code !==
31
- 'Neo.ClientError.Schema.EquivalentSchemaRuleAlreadyExists'
32
- )
33
- throw error;
34
- }
35
- }
36
- for (const query of [
37
- 'CREATE INDEX inbox_batch_request IF NOT EXISTS FOR (b:InboxBatch) ON (b.issuer, b.requestId)',
38
- 'CREATE INDEX inbox_batch_dispatch IF NOT EXISTS FOR (i:InboxBatchItem) ON (i.state, i.dispatchAt)',
39
- 'CREATE INDEX inbox_batch_recovery IF NOT EXISTS FOR (i:InboxBatchItem) ON (i.state, i.leaseUntil)',
40
- 'CREATE INDEX inbox_batch_replay_owner IF NOT EXISTS FOR (r:InboxBatchReplay) ON (r.itemId)',
41
- 'CREATE INDEX inbox_batch_completed IF NOT EXISTS FOR (b:InboxBatch) ON (b.completedAt)',
42
- 'CREATE INDEX inbox_batch_replay_expiry IF NOT EXISTS FOR (r:InboxBatchReplay) ON (r.expiresAt)',
43
- ])
44
- await neogma.queryRunner.run(query);
45
- })();
46
- void readiness.catch(() => {
47
- readiness = undefined;
48
- });
49
- }
50
- return readiness;
51
- };
15
+ export { ensureInboxBatchConstraints } from './schema';
52
16
 
53
17
  const transaction = async <T>(fn: (tx: ManagedTransaction) => Promise<T>): Promise<T> => {
54
18
  await ensureInboxBatchConstraints();
@@ -65,7 +65,12 @@ vi.mock('@tracing', () => ({
65
65
  traceInternal: async (_name: string, action: () => unknown) => action(),
66
66
  }));
67
67
 
68
- import { issueClaimLinkBoost, sendBoost } from './boost.helpers';
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 { isEncrypted, isVC2Format } from '@learncard/helpers';
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
- const credential = parseRenderedTemplate<UnsignedVC>(boostJsonString);
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
- // Set issuance date based on VC version
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
- if (!credentialContextSupportsDynamicEvidence(credential)) {
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:
@@ -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 { getBoostUri, isBoostViewableByClaimLink, isDraftBoost } from '@helpers/boost.helpers';
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,