@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
@@ -1,3 +1,10 @@
1
+ import {
2
+ assertInboxRefreshEnabled,
3
+ inboxRefreshRequestDigest,
4
+ getInboxRefreshReplay,
5
+ getInboxRefreshReceipt,
6
+ resumeInboxRefreshDelivery,
7
+ } from '@helpers/inbox-refresh.helpers';
1
8
  import { TRPCError } from '@trpc/server';
2
9
  import { z } from 'zod';
3
10
  import { v4 as uuid } from 'uuid';
@@ -27,11 +34,19 @@ import {
27
34
  PaginatedBoostRecipientsWithChildrenValidator,
28
35
  SkillQueryValidator,
29
36
  SendBoostInputValidator,
37
+ SendBoostTemplateValidator,
30
38
  SendBoostResponseValidator,
39
+ PrepareRefreshableSendInputValidator,
40
+ PrepareRefreshableSendResultValidator,
41
+ PrepareRefreshableSendResult,
31
42
  AllocateCredentialStatusInputValidator,
32
43
  AllocatedBitstringStatusListEntryValidator,
33
44
  } from '@learncard/types';
34
- import { isVC2Format } from '@learncard/helpers';
45
+ import {
46
+ isVC2Format,
47
+ injectManagedRefreshService,
48
+ getCredentialIssuerId,
49
+ } from '@learncard/helpers';
35
50
  import {
36
51
  renderBoostTemplate,
37
52
  parseRenderedTemplate,
@@ -47,6 +62,7 @@ import { t, profileRoute } from '@routes';
47
62
 
48
63
  import {
49
64
  getBoostByUri,
65
+ getBoostById,
50
66
  getBoostsForProfile,
51
67
  countBoostsForProfile,
52
68
  getBoostsByUri,
@@ -175,6 +191,29 @@ import {
175
191
  } from '@accesslayer/role/relationships/create';
176
192
  import { updateDefaultPermissionsForBoost } from '@accesslayer/role/relationships/update';
177
193
  import { issueCredentialWithSigningAuthority } from '@helpers/signingAuthority.helpers';
194
+ import type { IssuedCredential } from '../types/credential';
195
+ import {
196
+ allocateCredentialRefresh,
197
+ extractManagedRefreshHandoff,
198
+ getBoundRefreshBoostId,
199
+ getCredentialRefreshServiceUrl,
200
+ peekCredentialRefreshInitialBinding,
201
+ sendRefreshableCredential,
202
+ } from '@helpers/credential-refresh.helpers';
203
+ import {
204
+ claimRefreshSendIntent,
205
+ computeRefreshSendRequestDigest,
206
+ getRefreshSendIntent,
207
+ markRefreshSendIntentDelivered,
208
+ reconcileBoundRefreshSendIntent,
209
+ recordRefreshSendIntent,
210
+ recordRefreshSendIntentPending,
211
+ type RefreshSendIntent,
212
+ } from '@helpers/refresh-send-intent.helpers';
213
+ import { getCredentialRefreshRuntimeEnvironment } from '@environment';
214
+ import { ensureCredentialRefreshConstraints } from '../models/credential-refresh-constraints';
215
+ import { userHasRequiredScopes } from '@helpers/auth-grant.helpers';
216
+ import { AUTH_GRANT_NO_ACCESS_SCOPE } from 'src/constants/auth-grant';
178
217
  import { removeConnectionsForBoost } from '@helpers/connection.helpers';
179
218
  import { issueToInbox } from '@helpers/inbox.helpers';
180
219
  import { findInboxServiceEndpoint } from '@helpers/federation.helpers';
@@ -241,7 +280,336 @@ const resolveBoostCredentialInstance = async ({
241
280
  return getCredentialInstanceForBoostAndProfile(boostId, recipientProfileId);
242
281
  };
243
282
 
283
+ /**
284
+ * Resolves the approved consent-flow contract terms for a unified send, and links a
285
+ * newly created boost to the contract. Shared by the normal and managed-refresh send
286
+ * paths so contract linkage semantics stay identical.
287
+ */
288
+ const resolveContractForSend = async (params: {
289
+ profile: ProfileType;
290
+ targetProfile: ProfileType;
291
+ boost: BoostInstance;
292
+ boostCreated: boolean;
293
+ contractUri?: string;
294
+ }) => {
295
+ const { profile, targetProfile, boost, boostCreated, contractUri } = params;
296
+
297
+ let contractTerms = null as Awaited<ReturnType<typeof getContractTermsForProfile>> | null;
298
+ let contractDetails: Awaited<ReturnType<typeof getContractDetailsByUri>> | null = null;
299
+
300
+ if (contractUri) {
301
+ const decodedContractUri = decodeURIComponent(contractUri);
302
+ contractDetails = await traceDb('getContractDetailsByUri', () =>
303
+ getContractDetailsByUri(decodedContractUri)
304
+ );
305
+
306
+ if (!contractDetails) {
307
+ throw new TRPCError({
308
+ code: 'NOT_FOUND',
309
+ message: 'Could not find contract',
310
+ });
311
+ }
312
+
313
+ const terms = await traceDb('getContractTermsForProfile', () =>
314
+ getContractTermsForProfile(targetProfile, contractDetails!.contract)
315
+ );
316
+
317
+ const writers = await traceDb('getWritersForContract', () =>
318
+ getWritersForContract(contractDetails!.contract)
319
+ );
320
+ const isWriter = writers.some(writer => writer.profileId === profile.profileId);
321
+ const isDenied = terms?.terms.deniedWriters?.includes(profile.profileId) ?? false;
322
+ const categoryAllowed = boost.category
323
+ ? terms?.terms.write?.credentials?.categories?.[boost.category] === true
324
+ : true;
325
+
326
+ if (terms && isWriter && !isDenied && categoryAllowed) {
327
+ contractTerms = terms;
328
+ }
329
+ }
330
+
331
+ if (boostCreated && contractDetails) {
332
+ await traceDb('setRelatedBoostForContract', () =>
333
+ setRelatedBoostForContract(contractDetails!.contract, boost)
334
+ );
335
+ }
336
+
337
+ return contractTerms;
338
+ };
339
+
340
+ /** Validate managed send support and recipient before creating any send state. */
341
+ const validateRefreshSendRecipient = async ({
342
+ profile,
343
+ scope,
344
+ recipient,
345
+ domain,
346
+ }: {
347
+ profile: ProfileType;
348
+ scope?: string;
349
+ recipient: string;
350
+ domain: string;
351
+ }): Promise<ProfileType> => {
352
+ if (!getCredentialRefreshRuntimeEnvironment().CREDENTIAL_REFRESH_ENABLED) {
353
+ throw new TRPCError({
354
+ code: 'NOT_FOUND',
355
+ message: 'Credential refresh is not available',
356
+ });
357
+ }
358
+
359
+ await ensureCredentialRefreshConstraints();
360
+
361
+ // The route itself requires boosts:write; managed refresh additionally
362
+ // requires the same credentials:write scope as the dedicated
363
+ // /credential-refresh routes.
364
+ if (!userHasRequiredScopes(scope ?? AUTH_GRANT_NO_ACCESS_SCOPE, 'credentials:write')) {
365
+ throw new TRPCError({
366
+ code: 'UNAUTHORIZED',
367
+ message: 'This operation requires credentials:write scope',
368
+ });
369
+ }
370
+
371
+ if (isInboxRecipient(recipient)) {
372
+ throw new TRPCError({
373
+ code: 'BAD_REQUEST',
374
+ message:
375
+ 'Managed credential refresh requires a profile or DID recipient; email and phone recipients cannot request refresh.',
376
+ });
377
+ }
378
+
379
+ // Recipients must resolve to local profiles before anything is
380
+ // created; remote or unresolvable DIDs are rejected here instead of
381
+ // entering the federation/inbox flows.
382
+ const refreshRecipientProfileId = await traceInternal('getProfileIdFromString:refresh', () =>
383
+ getProfileIdFromString(recipient, domain)
384
+ );
385
+ const refreshTargetProfile = refreshRecipientProfileId
386
+ ? await traceDb('getProfileByProfileId:refresh', () =>
387
+ getProfileByProfileId(refreshRecipientProfileId)
388
+ )
389
+ : null;
390
+
391
+ if (!refreshTargetProfile) {
392
+ throw new TRPCError({
393
+ code: 'NOT_FOUND',
394
+ message:
395
+ 'Managed credential refresh requires a recipient resolvable to a local profile. Remote or unresolvable DIDs are not supported.',
396
+ });
397
+ }
398
+
399
+ const refreshTarget = refreshTargetProfile;
400
+
401
+ if (
402
+ await traceDb('isRelationshipBlocked:refresh', () =>
403
+ isRelationshipBlocked(profile, refreshTarget)
404
+ )
405
+ ) {
406
+ throw new TRPCError({
407
+ code: 'NOT_FOUND',
408
+ message: 'Profile not found. Are you sure this person exists?',
409
+ });
410
+ }
411
+
412
+ return refreshTargetProfile;
413
+ };
414
+
415
+ /** Preserve the inline send template's metadata, skills and claim permissions. */
416
+ const createInlineBoostForSend = async (
417
+ template: z.infer<typeof SendBoostTemplateValidator>,
418
+ profile: ProfileType,
419
+ domain: string
420
+ ): Promise<BoostInstance> => {
421
+ const { credential, claimPermissions, skills, ...metadata } = template;
422
+ const boost = await traceDb('createBoost', () =>
423
+ createBoost(credential, profile, metadata, domain)
424
+ );
425
+ if (Array.isArray(skills) && skills.length > 0) {
426
+ await traceDb('addAlignedSkillsToBoost', () => addAlignedSkillsToBoost(boost, skills));
427
+ }
428
+ if (claimPermissions) {
429
+ await traceDb('addClaimPermissionsForBoost', () =>
430
+ addClaimPermissionsForBoost(boost, { ...EMPTY_PERMISSIONS, ...claimPermissions })
431
+ );
432
+ }
433
+ return boost;
434
+ };
435
+
436
+ const managedRefreshServiceFor = (refreshId: string, domain: string) => ({
437
+ id: getCredentialRefreshServiceUrl(refreshId, domain),
438
+ type: 'LearnCardCredentialRefresh2026' as const,
439
+ authorization: { type: 'LearnCardDIDAuth' as const },
440
+ });
441
+
442
+ /**
443
+ * The single server preparation step for a managed refresh send (SDK and signing
444
+ * authority paths): after recipient validation, create/reuse the boost and allocate
445
+ * the refresh. With an idempotencyKey, progress is recorded on a RefreshSendIntent so a
446
+ * retried call reuses the same boost/allocation, or returns the completed result.
447
+ */
448
+ const prepareManagedRefreshSend = async (params: {
449
+ profile: ProfileType;
450
+ /** Returned by validateRefreshSendRecipient in this request, before any mutation. */
451
+ targetProfile: ProfileType;
452
+ domain: string;
453
+ templateUri?: string;
454
+ template?: z.infer<typeof SendBoostTemplateValidator>;
455
+ contractUri?: string;
456
+ credentialId?: string;
457
+ templateData?: Record<string, unknown>;
458
+ integrationId?: string;
459
+ idempotencyKey?: string;
460
+ }): Promise<PrepareRefreshableSendResult & { intent?: RefreshSendIntent }> => {
461
+ const { profile, targetProfile, domain, templateUri, template, contractUri } = params;
462
+ // Local recipients authenticate refresh requests as their network profile DID,
463
+ // including when the caller addressed them by their controller did:key.
464
+ const holderDid = getDidWeb(domain, targetProfile.profileId);
465
+
466
+ let intent: RefreshSendIntent | undefined;
467
+
468
+ if (params.idempotencyKey) {
469
+ const claim = await claimRefreshSendIntent({
470
+ issuerProfileId: profile.profileId,
471
+ idempotencyKey: params.idempotencyKey,
472
+ requestDigest: computeRefreshSendRequestDigest({
473
+ recipientProfileId: targetProfile.profileId,
474
+ holderDid,
475
+ templateUri,
476
+ template,
477
+ contractUri,
478
+ templateData: params.templateData,
479
+ integrationId: params.integrationId,
480
+ credentialId: params.credentialId,
481
+ }),
482
+ });
483
+
484
+ intent = claim.intent;
485
+
486
+ if (claim.kind !== 'owned') {
487
+ const prepared = {
488
+ boostUri: intent.boostUri!,
489
+ credentialId: intent.credentialId!,
490
+ refreshId: intent.refreshId!,
491
+ refreshService: managedRefreshServiceFor(intent.refreshId!, domain),
492
+ holderDid: intent.holderDid!,
493
+ intent,
494
+ };
495
+ const completed =
496
+ claim.kind === 'delivered'
497
+ ? intent.result
498
+ : await reconcileBoundRefreshSendIntent(intent, domain);
499
+
500
+ return completed ? { ...prepared, completed } : prepared;
501
+ }
502
+ }
503
+
504
+ if (template?.status === 'DRAFT') {
505
+ throw new TRPCError({
506
+ code: 'FORBIDDEN',
507
+ message: 'Draft Boosts can not be sent. Only Published Boosts can be sent.',
508
+ });
509
+ }
510
+
511
+ let boostUri = intent?.boostUri ?? templateUri;
512
+ let boost: BoostInstance | null;
513
+
514
+ if (boostUri) {
515
+ boost = await traceDb('getBoostByUri:prepareRefresh', () => getBoostByUri(boostUri!));
516
+
517
+ if (!boost) throw new TRPCError({ code: 'NOT_FOUND', message: 'Could not find boost' });
518
+ } else {
519
+ boost = await createInlineBoostForSend(template!, profile, domain);
520
+ boostUri = getBoostUri(boost.id, domain);
521
+
522
+ if (intent) intent = await recordRefreshSendIntent(intent, { boostUri });
523
+
524
+ await resolveContractForSend({
525
+ profile,
526
+ targetProfile,
527
+ boost,
528
+ boostCreated: true,
529
+ contractUri,
530
+ });
531
+ }
532
+
533
+ if (
534
+ !(await traceDb('canProfileIssueBoost:prepareRefresh', () =>
535
+ canProfileIssueBoost(profile, boost!)
536
+ ))
537
+ ) {
538
+ throw new TRPCError({
539
+ code: 'UNAUTHORIZED',
540
+ message: 'Profile does not have permissions to issue boost',
541
+ });
542
+ }
543
+
544
+ if (isDraftBoost(boost)) {
545
+ throw new TRPCError({
546
+ code: 'FORBIDDEN',
547
+ message: 'Draft Boosts can not be sent. Only Published Boosts can be sent.',
548
+ });
549
+ }
550
+
551
+ const credentialId = intent?.credentialId ?? params.credentialId ?? `urn:uuid:${uuid()}`;
552
+ let refreshId = intent?.refreshId;
553
+
554
+ if (!refreshId) {
555
+ const allocation = await traceInternal('allocateCredentialRefresh:prepare', () =>
556
+ allocateCredentialRefresh({
557
+ issuerProfile: profile,
558
+ holderProfile: targetProfile,
559
+ holderDid,
560
+ credentialId,
561
+ domain,
562
+ })
563
+ );
564
+
565
+ refreshId = allocation.refreshId;
566
+ }
567
+
568
+ if (intent) {
569
+ intent = await recordRefreshSendIntent(intent, {
570
+ boostUri,
571
+ credentialId,
572
+ refreshId,
573
+ holderDid,
574
+ state: 'prepared',
575
+ });
576
+ }
577
+
578
+ return {
579
+ boostUri,
580
+ credentialId,
581
+ refreshId,
582
+ refreshService: managedRefreshServiceFor(refreshId, domain),
583
+ holderDid,
584
+ ...(intent ? { intent } : {}),
585
+ };
586
+ };
587
+
244
588
  export const boostsRouter = t.router({
589
+ // SDK preparation only: guards, boost anchor and refresh allocation. Never signs or
590
+ // delivers. With an idempotencyKey a retried call reuses the same boost/allocation,
591
+ // or returns `completed` when that key already delivered.
592
+ prepareRefreshableSend: profileRoute
593
+ .meta({ requiredScope: 'boosts:write' })
594
+ .input(PrepareRefreshableSendInputValidator)
595
+ .output(PrepareRefreshableSendResultValidator)
596
+ .mutation(async ({ ctx, input }) => {
597
+ const targetProfile = await validateRefreshSendRecipient({
598
+ profile: ctx.user.profile,
599
+ scope: ctx.user.scope,
600
+ recipient: input.recipient,
601
+ domain: ctx.domain,
602
+ });
603
+ const { intent: _intent, ...prepared } = await prepareManagedRefreshSend({
604
+ ...input,
605
+ profile: ctx.user.profile,
606
+ targetProfile,
607
+ domain: ctx.domain,
608
+ });
609
+
610
+ return prepared;
611
+ }),
612
+
245
613
  getBoostAlignments: profileRoute
246
614
  .meta({
247
615
  openapi: {
@@ -691,6 +1059,7 @@ export const boostsRouter = t.router({
691
1059
  // Log credential activity FIRST to get activityId for chaining
692
1060
  const activityId = await logCredentialSent({
693
1061
  actorProfileId: profile.profileId,
1062
+ onBehalfOf: ctx.user.onBehalfOf,
694
1063
  recipientType: 'profile',
695
1064
  recipientIdentifier: targetProfile.profileId,
696
1065
  recipientProfileId: targetProfile.profileId,
@@ -735,6 +1104,147 @@ export const boostsRouter = t.router({
735
1104
  const { contractUri } = input;
736
1105
  const { domain } = ctx;
737
1106
 
1107
+ // LC-2198: managed refresh requests are validated in full BEFORE any
1108
+ // mutation — an unsupported refresh send must not create a boost,
1109
+ // allocation, inbox entry, activity, or delivery.
1110
+ const inboxRefreshRequested =
1111
+ input.refresh === true && !!isInboxRecipient(input.recipient);
1112
+ const refreshRequested = input.refresh === true && !inboxRefreshRequested;
1113
+ let inboxIntent: RefreshSendIntent | undefined;
1114
+ const inboxDigest = inboxRefreshRequested
1115
+ ? inboxRefreshRequestDigest(input)
1116
+ : undefined;
1117
+ const inboxKey = input.idempotencyKey
1118
+ ? `send:${input.idempotencyKey}`
1119
+ : undefined;
1120
+ if (inboxRefreshRequested) {
1121
+ await assertInboxRefreshEnabled(ctx.user.scope);
1122
+ if (input.signedCredential || input.contractUri)
1123
+ throw new TRPCError({
1124
+ code: 'BAD_REQUEST',
1125
+ message:
1126
+ 'Inbox refresh requires unsigned content and does not support a consent contract before holder binding.',
1127
+ });
1128
+ if (input.idempotencyKey) {
1129
+ const claim = await claimRefreshSendIntent({
1130
+ issuerProfileId: profile.profileId,
1131
+ idempotencyKey: input.idempotencyKey,
1132
+ requestDigest: inboxDigest!,
1133
+ });
1134
+ if (claim.kind === 'delivered' && claim.intent.result)
1135
+ return claim.intent.result;
1136
+ inboxIntent = claim.intent;
1137
+ const replay = await getInboxRefreshReplay(
1138
+ profile.profileId,
1139
+ inboxKey,
1140
+ inboxDigest!
1141
+ );
1142
+ if (
1143
+ replay &&
1144
+ (replay.claimUrl || replay.inbox.currentStatus !== 'PENDING')
1145
+ ) {
1146
+ await resumeInboxRefreshDelivery(replay.inbox.refreshId!, domain);
1147
+ const result = {
1148
+ type: 'boost' as const,
1149
+ uri: inboxIntent.boostUri!,
1150
+ credentialUri: '',
1151
+ activityId: replay.inbox.activityId ?? '',
1152
+ inbox: {
1153
+ issuanceId: replay.inbox.id,
1154
+ status: replay.inbox.currentStatus,
1155
+ claimUrl: replay.claimUrl,
1156
+ guardianStatus: replay.inbox.guardianStatus,
1157
+ refresh: await getInboxRefreshReceipt(
1158
+ replay.inbox.refreshId!,
1159
+ domain
1160
+ ),
1161
+ },
1162
+ };
1163
+ await markRefreshSendIntentDelivered(inboxIntent, result);
1164
+ return result;
1165
+ }
1166
+ }
1167
+ }
1168
+ let refreshTargetProfile: ProfileType | null = null;
1169
+
1170
+ if (input.idempotencyKey && !input.refresh) {
1171
+ throw new TRPCError({
1172
+ code: 'BAD_REQUEST',
1173
+ message: 'idempotencyKey is only supported with refresh: true.',
1174
+ });
1175
+ }
1176
+
1177
+ if (refreshRequested) {
1178
+ refreshTargetProfile = await validateRefreshSendRecipient({
1179
+ profile,
1180
+ scope: ctx.user.scope,
1181
+ recipient: input.recipient,
1182
+ domain,
1183
+ });
1184
+
1185
+ if (input.signedCredential) {
1186
+ // Reject signed credentials without a local managed allocation
1187
+ // before any mutation (e.g. boost auto-creation). No unsigned
1188
+ // fallback and no second allocation (plan decision 6).
1189
+ const handoff = extractManagedRefreshHandoff(
1190
+ input.signedCredential,
1191
+ domain
1192
+ );
1193
+
1194
+ if (!handoff) {
1195
+ throw new TRPCError({
1196
+ code: 'BAD_REQUEST',
1197
+ message:
1198
+ 'A signed credential sent with refresh must already contain its allocated managed refresh service.',
1199
+ });
1200
+ }
1201
+
1202
+ if (input.idempotencyKey) {
1203
+ const intent = await getRefreshSendIntent(
1204
+ profile.profileId,
1205
+ input.idempotencyKey
1206
+ );
1207
+ if (!intent) {
1208
+ throw new TRPCError({
1209
+ code: 'BAD_REQUEST',
1210
+ message:
1211
+ 'idempotencyKey with a pre-signed credential requires a prior boost.prepareRefreshableSend call (tRPC only). For a directly signed REST send, omit idempotencyKey and retry the same signed credential and templateUri.',
1212
+ });
1213
+ }
1214
+ if (
1215
+ intent.refreshId !== handoff.refreshId ||
1216
+ (input.templateUri && intent.boostUri !== input.templateUri)
1217
+ ) {
1218
+ throw new TRPCError({
1219
+ code: 'CONFLICT',
1220
+ message:
1221
+ 'This idempotencyKey does not match the prepared refresh send for this credential.',
1222
+ });
1223
+ }
1224
+ }
1225
+ }
1226
+ }
1227
+
1228
+ // Keyed managed send without a pre-signed credential: preparation (boost +
1229
+ // allocation) goes through the idempotency intent instead of the generic
1230
+ // boost creation below.
1231
+ const keyedPrepared =
1232
+ refreshRequested && input.idempotencyKey && !input.signedCredential
1233
+ ? await prepareManagedRefreshSend({
1234
+ profile,
1235
+ targetProfile: refreshTargetProfile!,
1236
+ domain,
1237
+ templateUri: input.templateUri,
1238
+ template: input.templateUri ? undefined : input.template,
1239
+ contractUri,
1240
+ templateData: input.templateData,
1241
+ integrationId: input.integrationId,
1242
+ idempotencyKey: input.idempotencyKey,
1243
+ })
1244
+ : undefined;
1245
+
1246
+ if (keyedPrepared?.completed) return keyedPrepared.completed;
1247
+
738
1248
  // Check if recipient is email/phone (routes to Universal Inbox)
739
1249
  const inboxRecipient = isInboxRecipient(input.recipient);
740
1250
 
@@ -742,9 +1252,23 @@ export const boostsRouter = t.router({
742
1252
  let boost = null as BoostInstance | null;
743
1253
  let boostUri = '';
744
1254
  let boostCreated = false;
745
- let boostCreatedFromSignedCredential = false;
746
1255
 
747
- if (input.templateUri) {
1256
+ if (inboxIntent?.boostUri) {
1257
+ boost = await getBoostByUri(inboxIntent.boostUri);
1258
+ boostUri = inboxIntent.boostUri;
1259
+ } else if (keyedPrepared) {
1260
+ const resolved = await traceDb('getBoostByUri', () =>
1261
+ getBoostByUri(keyedPrepared.boostUri)
1262
+ );
1263
+ if (!resolved) {
1264
+ throw new TRPCError({
1265
+ code: 'NOT_FOUND',
1266
+ message: 'Could not find boost',
1267
+ });
1268
+ }
1269
+ boost = resolved;
1270
+ boostUri = keyedPrepared.boostUri;
1271
+ } else if (input.templateUri) {
748
1272
  const resolved = await traceDb('getBoostByUri', () =>
749
1273
  getBoostByUri(input.templateUri!)
750
1274
  );
@@ -757,48 +1281,77 @@ export const boostsRouter = t.router({
757
1281
  boost = resolved;
758
1282
  boostUri = input.templateUri;
759
1283
  } else if (input.template) {
760
- const { credential, claimPermissions, skills, ...metadata } =
761
- input.template;
762
-
763
- boost = await traceDb('createBoost', () =>
764
- createBoost(credential, profile, metadata, domain)
765
- );
766
-
767
- if (Array.isArray(skills) && skills.length > 0) {
768
- await traceDb('addAlignedSkillsToBoost', () =>
769
- addAlignedSkillsToBoost(boost!, skills)
770
- );
771
- }
772
-
773
- if (claimPermissions) {
774
- await traceDb('addClaimPermissionsForBoost', () =>
775
- addClaimPermissionsForBoost(boost!, {
776
- ...EMPTY_PERMISSIONS,
777
- ...claimPermissions,
778
- })
779
- );
780
- }
1284
+ boost = await createInlineBoostForSend(input.template, profile, domain);
781
1285
 
782
1286
  boostUri = getBoostUri(boost.id, domain);
783
1287
  boostCreated = true;
784
1288
  } else if (input.signedCredential) {
785
- // Auto-create boost from the signed credential
786
- const credential = input.signedCredential as Record<string, unknown>;
787
- const name =
788
- typeof credential.name === 'string' ? credential.name : undefined;
789
-
790
- boost = await traceDb('createBoost:fromSignedCredential', () =>
791
- createBoost(
792
- input.signedCredential!,
793
- profile,
794
- { ...(name ? { name } : {}) },
795
- domain
796
- )
797
- );
1289
+ // A replayed managed handoff reuses the boost its refresh is already
1290
+ // bound to (the pre-mutation guard validated the handoff above).
1291
+ const boundBoostId = refreshRequested
1292
+ ? await traceDb('getBoundRefreshBoostId', () =>
1293
+ getBoundRefreshBoostId(
1294
+ extractManagedRefreshHandoff(input.signedCredential!, domain)!
1295
+ .refreshId
1296
+ )
1297
+ )
1298
+ : undefined;
1299
+ const boundBoost = boundBoostId
1300
+ ? await traceDb('getBoostById:boundRefresh', () =>
1301
+ getBoostById(boundBoostId)
1302
+ )
1303
+ : null;
1304
+ // A keyed handoff whose preparation already created/resolved a
1305
+ // boost (but never delivered) reuses that prepared boost.
1306
+ const preparedBoostUri =
1307
+ !boundBoost && refreshRequested && input.idempotencyKey
1308
+ ? (
1309
+ await traceDb('getRefreshSendIntent:signedCredential', () =>
1310
+ getRefreshSendIntent(
1311
+ profile.profileId,
1312
+ input.idempotencyKey!
1313
+ )
1314
+ )
1315
+ )?.boostUri
1316
+ : undefined;
1317
+ const preparedBoost = preparedBoostUri
1318
+ ? await traceDb('getBoostByUri:preparedRefresh', () =>
1319
+ getBoostByUri(preparedBoostUri)
1320
+ )
1321
+ : null;
1322
+
1323
+ if (boundBoost) {
1324
+ boost = boundBoost;
1325
+ boostUri = getBoostUri(boundBoost.id, domain);
1326
+ } else if (preparedBoost && preparedBoostUri) {
1327
+ boost = preparedBoost;
1328
+ boostUri = preparedBoostUri;
1329
+ } else {
1330
+ // Auto-create boost from the signed credential. A managed refresh
1331
+ // service belongs to one holder's credential, never to a reusable
1332
+ // template, so it is not stored on the boost.
1333
+ const { refreshService: _refreshService, ...templateCredential } =
1334
+ input.signedCredential as Record<string, unknown>;
1335
+ const name =
1336
+ typeof templateCredential.name === 'string'
1337
+ ? templateCredential.name
1338
+ : undefined;
1339
+
1340
+ boost = await traceDb('createBoost:fromSignedCredential', () =>
1341
+ createBoost(
1342
+ (refreshRequested
1343
+ ? templateCredential
1344
+ : input.signedCredential!) as typeof input.signedCredential &
1345
+ object,
1346
+ profile,
1347
+ { ...(name ? { name } : {}) },
1348
+ domain
1349
+ )
1350
+ );
798
1351
 
799
- boostUri = getBoostUri(boost.id, domain);
800
- boostCreated = true;
801
- boostCreatedFromSignedCredential = true;
1352
+ boostUri = getBoostUri(boost.id, domain);
1353
+ boostCreated = true;
1354
+ }
802
1355
  }
803
1356
 
804
1357
  if (!boost) {
@@ -828,6 +1381,298 @@ export const boostsRouter = t.router({
828
1381
  });
829
1382
  }
830
1383
 
1384
+ if (inboxIntent?.state === 'preparing')
1385
+ inboxIntent = await recordRefreshSendIntent(inboxIntent, {
1386
+ boostUri,
1387
+ state: 'prepared',
1388
+ });
1389
+
1390
+ // LC-2198: managed refresh delivery. The recipient was fully validated
1391
+ // in the pre-mutation guard above (feature, scope, supported recipient,
1392
+ // blocklist); the boost passed the same issue-permission and draft
1393
+ // checks as a normal send.
1394
+ if (refreshRequested) {
1395
+ return trace('route', 'sendRefreshable', async () => {
1396
+ const targetProfile = refreshTargetProfile!;
1397
+ let intent: RefreshSendIntent | undefined = keyedPrepared?.intent;
1398
+
1399
+ const contractTerms = await resolveContractForSend({
1400
+ profile,
1401
+ targetProfile,
1402
+ boost: boost!,
1403
+ boostCreated,
1404
+ contractUri,
1405
+ });
1406
+
1407
+ // Matches the unified send's self-send notification behavior.
1408
+ const skipNotification = profile.profileId === targetProfile.profileId;
1409
+
1410
+ let signedVc: VC;
1411
+ let refreshId: string;
1412
+
1413
+ if (input.signedCredential) {
1414
+ // Preallocated handoff (plan decision 6): the caller already
1415
+ // allocated and embedded the managed service before signing.
1416
+ // Ownership, holder, ID, boost anchor, proof, and descriptor
1417
+ // are enforced by the managed send helper.
1418
+ const handoff = extractManagedRefreshHandoff(
1419
+ input.signedCredential,
1420
+ domain
1421
+ )!;
1422
+ signedVc = input.signedCredential;
1423
+ refreshId = handoff.refreshId;
1424
+
1425
+ if (input.idempotencyKey) {
1426
+ const existing = await getRefreshSendIntent(
1427
+ profile.profileId,
1428
+ input.idempotencyKey
1429
+ );
1430
+
1431
+ if (
1432
+ !existing ||
1433
+ existing.refreshId !== refreshId ||
1434
+ existing.boostUri !== boostUri
1435
+ ) {
1436
+ throw new TRPCError({
1437
+ code: 'CONFLICT',
1438
+ message:
1439
+ 'This idempotencyKey does not match the prepared refresh send for this credential.',
1440
+ });
1441
+ }
1442
+
1443
+ if (existing.state === 'delivered' && existing.result) {
1444
+ return existing.result;
1445
+ }
1446
+
1447
+ intent = existing;
1448
+ const reconciled = await reconcileBoundRefreshSendIntent(
1449
+ existing,
1450
+ domain
1451
+ );
1452
+ if (reconciled) return reconciled;
1453
+ }
1454
+
1455
+ // Resume detection: reuse the original delivery activity
1456
+ // instead of duplicating issuance or activity when the exact
1457
+ // same credential is redelivered (e.g. a network retry after
1458
+ // binding already succeeded).
1459
+ const binding = await traceInternal(
1460
+ 'peekCredentialRefreshInitialBinding',
1461
+ () =>
1462
+ peekCredentialRefreshInitialBinding({
1463
+ refreshId,
1464
+ credential: signedVc,
1465
+ boostId: boost!.id,
1466
+ })
1467
+ );
1468
+
1469
+ if (binding.bound) {
1470
+ const { uri, receipt } = await sendRefreshableCredential({
1471
+ issuerProfile: profile,
1472
+ refreshId,
1473
+ credential: signedVc,
1474
+ boostUri,
1475
+ skipNotification,
1476
+ domain,
1477
+ activityId: binding.activityId,
1478
+ integrationId: input.integrationId,
1479
+ contractTerms: contractTerms ?? undefined,
1480
+ });
1481
+
1482
+ const response = {
1483
+ type: 'boost' as const,
1484
+ credentialUri: uri,
1485
+ uri: boostUri,
1486
+ activityId: binding.activityId ?? '',
1487
+ refresh: receipt,
1488
+ };
1489
+
1490
+ if (intent)
1491
+ await markRefreshSendIntentDelivered(intent, response);
1492
+
1493
+ return response;
1494
+ }
1495
+ } else {
1496
+ if (keyedPrepared && intent) {
1497
+ const reconciled = await reconcileBoundRefreshSendIntent(
1498
+ intent,
1499
+ domain
1500
+ );
1501
+
1502
+ if (reconciled) return reconciled;
1503
+ }
1504
+
1505
+ const signingAuthority = await traceDb(
1506
+ 'getPrimarySigningAuthorityForUser:refresh',
1507
+ () => getPrimarySigningAuthorityForUser(profile)
1508
+ );
1509
+
1510
+ if (!signingAuthority) {
1511
+ throw new TRPCError({
1512
+ code: 'PRECONDITION_FAILED',
1513
+ message:
1514
+ 'You must register a signing authority before using send without a pre-signed credential. Please register one via registerSigningAuthority or sign the credential client-side.',
1515
+ });
1516
+ }
1517
+
1518
+ let unsignedVc: UnsignedVC;
1519
+
1520
+ try {
1521
+ unsignedVc = await traceInternal(
1522
+ 'prepareCredentialFromBoost:refresh',
1523
+ () =>
1524
+ prepareCredentialFromBoost(boost!, boostUri, domain, {
1525
+ templateData: input.templateData as Record<
1526
+ string,
1527
+ unknown
1528
+ >,
1529
+ issuerDid: signingAuthority.relationship.did,
1530
+ recipientDid:
1531
+ keyedPrepared?.holderDid ??
1532
+ getDidWeb(domain, targetProfile.profileId),
1533
+ recipientName: targetProfile.displayName,
1534
+ })
1535
+ );
1536
+ } catch (e) {
1537
+ console.error('Failed to prepare boost credential', e);
1538
+ throw new TRPCError({
1539
+ code: 'INTERNAL_SERVER_ERROR',
1540
+ message: 'Failed to prepare boost credential',
1541
+ });
1542
+ }
1543
+
1544
+ // A stable credential ID must exist before allocation: the
1545
+ // refresh aggregate is permanently bound to it.
1546
+ unsignedVc.id =
1547
+ keyedPrepared?.credentialId ??
1548
+ unsignedVc.id ??
1549
+ `urn:uuid:${uuid()}`;
1550
+
1551
+ // A keyed send already allocated its refresh during preparation;
1552
+ // otherwise allocate once, then inject the managed service +
1553
+ // inline JSON-LD context so both become part of the signed payload.
1554
+ const allocation = keyedPrepared
1555
+ ? {
1556
+ refreshId: keyedPrepared.refreshId,
1557
+ refreshService: keyedPrepared.refreshService,
1558
+ }
1559
+ : await traceInternal('allocateCredentialRefresh:send', () =>
1560
+ allocateCredentialRefresh({
1561
+ issuerProfile: profile,
1562
+ holderProfile: targetProfile,
1563
+ holderDid: getDidWeb(domain, targetProfile.profileId),
1564
+ credentialId: unsignedVc.id!,
1565
+ domain,
1566
+ })
1567
+ );
1568
+
1569
+ refreshId = allocation.refreshId;
1570
+
1571
+ const prepared = injectManagedRefreshService(
1572
+ unsignedVc,
1573
+ allocation.refreshService
1574
+ );
1575
+
1576
+ signedVc = await traceInternal(
1577
+ 'issueCredentialWithSigningAuthority:refresh',
1578
+ async () =>
1579
+ (
1580
+ await issueCredentialWithSigningAuthority(
1581
+ { type: 'profile', profile },
1582
+ prepared,
1583
+ signingAuthority,
1584
+ domain,
1585
+ false
1586
+ )
1587
+ ).credential as VC
1588
+ );
1589
+ }
1590
+
1591
+ // Plaintext templateData is intentionally omitted from refresh
1592
+ // activity metadata: refresh records never persist claim content.
1593
+ const activityId = await traceDb('logCredentialSent:refresh', () =>
1594
+ logCredentialSent({
1595
+ actorProfileId: profile.profileId,
1596
+ onBehalfOf: ctx.user.onBehalfOf,
1597
+ recipientType: 'profile',
1598
+ recipientIdentifier: targetProfile.profileId,
1599
+ recipientProfileId: targetProfile.profileId,
1600
+ boostUri,
1601
+ source: 'send',
1602
+ integrationId: input.integrationId,
1603
+ })
1604
+ );
1605
+
1606
+ if (intent) {
1607
+ await recordRefreshSendIntentPending(
1608
+ intent,
1609
+ {
1610
+ refreshId,
1611
+ refreshService: managedRefreshServiceFor(refreshId, domain),
1612
+ credentialId: signedVc.id as string,
1613
+ issuerDid:
1614
+ getCredentialIssuerId(signedVc) ??
1615
+ getDidWeb(domain, profile.profileId),
1616
+ holderDid:
1617
+ intent.holderDid ??
1618
+ getDidWeb(domain, targetProfile.profileId),
1619
+ ...(signedVc.credentialStatus
1620
+ ? { credentialStatus: signedVc.credentialStatus }
1621
+ : {}),
1622
+ },
1623
+ activityId
1624
+ );
1625
+ }
1626
+
1627
+ try {
1628
+ const { uri, receipt } = await sendRefreshableCredential({
1629
+ issuerProfile: profile,
1630
+ refreshId,
1631
+ credential: signedVc,
1632
+ boostUri,
1633
+ skipNotification,
1634
+ domain,
1635
+ activityId,
1636
+ integrationId: input.integrationId,
1637
+ contractTerms: contractTerms ?? undefined,
1638
+ });
1639
+
1640
+ const response = {
1641
+ type: 'boost' as const,
1642
+ credentialUri: uri,
1643
+ uri: boostUri,
1644
+ activityId,
1645
+ refresh: receipt,
1646
+ };
1647
+
1648
+ if (intent) await markRefreshSendIntentDelivered(intent, response);
1649
+
1650
+ return response;
1651
+ } catch (error) {
1652
+ await traceDb('logCredentialFailed:refresh', () =>
1653
+ logCredentialFailed({
1654
+ activityId,
1655
+ actorProfileId: profile.profileId,
1656
+ onBehalfOf: ctx.user.onBehalfOf,
1657
+ recipientType: 'profile',
1658
+ recipientIdentifier: targetProfile.profileId,
1659
+ recipientProfileId: targetProfile.profileId,
1660
+ boostUri,
1661
+ integrationId: input.integrationId,
1662
+ source: 'send',
1663
+ metadata: {
1664
+ error:
1665
+ error instanceof Error
1666
+ ? error.message
1667
+ : 'Unknown error',
1668
+ },
1669
+ })
1670
+ );
1671
+ throw error;
1672
+ }
1673
+ });
1674
+ }
1675
+
831
1676
  // Route to Universal Inbox for email/phone recipients
832
1677
  if (inboxRecipient) {
833
1678
  // Try to resolve recipient profile for auto-populating template variables + recipient DID
@@ -884,11 +1729,12 @@ export const boostsRouter = t.router({
884
1729
  });
885
1730
  }
886
1731
 
887
- credential = await appendBitstringStatusListEntries(
888
- credential,
889
- profile.profileId,
890
- domain
891
- );
1732
+ if (!inboxRefreshRequested)
1733
+ credential = await appendBitstringStatusListEntries(
1734
+ credential,
1735
+ profile.profileId,
1736
+ domain
1737
+ );
892
1738
  }
893
1739
 
894
1740
  // Build inbox configuration from SendOptions
@@ -896,6 +1742,7 @@ export const boostsRouter = t.router({
896
1742
  const activityId = await traceDb('logCredentialSent:inbox', () =>
897
1743
  logCredentialSent({
898
1744
  actorProfileId: profile.profileId,
1745
+ onBehalfOf: ctx.user.onBehalfOf,
899
1746
  recipientType: inboxRecipient.type,
900
1747
  recipientIdentifier: inboxRecipient.value,
901
1748
  boostUri,
@@ -915,6 +1762,9 @@ export const boostsRouter = t.router({
915
1762
  credential,
916
1763
  {
917
1764
  ...inboxConfig,
1765
+ refresh: inboxRefreshRequested,
1766
+ idempotencyKey: inboxKey,
1767
+ refreshRequestDigest: inboxDigest,
918
1768
  activityId,
919
1769
  integrationId: input.integrationId,
920
1770
  },
@@ -922,12 +1772,15 @@ export const boostsRouter = t.router({
922
1772
  )
923
1773
  );
924
1774
 
925
- return {
1775
+ const result = {
926
1776
  type: 'boost' as const,
927
1777
  credentialUri: '',
928
1778
  uri: boostUri,
929
- activityId,
1779
+ activityId: inboxResult.inboxCredential.activityId ?? activityId,
930
1780
  inbox: {
1781
+ ...(inboxResult.refresh
1782
+ ? { refresh: inboxResult.refresh }
1783
+ : {}),
931
1784
  issuanceId: inboxResult.inboxCredential.id,
932
1785
  status: inboxResult.status,
933
1786
  claimUrl: inboxResult.claimUrl,
@@ -936,12 +1789,16 @@ export const boostsRouter = t.router({
936
1789
  : {}),
937
1790
  },
938
1791
  };
1792
+ if (inboxIntent)
1793
+ await markRefreshSendIntentDelivered(inboxIntent, result);
1794
+ return result;
939
1795
  } catch (error) {
940
1796
  // Log FAILED activity when issueToInbox fails
941
1797
  await traceDb('logCredentialFailed:inbox', () =>
942
1798
  logCredentialFailed({
943
1799
  activityId,
944
1800
  actorProfileId: profile.profileId,
1801
+ onBehalfOf: ctx.user.onBehalfOf,
945
1802
  recipientType: inboxRecipient.type,
946
1803
  recipientIdentifier: inboxRecipient.value,
947
1804
  boostUri,
@@ -1011,23 +1868,26 @@ export const boostsRouter = t.router({
1011
1868
  signedVc = await traceInternal(
1012
1869
  'issueCredentialWithSigningAuthority:remoteInbox',
1013
1870
  async () =>
1014
- issueCredentialWithSigningAuthority(
1015
- { type: 'profile', profile },
1016
- await appendBitstringStatusListEntries(
1017
- unsignedVc,
1018
- profile.profileId,
1019
- domain
1020
- ),
1021
- signingAuthority,
1022
- domain,
1023
- false
1024
- )
1871
+ (
1872
+ await issueCredentialWithSigningAuthority(
1873
+ { type: 'profile', profile },
1874
+ await appendBitstringStatusListEntries(
1875
+ unsignedVc,
1876
+ profile.profileId,
1877
+ domain
1878
+ ),
1879
+ signingAuthority,
1880
+ domain,
1881
+ false
1882
+ )
1883
+ ).credential
1025
1884
  );
1026
1885
  }
1027
1886
 
1028
1887
  const activityId = await traceDb('logCredentialSent:remoteInbox', () =>
1029
1888
  logCredentialSent({
1030
1889
  actorProfileId: profile.profileId,
1890
+ onBehalfOf: ctx.user.onBehalfOf,
1031
1891
  recipientType: 'profile',
1032
1892
  recipientIdentifier: input.recipient,
1033
1893
  boostUri,
@@ -1070,6 +1930,7 @@ export const boostsRouter = t.router({
1070
1930
  logCredentialFailed({
1071
1931
  activityId,
1072
1932
  actorProfileId: profile.profileId,
1933
+ onBehalfOf: ctx.user.onBehalfOf,
1073
1934
  recipientType: 'profile',
1074
1935
  recipientIdentifier: input.recipient,
1075
1936
  boostUri,
@@ -1129,53 +1990,13 @@ export const boostsRouter = t.router({
1129
1990
  });
1130
1991
  }
1131
1992
 
1132
- let contractTerms = null as Awaited<
1133
- ReturnType<typeof getContractTermsForProfile>
1134
- > | null;
1135
- let decodedContractUri: string | null = null;
1136
- let contractDetails: Awaited<
1137
- ReturnType<typeof getContractDetailsByUri>
1138
- > | null = null;
1139
-
1140
- if (contractUri) {
1141
- decodedContractUri = decodeURIComponent(contractUri);
1142
- contractDetails = await traceDb('getContractDetailsByUri', () =>
1143
- getContractDetailsByUri(decodedContractUri!)
1144
- );
1145
-
1146
- if (!contractDetails) {
1147
- throw new TRPCError({
1148
- code: 'NOT_FOUND',
1149
- message: 'Could not find contract',
1150
- });
1151
- }
1152
-
1153
- const terms = await traceDb('getContractTermsForProfile', () =>
1154
- getContractTermsForProfile(targetProfile, contractDetails!.contract)
1155
- );
1156
-
1157
- const writers = await traceDb('getWritersForContract', () =>
1158
- getWritersForContract(contractDetails!.contract)
1159
- );
1160
- const isWriter = writers.some(
1161
- writer => writer.profileId === profile.profileId
1162
- );
1163
- const isDenied =
1164
- terms?.terms.deniedWriters?.includes(profile.profileId) ?? false;
1165
- const categoryAllowed = boost.category
1166
- ? terms?.terms.write?.credentials?.categories?.[boost.category] === true
1167
- : true;
1168
-
1169
- if (terms && isWriter && !isDenied && categoryAllowed) {
1170
- contractTerms = terms;
1171
- }
1172
- }
1173
-
1174
- if (boostCreated && contractDetails) {
1175
- await traceDb('setRelatedBoostForContract', () =>
1176
- setRelatedBoostForContract(contractDetails!.contract, boost!)
1177
- );
1178
- }
1993
+ const contractTerms = await resolveContractForSend({
1994
+ profile,
1995
+ targetProfile: targetProfile!,
1996
+ boost: boost!,
1997
+ boostCreated,
1998
+ contractUri,
1999
+ });
1179
2000
 
1180
2001
  let signedVc: VC | JWE;
1181
2002
 
@@ -1217,17 +2038,19 @@ export const boostsRouter = t.router({
1217
2038
  signedVc = await traceInternal(
1218
2039
  'issueCredentialWithSigningAuthority',
1219
2040
  async () =>
1220
- issueCredentialWithSigningAuthority(
1221
- { type: 'profile', profile },
1222
- await appendBitstringStatusListEntries(
1223
- unsignedVc,
1224
- profile.profileId,
1225
- domain
1226
- ),
1227
- signingAuthority,
1228
- domain,
1229
- false
1230
- )
2041
+ (
2042
+ await issueCredentialWithSigningAuthority(
2043
+ { type: 'profile', profile },
2044
+ await appendBitstringStatusListEntries(
2045
+ unsignedVc,
2046
+ profile.profileId,
2047
+ domain
2048
+ ),
2049
+ signingAuthority,
2050
+ domain,
2051
+ false
2052
+ )
2053
+ ).credential
1231
2054
  );
1232
2055
  }
1233
2056
 
@@ -1237,6 +2060,7 @@ export const boostsRouter = t.router({
1237
2060
  const activityId = await traceDb('logCredentialSent', () =>
1238
2061
  logCredentialSent({
1239
2062
  actorProfileId: profile.profileId,
2063
+ onBehalfOf: ctx.user.onBehalfOf,
1240
2064
  recipientType: 'profile',
1241
2065
  recipientIdentifier: targetProfile.profileId,
1242
2066
  recipientProfileId: targetProfile.profileId,
@@ -1255,7 +2079,6 @@ export const boostsRouter = t.router({
1255
2079
  credential: signedVc,
1256
2080
  domain,
1257
2081
  skipNotification,
1258
- skipCertification: boostCreatedFromSignedCredential,
1259
2082
  contractTerms: contractTerms ?? undefined,
1260
2083
  activityId,
1261
2084
  integrationId: input.integrationId,
@@ -1268,6 +2091,7 @@ export const boostsRouter = t.router({
1268
2091
  logCredentialFailed({
1269
2092
  activityId,
1270
2093
  actorProfileId: profile.profileId,
2094
+ onBehalfOf: ctx.user.onBehalfOf,
1271
2095
  recipientType: 'profile',
1272
2096
  recipientIdentifier: targetProfile.profileId,
1273
2097
  recipientProfileId: targetProfile.profileId,
@@ -1453,8 +2277,7 @@ export const boostsRouter = t.router({
1453
2277
 
1454
2278
  const decodedUri = decodeURIComponent(uri);
1455
2279
  const { domain: uriDomain } = getUriParts(decodedUri, true);
1456
- // Match the bare domain verifyCredentialIsDerivedFromBoost uses, so
1457
- // injected alignment targetUrls equal the verifier's re-computed ones.
2280
+ // Use the bare domain so alignment targetUrls match issuance-time injection.
1458
2281
  const alignmentsDomain = getDomainFromUri(decodedUri);
1459
2282
  const [boost, boostInstance] = await Promise.all([
1460
2283
  getBoostByUriWithDefaultClaimPermissions(decodedUri),
@@ -3760,7 +4583,7 @@ export const boostsRouter = t.router({
3760
4583
  });
3761
4584
  }
3762
4585
 
3763
- let credential: VC | JWE;
4586
+ let credential: IssuedCredential;
3764
4587
  try {
3765
4588
  credential = await issueCredentialWithSigningAuthority(
3766
4589
  { type: 'profile', profile },