@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
@@ -6,18 +6,27 @@ import {
6
6
  VC,
7
7
  type AllocateCredentialRefreshResult,
8
8
  type CredentialRefreshSigningMode,
9
+ type ManagedCredentialRefreshReceipt,
10
+ type ManagedCredentialRefreshService,
9
11
  type PublishCredentialRefreshInput,
10
12
  type PublishCredentialRefreshNotification,
11
13
  type PublishCredentialRefreshResult,
12
14
  } from '@learncard/types';
13
- import { getCredentialEffectiveTime, getCredentialIssuerId } from '@learncard/helpers';
15
+ import {
16
+ getCredentialEffectiveTime,
17
+ getCredentialIssuerId,
18
+ getManagedRefreshServices,
19
+ prepareManagedRefreshContext,
20
+ } from '@learncard/helpers';
14
21
 
15
22
  import { neogma } from '@instance';
16
23
 
17
24
  import { storeCredential } from '@accesslayer/credential/create';
18
25
  import { deleteCredential } from '@accesslayer/credential/delete';
19
26
  import { getBoostByUri } from '@accesslayer/boost/read';
27
+ import { canProfileIssueBoost } from '@accesslayer/boost/relationships/read';
20
28
  import { getProfileByProfileId } from '@accesslayer/profile/read';
29
+ import { getProfilesThatManageAProfile } from '@accesslayer/profile/relationships/read';
21
30
  import {
22
31
  advanceCredentialRefreshHead,
23
32
  generateRefreshId,
@@ -34,7 +43,12 @@ import type {
34
43
  CredentialRefreshVersionNode,
35
44
  } from 'types/credential-refresh';
36
45
 
46
+ import { createCredentialIssuedViaContractRelationship } from '@accesslayer/credential/relationships/create';
47
+ import type { CredentialInstance } from '@models';
48
+ import type { DbTermsType } from 'types/consentflowcontract';
49
+
37
50
  import { createDagJweForRecipients, getLearnCard } from './learnCard.helpers';
51
+ import { verifyManagedRefreshProof } from './credential-refresh-proof.helpers';
38
52
  import { issueCredentialWithSigningAuthority } from './signingAuthority.helpers';
39
53
  import {
40
54
  computeCredentialMaterialDigest,
@@ -51,6 +65,9 @@ import {
51
65
  buildInitialCredentialReceivedNotification,
52
66
  buildCredentialRefreshedNotification,
53
67
  } from './notifications.helpers';
68
+ import { deliverCredentialRefreshEmailNotification } from './credential-refresh-email.helpers';
69
+ import { extractBoundedCredentialDisplayTitle } from './credential-refresh-email-content.helpers';
70
+ import type { TenantBranding } from '@learncard/email-templates';
54
71
  import { getDidWeb } from './did.helpers';
55
72
  import { isRelationshipBlocked } from './connection.helpers';
56
73
  import { ProfileType } from 'types/profile';
@@ -69,7 +86,7 @@ import { ProfileType } from 'types/profile';
69
86
  export const getCredentialRefreshServiceUrl = (refreshId: string, domain: string): string =>
70
87
  `${getStatusListBaseUrl(domain)}/refresh/${refreshId}`;
71
88
 
72
- const getManagedCredentialUri = (id: string, domain: string): string =>
89
+ export const getManagedCredentialUri = (id: string, domain: string): string =>
73
90
  constructUri('credential', id, domain);
74
91
 
75
92
  export type AllocateCredentialRefreshParams = {
@@ -136,6 +153,134 @@ export const allocateCredentialRefresh = async (
136
153
  };
137
154
  };
138
155
 
156
+ export type ManagedRefreshHandoff = {
157
+ refreshId: string;
158
+ refreshService: ManagedCredentialRefreshService;
159
+ };
160
+
161
+ /**
162
+ * Extracts and validates the local managed refresh service embedded in a signed
163
+ * credential (unified-send handoff, LC-2198 decision 6).
164
+ *
165
+ * Only THIS deployment's managed refresh endpoint origin/path is accepted: the
166
+ * service ID must be exactly the canonical URL for the refreshId it carries, and
167
+ * the refreshId is always derived from the service URL — never from caller-supplied
168
+ * receipt fields. Returns null when the credential carries no managed service;
169
+ * throws when it carries conflicting managed services or one pointing elsewhere
170
+ * (a third-party `1EdTechCredentialRefresh` service is not a handoff).
171
+ */
172
+ export const extractManagedRefreshHandoff = (
173
+ credential: VC,
174
+ domain: string
175
+ ): ManagedRefreshHandoff | null => {
176
+ const services = getManagedRefreshServices(credential);
177
+
178
+ if (services.length === 0) return null;
179
+
180
+ const distinctIds = new Set(services.map(service => service.id));
181
+
182
+ if (distinctIds.size > 1) {
183
+ throw new TRPCError({
184
+ code: 'BAD_REQUEST',
185
+ message:
186
+ 'Credential carries conflicting managed refresh services; a credential can only reference one managed refresh service.',
187
+ });
188
+ }
189
+
190
+ const service = services[0]!;
191
+ const prefix = `${getStatusListBaseUrl(domain)}/refresh/`;
192
+
193
+ if (!service.id.startsWith(prefix) || service.id.slice(prefix.length).length === 0) {
194
+ throw new TRPCError({
195
+ code: 'BAD_REQUEST',
196
+ message:
197
+ "Credential refreshService does not reference this deployment's managed refresh endpoint.",
198
+ });
199
+ }
200
+
201
+ const refreshId = service.id.slice(prefix.length);
202
+
203
+ if (!/^[A-Za-z0-9_-]{43}$/.test(refreshId)) {
204
+ throw new TRPCError({
205
+ code: 'BAD_REQUEST',
206
+ message:
207
+ "Credential refreshService does not reference this deployment's managed refresh endpoint.",
208
+ });
209
+ }
210
+
211
+ return { refreshId, refreshService: service };
212
+ };
213
+
214
+ export type ManagedRefreshInitialBindingInfo = {
215
+ /** True when the refresh already has a bound root matching this exact credential. */
216
+ bound: boolean;
217
+ activityId?: string;
218
+ integrationId?: string;
219
+ };
220
+
221
+ /**
222
+ * Reports whether the refresh already has a bound root for this exact credential so
223
+ * unified-send retries can reuse the original delivery activity instead of creating
224
+ * a duplicate. Throws CONFLICT when the refresh is bound to a DIFFERENT credential
225
+ * or boost — rejecting the retry before any write or activity logging.
226
+ */
227
+ export const peekCredentialRefreshInitialBinding = async (params: {
228
+ refreshId: string;
229
+ credential: VC;
230
+ boostId?: string;
231
+ }): Promise<ManagedRefreshInitialBindingInfo> => {
232
+ const { refreshId, credential, boostId } = params;
233
+
234
+ const root = await getInitialRefreshRoot(refreshId);
235
+
236
+ if (!root) return { bound: false };
237
+
238
+ assertInitialCredentialMatches(
239
+ root,
240
+ computeCredentialMaterialDigest(credential as unknown as Record<string, unknown>),
241
+ computeCredentialStatusDigest(
242
+ (credential as unknown as Record<string, unknown>).credentialStatus
243
+ ),
244
+ boostId
245
+ );
246
+
247
+ const result = await neogma.queryRunner.run(
248
+ `MATCH (:Profile)-[sent:CREDENTIAL_SENT]->(root:Credential {id: $rootId})
249
+ RETURN sent.activityId AS activityId, sent.integrationId AS integrationId
250
+ LIMIT 1`,
251
+ { rootId: root.rootId }
252
+ );
253
+ const row = result.records[0];
254
+
255
+ return {
256
+ bound: true,
257
+ activityId: row?.get('activityId') ?? undefined,
258
+ integrationId: row?.get('integrationId') ?? undefined,
259
+ };
260
+ };
261
+
262
+ /**
263
+ * Idempotently links a managed root credential to the approved consent-flow contract
264
+ * terms it was issued through, on both fresh binds and resume paths.
265
+ */
266
+ const ensureCredentialIssuedViaContractRelationship = async (
267
+ credentialNodeId: string,
268
+ terms: DbTermsType
269
+ ): Promise<void> => {
270
+ const existing = await neogma.queryRunner.run(
271
+ `MATCH (root:Credential {id: $nodeId})-[:ISSUED_VIA_TRANSACTION]->()
272
+ RETURN 1 AS one LIMIT 1`,
273
+ { nodeId: credentialNodeId }
274
+ );
275
+
276
+ if (existing.records.length > 0) return;
277
+
278
+ await createCredentialIssuedViaContractRelationship(
279
+ { id: credentialNodeId } as unknown as CredentialInstance,
280
+ terms
281
+ );
282
+ };
283
+
139
284
  /** Collects the subject identifiers of a credential (single object or array form). */
140
285
  const getCredentialSubjectIds = (credential: VC): string[] => {
141
286
  const subjects = Array.isArray(credential.credentialSubject)
@@ -177,9 +322,21 @@ export type SendRefreshableCredentialParams = {
177
322
  boostUri?: string;
178
323
  /** Suppress the initial CREDENTIAL_RECEIVED notification for this delivery. */
179
324
  skipNotification?: boolean;
325
+ /** Issuance activity to correlate (unified send, LC-2198). Stored on CREDENTIAL_SENT. */
326
+ activityId?: string;
327
+ /** Integration tracking for the unified send, stored on CREDENTIAL_SENT. */
328
+ integrationId?: string;
329
+ /** Approved consent-flow terms the credential was issued through (LC-2198). */
330
+ contractTerms?: DbTermsType;
180
331
  domain: string;
181
332
  };
182
333
 
334
+ /** Metadata-only issuance receipt derived from the signed version 1 and the validated allocation. */
335
+ export type SendRefreshableCredentialResult = {
336
+ uri: string;
337
+ receipt: ManagedCredentialRefreshReceipt;
338
+ };
339
+
183
340
  type InitialRefreshRoot = {
184
341
  rootId: string;
185
342
  materialDigest?: string;
@@ -187,7 +344,9 @@ type InitialRefreshRoot = {
187
344
  boostId?: string;
188
345
  };
189
346
 
190
- const getInitialRefreshRoot = async (refreshId: string): Promise<InitialRefreshRoot | null> => {
347
+ export const getInitialRefreshRoot = async (
348
+ refreshId: string
349
+ ): Promise<InitialRefreshRoot | null> => {
191
350
  const result = await neogma.queryRunner.run(
192
351
  `MATCH (refresh:CredentialRefresh {refreshId: $refreshId})-[:ROOT]->(root:Credential)
193
352
  OPTIONAL MATCH (root)-[:INSTANCE_OF]->(boost:Boost)
@@ -211,6 +370,13 @@ const getInitialRefreshRoot = async (refreshId: string): Promise<InitialRefreshR
211
370
  };
212
371
  };
213
372
 
373
+ /**
374
+ * Returns the boost a managed refresh's version 1 is already bound to, if any. Lets a
375
+ * replayed pre-signed send reuse its original boost instead of auto-creating another.
376
+ */
377
+ export const getBoundRefreshBoostId = async (refreshId: string): Promise<string | undefined> =>
378
+ (await getInitialRefreshRoot(refreshId))?.boostId;
379
+
214
380
  const assertInitialCredentialMatches = (
215
381
  root: InitialRefreshRoot,
216
382
  materialDigest: string,
@@ -242,8 +408,11 @@ const ensureInitialRefreshRelationships = async (params: {
242
408
  issuerProfileId: string;
243
409
  holderProfileId: string;
244
410
  boostId?: string;
411
+ activityId?: string;
412
+ integrationId?: string;
245
413
  }): Promise<string | undefined> => {
246
- const { refreshId, issuerProfileId, holderProfileId, boostId } = params;
414
+ const { refreshId, issuerProfileId, holderProfileId, boostId, activityId, integrationId } =
415
+ params;
247
416
  const now = new Date().toISOString();
248
417
  const result = await neogma.queryRunner.run(
249
418
  `MATCH (refresh:CredentialRefresh {refreshId: $refreshId})-[:ROOT]->(root:Credential)
@@ -252,6 +421,8 @@ const ensureInitialRefreshRelationships = async (params: {
252
421
  OPTIONAL MATCH (boost:Boost {id: $boostId})
253
422
  MERGE (issuer)-[sent:CREDENTIAL_SENT {to: $holderProfileId}]->(root)
254
423
  ON CREATE SET sent.date = $now
424
+ SET sent.activityId = coalesce(sent.activityId, $activityId),
425
+ sent.integrationId = coalesce(sent.integrationId, $integrationId)
255
426
  FOREACH (_ IN CASE WHEN boost IS NULL THEN [] ELSE [1] END |
256
427
  MERGE (root)-[:INSTANCE_OF]->(boost)
257
428
  )
@@ -261,6 +432,8 @@ const ensureInitialRefreshRelationships = async (params: {
261
432
  issuerProfileId,
262
433
  holderProfileId,
263
434
  boostId: boostId ?? null,
435
+ activityId: activityId ?? null,
436
+ integrationId: integrationId ?? null,
264
437
  now,
265
438
  }
266
439
  );
@@ -268,7 +441,7 @@ const ensureInitialRefreshRelationships = async (params: {
268
441
  return result.records[0]?.get('rootId') ?? undefined;
269
442
  };
270
443
 
271
- const sendInitialCredentialNotificationOnce = async (params: {
444
+ export const sendInitialCredentialNotificationOnce = async (params: {
272
445
  refreshId: string;
273
446
  uri: string;
274
447
  issuerProfile: ProfileType;
@@ -301,6 +474,24 @@ const sendInitialCredentialNotificationOnce = async (params: {
301
474
  );
302
475
  };
303
476
 
477
+ /**
478
+ * Holder-side JWE recipients for managed refresh storage.
479
+ *
480
+ * Mirrors the human-controlled keyAgreement keys of the holder's did:web document
481
+ * (see src/dids.ts): the profile's own controller did:key plus every profile that
482
+ * manages it. Managed profiles are created with a did:key whose seed is discarded,
483
+ * so their managers are the only parties able to decrypt. All recipients are
484
+ * did:keys, so encryption never needs a remote did:web fetch. Signing authorities
485
+ * and the brain DID are deliberately excluded.
486
+ * Do not add the aggregate's holder did:web here: its keyAgreement document can
487
+ * also include registered signing-authority keys, outside the holder-only boundary.
488
+ */
489
+ const getHolderEncryptionRecipients = async (holderProfile: ProfileType): Promise<string[]> => {
490
+ const managers = await getProfilesThatManageAProfile(holderProfile.profileId);
491
+
492
+ return [...new Set([holderProfile.did, ...managers.map(manager => manager.did)])];
493
+ };
494
+
304
495
  /**
305
496
  * Binds the signed original credential to a previously allocated refresh aggregate.
306
497
  *
@@ -311,13 +502,16 @@ const sendInitialCredentialNotificationOnce = async (params: {
311
502
  */
312
503
  export const sendRefreshableCredential = async (
313
504
  params: SendRefreshableCredentialParams
314
- ): Promise<string> => {
505
+ ): Promise<SendRefreshableCredentialResult> => {
315
506
  const {
316
507
  issuerProfile,
317
508
  refreshId,
318
509
  credential,
319
510
  boostUri,
320
511
  skipNotification = false,
512
+ activityId,
513
+ integrationId,
514
+ contractTerms,
321
515
  domain,
322
516
  } = params;
323
517
 
@@ -334,6 +528,17 @@ export const sendRefreshableCredential = async (
334
528
  });
335
529
  }
336
530
 
531
+ if (
532
+ !aggregate.holderDid ||
533
+ aggregate.state === 'pending_holder' ||
534
+ aggregate.inboxCredentialId
535
+ ) {
536
+ throw new TRPCError({
537
+ code: 'CONFLICT',
538
+ message: 'Inbox refresh must be bound through its verified claim flow',
539
+ });
540
+ }
541
+
337
542
  if (credential.id !== aggregate.credentialId) {
338
543
  throw new TRPCError({
339
544
  code: 'BAD_REQUEST',
@@ -359,7 +564,10 @@ export const sendRefreshableCredential = async (
359
564
  });
360
565
  }
361
566
 
362
- if (!getCredentialSubjectIds(credential).includes(aggregate.holderDid)) {
567
+ if (
568
+ !aggregate.holderDid ||
569
+ !getCredentialSubjectIds(credential).includes(aggregate.holderDid)
570
+ ) {
363
571
  throw new TRPCError({
364
572
  code: 'BAD_REQUEST',
365
573
  message: 'Credential subject does not match the intended holder',
@@ -373,6 +581,24 @@ export const sendRefreshableCredential = async (
373
581
  });
374
582
  }
375
583
 
584
+ // Metadata-only issuance receipt (LC-2198 decision 2): populated from the actual
585
+ // signed credential and the validated allocation. It deliberately excludes all
586
+ // credential claims, subject bodies, plaintext VC content, and JWE payloads.
587
+ const buildReceipt = (): ManagedCredentialRefreshReceipt => ({
588
+ refreshId,
589
+ refreshService: {
590
+ id: getCredentialRefreshServiceUrl(refreshId, domain),
591
+ type: 'LearnCardCredentialRefresh2026',
592
+ authorization: { type: 'LearnCardDIDAuth' },
593
+ },
594
+ credentialId: aggregate.credentialId,
595
+ issuerDid: credentialIssuerDid,
596
+ holderDid: aggregate.holderDid!,
597
+ ...(credential.credentialStatus !== undefined && credential.credentialStatus !== null
598
+ ? { credentialStatus: credential.credentialStatus }
599
+ : {}),
600
+ });
601
+
376
602
  const holderProfile = aggregate.holderProfileId
377
603
  ? await getProfileByProfileId(aggregate.holderProfileId)
378
604
  : null;
@@ -395,41 +621,17 @@ export const sendRefreshableCredential = async (
395
621
  throw new TRPCError({ code: 'NOT_FOUND', message: 'Boost not found' });
396
622
  }
397
623
 
398
- if (boost) {
399
- const ownership = await neogma.queryRunner.run(
400
- `MATCH (boost:Boost {id: $boostId})-[:CREATED_BY]->
401
- (:Profile {profileId: $issuerProfileId})
402
- RETURN boost LIMIT 1`,
403
- { boostId: boost.id, issuerProfileId: issuerProfile.profileId }
404
- );
405
-
406
- if (ownership.records.length === 0) {
407
- throw new TRPCError({
408
- code: 'UNAUTHORIZED',
409
- message: 'Profile does not own this boost',
410
- });
411
- }
412
- }
413
-
414
- // Transient plaintext proof verification — the result is never persisted.
415
- // Do not dereference credentialStatus here: the descriptor is fingerprinted below,
416
- // while canonical lifecycle revocation is enforced by the graph relationships.
417
- const learnCard = await getLearnCard();
418
- const verification = await learnCard.invoke.verifyCredential(credential, {
419
- checks: ['proof'],
420
- });
421
-
422
- if (
423
- verification.errors.length > 0 ||
424
- verification.warnings.length > 0 ||
425
- !verification.checks.includes('proof')
426
- ) {
624
+ // Match ordinary send authorization: a delegated issuer owns its refresh
625
+ // aggregate even when another profile created the reusable boost template.
626
+ if (boost && !(await canProfileIssueBoost(issuerProfile, boost))) {
427
627
  throw new TRPCError({
428
- code: 'BAD_REQUEST',
429
- message: 'Credential proof could not be verified',
628
+ code: 'UNAUTHORIZED',
629
+ message: 'Profile does not have permissions to issue boost',
430
630
  });
431
631
  }
432
632
 
633
+ await verifyManagedRefreshProof((await getLearnCard()).invoke, credential, publicIssuerDid);
634
+
433
635
  const rootMaterialDigest = computeCredentialMaterialDigest(
434
636
  credential as unknown as Record<string, unknown>
435
637
  );
@@ -452,6 +654,8 @@ export const sendRefreshableCredential = async (
452
654
  issuerProfileId: issuerProfile.profileId,
453
655
  holderProfileId: holderProfile.profileId,
454
656
  boostId: boost?.id,
657
+ activityId,
658
+ integrationId,
455
659
  });
456
660
 
457
661
  if (!rootId) {
@@ -461,6 +665,10 @@ export const sendRefreshableCredential = async (
461
665
  });
462
666
  }
463
667
 
668
+ if (contractTerms) {
669
+ await ensureCredentialIssuedViaContractRelationship(rootId, contractTerms);
670
+ }
671
+
464
672
  const uri = getManagedCredentialUri(rootId, domain);
465
673
  const initialNotificationSuppressed = await ensureInitialNotificationPolicy(
466
674
  refreshId,
@@ -474,11 +682,15 @@ export const sendRefreshableCredential = async (
474
682
  initialNotificationSuppressed,
475
683
  });
476
684
 
477
- return uri;
685
+ return { uri, receipt: buildReceipt() };
478
686
  }
479
687
 
480
- // Holder-only encryption: the brain DID must NOT be a recipient.
481
- const jwe = await createDagJweForRecipients(credential, [aggregate.holderDid]);
688
+ // Holder-side encryption only (never the brain DID): the holder's did:key plus its
689
+ // managers' did:keys — see getHolderEncryptionRecipients.
690
+ const jwe = await createDagJweForRecipients(
691
+ credential,
692
+ await getHolderEncryptionRecipients(holderProfile)
693
+ );
482
694
 
483
695
  const credentialInstance = await storeCredential(jwe);
484
696
 
@@ -496,7 +708,7 @@ export const sendRefreshableCredential = async (
496
708
  OPTIONAL MATCH (boost:Boost {id: $boostId})
497
709
  CREATE (refresh)-[:ROOT]->(root)
498
710
  CREATE (refresh)-[:HEAD]->(root)
499
- CREATE (issuer)-[:CREDENTIAL_SENT {to: $holderProfileId, date: $now}]->(root)
711
+ CREATE (issuer)-[:CREDENTIAL_SENT {to: $holderProfileId, date: $now, activityId: $activityId, integrationId: $integrationId}]->(root)
500
712
  FOREACH (_ IN CASE WHEN boost IS NULL THEN [] ELSE [1] END |
501
713
  CREATE (root)-[:INSTANCE_OF]->(boost)
502
714
  )
@@ -521,6 +733,8 @@ export const sendRefreshableCredential = async (
521
733
  issuerProfileId: issuerProfile.profileId,
522
734
  holderProfileId: holderProfile.profileId,
523
735
  boostId: boost?.id ?? null,
736
+ activityId: activityId ?? null,
737
+ integrationId: integrationId ?? null,
524
738
  versionKey: `${refreshId}:1`,
525
739
  now,
526
740
  credentialIssuerDid,
@@ -572,6 +786,8 @@ export const sendRefreshableCredential = async (
572
786
  issuerProfileId: issuerProfile.profileId,
573
787
  holderProfileId: holderProfile.profileId,
574
788
  boostId: boost?.id,
789
+ activityId,
790
+ integrationId,
575
791
  });
576
792
 
577
793
  if (!rootId) {
@@ -581,6 +797,10 @@ export const sendRefreshableCredential = async (
581
797
  });
582
798
  }
583
799
 
800
+ if (contractTerms) {
801
+ await ensureCredentialIssuedViaContractRelationship(rootId, contractTerms);
802
+ }
803
+
584
804
  const uri = getManagedCredentialUri(rootId, domain);
585
805
  const initialNotificationSuppressed = await ensureInitialNotificationPolicy(
586
806
  refreshId,
@@ -594,7 +814,11 @@ export const sendRefreshableCredential = async (
594
814
  initialNotificationSuppressed,
595
815
  });
596
816
 
597
- return uri;
817
+ return { uri, receipt: buildReceipt() };
818
+ }
819
+
820
+ if (contractTerms) {
821
+ await ensureCredentialIssuedViaContractRelationship(credentialInstance.id, contractTerms);
598
822
  }
599
823
 
600
824
  const uri = getManagedCredentialUri(credentialInstance.id, domain);
@@ -610,7 +834,7 @@ export const sendRefreshableCredential = async (
610
834
  initialNotificationSuppressed,
611
835
  });
612
836
 
613
- return uri;
837
+ return { uri, receipt: buildReceipt() };
614
838
  };
615
839
 
616
840
  // --- Publication (LC-2135) -----------------------------------------------------
@@ -635,48 +859,71 @@ const deliverCredentialRefreshNotification = async (params: {
635
859
  version: CredentialRefreshVersionNode;
636
860
  issuerProfile: ProfileType;
637
861
  holderProfile: ProfileType;
862
+ branding?: Partial<TenantBranding>;
638
863
  }): Promise<PublishCredentialRefreshNotification> => {
639
- const { version, issuerProfile, holderProfile } = params;
640
-
641
- if (version.notificationDeliveredAt) return 'queued';
864
+ const { version, issuerProfile, holderProfile, branding } = params;
642
865
 
643
- const event = buildCredentialRefreshedNotification({
644
- holderProfile,
645
- issuerProfile,
646
- refreshId: version.refreshId,
647
- version: version.version,
648
- notificationId: version.notificationId,
649
- deliveryKey: version.notificationDeliveryKey,
650
- notifiedAt: version.notificationCreatedAt,
651
- });
866
+ let inAppOutcome: PublishCredentialRefreshNotification = 'queued';
867
+ let deliveryWindowKey = version.notificationDeliveryKey;
652
868
 
653
- try {
654
- await addNotificationToQueue(event.notification);
655
- await recordCredentialRefreshNotification({
869
+ if (!version.notificationDeliveredAt) {
870
+ const event = buildCredentialRefreshedNotification({
871
+ holderProfile,
872
+ issuerProfile,
656
873
  refreshId: version.refreshId,
657
874
  version: version.version,
658
- notificationId: event.notificationId,
659
- deliveryKey: event.deliveryKey,
660
- notifiedAt: event.notifiedAt,
875
+ notificationId: version.notificationId,
876
+ deliveryKey: version.notificationDeliveryKey,
877
+ notifiedAt: version.notificationCreatedAt,
661
878
  });
879
+ deliveryWindowKey = event.deliveryKey;
662
880
 
663
- return 'queued';
881
+ try {
882
+ await addNotificationToQueue(event.notification);
883
+ await recordCredentialRefreshNotification({
884
+ refreshId: version.refreshId,
885
+ version: version.version,
886
+ notificationId: event.notificationId,
887
+ deliveryKey: event.deliveryKey,
888
+ notifiedAt: event.notifiedAt,
889
+ });
890
+ } catch (error) {
891
+ console.error(
892
+ 'Credential Refresh Helpers - Failed to enqueue CREDENTIAL_REFRESHED notification:',
893
+ error
894
+ );
895
+
896
+ inAppOutcome = 'delivery-failed';
897
+ }
898
+ }
899
+
900
+ // Email is an independent channel: attempted even when the durable in-app
901
+ // event was already delivered or just failed. Its own outcome is never allowed
902
+ // to change the publication result or duplicate the push notification.
903
+ try {
904
+ await deliverCredentialRefreshEmailNotification({
905
+ version: { ...version, notificationDeliveryKey: deliveryWindowKey },
906
+ issuerProfile,
907
+ holderProfile,
908
+ branding,
909
+ });
664
910
  } catch (error) {
665
911
  console.error(
666
- 'Credential Refresh Helpers - Failed to enqueue CREDENTIAL_REFRESHED notification:',
912
+ 'Credential Refresh Helpers - Unexpected error delivering refresh update email:',
667
913
  error
668
914
  );
669
-
670
- return 'delivery-failed';
671
915
  }
916
+
917
+ return inAppOutcome;
672
918
  };
673
919
 
674
920
  const resolveCredentialRefreshReplayNotification = async (params: {
675
921
  version: CredentialRefreshVersionNode;
676
922
  aggregate: CredentialRefreshRecord;
677
923
  issuerProfile: ProfileType;
924
+ branding?: Partial<TenantBranding>;
678
925
  }): Promise<PublishCredentialRefreshNotification> => {
679
- const { version, aggregate, issuerProfile } = params;
926
+ const { version, aggregate, issuerProfile, branding } = params;
680
927
  const persistedOutcome = version.notificationOutcome ?? 'suppressed';
681
928
 
682
929
  if (persistedOutcome !== 'queued') return persistedOutcome;
@@ -686,7 +933,7 @@ const resolveCredentialRefreshReplayNotification = async (params: {
686
933
  : null;
687
934
 
688
935
  return holderProfile
689
- ? deliverCredentialRefreshNotification({ version, issuerProfile, holderProfile })
936
+ ? deliverCredentialRefreshNotification({ version, issuerProfile, holderProfile, branding })
690
937
  : 'delivery-failed';
691
938
  };
692
939
 
@@ -698,8 +945,10 @@ export const deliverPendingCredentialRefreshNotificationForAcceptedCredential =
698
945
  credentialNodeId: string;
699
946
  issuerProfile: ProfileType;
700
947
  holderProfile: ProfileType;
948
+ /** Optional tenant branding for the update email; defaults to LearnCard. */
949
+ branding?: Partial<TenantBranding>;
701
950
  }): Promise<PublishCredentialRefreshNotification> => {
702
- const { credentialNodeId, issuerProfile, holderProfile } = params;
951
+ const { credentialNodeId, issuerProfile, holderProfile, branding } = params;
703
952
  const result = await neogma.queryRunner.run(
704
953
  `MATCH (refresh:CredentialRefresh)-[:ROOT]->(:Credential {id: $credentialNodeId})
705
954
  RETURN refresh.refreshId AS refreshId, refresh.state AS state
@@ -721,6 +970,7 @@ export const deliverPendingCredentialRefreshNotificationForAcceptedCredential =
721
970
  version: head,
722
971
  issuerProfile,
723
972
  holderProfile,
973
+ branding,
724
974
  });
725
975
  };
726
976
 
@@ -749,7 +999,10 @@ const assertRefreshVersionInvariants = (
749
999
  });
750
1000
  }
751
1001
 
752
- if (!getCredentialSubjectIds(credential).includes(aggregate.holderDid)) {
1002
+ if (
1003
+ !aggregate.holderDid ||
1004
+ !getCredentialSubjectIds(credential).includes(aggregate.holderDid)
1005
+ ) {
753
1006
  throw new TRPCError({
754
1007
  code: 'BAD_REQUEST',
755
1008
  message: 'Credential subject does not match the intended holder',
@@ -788,34 +1041,12 @@ const assertRefreshVersionInvariants = (
788
1041
  }
789
1042
  };
790
1043
 
791
- /**
792
- * Transient in-memory proof verification; the plaintext result is never persisted.
793
- * Only the proof check runs: the credentialStatus check would fetch remote status
794
- * list credentials at publish time (availability + SSRF hazard), and revocation
795
- * state moves through its own lifecycle rather than the refresh publication path.
796
- */
797
- const verifyRefreshVersionProof = async (credential: VC): Promise<void> => {
798
- const learnCard = await getLearnCard();
799
- const verification = await learnCard.invoke.verifyCredential(credential, {
800
- checks: ['proof'],
801
- });
802
-
803
- if (
804
- verification.errors.length > 0 ||
805
- verification.warnings.length > 0 ||
806
- !verification.checks.includes('proof')
807
- ) {
808
- throw new TRPCError({
809
- code: 'BAD_REQUEST',
810
- message: 'Credential proof could not be verified',
811
- });
812
- }
813
- };
814
-
815
1044
  export type PublishCredentialRefreshParams = {
816
1045
  issuerProfile: ProfileType;
817
1046
  input: PublishCredentialRefreshInput;
818
1047
  domain: string;
1048
+ /** Optional tenant branding for the update email; defaults to LearnCard. */
1049
+ branding?: Partial<TenantBranding>;
819
1050
  };
820
1051
 
821
1052
  /**
@@ -833,6 +1064,14 @@ export const publishCredentialRefresh = async (
833
1064
  ): Promise<PublishCredentialRefreshResult> => {
834
1065
  const { issuerProfile, input, domain } = params;
835
1066
  const { refreshId, idempotencyKey, notifyHolder, updateSummary } = input;
1067
+ const branding = params.branding;
1068
+
1069
+ // Email-only bounded display title. Read from the issuer-supplied publication
1070
+ // input (never from the holder-encrypted payload); `undefined` falls back to
1071
+ // the original boost template at delivery time, then to generic copy.
1072
+ const credentialDisplayName = extractBoundedCredentialDisplayTitle(
1073
+ input.mode === 'signing-authority' ? input.credential.name : input.signedCredential.name
1074
+ );
836
1075
 
837
1076
  const aggregate = await getCredentialRefresh(refreshId);
838
1077
 
@@ -863,6 +1102,17 @@ export const publishCredentialRefresh = async (
863
1102
  });
864
1103
  }
865
1104
 
1105
+ if (aggregate.inboxCredentialId) {
1106
+ const { getPendingInboxPublication } =
1107
+ await import('@accesslayer/inbox-credential/refresh');
1108
+ const replay = await getPendingInboxPublication(refreshId, idempotencyKey);
1109
+ if (replay) return replay;
1110
+ if (aggregate.state === 'pending_holder') {
1111
+ const { publishPendingInboxRefresh } = await import('./inbox-refresh.helpers');
1112
+ return publishPendingInboxRefresh(aggregate, issuerProfile, input, domain);
1113
+ }
1114
+ }
1115
+
866
1116
  if (idempotencyKey) {
867
1117
  const replayed = await getCredentialRefreshVersionByIdempotencyKey(
868
1118
  refreshId,
@@ -874,6 +1124,7 @@ export const publishCredentialRefresh = async (
874
1124
  version: replayed,
875
1125
  aggregate,
876
1126
  issuerProfile,
1127
+ branding,
877
1128
  });
878
1129
 
879
1130
  return {
@@ -902,7 +1153,11 @@ export const publishCredentialRefresh = async (
902
1153
  signingMode = 'issuer-signed';
903
1154
 
904
1155
  assertRefreshVersionInvariants(signedCredential, aggregate, domain);
905
- await verifyRefreshVersionProof(signedCredential);
1156
+ await verifyManagedRefreshProof(
1157
+ (await getLearnCard()).invoke,
1158
+ signedCredential,
1159
+ getDidWeb(domain, issuerProfile.profileId)
1160
+ );
906
1161
  } else {
907
1162
  const reference = SigningAuthorityReferenceValidator.safeParse(input.signingAuthority);
908
1163
 
@@ -931,22 +1186,33 @@ export const publishCredentialRefresh = async (
931
1186
  // body as supplied, so the completed credential is rechecked in full plus proof.
932
1187
  assertRefreshVersionInvariants(input.credential as VC, aggregate, domain);
933
1188
 
1189
+ // Managed refresh services require their inline JSON-LD context terms to be
1190
+ // defined or signing fails (LC-2198): prepare the context exactly as the SDK
1191
+ // path does. Idempotent when the issuer already supplied the definitions.
1192
+ const preparedCredential = prepareManagedRefreshContext(input.credential);
1193
+
934
1194
  // appendCredentialStatus: false — a refresh version must preserve the
935
1195
  // issuer-supplied credentialStatus descriptor; no new status-list entry is
936
1196
  // allocated for a version of an already-issued credential.
937
- signedCredential = (await issueCredentialWithSigningAuthority(
938
- { type: 'profile', profile: issuerProfile },
939
- input.credential,
940
- signingAuthority,
941
- domain,
942
- false,
943
- undefined,
944
- false
945
- )) as VC;
1197
+ signedCredential = (
1198
+ await issueCredentialWithSigningAuthority(
1199
+ { type: 'profile', profile: issuerProfile },
1200
+ preparedCredential,
1201
+ signingAuthority,
1202
+ domain,
1203
+ false,
1204
+ undefined,
1205
+ false
1206
+ )
1207
+ ).credential as VC;
946
1208
  signingMode = 'signing-authority';
947
1209
 
948
1210
  assertRefreshVersionInvariants(signedCredential, aggregate, domain);
949
- await verifyRefreshVersionProof(signedCredential);
1211
+ await verifyManagedRefreshProof(
1212
+ (await getLearnCard()).invoke,
1213
+ signedCredential,
1214
+ getDidWeb(domain, issuerProfile.profileId)
1215
+ );
950
1216
  }
951
1217
 
952
1218
  // Reject a strictly older effective/issuance timestamp. Equal or missing
@@ -998,8 +1264,17 @@ export const publishCredentialRefresh = async (
998
1264
  })
999
1265
  : undefined;
1000
1266
 
1001
- // Holder-only encryption: the brain DID must NOT be a recipient.
1002
- const jwe = await createDagJweForRecipients(signedCredential, [aggregate.holderDid]);
1267
+ const holderProfileForEncryption = aggregate.holderProfileId
1268
+ ? await getProfileByProfileId(aggregate.holderProfileId)
1269
+ : null;
1270
+
1271
+ // Same holder-side recipients as the initial send — see getHolderEncryptionRecipients.
1272
+ const jwe = await createDagJweForRecipients(
1273
+ signedCredential,
1274
+ holderProfileForEncryption
1275
+ ? await getHolderEncryptionRecipients(holderProfileForEncryption)
1276
+ : [aggregate.holderDid!]
1277
+ );
1003
1278
  const encryptedCredential = JSON.stringify(jwe);
1004
1279
  const etag = computeRefreshEtag(encryptedCredential);
1005
1280
 
@@ -1013,6 +1288,7 @@ export const publishCredentialRefresh = async (
1013
1288
  etag,
1014
1289
  materialDigest: nextDigest,
1015
1290
  updateSummary,
1291
+ credentialDisplayName,
1016
1292
  effectiveAt:
1017
1293
  effectiveTime !== undefined ? new Date(effectiveTime).toISOString() : undefined,
1018
1294
  notificationOutcome: notification,
@@ -1048,6 +1324,7 @@ export const publishCredentialRefresh = async (
1048
1324
  version: replayed,
1049
1325
  aggregate,
1050
1326
  issuerProfile,
1327
+ branding,
1051
1328
  }),
1052
1329
  };
1053
1330
  }
@@ -1063,6 +1340,7 @@ export const publishCredentialRefresh = async (
1063
1340
  version: persistedVersion,
1064
1341
  issuerProfile,
1065
1342
  holderProfile,
1343
+ branding,
1066
1344
  })
1067
1345
  : 'delivery-failed';
1068
1346
  }