@learncard/network-brain-service 4.0.3 → 4.1.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 (176) hide show
  1. package/.env.example +14 -0
  2. package/CHANGELOG.md +76 -0
  3. package/config/config.dev.json +5 -0
  4. package/config/config.example.json +5 -0
  5. package/config/config.production.json +5 -0
  6. package/config/config.scouts.dev.json +5 -0
  7. package/config/config.scouts.production.json +5 -0
  8. package/dist/accesslayer/campaign/audience.d.ts +7 -0
  9. package/dist/accesslayer/campaign/audience.d.ts.map +1 -0
  10. package/dist/accesslayer/campaign/create.d.ts +3 -0
  11. package/dist/accesslayer/campaign/create.d.ts.map +1 -0
  12. package/dist/accesslayer/campaign/delete.d.ts +2 -0
  13. package/dist/accesslayer/campaign/delete.d.ts.map +1 -0
  14. package/dist/accesslayer/campaign/read.d.ts +6 -0
  15. package/dist/accesslayer/campaign/read.d.ts.map +1 -0
  16. package/dist/accesslayer/campaign/shared.d.ts +16 -0
  17. package/dist/accesslayer/campaign/shared.d.ts.map +1 -0
  18. package/dist/accesslayer/campaign/state.d.ts +4 -0
  19. package/dist/accesslayer/campaign/state.d.ts.map +1 -0
  20. package/dist/accesslayer/campaign/update.d.ts +3 -0
  21. package/dist/accesslayer/campaign/update.d.ts.map +1 -0
  22. package/dist/accesslayer/consentflowcontract/relationships/create.d.ts.map +1 -1
  23. package/dist/accesslayer/consentflowcontract/relationships/update.d.ts.map +1 -1
  24. package/dist/accesslayer/credential-share-previews.d.ts +5 -0
  25. package/dist/accesslayer/credential-share-previews.d.ts.map +1 -0
  26. package/dist/accesslayer/inbox-batch/schema.d.ts +4 -0
  27. package/dist/accesslayer/inbox-batch/schema.d.ts.map +1 -0
  28. package/dist/accesslayer/inbox-batch/schema.test.d.ts +2 -0
  29. package/dist/accesslayer/inbox-batch/schema.test.d.ts.map +1 -0
  30. package/dist/accesslayer/inbox-batch/store.d.ts +1 -1
  31. package/dist/accesslayer/inbox-batch/store.d.ts.map +1 -1
  32. package/dist/accesslayer/profile/delete.d.ts.map +1 -1
  33. package/dist/accesslayer/skill/read.d.ts +12 -2
  34. package/dist/accesslayer/skill/read.d.ts.map +1 -1
  35. package/dist/accesslayer/skill/update.d.ts +11 -1
  36. package/dist/accesslayer/skill/update.d.ts.map +1 -1
  37. package/dist/app.d.ts +4 -0
  38. package/dist/app.d.ts.map +1 -1
  39. package/dist/cache/app-store.caches.d.ts +2 -1
  40. package/dist/cache/app-store.caches.d.ts.map +1 -1
  41. package/dist/config/environment.d.ts +268 -178
  42. package/dist/config/environment.d.ts.map +1 -1
  43. package/dist/config/skillEmbedding.d.ts +70 -0
  44. package/dist/config/skillEmbedding.d.ts.map +1 -0
  45. package/dist/config/skillEmbedding.neo4j.test.d.ts +2 -0
  46. package/dist/config/skillEmbedding.neo4j.test.d.ts.map +1 -0
  47. package/dist/config/stageConfig.d.ts +20 -0
  48. package/dist/config/stageConfig.d.ts.map +1 -1
  49. package/dist/constants/campaigns.d.ts +3 -0
  50. package/dist/constants/campaigns.d.ts.map +1 -0
  51. package/dist/didkit_wasm_bg.wasm +0 -0
  52. package/dist/helpers/bedrock-embedding.helpers.d.ts +40 -0
  53. package/dist/helpers/bedrock-embedding.helpers.d.ts.map +1 -0
  54. package/dist/helpers/bedrock-embedding.helpers.test.d.ts +2 -0
  55. package/dist/helpers/bedrock-embedding.helpers.test.d.ts.map +1 -0
  56. package/dist/helpers/bedrock-embedding.smoke.test.d.ts +2 -0
  57. package/dist/helpers/bedrock-embedding.smoke.test.d.ts.map +1 -0
  58. package/dist/helpers/boost.helpers.d.ts +1 -0
  59. package/dist/helpers/boost.helpers.d.ts.map +1 -1
  60. package/dist/helpers/skill-embedding.helpers.d.ts +65 -7
  61. package/dist/helpers/skill-embedding.helpers.d.ts.map +1 -1
  62. package/dist/helpers/skill-embedding.helpers.test.d.ts +2 -0
  63. package/dist/helpers/skill-embedding.helpers.test.d.ts.map +1 -0
  64. package/dist/index.js +2 -2
  65. package/dist/models/Campaign.d.ts +10 -0
  66. package/dist/models/Campaign.d.ts.map +1 -0
  67. package/dist/models/CredentialSharePreview.d.ts +14 -0
  68. package/dist/models/CredentialSharePreview.d.ts.map +1 -0
  69. package/dist/models/Integration.d.ts +1 -1
  70. package/dist/models/Profile.d.ts.map +1 -1
  71. package/dist/models/campaign-constraints.d.ts +2 -0
  72. package/dist/models/campaign-constraints.d.ts.map +1 -0
  73. package/dist/models/index.d.ts +2 -0
  74. package/dist/models/index.d.ts.map +1 -1
  75. package/dist/publish-metafile.json +1 -1
  76. package/dist/routes/app-store.d.ts +1 -1
  77. package/dist/routes/boosts.d.ts +24 -4
  78. package/dist/routes/boosts.d.ts.map +1 -1
  79. package/dist/routes/campaigns.d.ts +1411 -0
  80. package/dist/routes/campaigns.d.ts.map +1 -0
  81. package/dist/routes/contracts.d.ts +19 -6
  82. package/dist/routes/contracts.d.ts.map +1 -1
  83. package/dist/routes/credential-share-previews.d.ts +39 -0
  84. package/dist/routes/credential-share-previews.d.ts.map +1 -0
  85. package/dist/routes/credentials.d.ts +4 -1
  86. package/dist/routes/credentials.d.ts.map +1 -1
  87. package/dist/routes/inbox.d.ts +5 -5
  88. package/dist/routes/index.d.ts +4 -0
  89. package/dist/routes/index.d.ts.map +1 -1
  90. package/dist/routes/integrations.d.ts +3 -3
  91. package/dist/routes/profile-manager.d.ts +4 -0
  92. package/dist/routes/profile-manager.d.ts.map +1 -1
  93. package/dist/routes/profiles.d.ts +44 -0
  94. package/dist/routes/profiles.d.ts.map +1 -1
  95. package/dist/routes/skill-frameworks.d.ts +1 -0
  96. package/dist/routes/skill-frameworks.d.ts.map +1 -1
  97. package/dist/routes/skills-semantic-search.test.d.ts +2 -0
  98. package/dist/routes/skills-semantic-search.test.d.ts.map +1 -0
  99. package/dist/routes/skills.d.ts.map +1 -1
  100. package/dist/routes/test.d.ts +3 -1
  101. package/dist/routes/test.d.ts.map +1 -1
  102. package/dist/routes/workflows.d.ts.map +1 -1
  103. package/dist/seed/seedSkillFrameworks.d.ts +1 -0
  104. package/dist/seed/seedSkillFrameworks.d.ts.map +1 -1
  105. package/dist/seed/seedSkillFrameworks.embeddings.test.d.ts +2 -0
  106. package/dist/seed/seedSkillFrameworks.embeddings.test.d.ts.map +1 -0
  107. package/dist/types/app-manifest.d.ts +2 -2
  108. package/dist/types/campaign.d.ts +19 -0
  109. package/dist/types/campaign.d.ts.map +1 -0
  110. package/dist/types/consentflowcontract.d.ts +1 -1
  111. package/dist/types/integration.d.ts +3 -3
  112. package/dist/types/profile.d.ts +9 -0
  113. package/dist/types/profile.d.ts.map +1 -1
  114. package/dist/types/skill.d.ts.map +1 -1
  115. package/esbuildPlugins.cjs +9 -6
  116. package/package.json +20 -17
  117. package/project.json +14 -0
  118. package/scripts/skill-embeddings-backfill.ts +161 -0
  119. package/serverless-local.yml +4 -0
  120. package/serverless.function-env.cjs +3 -0
  121. package/serverless.local.yml +4 -0
  122. package/serverless.yml +12 -1
  123. package/src/accesslayer/campaign/README.md +174 -0
  124. package/src/accesslayer/campaign/audience.ts +152 -0
  125. package/src/accesslayer/campaign/create.ts +47 -0
  126. package/src/accesslayer/campaign/delete.ts +18 -0
  127. package/src/accesslayer/campaign/read.ts +125 -0
  128. package/src/accesslayer/campaign/shared.ts +92 -0
  129. package/src/accesslayer/campaign/state.ts +80 -0
  130. package/src/accesslayer/campaign/update.ts +50 -0
  131. package/src/accesslayer/consentflowcontract/relationships/create.ts +3 -1
  132. package/src/accesslayer/consentflowcontract/relationships/update.ts +5 -1
  133. package/src/accesslayer/credential-share-previews.ts +76 -0
  134. package/src/accesslayer/inbox-batch/schema.test.ts +91 -0
  135. package/src/accesslayer/inbox-batch/schema.ts +54 -0
  136. package/src/accesslayer/inbox-batch/store.ts +2 -38
  137. package/src/accesslayer/profile/delete.ts +6 -0
  138. package/src/accesslayer/skill/read.ts +56 -16
  139. package/src/accesslayer/skill/update.ts +43 -1
  140. package/src/app.ts +9 -0
  141. package/src/config/environment.test.ts +208 -0
  142. package/src/config/environment.ts +166 -11
  143. package/src/config/skillEmbedding.neo4j.test.ts +138 -0
  144. package/src/config/skillEmbedding.ts +202 -0
  145. package/src/constants/campaigns.ts +9 -0
  146. package/src/helpers/bedrock-embedding.helpers.test.ts +208 -0
  147. package/src/helpers/bedrock-embedding.helpers.ts +243 -0
  148. package/src/helpers/bedrock-embedding.smoke.test.ts +53 -0
  149. package/src/helpers/boost-storage.helpers.test.ts +155 -1
  150. package/src/helpers/boost.helpers.ts +52 -10
  151. package/src/helpers/skill-embedding.helpers.test.ts +1044 -0
  152. package/src/helpers/skill-embedding.helpers.ts +611 -123
  153. package/src/models/Campaign.ts +32 -0
  154. package/src/models/CredentialSharePreview.ts +30 -0
  155. package/src/models/Profile.ts +1 -0
  156. package/src/models/campaign-constraints.ts +22 -0
  157. package/src/models/index.ts +29 -1
  158. package/src/routes/campaigns.ts +179 -0
  159. package/src/routes/credential-share-previews.ts +74 -0
  160. package/src/routes/profiles.ts +7 -0
  161. package/src/routes/skills-semantic-search.test.ts +168 -0
  162. package/src/routes/skills.ts +42 -6
  163. package/src/routes/workflows.ts +8 -1
  164. package/src/seed/seedSkillFrameworks.embeddings.test.ts +82 -0
  165. package/src/seed/seedSkillFrameworks.test.ts +6 -0
  166. package/src/seed/seedSkillFrameworks.ts +83 -18
  167. package/src/types/campaign.ts +17 -0
  168. package/src/types/skill.ts +6 -0
  169. package/test/campaigns.spec.ts +789 -0
  170. package/test/consentflow.spec.ts +101 -0
  171. package/test/credential-share-previews.spec.ts +97 -0
  172. package/test/inbox-batch.spec.ts +189 -1
  173. package/test/verification/campaigns.ts +49 -0
  174. package/vitest.campaigns.config.ts +21 -0
  175. package/vitest.config.ts +11 -0
  176. package/vitest.integration.config.ts +1 -0
