@learncard/network-brain-service 3.18.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 (199) hide show
  1. package/.env.example +4 -0
  2. package/CHANGELOG.md +80 -0
  3. package/compose.inbox.yml +7 -0
  4. package/dist/accesslayer/auth-grant/read.d.ts +1 -0
  5. package/dist/accesslayer/auth-grant/read.d.ts.map +1 -1
  6. package/dist/accesslayer/credential-activity/create.d.ts.map +1 -1
  7. package/dist/accesslayer/credential-refresh/email-delivery.d.ts +18 -0
  8. package/dist/accesslayer/credential-refresh/email-delivery.d.ts.map +1 -0
  9. package/dist/accesslayer/credential-refresh/index.d.ts +1 -0
  10. package/dist/accesslayer/credential-refresh/index.d.ts.map +1 -1
  11. package/dist/accesslayer/credential-refresh/read.d.ts.map +1 -1
  12. package/dist/accesslayer/credential-refresh/update.d.ts.map +1 -1
  13. package/dist/accesslayer/inbox-batch/store.d.ts +33 -0
  14. package/dist/accesslayer/inbox-batch/store.d.ts.map +1 -0
  15. package/dist/accesslayer/inbox-credential/create.d.ts +5 -0
  16. package/dist/accesslayer/inbox-credential/create.d.ts.map +1 -1
  17. package/dist/accesslayer/inbox-credential/refresh.d.ts +71 -0
  18. package/dist/accesslayer/inbox-credential/refresh.d.ts.map +1 -0
  19. package/dist/accesslayer/inbox-credential/update.d.ts +1 -1
  20. package/dist/accesslayer/inbox-credential/update.d.ts.map +1 -1
  21. package/dist/cache/atomic-counter.test-helpers.d.ts +3 -0
  22. package/dist/cache/atomic-counter.test-helpers.d.ts.map +1 -0
  23. package/dist/cache/atomic-counter.test.d.ts +2 -0
  24. package/dist/cache/atomic-counter.test.d.ts.map +1 -0
  25. package/dist/cache/index.d.ts +1 -1
  26. package/dist/cache/index.d.ts.map +1 -1
  27. package/dist/config/environment.d.ts +46 -0
  28. package/dist/config/environment.d.ts.map +1 -1
  29. package/dist/credential-refresh.d.ts.map +1 -1
  30. package/dist/helpers/activity.helpers.d.ts +4 -0
  31. package/dist/helpers/activity.helpers.d.ts.map +1 -1
  32. package/dist/helpers/contact-method.helpers.d.ts.map +1 -1
  33. package/dist/helpers/contact-method.helpers.test.d.ts +2 -0
  34. package/dist/helpers/contact-method.helpers.test.d.ts.map +1 -0
  35. package/dist/helpers/credential-refresh-email-content.helpers.d.ts +44 -0
  36. package/dist/helpers/credential-refresh-email-content.helpers.d.ts.map +1 -0
  37. package/dist/helpers/credential-refresh-email-content.helpers.test.d.ts +2 -0
  38. package/dist/helpers/credential-refresh-email-content.helpers.test.d.ts.map +1 -0
  39. package/dist/helpers/credential-refresh-email.helpers.d.ts +17 -0
  40. package/dist/helpers/credential-refresh-email.helpers.d.ts.map +1 -0
  41. package/dist/helpers/credential-refresh-proof.helpers.d.ts +4 -0
  42. package/dist/helpers/credential-refresh-proof.helpers.d.ts.map +1 -0
  43. package/dist/helpers/credential-refresh-proof.helpers.test.d.ts +2 -0
  44. package/dist/helpers/credential-refresh-proof.helpers.test.d.ts.map +1 -0
  45. package/dist/helpers/credential-refresh.helpers.d.ts +45 -2
  46. package/dist/helpers/credential-refresh.helpers.d.ts.map +1 -1
  47. package/dist/helpers/credential.helpers.d.ts +2 -1
  48. package/dist/helpers/credential.helpers.d.ts.map +1 -1
  49. package/dist/helpers/finalize-inbox.helpers.d.ts.map +1 -1
  50. package/dist/helpers/inbox-batch-http.helpers.d.ts +15 -0
  51. package/dist/helpers/inbox-batch-http.helpers.d.ts.map +1 -0
  52. package/dist/helpers/inbox-batch-http.helpers.test.d.ts +2 -0
  53. package/dist/helpers/inbox-batch-http.helpers.test.d.ts.map +1 -0
  54. package/dist/helpers/inbox-batch-status.helpers.d.ts +3 -0
  55. package/dist/helpers/inbox-batch-status.helpers.d.ts.map +1 -0
  56. package/dist/helpers/inbox-batch.helpers.d.ts +10 -0
  57. package/dist/helpers/inbox-batch.helpers.d.ts.map +1 -0
  58. package/dist/helpers/inbox-batch.helpers.test.d.ts +2 -0
  59. package/dist/helpers/inbox-batch.helpers.test.d.ts.map +1 -0
  60. package/dist/helpers/inbox-issuance-error.helpers.d.ts +13 -0
  61. package/dist/helpers/inbox-issuance-error.helpers.d.ts.map +1 -0
  62. package/dist/helpers/inbox-queue.helpers.d.ts +11 -0
  63. package/dist/helpers/inbox-queue.helpers.d.ts.map +1 -0
  64. package/dist/helpers/inbox-queue.helpers.test.d.ts +2 -0
  65. package/dist/helpers/inbox-queue.helpers.test.d.ts.map +1 -0
  66. package/dist/helpers/inbox-refresh.helpers.d.ts +186 -0
  67. package/dist/helpers/inbox-refresh.helpers.d.ts.map +1 -0
  68. package/dist/helpers/inbox.helpers.d.ts +9 -1
  69. package/dist/helpers/inbox.helpers.d.ts.map +1 -1
  70. package/dist/helpers/notifications.helpers.d.ts.map +1 -1
  71. package/dist/helpers/rateLimit.helpers.d.ts.map +1 -1
  72. package/dist/helpers/refresh-send-intent.helpers.d.ts +51 -0
  73. package/dist/helpers/refresh-send-intent.helpers.d.ts.map +1 -0
  74. package/dist/helpers/shim.d.ts +1 -1
  75. package/dist/helpers/shim.d.ts.map +1 -1
  76. package/dist/inbox-queue-local.d.ts +2 -0
  77. package/dist/inbox-queue-local.d.ts.map +1 -0
  78. package/dist/index.js +2 -2
  79. package/dist/models/AuthGrant.d.ts.map +1 -1
  80. package/dist/models/CredentialActivity.d.ts +1 -0
  81. package/dist/models/CredentialActivity.d.ts.map +1 -1
  82. package/dist/models/CredentialRefresh.d.ts +5 -2
  83. package/dist/models/CredentialRefresh.d.ts.map +1 -1
  84. package/dist/models/InboxCredential.d.ts +1 -0
  85. package/dist/models/InboxCredential.d.ts.map +1 -1
  86. package/dist/models/credential-refresh-constraints.d.ts.map +1 -1
  87. package/dist/openapi.d.ts.map +1 -1
  88. package/dist/routes/act-as.test.d.ts +2 -0
  89. package/dist/routes/act-as.test.d.ts.map +1 -0
  90. package/dist/routes/activity.d.ts +3 -0
  91. package/dist/routes/activity.d.ts.map +1 -1
  92. package/dist/routes/auth-grants.d.ts +10 -0
  93. package/dist/routes/auth-grants.d.ts.map +1 -1
  94. package/dist/routes/boosts.d.ts +494 -0
  95. package/dist/routes/boosts.d.ts.map +1 -1
  96. package/dist/routes/credential-refreshes.d.ts.map +1 -1
  97. package/dist/routes/credentials.d.ts.map +1 -1
  98. package/dist/routes/inbox.d.ts +953 -11
  99. package/dist/routes/inbox.d.ts.map +1 -1
  100. package/dist/routes/index.d.ts +42 -126
  101. package/dist/routes/index.d.ts.map +1 -1
  102. package/dist/routes/workflows.d.ts.map +1 -1
  103. package/dist/services/delivery/adapters/postmark.adapter.d.ts.map +1 -1
  104. package/dist/services/delivery/adapters/postmark.adapter.test.d.ts +2 -0
  105. package/dist/services/delivery/adapters/postmark.adapter.test.d.ts.map +1 -0
  106. package/dist/services/registry/registry.factory.d.ts.map +1 -1
  107. package/dist/types/activity.d.ts +4 -0
  108. package/dist/types/activity.d.ts.map +1 -1
  109. package/dist/types/credential-refresh.d.ts +10 -1
  110. package/dist/types/credential-refresh.d.ts.map +1 -1
  111. package/dist/types/inbox-batch.d.ts +38 -0
  112. package/dist/types/inbox-batch.d.ts.map +1 -0
  113. package/inbox-e2e-setup.ts +53 -1
  114. package/inbox-elasticmq.conf +10 -0
  115. package/lambda.ts +61 -16
  116. package/package.json +18 -16
  117. package/scripts/setup-inbox-queues.ts +60 -0
  118. package/serverless-local.yml +2 -0
  119. package/serverless.yml +75 -0
  120. package/src/accesslayer/auth-grant/read.ts +2 -1
  121. package/src/accesslayer/credential-activity/create.ts +1 -0
  122. package/src/accesslayer/credential-refresh/email-delivery.ts +116 -0
  123. package/src/accesslayer/credential-refresh/index.ts +1 -0
  124. package/src/accesslayer/credential-refresh/read.ts +8 -2
  125. package/src/accesslayer/credential-refresh/update.ts +4 -0
  126. package/src/accesslayer/inbox-batch/store.ts +469 -0
  127. package/src/accesslayer/inbox-credential/create.ts +51 -19
  128. package/src/accesslayer/inbox-credential/refresh.ts +221 -0
  129. package/src/accesslayer/inbox-credential/update.ts +1 -1
  130. package/src/cache/atomic-counter.test-helpers.ts +27 -0
  131. package/src/cache/atomic-counter.test.ts +6 -0
  132. package/src/cache/index.ts +25 -13
  133. package/src/config/environment.ts +26 -0
  134. package/src/credential-refresh.ts +5 -1
  135. package/src/docker-entry.ts +8 -0
  136. package/src/helpers/activity.helpers.ts +4 -0
  137. package/src/helpers/app-counter.helpers.ts +1 -1
  138. package/src/helpers/contact-method.helpers.test.ts +43 -0
  139. package/src/helpers/contact-method.helpers.ts +3 -4
  140. package/src/helpers/credential-refresh-email-content.helpers.test.ts +222 -0
  141. package/src/helpers/credential-refresh-email-content.helpers.ts +155 -0
  142. package/src/helpers/credential-refresh-email.helpers.ts +201 -0
  143. package/src/helpers/credential-refresh-proof.helpers.test.ts +103 -0
  144. package/src/helpers/credential-refresh-proof.helpers.ts +43 -0
  145. package/src/helpers/credential-refresh.helpers.ts +375 -99
  146. package/src/helpers/credential.helpers.ts +4 -1
  147. package/src/helpers/finalize-inbox.helpers.ts +29 -14
  148. package/src/helpers/inbox-batch-http.helpers.test.ts +188 -0
  149. package/src/helpers/inbox-batch-http.helpers.ts +150 -0
  150. package/src/helpers/inbox-batch-status.helpers.ts +11 -0
  151. package/src/helpers/inbox-batch.helpers.test.ts +668 -0
  152. package/src/helpers/inbox-batch.helpers.ts +374 -0
  153. package/src/helpers/inbox-issuance-error.helpers.ts +25 -0
  154. package/src/helpers/inbox-queue.helpers.test.ts +253 -0
  155. package/src/helpers/inbox-queue.helpers.ts +372 -0
  156. package/src/helpers/inbox-refresh.helpers.ts +432 -0
  157. package/src/helpers/inbox.helpers.ts +268 -32
  158. package/src/helpers/notifications.helpers.ts +4 -2
  159. package/src/helpers/rateLimit.helpers.ts +2 -5
  160. package/src/helpers/refresh-send-intent.helpers.ts +273 -0
  161. package/src/helpers/shim.ts +32 -31
  162. package/src/inbox-queue-local.ts +28 -0
  163. package/src/models/AuthGrant.ts +1 -0
  164. package/src/models/CredentialActivity.ts +3 -5
  165. package/src/models/CredentialRefresh.ts +4 -2
  166. package/src/models/InboxCredential.ts +2 -0
  167. package/src/models/credential-refresh-constraints.test.ts +8 -3
  168. package/src/models/credential-refresh-constraints.ts +5 -0
  169. package/src/openapi.ts +8 -0
  170. package/src/routes/act-as.test.ts +239 -0
  171. package/src/routes/app-store.ts +2 -2
  172. package/src/routes/auth-grants.ts +13 -11
  173. package/src/routes/boosts.ts +914 -92
  174. package/src/routes/credential-refreshes.test.ts +8 -2
  175. package/src/routes/credential-refreshes.ts +4 -1
  176. package/src/routes/credentials.ts +2 -1
  177. package/src/routes/inbox.ts +95 -49
  178. package/src/routes/index.ts +82 -7
  179. package/src/routes/skills.ts +1 -1
  180. package/src/routes/workflows.ts +43 -24
  181. package/src/services/delivery/adapters/postmark.adapter.test.ts +54 -0
  182. package/src/services/delivery/adapters/postmark.adapter.ts +12 -0
  183. package/src/services/registry/registry.factory.ts +2 -1
  184. package/src/types/activity.ts +2 -0
  185. package/src/types/credential-refresh.ts +21 -2
  186. package/src/types/inbox-batch.ts +46 -0
  187. package/test/atomic-counter.e2e.ts +12 -0
  188. package/test/auth-grant.spec.ts +21 -21
  189. package/test/credential-refresh-email.spec.ts +663 -0
  190. package/test/credential-refresh-publication.spec.ts +2 -0
  191. package/test/helpers/inbox-e2e-environment.ts +6 -0
  192. package/test/inbox-batch.spec.ts +1338 -0
  193. package/test/inbox-refresh.spec.ts +534 -0
  194. package/test/openapi.spec.ts +41 -1
  195. package/test/refresh-send.spec.ts +1799 -0
  196. package/test/registry.spec.ts +56 -11
  197. package/vitest.config.ts +9 -0
  198. package/vitest.inbox-e2e.config.ts +5 -1
  199. 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,
@@ -176,6 +192,28 @@ import {
176
192
  import { updateDefaultPermissionsForBoost } from '@accesslayer/role/relationships/update';
177
193
  import { issueCredentialWithSigningAuthority } from '@helpers/signingAuthority.helpers';
178
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';
179
217
  import { removeConnectionsForBoost } from '@helpers/connection.helpers';
180
218
  import { issueToInbox } from '@helpers/inbox.helpers';
181
219
  import { findInboxServiceEndpoint } from '@helpers/federation.helpers';
@@ -242,7 +280,336 @@ const resolveBoostCredentialInstance = async ({
242
280
  return getCredentialInstanceForBoostAndProfile(boostId, recipientProfileId);
243
281
  };
244
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
+
245
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
+
246
613
  getBoostAlignments: profileRoute
247
614
  .meta({
248
615
  openapi: {
@@ -692,6 +1059,7 @@ export const boostsRouter = t.router({
692
1059
  // Log credential activity FIRST to get activityId for chaining
693
1060
  const activityId = await logCredentialSent({
694
1061
  actorProfileId: profile.profileId,
1062
+ onBehalfOf: ctx.user.onBehalfOf,
695
1063
  recipientType: 'profile',
696
1064
  recipientIdentifier: targetProfile.profileId,
697
1065
  recipientProfileId: targetProfile.profileId,
@@ -736,6 +1104,147 @@ export const boostsRouter = t.router({
736
1104
  const { contractUri } = input;
737
1105
  const { domain } = ctx;
738
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
+
739
1248
  // Check if recipient is email/phone (routes to Universal Inbox)
740
1249
  const inboxRecipient = isInboxRecipient(input.recipient);
741
1250
 
@@ -744,7 +1253,22 @@ export const boostsRouter = t.router({
744
1253
  let boostUri = '';
745
1254
  let boostCreated = 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,47 +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;
1352
+ boostUri = getBoostUri(boost.id, domain);
1353
+ boostCreated = true;
1354
+ }
801
1355
  }
802
1356
 
803
1357
  if (!boost) {
@@ -827,6 +1381,298 @@ export const boostsRouter = t.router({
827
1381
  });
828
1382
  }
829
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
+
830
1676
  // Route to Universal Inbox for email/phone recipients
831
1677
  if (inboxRecipient) {
832
1678
  // Try to resolve recipient profile for auto-populating template variables + recipient DID
@@ -883,11 +1729,12 @@ export const boostsRouter = t.router({
883
1729
  });
884
1730
  }
885
1731
 
886
- credential = await appendBitstringStatusListEntries(
887
- credential,
888
- profile.profileId,
889
- domain
890
- );
1732
+ if (!inboxRefreshRequested)
1733
+ credential = await appendBitstringStatusListEntries(
1734
+ credential,
1735
+ profile.profileId,
1736
+ domain
1737
+ );
891
1738
  }
892
1739
 
893
1740
  // Build inbox configuration from SendOptions
@@ -895,6 +1742,7 @@ export const boostsRouter = t.router({
895
1742
  const activityId = await traceDb('logCredentialSent:inbox', () =>
896
1743
  logCredentialSent({
897
1744
  actorProfileId: profile.profileId,
1745
+ onBehalfOf: ctx.user.onBehalfOf,
898
1746
  recipientType: inboxRecipient.type,
899
1747
  recipientIdentifier: inboxRecipient.value,
900
1748
  boostUri,
@@ -914,6 +1762,9 @@ export const boostsRouter = t.router({
914
1762
  credential,
915
1763
  {
916
1764
  ...inboxConfig,
1765
+ refresh: inboxRefreshRequested,
1766
+ idempotencyKey: inboxKey,
1767
+ refreshRequestDigest: inboxDigest,
917
1768
  activityId,
918
1769
  integrationId: input.integrationId,
919
1770
  },
@@ -921,12 +1772,15 @@ export const boostsRouter = t.router({
921
1772
  )
922
1773
  );
923
1774
 
924
- return {
1775
+ const result = {
925
1776
  type: 'boost' as const,
926
1777
  credentialUri: '',
927
1778
  uri: boostUri,
928
- activityId,
1779
+ activityId: inboxResult.inboxCredential.activityId ?? activityId,
929
1780
  inbox: {
1781
+ ...(inboxResult.refresh
1782
+ ? { refresh: inboxResult.refresh }
1783
+ : {}),
930
1784
  issuanceId: inboxResult.inboxCredential.id,
931
1785
  status: inboxResult.status,
932
1786
  claimUrl: inboxResult.claimUrl,
@@ -935,12 +1789,16 @@ export const boostsRouter = t.router({
935
1789
  : {}),
936
1790
  },
937
1791
  };
1792
+ if (inboxIntent)
1793
+ await markRefreshSendIntentDelivered(inboxIntent, result);
1794
+ return result;
938
1795
  } catch (error) {
939
1796
  // Log FAILED activity when issueToInbox fails
940
1797
  await traceDb('logCredentialFailed:inbox', () =>
941
1798
  logCredentialFailed({
942
1799
  activityId,
943
1800
  actorProfileId: profile.profileId,
1801
+ onBehalfOf: ctx.user.onBehalfOf,
944
1802
  recipientType: inboxRecipient.type,
945
1803
  recipientIdentifier: inboxRecipient.value,
946
1804
  boostUri,
@@ -1029,6 +1887,7 @@ export const boostsRouter = t.router({
1029
1887
  const activityId = await traceDb('logCredentialSent:remoteInbox', () =>
1030
1888
  logCredentialSent({
1031
1889
  actorProfileId: profile.profileId,
1890
+ onBehalfOf: ctx.user.onBehalfOf,
1032
1891
  recipientType: 'profile',
1033
1892
  recipientIdentifier: input.recipient,
1034
1893
  boostUri,
@@ -1071,6 +1930,7 @@ export const boostsRouter = t.router({
1071
1930
  logCredentialFailed({
1072
1931
  activityId,
1073
1932
  actorProfileId: profile.profileId,
1933
+ onBehalfOf: ctx.user.onBehalfOf,
1074
1934
  recipientType: 'profile',
1075
1935
  recipientIdentifier: input.recipient,
1076
1936
  boostUri,
@@ -1130,53 +1990,13 @@ export const boostsRouter = t.router({
1130
1990
  });
1131
1991
  }
1132
1992
 
1133
- let contractTerms = null as Awaited<
1134
- ReturnType<typeof getContractTermsForProfile>
1135
- > | null;
1136
- let decodedContractUri: string | null = null;
1137
- let contractDetails: Awaited<
1138
- ReturnType<typeof getContractDetailsByUri>
1139
- > | null = null;
1140
-
1141
- if (contractUri) {
1142
- decodedContractUri = decodeURIComponent(contractUri);
1143
- contractDetails = await traceDb('getContractDetailsByUri', () =>
1144
- getContractDetailsByUri(decodedContractUri!)
1145
- );
1146
-
1147
- if (!contractDetails) {
1148
- throw new TRPCError({
1149
- code: 'NOT_FOUND',
1150
- message: 'Could not find contract',
1151
- });
1152
- }
1153
-
1154
- const terms = await traceDb('getContractTermsForProfile', () =>
1155
- getContractTermsForProfile(targetProfile, contractDetails!.contract)
1156
- );
1157
-
1158
- const writers = await traceDb('getWritersForContract', () =>
1159
- getWritersForContract(contractDetails!.contract)
1160
- );
1161
- const isWriter = writers.some(
1162
- writer => writer.profileId === profile.profileId
1163
- );
1164
- const isDenied =
1165
- terms?.terms.deniedWriters?.includes(profile.profileId) ?? false;
1166
- const categoryAllowed = boost.category
1167
- ? terms?.terms.write?.credentials?.categories?.[boost.category] === true
1168
- : true;
1169
-
1170
- if (terms && isWriter && !isDenied && categoryAllowed) {
1171
- contractTerms = terms;
1172
- }
1173
- }
1174
-
1175
- if (boostCreated && contractDetails) {
1176
- await traceDb('setRelatedBoostForContract', () =>
1177
- setRelatedBoostForContract(contractDetails!.contract, boost!)
1178
- );
1179
- }
1993
+ const contractTerms = await resolveContractForSend({
1994
+ profile,
1995
+ targetProfile: targetProfile!,
1996
+ boost: boost!,
1997
+ boostCreated,
1998
+ contractUri,
1999
+ });
1180
2000
 
1181
2001
  let signedVc: VC | JWE;
1182
2002
 
@@ -1240,6 +2060,7 @@ export const boostsRouter = t.router({
1240
2060
  const activityId = await traceDb('logCredentialSent', () =>
1241
2061
  logCredentialSent({
1242
2062
  actorProfileId: profile.profileId,
2063
+ onBehalfOf: ctx.user.onBehalfOf,
1243
2064
  recipientType: 'profile',
1244
2065
  recipientIdentifier: targetProfile.profileId,
1245
2066
  recipientProfileId: targetProfile.profileId,
@@ -1270,6 +2091,7 @@ export const boostsRouter = t.router({
1270
2091
  logCredentialFailed({
1271
2092
  activityId,
1272
2093
  actorProfileId: profile.profileId,
2094
+ onBehalfOf: ctx.user.onBehalfOf,
1273
2095
  recipientType: 'profile',
1274
2096
  recipientIdentifier: targetProfile.profileId,
1275
2097
  recipientProfileId: targetProfile.profileId,