@learncard/network-brain-service 3.17.0 → 3.19.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 (274) hide show
  1. package/.env.example +4 -0
  2. package/AGENTS.md +177 -159
  3. package/CHANGELOG.md +148 -0
  4. package/README.md +26 -8
  5. package/compose.inbox.yml +7 -0
  6. package/dist/accesslayer/app-store-listing/relationships/read.d.ts.map +1 -1
  7. package/dist/accesslayer/auth-grant/read.d.ts +1 -0
  8. package/dist/accesslayer/auth-grant/read.d.ts.map +1 -1
  9. package/dist/accesslayer/consentflowcontract/relationships/create.d.ts +3 -2
  10. package/dist/accesslayer/consentflowcontract/relationships/create.d.ts.map +1 -1
  11. package/dist/accesslayer/consentflowcontract/relationships/read.d.ts +1 -0
  12. package/dist/accesslayer/consentflowcontract/relationships/read.d.ts.map +1 -1
  13. package/dist/accesslayer/consentflowcontract/relationships/update.d.ts +5 -3
  14. package/dist/accesslayer/consentflowcontract/relationships/update.d.ts.map +1 -1
  15. package/dist/accesslayer/credential/create.d.ts +2 -1
  16. package/dist/accesslayer/credential/create.d.ts.map +1 -1
  17. package/dist/accesslayer/credential/create.test.d.ts +2 -0
  18. package/dist/accesslayer/credential/create.test.d.ts.map +1 -0
  19. package/dist/accesslayer/credential/relationships/read.d.ts +1 -0
  20. package/dist/accesslayer/credential/relationships/read.d.ts.map +1 -1
  21. package/dist/accesslayer/credential-activity/create.d.ts.map +1 -1
  22. package/dist/accesslayer/credential-refresh/email-delivery.d.ts +18 -0
  23. package/dist/accesslayer/credential-refresh/email-delivery.d.ts.map +1 -0
  24. package/dist/accesslayer/credential-refresh/index.d.ts +1 -0
  25. package/dist/accesslayer/credential-refresh/index.d.ts.map +1 -1
  26. package/dist/accesslayer/credential-refresh/read.d.ts.map +1 -1
  27. package/dist/accesslayer/credential-refresh/update.d.ts.map +1 -1
  28. package/dist/accesslayer/inbox-batch/store.d.ts +33 -0
  29. package/dist/accesslayer/inbox-batch/store.d.ts.map +1 -0
  30. package/dist/accesslayer/inbox-credential/create.d.ts +5 -0
  31. package/dist/accesslayer/inbox-credential/create.d.ts.map +1 -1
  32. package/dist/accesslayer/inbox-credential/refresh.d.ts +71 -0
  33. package/dist/accesslayer/inbox-credential/refresh.d.ts.map +1 -0
  34. package/dist/accesslayer/inbox-credential/update.d.ts +1 -1
  35. package/dist/accesslayer/inbox-credential/update.d.ts.map +1 -1
  36. package/dist/cache/atomic-counter.test-helpers.d.ts +3 -0
  37. package/dist/cache/atomic-counter.test-helpers.d.ts.map +1 -0
  38. package/dist/cache/atomic-counter.test.d.ts +2 -0
  39. package/dist/cache/atomic-counter.test.d.ts.map +1 -0
  40. package/dist/cache/index.d.ts +1 -1
  41. package/dist/cache/index.d.ts.map +1 -1
  42. package/dist/config/environment.d.ts +46 -0
  43. package/dist/config/environment.d.ts.map +1 -1
  44. package/dist/credential-refresh.d.ts.map +1 -1
  45. package/dist/didkit_wasm_bg.wasm +0 -0
  46. package/dist/helpers/activity.helpers.d.ts +4 -0
  47. package/dist/helpers/activity.helpers.d.ts.map +1 -1
  48. package/dist/helpers/boost-storage.helpers.test.d.ts +2 -0
  49. package/dist/helpers/boost-storage.helpers.test.d.ts.map +1 -0
  50. package/dist/helpers/boost.helpers.d.ts +3 -7
  51. package/dist/helpers/boost.helpers.d.ts.map +1 -1
  52. package/dist/helpers/contact-method.helpers.d.ts.map +1 -1
  53. package/dist/helpers/contact-method.helpers.test.d.ts +2 -0
  54. package/dist/helpers/contact-method.helpers.test.d.ts.map +1 -0
  55. package/dist/helpers/credential-refresh-email-content.helpers.d.ts +44 -0
  56. package/dist/helpers/credential-refresh-email-content.helpers.d.ts.map +1 -0
  57. package/dist/helpers/credential-refresh-email-content.helpers.test.d.ts +2 -0
  58. package/dist/helpers/credential-refresh-email-content.helpers.test.d.ts.map +1 -0
  59. package/dist/helpers/credential-refresh-email.helpers.d.ts +17 -0
  60. package/dist/helpers/credential-refresh-email.helpers.d.ts.map +1 -0
  61. package/dist/helpers/credential-refresh-proof.helpers.d.ts +4 -0
  62. package/dist/helpers/credential-refresh-proof.helpers.d.ts.map +1 -0
  63. package/dist/helpers/credential-refresh-proof.helpers.test.d.ts +2 -0
  64. package/dist/helpers/credential-refresh-proof.helpers.test.d.ts.map +1 -0
  65. package/dist/helpers/credential-refresh.helpers.d.ts +45 -2
  66. package/dist/helpers/credential-refresh.helpers.d.ts.map +1 -1
  67. package/dist/helpers/credential.helpers.d.ts +4 -2
  68. package/dist/helpers/credential.helpers.d.ts.map +1 -1
  69. package/dist/helpers/finalize-inbox.helpers.d.ts.map +1 -1
  70. package/dist/helpers/inbox-batch-http.helpers.d.ts +15 -0
  71. package/dist/helpers/inbox-batch-http.helpers.d.ts.map +1 -0
  72. package/dist/helpers/inbox-batch-http.helpers.test.d.ts +2 -0
  73. package/dist/helpers/inbox-batch-http.helpers.test.d.ts.map +1 -0
  74. package/dist/helpers/inbox-batch-status.helpers.d.ts +3 -0
  75. package/dist/helpers/inbox-batch-status.helpers.d.ts.map +1 -0
  76. package/dist/helpers/inbox-batch.helpers.d.ts +10 -0
  77. package/dist/helpers/inbox-batch.helpers.d.ts.map +1 -0
  78. package/dist/helpers/inbox-batch.helpers.test.d.ts +2 -0
  79. package/dist/helpers/inbox-batch.helpers.test.d.ts.map +1 -0
  80. package/dist/helpers/inbox-issuance-error.helpers.d.ts +13 -0
  81. package/dist/helpers/inbox-issuance-error.helpers.d.ts.map +1 -0
  82. package/dist/helpers/inbox-queue.helpers.d.ts +11 -0
  83. package/dist/helpers/inbox-queue.helpers.d.ts.map +1 -0
  84. package/dist/helpers/inbox-queue.helpers.test.d.ts +2 -0
  85. package/dist/helpers/inbox-queue.helpers.test.d.ts.map +1 -0
  86. package/dist/helpers/inbox-refresh.helpers.d.ts +186 -0
  87. package/dist/helpers/inbox-refresh.helpers.d.ts.map +1 -0
  88. package/dist/helpers/inbox.helpers.d.ts +9 -1
  89. package/dist/helpers/inbox.helpers.d.ts.map +1 -1
  90. package/dist/helpers/issuedCredentialStatus.helpers.d.ts +5 -0
  91. package/dist/helpers/issuedCredentialStatus.helpers.d.ts.map +1 -0
  92. package/dist/helpers/notifications.helpers.d.ts.map +1 -1
  93. package/dist/helpers/rateLimit.helpers.d.ts.map +1 -1
  94. package/dist/helpers/refresh-send-intent.helpers.d.ts +51 -0
  95. package/dist/helpers/refresh-send-intent.helpers.d.ts.map +1 -0
  96. package/dist/helpers/shim.d.ts +1 -1
  97. package/dist/helpers/shim.d.ts.map +1 -1
  98. package/dist/helpers/signingAuthority.helpers.d.ts +3 -2
  99. package/dist/helpers/signingAuthority.helpers.d.ts.map +1 -1
  100. package/dist/helpers/signingAuthority.helpers.test.d.ts +2 -0
  101. package/dist/helpers/signingAuthority.helpers.test.d.ts.map +1 -0
  102. package/dist/helpers/status-list.helpers.d.ts.map +1 -1
  103. package/dist/helpers/status-list.helpers.test.d.ts +2 -0
  104. package/dist/helpers/status-list.helpers.test.d.ts.map +1 -0
  105. package/dist/inbox-queue-local.d.ts +2 -0
  106. package/dist/inbox-queue-local.d.ts.map +1 -0
  107. package/dist/index.js +2 -2
  108. package/dist/models/AuthGrant.d.ts.map +1 -1
  109. package/dist/models/ConsentFlowTerms.d.ts.map +1 -1
  110. package/dist/models/ConsentFlowTransaction.d.ts.map +1 -1
  111. package/dist/models/Credential.d.ts +1 -0
  112. package/dist/models/Credential.d.ts.map +1 -1
  113. package/dist/models/CredentialActivity.d.ts +1 -0
  114. package/dist/models/CredentialActivity.d.ts.map +1 -1
  115. package/dist/models/CredentialRefresh.d.ts +5 -2
  116. package/dist/models/CredentialRefresh.d.ts.map +1 -1
  117. package/dist/models/InboxCredential.d.ts +1 -0
  118. package/dist/models/InboxCredential.d.ts.map +1 -1
  119. package/dist/models/credential-refresh-constraints.d.ts.map +1 -1
  120. package/dist/openapi.d.ts.map +1 -1
  121. package/dist/routes/act-as.test.d.ts +2 -0
  122. package/dist/routes/act-as.test.d.ts.map +1 -0
  123. package/dist/routes/activity.d.ts +3 -0
  124. package/dist/routes/activity.d.ts.map +1 -1
  125. package/dist/routes/app-store.d.ts.map +1 -1
  126. package/dist/routes/auth-grants.d.ts +10 -0
  127. package/dist/routes/auth-grants.d.ts.map +1 -1
  128. package/dist/routes/boosts.d.ts +506 -12
  129. package/dist/routes/boosts.d.ts.map +1 -1
  130. package/dist/routes/contracts.d.ts +48 -2
  131. package/dist/routes/contracts.d.ts.map +1 -1
  132. package/dist/routes/credential-refreshes.d.ts +4 -4
  133. package/dist/routes/credential-refreshes.d.ts.map +1 -1
  134. package/dist/routes/credentials.d.ts +8 -2
  135. package/dist/routes/credentials.d.ts.map +1 -1
  136. package/dist/routes/did-metadata.d.ts +8 -8
  137. package/dist/routes/federation.d.ts +2 -2
  138. package/dist/routes/inbox.d.ts +973 -31
  139. package/dist/routes/inbox.d.ts.map +1 -1
  140. package/dist/routes/index.d.ts +47 -127
  141. package/dist/routes/index.d.ts.map +1 -1
  142. package/dist/routes/presentations.d.ts +6 -6
  143. package/dist/routes/storage.d.ts +63 -63
  144. package/dist/routes/test.d.ts +6 -0
  145. package/dist/routes/test.d.ts.map +1 -1
  146. package/dist/routes/workflows.d.ts +14 -14
  147. package/dist/routes/workflows.d.ts.map +1 -1
  148. package/dist/services/delivery/adapters/postmark.adapter.d.ts.map +1 -1
  149. package/dist/services/delivery/adapters/postmark.adapter.test.d.ts +2 -0
  150. package/dist/services/delivery/adapters/postmark.adapter.test.d.ts.map +1 -0
  151. package/dist/services/registry/registry.factory.d.ts.map +1 -1
  152. package/dist/types/activity.d.ts +4 -0
  153. package/dist/types/activity.d.ts.map +1 -1
  154. package/dist/types/consentflowcontract.d.ts +22 -8
  155. package/dist/types/consentflowcontract.d.ts.map +1 -1
  156. package/dist/types/credential-refresh.d.ts +10 -1
  157. package/dist/types/credential-refresh.d.ts.map +1 -1
  158. package/dist/types/credential.d.ts +7 -0
  159. package/dist/types/credential.d.ts.map +1 -1
  160. package/dist/types/did-metadata.d.ts +2 -2
  161. package/dist/types/inbox-batch.d.ts +38 -0
  162. package/dist/types/inbox-batch.d.ts.map +1 -0
  163. package/inbox-e2e-setup.ts +53 -1
  164. package/inbox-elasticmq.conf +10 -0
  165. package/lambda.ts +61 -16
  166. package/package.json +18 -16
  167. package/scripts/setup-inbox-queues.ts +60 -0
  168. package/serverless-local.yml +2 -0
  169. package/serverless.yml +75 -0
  170. package/src/accesslayer/app-store-listing/relationships/read.ts +4 -3
  171. package/src/accesslayer/auth-grant/read.ts +2 -1
  172. package/src/accesslayer/consentflowcontract/relationships/create.ts +15 -5
  173. package/src/accesslayer/consentflowcontract/relationships/read.ts +161 -63
  174. package/src/accesslayer/consentflowcontract/relationships/update.ts +43 -10
  175. package/src/accesslayer/credential/create.test.ts +141 -0
  176. package/src/accesslayer/credential/create.ts +27 -3
  177. package/src/accesslayer/credential-activity/create.ts +1 -0
  178. package/src/accesslayer/credential-refresh/email-delivery.ts +116 -0
  179. package/src/accesslayer/credential-refresh/index.ts +1 -0
  180. package/src/accesslayer/credential-refresh/read.ts +8 -2
  181. package/src/accesslayer/credential-refresh/update.ts +4 -0
  182. package/src/accesslayer/inbox-batch/store.ts +469 -0
  183. package/src/accesslayer/inbox-credential/create.ts +51 -19
  184. package/src/accesslayer/inbox-credential/refresh.ts +221 -0
  185. package/src/accesslayer/inbox-credential/update.ts +1 -1
  186. package/src/cache/atomic-counter.test-helpers.ts +27 -0
  187. package/src/cache/atomic-counter.test.ts +6 -0
  188. package/src/cache/index.ts +25 -13
  189. package/src/config/environment.ts +26 -0
  190. package/src/credential-refresh.ts +5 -1
  191. package/src/docker-entry.ts +8 -0
  192. package/src/helpers/activity.helpers.ts +4 -0
  193. package/src/helpers/app-counter.helpers.ts +1 -1
  194. package/src/helpers/boost-storage.helpers.test.ts +242 -0
  195. package/src/helpers/boost.helpers.ts +58 -437
  196. package/src/helpers/contact-method.helpers.test.ts +43 -0
  197. package/src/helpers/contact-method.helpers.ts +3 -4
  198. package/src/helpers/credential-refresh-email-content.helpers.test.ts +222 -0
  199. package/src/helpers/credential-refresh-email-content.helpers.ts +155 -0
  200. package/src/helpers/credential-refresh-email.helpers.ts +201 -0
  201. package/src/helpers/credential-refresh-proof.helpers.test.ts +103 -0
  202. package/src/helpers/credential-refresh-proof.helpers.ts +43 -0
  203. package/src/helpers/credential-refresh.helpers.ts +385 -107
  204. package/src/helpers/credential.helpers.ts +6 -2
  205. package/src/helpers/finalize-inbox.helpers.ts +39 -22
  206. package/src/helpers/inbox-batch-http.helpers.test.ts +188 -0
  207. package/src/helpers/inbox-batch-http.helpers.ts +150 -0
  208. package/src/helpers/inbox-batch-status.helpers.ts +11 -0
  209. package/src/helpers/inbox-batch.helpers.test.ts +668 -0
  210. package/src/helpers/inbox-batch.helpers.ts +374 -0
  211. package/src/helpers/inbox-issuance-error.helpers.ts +25 -0
  212. package/src/helpers/inbox-queue.helpers.test.ts +253 -0
  213. package/src/helpers/inbox-queue.helpers.ts +372 -0
  214. package/src/helpers/inbox-refresh.helpers.ts +432 -0
  215. package/src/helpers/inbox.helpers.ts +301 -50
  216. package/src/helpers/issuedCredentialStatus.helpers.ts +11 -0
  217. package/src/helpers/notifications.helpers.ts +4 -2
  218. package/src/helpers/rateLimit.helpers.ts +2 -5
  219. package/src/helpers/refresh-send-intent.helpers.ts +273 -0
  220. package/src/helpers/shim.ts +32 -31
  221. package/src/helpers/signingAuthority.helpers.test.ts +198 -0
  222. package/src/helpers/signingAuthority.helpers.ts +41 -18
  223. package/src/helpers/status-list.helpers.test.ts +77 -0
  224. package/src/helpers/status-list.helpers.ts +31 -8
  225. package/src/inbox-queue-local.ts +28 -0
  226. package/src/models/AuthGrant.ts +1 -0
  227. package/src/models/ConsentFlowTerms.ts +4 -0
  228. package/src/models/ConsentFlowTransaction.ts +4 -0
  229. package/src/models/Credential.ts +1 -0
  230. package/src/models/CredentialActivity.ts +3 -5
  231. package/src/models/CredentialRefresh.ts +4 -2
  232. package/src/models/InboxCredential.ts +2 -0
  233. package/src/models/credential-refresh-constraints.test.ts +8 -3
  234. package/src/models/credential-refresh-constraints.ts +5 -0
  235. package/src/openapi.ts +8 -0
  236. package/src/routes/act-as.test.ts +239 -0
  237. package/src/routes/app-store.ts +69 -52
  238. package/src/routes/auth-grants.ts +13 -11
  239. package/src/routes/boosts.ts +943 -120
  240. package/src/routes/contracts.ts +55 -7
  241. package/src/routes/credential-refreshes.test.ts +8 -2
  242. package/src/routes/credential-refreshes.ts +4 -1
  243. package/src/routes/credentials.ts +2 -1
  244. package/src/routes/inbox.ts +95 -49
  245. package/src/routes/index.ts +164 -88
  246. package/src/routes/skills.ts +1 -1
  247. package/src/routes/workflows.ts +80 -31
  248. package/src/services/delivery/adapters/postmark.adapter.test.ts +54 -0
  249. package/src/services/delivery/adapters/postmark.adapter.ts +12 -0
  250. package/src/services/registry/registry.factory.ts +2 -1
  251. package/src/types/activity.ts +2 -0
  252. package/src/types/consentflowcontract.ts +21 -3
  253. package/src/types/credential-refresh.ts +21 -2
  254. package/src/types/credential.ts +13 -1
  255. package/src/types/inbox-batch.ts +46 -0
  256. package/test/app-store-listing.spec.ts +66 -30
  257. package/test/atomic-counter.e2e.ts +12 -0
  258. package/test/auth-grant.spec.ts +21 -21
  259. package/test/bitstring-status-list.spec.ts +12 -9
  260. package/test/consentflow.spec.ts +453 -26
  261. package/test/credential-refresh-email.spec.ts +663 -0
  262. package/test/credential-refresh-publication.spec.ts +13 -5
  263. package/test/guardian-gated-routes.spec.ts +337 -180
  264. package/test/helpers/inbox-e2e-environment.ts +6 -0
  265. package/test/inbox-batch.spec.ts +1338 -0
  266. package/test/inbox-refresh.spec.ts +534 -0
  267. package/test/inbox-security.e2e.ts +48 -3
  268. package/test/openapi.spec.ts +41 -1
  269. package/test/refresh-send.spec.ts +1799 -0
  270. package/test/registry.spec.ts +56 -11
  271. package/test/revoke-suspend-per-instance.spec.ts +9 -1
  272. package/vitest.config.ts +13 -0
  273. package/vitest.inbox-e2e.config.ts +5 -1
  274. package/vitest.integration.config.ts +2 -0
@@ -0,0 +1,663 @@
1
+ import { acceptCredential } from '@helpers/credential.helpers';
2
+ import { vi } from 'vitest';
3
+ import { environment } from '@environment';
4
+ import { deliverCredentialRefreshEmailNotification } from '@helpers/credential-refresh-email.helpers';
5
+ import { VC, UnsignedVC, LCNNotification } from '@learncard/types';
6
+
7
+ import { neogma } from '@instance';
8
+
9
+ import { getUser } from './helpers/getClient';
10
+ import { addNotificationToQueueSpy } from './helpers/spies';
11
+ import * as Notifications from '@helpers/notifications.helpers';
12
+ import {
13
+ getCredentialRefresh,
14
+ getCredentialRefreshEmailDelivery,
15
+ getCredentialRefreshHead,
16
+ setCredentialRefreshState,
17
+ } from '@accesslayer/credential-refresh';
18
+ import { getProfileByProfileId } from '@accesslayer/profile/read';
19
+ import { isProfileManaged } from '@accesslayer/profile/relationships/read';
20
+ import { createContactMethod } from '@accesslayer/contact-method/create';
21
+ import { createProfileContactMethodRelationship } from '@accesslayer/contact-method/relationships/create';
22
+ import { setPrimaryContactMethod, verifyContactMethod } from '@accesslayer/contact-method/update';
23
+ import { createProfileManager } from '@accesslayer/profile-manager/create';
24
+ import { createManagesRelationship } from '@accesslayer/profile-manager/relationships/create';
25
+ import { LogAdapter } from '@services/delivery/adapters/log.adapter';
26
+ import type { Notification } from '@services/delivery/delivery.service';
27
+ import { computeCredentialRefreshDeliveryKey } from '@helpers/credential-refresh-materiality.helpers';
28
+ import { ensureCredentialRefreshConstraints } from '../src/models/credential-refresh-constraints';
29
+
30
+ /**
31
+ * LC-2198 — production update emails for managed credential refresh.
32
+ *
33
+ * Covers the privacy-minimal recipient/title decisions end-to-end:
34
+ * - verified contact methods of the bound holder only (never the original inbox
35
+ * address, never caller-supplied)
36
+ * - managed children route to a verified guardian or are skipped entirely
37
+ * - notifyHolder:false / non-material / pre-claim paths emit no email
38
+ * - delivery windows collapse retries and concurrent races to one email
39
+ * - email failure never changes the publication outcome or duplicates the push
40
+ *
41
+ * The delivery adapter stays log-only; Postmark is never contacted.
42
+ */
43
+
44
+ const runQuery = async (cypher: string, params: Record<string, unknown> = {}) =>
45
+ neogma.queryRunner.run(cypher, params);
46
+
47
+ const ISSUER_PROFILE_ID = 'refresh-email-issuer';
48
+ const HOLDER_PROFILE_ID = 'refresh-email-holder';
49
+ const GUARDIAN_PROFILE_ID = 'refresh-email-guardian';
50
+ const CREDENTIAL_ID = 'urn:uuid:refreshable-credential-email';
51
+
52
+ const HOLDER_VERIFIED_EMAIL = 'holder-verified@example.com';
53
+ const HOLDER_UNVERIFIED_EMAIL = 'holder-unverified@example.com';
54
+ const GUARDIAN_VERIFIED_EMAIL = 'guardian-verified@example.com';
55
+
56
+ const SECRET_TITLE = 'Introduction to Biology';
57
+ const SECRET_GRADE_MARKER = 'SECRET-GRADE-MARKER-1111';
58
+ const SECRET_SUMMARY_MARKER = 'SECRET-SUMMARY-MARKER-2222';
59
+ const SECRET_SUBJECT_MARKER = 'SECRET-SUBJECT-MARKER-3333';
60
+
61
+ type AllocationResult = {
62
+ refreshId: string;
63
+ refreshService: { id: string; type: string; authorization?: { type: string } };
64
+ };
65
+
66
+ let issuer: Awaited<ReturnType<typeof getUser>>;
67
+ let holder: Awaited<ReturnType<typeof getUser>>;
68
+ let emailSendSpy: ReturnType<typeof vi.spyOn>;
69
+
70
+ const emailDeliveries = (): Notification[] =>
71
+ emailSendSpy.mock.calls
72
+ .map(call => call[0] as Notification)
73
+ .filter(notification => notification.templateId === 'credential-updated');
74
+
75
+ const addEmail = async (
76
+ profileId: string,
77
+ value: string,
78
+ { verified = true, primary = false }: { verified?: boolean; primary?: boolean } = {}
79
+ ) => {
80
+ const contactMethod = await createContactMethod({
81
+ type: 'email',
82
+ value,
83
+ isVerified: false,
84
+ isPrimary: false,
85
+ });
86
+
87
+ await createProfileContactMethodRelationship(profileId, contactMethod.id);
88
+
89
+ if (verified) await verifyContactMethod(contactMethod.id);
90
+
91
+ if (primary) {
92
+ const profile = await getProfileByProfileId(profileId);
93
+ await setPrimaryContactMethod(profile!.did, contactMethod.id);
94
+ }
95
+
96
+ return contactMethod;
97
+ };
98
+
99
+ const linkGuardian = async (profileId: string, childProfileId: string) => {
100
+ const manager = await createProfileManager({
101
+ displayName: 'Guardian',
102
+ managerType: 'guardian',
103
+ });
104
+
105
+ await createManagesRelationship(manager.id, childProfileId);
106
+ await manager.relateTo({ alias: 'administratedBy', where: { profileId } });
107
+ };
108
+
109
+ const allocate = async (): Promise<AllocationResult> =>
110
+ issuer.clients.fullAuth.credentialRefresh.allocateCredentialRefresh({
111
+ holder: { profileId: HOLDER_PROFILE_ID, did: holder.learnCard.id.did() },
112
+ credentialId: CREDENTIAL_ID,
113
+ });
114
+
115
+ const buildUnsignedCredential = (
116
+ allocation: AllocationResult,
117
+ overrides: Record<string, unknown> = {}
118
+ ): UnsignedVC =>
119
+ ({
120
+ '@context': [
121
+ 'https://www.w3.org/ns/credentials/v2',
122
+ {
123
+ 'LearnCardCredentialRefresh2026':
124
+ 'https://learncard.com/refresh#LearnCardCredentialRefresh2026',
125
+ authorization: {
126
+ '@id': 'https://purl.imsglobal.org/spec/ob/v3p0#authorization',
127
+ '@context': {
128
+ LearnCardDIDAuth: 'https://docs.learncard.com/definitions#LearnCardDIDAuth',
129
+ },
130
+ },
131
+ },
132
+ ],
133
+ id: CREDENTIAL_ID,
134
+ type: ['VerifiableCredential'],
135
+ issuer: issuer.learnCard.id.did(),
136
+ validFrom: '2026-01-01T00:00:00Z',
137
+ name: 'Original Transcript',
138
+ credentialSubject: { id: holder.learnCard.id.did() },
139
+ refreshService: allocation.refreshService,
140
+ ...overrides,
141
+ }) as UnsignedVC;
142
+
143
+ const signAs = async (
144
+ user: Awaited<ReturnType<typeof getUser>>,
145
+ unsigned: UnsignedVC
146
+ ): Promise<VC> => user.learnCard.invoke.issueCredential(unsigned);
147
+
148
+ const publishIssuerSigned = async (
149
+ refreshId: string,
150
+ credential: VC,
151
+ extras: Record<string, unknown> = {}
152
+ ) =>
153
+ issuer.clients.fullAuth.credentialRefresh.publishCredentialRefresh({
154
+ mode: 'issuer-signed',
155
+ refreshId,
156
+ signedCredential: credential,
157
+ ...extras,
158
+ } as Parameters<typeof issuer.clients.fullAuth.credentialRefresh.publishCredentialRefresh>[0]);
159
+
160
+ const sendOriginal = async (): Promise<{ allocation: AllocationResult; uri: string }> => {
161
+ const allocation = await allocate();
162
+ const credential = await signAs(issuer, buildUnsignedCredential(allocation));
163
+
164
+ const uri = await issuer.clients.fullAuth.credentialRefresh.sendRefreshableCredential({
165
+ refreshId: allocation.refreshId,
166
+ credential,
167
+ });
168
+
169
+ return { allocation, uri };
170
+ };
171
+
172
+ const activate = async (refreshId: string): Promise<void> => {
173
+ await setCredentialRefreshState(refreshId, 'active');
174
+ };
175
+
176
+ const updatedCredential = async (
177
+ allocation: AllocationResult,
178
+ overrides: Record<string, unknown> = {}
179
+ ): Promise<VC> =>
180
+ signAs(
181
+ issuer,
182
+ buildUnsignedCredential(allocation, {
183
+ validFrom: '2026-02-01T00:00:00Z',
184
+ name: SECRET_TITLE,
185
+ ...overrides,
186
+ })
187
+ );
188
+
189
+ describe('credential refresh update emails', () => {
190
+ beforeAll(async () => {
191
+ issuer = await getUser('d'.repeat(64));
192
+ holder = await getUser('b'.repeat(64));
193
+
194
+ vi.spyOn(Notifications, 'addNotificationToQueue').mockImplementation(
195
+ addNotificationToQueueSpy
196
+ );
197
+
198
+ emailSendSpy = vi.spyOn(LogAdapter.prototype, 'send').mockResolvedValue(undefined);
199
+
200
+ await ensureCredentialRefreshConstraints();
201
+ });
202
+
203
+ beforeEach(async () => {
204
+ await runQuery('MATCH (d:CredentialRefreshEmailDelivery) DETACH DELETE d');
205
+ await runQuery('MATCH (r:CredentialRefresh) DETACH DELETE r');
206
+ await runQuery('MATCH (c:Credential) DETACH DELETE c');
207
+ await runQuery('MATCH (cm:ContactMethod) DETACH DELETE cm');
208
+ await runQuery('MATCH (pm:ProfileManager) DETACH DELETE pm');
209
+ await runQuery('MATCH (p:Profile) DETACH DELETE p');
210
+
211
+ await issuer.clients.fullAuth.profile.createProfile({
212
+ profileId: ISSUER_PROFILE_ID,
213
+ displayName: 'Email Test School',
214
+ });
215
+ await holder.clients.fullAuth.profile.createProfile({
216
+ profileId: HOLDER_PROFILE_ID,
217
+ displayName: 'Email Test Holder',
218
+ });
219
+
220
+ addNotificationToQueueSpy.mockReset();
221
+ emailSendSpy.mockReset();
222
+ emailSendSpy.mockResolvedValue(undefined);
223
+ });
224
+
225
+ afterAll(() => {
226
+ emailSendSpy.mockRestore();
227
+ });
228
+
229
+ it('keeps the email recipient identity after local webhook delivery normalizes notification DIDs', async () => {
230
+ await addEmail(HOLDER_PROFILE_ID, HOLDER_VERIFIED_EMAIL, { primary: true });
231
+ const { allocation } = await sendOriginal();
232
+ await activate(allocation.refreshId);
233
+
234
+ // The offline webhook path rewrites notification profiles to public DIDs.
235
+ // Those objects must not alias the profiles used for email contact lookup.
236
+ addNotificationToQueueSpy.mockImplementation(async (notification: LCNNotification) => {
237
+ if (typeof notification.to !== 'string') {
238
+ notification.to.did = `did:web:localhost%3A3000:users:${HOLDER_PROFILE_ID}`;
239
+ }
240
+ if (typeof notification.from !== 'string') {
241
+ notification.from.did = `did:web:localhost%3A3000:users:${ISSUER_PROFILE_ID}`;
242
+ }
243
+ });
244
+
245
+ const result = await publishIssuerSigned(
246
+ allocation.refreshId,
247
+ await updatedCredential(allocation)
248
+ );
249
+ expect(result.notification).toBe('queued');
250
+ expect(emailDeliveries()).toHaveLength(1);
251
+ expect(emailDeliveries()[0].contactMethod.value).toBe(HOLDER_VERIFIED_EMAIL);
252
+ });
253
+
254
+ it('emails the holder verified email with only issuer name and title', async () => {
255
+ await addEmail(HOLDER_PROFILE_ID, HOLDER_VERIFIED_EMAIL, { primary: true });
256
+ await addEmail(HOLDER_PROFILE_ID, HOLDER_UNVERIFIED_EMAIL, { verified: false });
257
+
258
+ const { allocation } = await sendOriginal();
259
+ await activate(allocation.refreshId);
260
+
261
+ const updated = await updatedCredential(allocation, {
262
+ // `achievement` is not defined by the VCDM 2.0 context, so it must be
263
+ // mapped inline or data-loss detection refuses to sign.
264
+ '@context': [
265
+ 'https://www.w3.org/ns/credentials/v2',
266
+ {
267
+ 'LearnCardCredentialRefresh2026':
268
+ 'https://learncard.com/refresh#LearnCardCredentialRefresh2026',
269
+ authorization: {
270
+ '@id': 'https://purl.imsglobal.org/spec/ob/v3p0#authorization',
271
+ '@context': {
272
+ LearnCardDIDAuth:
273
+ 'https://docs.learncard.com/definitions#LearnCardDIDAuth',
274
+ },
275
+ },
276
+ achievement: 'https://purl.imsglobal.org/spec/ob/v3p0#Achievement',
277
+ },
278
+ ],
279
+ credentialSubject: {
280
+ id: holder.learnCard.id.did(),
281
+ achievement: { name: SECRET_GRADE_MARKER },
282
+ },
283
+ description: SECRET_SUBJECT_MARKER,
284
+ });
285
+
286
+ await publishIssuerSigned(allocation.refreshId, updated, {
287
+ updateSummary: SECRET_SUMMARY_MARKER,
288
+ });
289
+
290
+ const deliveries = emailDeliveries();
291
+ expect(deliveries).toHaveLength(1);
292
+
293
+ const delivery = deliveries[0]!;
294
+ expect(delivery.contactMethod).toEqual({
295
+ type: 'email',
296
+ value: HOLDER_VERIFIED_EMAIL,
297
+ });
298
+ expect(delivery.templateId).toEqual('credential-updated');
299
+ expect(delivery.templateModel).toEqual({
300
+ issuer: { name: expect.any(String) },
301
+ credential: { name: SECRET_TITLE },
302
+ });
303
+
304
+ // No grades, description/body content, or issuer summary may reach the template.
305
+ const serialized = JSON.stringify(delivery);
306
+ expect(serialized).not.toContain(SECRET_GRADE_MARKER);
307
+ expect(serialized).not.toContain(SECRET_SUBJECT_MARKER);
308
+ expect(serialized).not.toContain(SECRET_SUMMARY_MARKER);
309
+ expect(serialized).not.toContain(HOLDER_UNVERIFIED_EMAIL);
310
+ expect(serialized).not.toContain('credentialSubject');
311
+ });
312
+
313
+ it('does not email when the holder has no verified email', async () => {
314
+ await addEmail(HOLDER_PROFILE_ID, HOLDER_UNVERIFIED_EMAIL, { verified: false });
315
+
316
+ const { allocation } = await sendOriginal();
317
+ await activate(allocation.refreshId);
318
+
319
+ const result = await publishIssuerSigned(
320
+ allocation.refreshId,
321
+ await updatedCredential(allocation)
322
+ );
323
+
324
+ // In-app remains queued; only the email channel skips.
325
+ expect(result.notification).toEqual('queued');
326
+ expect(emailDeliveries()).toHaveLength(0);
327
+ });
328
+
329
+ it('honors notifyHolder:false and does not email', async () => {
330
+ await addEmail(HOLDER_PROFILE_ID, HOLDER_VERIFIED_EMAIL);
331
+
332
+ const { allocation } = await sendOriginal();
333
+ await activate(allocation.refreshId);
334
+
335
+ const result = await publishIssuerSigned(
336
+ allocation.refreshId,
337
+ await updatedCredential(allocation),
338
+ { notifyHolder: false }
339
+ );
340
+
341
+ expect(result.notification).toEqual('suppressed');
342
+ expect(emailDeliveries()).toHaveLength(0);
343
+ });
344
+
345
+ it('does not email for a non-material update', async () => {
346
+ await addEmail(HOLDER_PROFILE_ID, HOLDER_VERIFIED_EMAIL);
347
+
348
+ const { allocation } = await sendOriginal();
349
+ await activate(allocation.refreshId);
350
+
351
+ // Same user-visible content as version 1; only the proof differs.
352
+ const unchanged = await signAs(
353
+ issuer,
354
+ buildUnsignedCredential(allocation, {
355
+ validFrom: '2026-02-01T00:00:00Z',
356
+ name: 'Original Transcript',
357
+ })
358
+ );
359
+ const result = await publishIssuerSigned(allocation.refreshId, unchanged);
360
+
361
+ expect(result.notification).toEqual('suppressed');
362
+ expect(emailDeliveries()).toHaveLength(0);
363
+ });
364
+
365
+ it('does not email pre-claim, then emails once after the holder accepts', async () => {
366
+ await addEmail(HOLDER_PROFILE_ID, HOLDER_VERIFIED_EMAIL);
367
+
368
+ const { allocation, uri } = await sendOriginal();
369
+
370
+ await publishIssuerSigned(allocation.refreshId, await updatedCredential(allocation));
371
+ expect(emailDeliveries()).toHaveLength(0);
372
+
373
+ await acceptCredential(
374
+ (await getProfileByProfileId(HOLDER_PROFILE_ID))!,
375
+ uri,
376
+ {},
377
+ { appUrl: 'https://tenant.example', brandName: 'Tenant School' }
378
+ );
379
+ expect(emailDeliveries()[0]?.branding).toMatchObject({
380
+ appUrl: 'https://tenant.example',
381
+ brandName: 'Tenant School',
382
+ });
383
+
384
+ expect(emailDeliveries()).toHaveLength(1);
385
+ expect(emailDeliveries()[0]!.templateModel).toMatchObject({
386
+ credential: { name: SECRET_TITLE },
387
+ });
388
+
389
+ // A replayed accept does not email twice.
390
+ await holder.clients.fullAuth.credential.acceptCredential({ uri });
391
+ expect(emailDeliveries()).toHaveLength(1);
392
+ });
393
+
394
+ it('collapses repeat material updates in one delivery window to one email', async () => {
395
+ await addEmail(HOLDER_PROFILE_ID, HOLDER_VERIFIED_EMAIL);
396
+
397
+ const { allocation } = await sendOriginal();
398
+ await activate(allocation.refreshId);
399
+
400
+ await publishIssuerSigned(allocation.refreshId, await updatedCredential(allocation));
401
+ await publishIssuerSigned(
402
+ allocation.refreshId,
403
+ await updatedCredential(allocation, {
404
+ validFrom: '2026-03-01T00:00:00Z',
405
+ name: 'Final Biology',
406
+ })
407
+ );
408
+
409
+ expect(emailDeliveries()).toHaveLength(1);
410
+ });
411
+
412
+ it('does not email twice when the same publication is replayed', async () => {
413
+ await addEmail(HOLDER_PROFILE_ID, HOLDER_VERIFIED_EMAIL);
414
+
415
+ const { allocation } = await sendOriginal();
416
+ await activate(allocation.refreshId);
417
+
418
+ const updated = await updatedCredential(allocation);
419
+
420
+ await publishIssuerSigned(allocation.refreshId, updated, {
421
+ idempotencyKey: 'email-replay',
422
+ });
423
+ await publishIssuerSigned(allocation.refreshId, updated, {
424
+ idempotencyKey: 'email-replay',
425
+ });
426
+
427
+ expect(emailDeliveries()).toHaveLength(1);
428
+ });
429
+
430
+ it('does not resend a persisted publication in a later delivery window', async () => {
431
+ await addEmail(HOLDER_PROFILE_ID, HOLDER_VERIFIED_EMAIL);
432
+ const { allocation } = await sendOriginal();
433
+ await activate(allocation.refreshId);
434
+ await publishIssuerSigned(allocation.refreshId, await updatedCredential(allocation));
435
+ const version = (await getCredentialRefreshHead(allocation.refreshId))!;
436
+ const issuerProfile = (await getProfileByProfileId(ISSUER_PROFILE_ID))!;
437
+ const holderProfile = (await getProfileByProfileId(HOLDER_PROFILE_ID))!;
438
+ expect(emailDeliveries()).toHaveLength(1);
439
+ vi.useFakeTimers({ toFake: ['Date'] });
440
+ try {
441
+ vi.setSystemTime(new Date(Date.now() + 3 * 24 * 60 * 60 * 1000));
442
+ expect(
443
+ await deliverCredentialRefreshEmailNotification({
444
+ version,
445
+ issuerProfile,
446
+ holderProfile,
447
+ })
448
+ ).toBe('duplicate-window');
449
+ expect(emailDeliveries()).toHaveLength(1);
450
+ } finally {
451
+ vi.useRealTimers();
452
+ }
453
+ });
454
+
455
+ it('uses the local frontend in update email branding when running offline', async () => {
456
+ // Publish without an email first so we can test delivery without changing
457
+ // proof verification or the rest of the application's environment.
458
+ const { allocation } = await sendOriginal();
459
+ await activate(allocation.refreshId);
460
+ await publishIssuerSigned(allocation.refreshId, await updatedCredential(allocation));
461
+ await addEmail(HOLDER_PROFILE_ID, HOLDER_VERIFIED_EMAIL);
462
+ const version = (await getCredentialRefreshHead(allocation.refreshId))!;
463
+ const previous = {
464
+ offline: environment.IS_OFFLINE,
465
+ domain: environment.CLIENT_APP_DOMAIN_NAME,
466
+ };
467
+ environment.IS_OFFLINE = true;
468
+ environment.CLIENT_APP_DOMAIN_NAME = 'localhost:3000';
469
+ try {
470
+ await deliverCredentialRefreshEmailNotification({
471
+ version,
472
+ issuerProfile: (await getProfileByProfileId(ISSUER_PROFILE_ID))!,
473
+ holderProfile: (await getProfileByProfileId(HOLDER_PROFILE_ID))!,
474
+ branding: { appUrl: 'https://production.example', brandName: 'Example School' },
475
+ });
476
+ expect(emailDeliveries()).toHaveLength(1);
477
+ expect(emailDeliveries()[0]?.branding).toMatchObject({
478
+ appUrl: 'http://localhost:3000',
479
+ brandName: 'Example School',
480
+ });
481
+ } finally {
482
+ environment.IS_OFFLINE = previous.offline;
483
+ environment.CLIENT_APP_DOMAIN_NAME = previous.domain;
484
+ }
485
+ });
486
+
487
+ it('claims the window exactly once when concurrent publications race', async () => {
488
+ await addEmail(HOLDER_PROFILE_ID, HOLDER_VERIFIED_EMAIL);
489
+
490
+ const { allocation } = await sendOriginal();
491
+ await activate(allocation.refreshId);
492
+
493
+ const updated = await updatedCredential(allocation);
494
+
495
+ const [first, second] = await Promise.all([
496
+ publishIssuerSigned(allocation.refreshId, updated, {
497
+ idempotencyKey: 'email-concurrent',
498
+ notifyHolder: true,
499
+ }),
500
+ publishIssuerSigned(allocation.refreshId, updated, {
501
+ idempotencyKey: 'email-concurrent',
502
+ notifyHolder: true,
503
+ }),
504
+ ]);
505
+
506
+ expect(first.version).toEqual(2);
507
+ expect(second.version).toEqual(2);
508
+ expect(emailDeliveries()).toHaveLength(1);
509
+
510
+ const windowKey = computeCredentialRefreshDeliveryKey(allocation.refreshId);
511
+ const record = await getCredentialRefreshEmailDelivery(allocation.refreshId, windowKey);
512
+ expect(record?.state).toEqual('delivered');
513
+ expect(record?.attempts).toEqual(1);
514
+ });
515
+
516
+ it('records email failure without changing the in-app outcome or retrying the window', async () => {
517
+ await addEmail(HOLDER_PROFILE_ID, HOLDER_VERIFIED_EMAIL);
518
+
519
+ const { allocation } = await sendOriginal();
520
+ await activate(allocation.refreshId);
521
+
522
+ emailSendSpy.mockRejectedValueOnce(new Error('smtp unavailable'));
523
+
524
+ const updated = await updatedCredential(allocation);
525
+
526
+ const first = await publishIssuerSigned(allocation.refreshId, updated, {
527
+ idempotencyKey: 'email-failure',
528
+ });
529
+
530
+ // Publication + in-app notification are unaffected by the email failure.
531
+ const refreshEvents = addNotificationToQueueSpy.mock.calls
532
+ .map(call => call[0] as LCNNotification)
533
+ .filter(notification => notification.type === 'CREDENTIAL_REFRESHED');
534
+ expect(first.notification).toEqual('queued');
535
+ expect(refreshEvents).toHaveLength(1);
536
+ expect(emailSendSpy).toHaveBeenCalledTimes(1);
537
+
538
+ const windowKey = computeCredentialRefreshDeliveryKey(allocation.refreshId);
539
+ const record = await getCredentialRefreshEmailDelivery(allocation.refreshId, windowKey);
540
+ expect(record?.state).toEqual('failed');
541
+
542
+ // A retry in the same window must not attempt a second send.
543
+ const retry = await publishIssuerSigned(allocation.refreshId, updated, {
544
+ idempotencyKey: 'email-failure',
545
+ });
546
+ expect(retry.notification).toEqual('queued');
547
+ expect(emailSendSpy).toHaveBeenCalledTimes(1);
548
+ });
549
+
550
+ it('routes a managed child update to the verified guardian, not the child', async () => {
551
+ const guardian = await getUser('e'.repeat(64));
552
+ await guardian.clients.fullAuth.profile.createProfile({
553
+ profileId: GUARDIAN_PROFILE_ID,
554
+ displayName: 'Email Test Guardian',
555
+ });
556
+
557
+ await addEmail(HOLDER_PROFILE_ID, HOLDER_VERIFIED_EMAIL, { primary: true });
558
+ await addEmail(GUARDIAN_PROFILE_ID, GUARDIAN_VERIFIED_EMAIL, { primary: true });
559
+
560
+ await linkGuardian(GUARDIAN_PROFILE_ID, HOLDER_PROFILE_ID);
561
+ expect(await isProfileManaged(HOLDER_PROFILE_ID)).toBe(true);
562
+
563
+ const { allocation } = await sendOriginal();
564
+ await activate(allocation.refreshId);
565
+
566
+ const result = await publishIssuerSigned(
567
+ allocation.refreshId,
568
+ await updatedCredential(allocation)
569
+ );
570
+
571
+ // In-app remains addressed to the child (unchanged); email goes to the guardian.
572
+ expect(result.notification).toEqual('queued');
573
+
574
+ const deliveries = emailDeliveries();
575
+ expect(deliveries).toHaveLength(1);
576
+ expect(deliveries[0]!.contactMethod.value).toEqual(GUARDIAN_VERIFIED_EMAIL);
577
+ expect(JSON.stringify(deliveries[0])).not.toContain(HOLDER_VERIFIED_EMAIL);
578
+ });
579
+
580
+ it('skips a managed child when no guardian has a verified email', async () => {
581
+ const guardian = await getUser('e'.repeat(64));
582
+ await guardian.clients.fullAuth.profile.createProfile({
583
+ profileId: GUARDIAN_PROFILE_ID,
584
+ displayName: 'Email Test Guardian',
585
+ });
586
+
587
+ await addEmail(HOLDER_PROFILE_ID, HOLDER_VERIFIED_EMAIL, { primary: true });
588
+
589
+ await linkGuardian(GUARDIAN_PROFILE_ID, HOLDER_PROFILE_ID);
590
+
591
+ const { allocation } = await sendOriginal();
592
+ await activate(allocation.refreshId);
593
+
594
+ const result = await publishIssuerSigned(
595
+ allocation.refreshId,
596
+ await updatedCredential(allocation)
597
+ );
598
+
599
+ expect(result.notification).toEqual('queued');
600
+ expect(emailDeliveries()).toHaveLength(0);
601
+ });
602
+
603
+ it('keeps in-app payloads free of the email title', async () => {
604
+ await addEmail(HOLDER_PROFILE_ID, HOLDER_VERIFIED_EMAIL);
605
+
606
+ const { allocation } = await sendOriginal();
607
+ await activate(allocation.refreshId);
608
+
609
+ await publishIssuerSigned(allocation.refreshId, await updatedCredential(allocation));
610
+
611
+ const refreshEvents = addNotificationToQueueSpy.mock.calls
612
+ .map(call => call[0] as LCNNotification)
613
+ .filter(notification => notification.type === 'CREDENTIAL_REFRESHED');
614
+
615
+ expect(refreshEvents).toHaveLength(1);
616
+ expect(JSON.stringify(refreshEvents[0])).not.toContain(SECRET_TITLE);
617
+
618
+ // Email still carries the title.
619
+ expect(emailDeliveries()[0]!.templateModel).toMatchObject({
620
+ credential: { name: SECRET_TITLE },
621
+ });
622
+ });
623
+
624
+ it('renders generic copy and persists no title when the publication has none', async () => {
625
+ // An issuer-signed update that omits `name` must not fail delivery: the
626
+ // aggregate keeps no display title and the email falls back to generic copy.
627
+ await addEmail(HOLDER_PROFILE_ID, HOLDER_VERIFIED_EMAIL);
628
+
629
+ const { allocation } = await sendOriginal();
630
+ await activate(allocation.refreshId);
631
+
632
+ const titleless = await signAs(
633
+ issuer,
634
+ buildUnsignedCredential(allocation, {
635
+ validFrom: '2026-02-01T00:00:00Z',
636
+ name: undefined,
637
+ })
638
+ );
639
+ await publishIssuerSigned(allocation.refreshId, titleless);
640
+
641
+ const deliveries = emailDeliveries();
642
+ expect(deliveries).toHaveLength(1);
643
+ expect(deliveries[0]!.templateModel).toEqual({ issuer: { name: expect.any(String) } });
644
+
645
+ const aggregate = await getCredentialRefresh(allocation.refreshId);
646
+ expect(aggregate?.credentialDisplayName ?? undefined).toBeUndefined();
647
+ });
648
+
649
+ it('persists the bounded display title on the aggregate and head version', async () => {
650
+ await addEmail(HOLDER_PROFILE_ID, HOLDER_VERIFIED_EMAIL);
651
+
652
+ const { allocation } = await sendOriginal();
653
+ await activate(allocation.refreshId);
654
+
655
+ await publishIssuerSigned(allocation.refreshId, await updatedCredential(allocation));
656
+
657
+ const aggregate = await getCredentialRefresh(allocation.refreshId);
658
+ const head = await getCredentialRefreshHead(allocation.refreshId);
659
+
660
+ expect(aggregate?.credentialDisplayName).toEqual(SECRET_TITLE);
661
+ expect(head?.credentialDisplayName).toEqual(SECRET_TITLE);
662
+ });
663
+ });