@@ -0,0 +1,53 @@
1
+ /**
2
+ * LC-2206 opt-in smoke test against REAL Amazon Bedrock (paid: three InvokeModel calls).
3
+ * Skipped unless SKILL_EMBEDDING_BEDROCK_SMOKE=true. Never enable it in CI.
4
+ *
5
+ * SKILL_EMBEDDING_BEDROCK_SMOKE=true AWS_PROFILE=<profile> \
6
+ * bunx vitest run --config vitest.config.ts src/helpers/bedrock-embedding.smoke.test.ts
7
+ *
8
+ * Uses the normal AWS credential chain (SigV4) in us-east-1 and the same request shape,
9
+ * purposes, dimension and normalisation as production.
10
+ */
11
+ import { describe, expect, it } from 'vitest';
12
+
13
+ import {
14
+ DEFAULT_SKILL_EMBEDDING_DIMENSION,
15
+ DEFAULT_SKILL_EMBEDDING_REGION,
16
+ NOVA2_EMBEDDING_MODEL_ID,
17
+ } from '../config/skillEmbedding';
18
+ import { embedTextWithBedrock, type BedrockEmbeddingConfig } from './bedrock-embedding.helpers';
19
+
20
+ const enabled = process.env.SKILL_EMBEDDING_BEDROCK_SMOKE === 'true';
21
+
22
+ const config: BedrockEmbeddingConfig = {
23
+ modelId: process.env.SKILL_EMBEDDING_BEDROCK_MODEL_ID || NOVA2_EMBEDDING_MODEL_ID,
24
+ region: process.env.SKILL_EMBEDDING_BEDROCK_REGION || DEFAULT_SKILL_EMBEDDING_REGION,
25
+ dimension: Number(process.env.SKILL_EMBEDDING_DIMENSION || DEFAULT_SKILL_EMBEDDING_DIMENSION),
26
+ timeoutMs: 15_000,
27
+ };
28
+
29
+ const dot = (a: number[], b: number[]) =>
30
+ a.reduce((sum, value, index) => sum + value * (b[index] ?? 0), 0);
31
+
32
+ describe.skipIf(!enabled)('Bedrock Nova 2 embeddings smoke (real AWS, opt-in)', () => {
33
+ it('returns unit vectors where a related skill outranks an unrelated one', async () => {
34
+ const query = await embedTextWithBedrock('python data analysis', 'query', config);
35
+ const related = await embedTextWithBedrock(
36
+ 'Data Analysis\nAnalyse datasets with Python and statistics',
37
+ 'index',
38
+ config
39
+ );
40
+ const unrelated = await embedTextWithBedrock(
41
+ 'Customer service\nHelp customers resolve issues politely',
42
+ 'index',
43
+ config
44
+ );
45
+
46
+ for (const vector of [query, related, unrelated]) {
47
+ expect(vector).toHaveLength(config.dimension);
48
+ expect(Math.hypot(...vector)).toBeCloseTo(1, 6);
49
+ }
50
+
51
+ expect(dot(query, related)).toBeGreaterThan(dot(query, unrelated));
52
+ }, 60_000);
53
+ });
@@ -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: