@learncard/network-brain-service 3.17.0 → 3.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (274) hide show
  1. package/.env.example +4 -0
  2. package/AGENTS.md +177 -159
  3. package/CHANGELOG.md +148 -0
  4. package/README.md +26 -8
  5. package/compose.inbox.yml +7 -0
  6. package/dist/accesslayer/app-store-listing/relationships/read.d.ts.map +1 -1
  7. package/dist/accesslayer/auth-grant/read.d.ts +1 -0
  8. package/dist/accesslayer/auth-grant/read.d.ts.map +1 -1
  9. package/dist/accesslayer/consentflowcontract/relationships/create.d.ts +3 -2
  10. package/dist/accesslayer/consentflowcontract/relationships/create.d.ts.map +1 -1
  11. package/dist/accesslayer/consentflowcontract/relationships/read.d.ts +1 -0
  12. package/dist/accesslayer/consentflowcontract/relationships/read.d.ts.map +1 -1
  13. package/dist/accesslayer/consentflowcontract/relationships/update.d.ts +5 -3
  14. package/dist/accesslayer/consentflowcontract/relationships/update.d.ts.map +1 -1
  15. package/dist/accesslayer/credential/create.d.ts +2 -1
  16. package/dist/accesslayer/credential/create.d.ts.map +1 -1
  17. package/dist/accesslayer/credential/create.test.d.ts +2 -0
  18. package/dist/accesslayer/credential/create.test.d.ts.map +1 -0
  19. package/dist/accesslayer/credential/relationships/read.d.ts +1 -0
  20. package/dist/accesslayer/credential/relationships/read.d.ts.map +1 -1
  21. package/dist/accesslayer/credential-activity/create.d.ts.map +1 -1
  22. package/dist/accesslayer/credential-refresh/email-delivery.d.ts +18 -0
  23. package/dist/accesslayer/credential-refresh/email-delivery.d.ts.map +1 -0
  24. package/dist/accesslayer/credential-refresh/index.d.ts +1 -0
  25. package/dist/accesslayer/credential-refresh/index.d.ts.map +1 -1
  26. package/dist/accesslayer/credential-refresh/read.d.ts.map +1 -1
  27. package/dist/accesslayer/credential-refresh/update.d.ts.map +1 -1
  28. package/dist/accesslayer/inbox-batch/store.d.ts +33 -0
  29. package/dist/accesslayer/inbox-batch/store.d.ts.map +1 -0
  30. package/dist/accesslayer/inbox-credential/create.d.ts +5 -0
  31. package/dist/accesslayer/inbox-credential/create.d.ts.map +1 -1
  32. package/dist/accesslayer/inbox-credential/refresh.d.ts +71 -0
  33. package/dist/accesslayer/inbox-credential/refresh.d.ts.map +1 -0
  34. package/dist/accesslayer/inbox-credential/update.d.ts +1 -1
  35. package/dist/accesslayer/inbox-credential/update.d.ts.map +1 -1
  36. package/dist/cache/atomic-counter.test-helpers.d.ts +3 -0
  37. package/dist/cache/atomic-counter.test-helpers.d.ts.map +1 -0
  38. package/dist/cache/atomic-counter.test.d.ts +2 -0
  39. package/dist/cache/atomic-counter.test.d.ts.map +1 -0
  40. package/dist/cache/index.d.ts +1 -1
  41. package/dist/cache/index.d.ts.map +1 -1
  42. package/dist/config/environment.d.ts +46 -0
  43. package/dist/config/environment.d.ts.map +1 -1
  44. package/dist/credential-refresh.d.ts.map +1 -1
  45. package/dist/didkit_wasm_bg.wasm +0 -0
  46. package/dist/helpers/activity.helpers.d.ts +4 -0
  47. package/dist/helpers/activity.helpers.d.ts.map +1 -1
  48. package/dist/helpers/boost-storage.helpers.test.d.ts +2 -0
  49. package/dist/helpers/boost-storage.helpers.test.d.ts.map +1 -0
  50. package/dist/helpers/boost.helpers.d.ts +3 -7
  51. package/dist/helpers/boost.helpers.d.ts.map +1 -1
  52. package/dist/helpers/contact-method.helpers.d.ts.map +1 -1
  53. package/dist/helpers/contact-method.helpers.test.d.ts +2 -0
  54. package/dist/helpers/contact-method.helpers.test.d.ts.map +1 -0
  55. package/dist/helpers/credential-refresh-email-content.helpers.d.ts +44 -0
  56. package/dist/helpers/credential-refresh-email-content.helpers.d.ts.map +1 -0
  57. package/dist/helpers/credential-refresh-email-content.helpers.test.d.ts +2 -0
  58. package/dist/helpers/credential-refresh-email-content.helpers.test.d.ts.map +1 -0
  59. package/dist/helpers/credential-refresh-email.helpers.d.ts +17 -0
  60. package/dist/helpers/credential-refresh-email.helpers.d.ts.map +1 -0
  61. package/dist/helpers/credential-refresh-proof.helpers.d.ts +4 -0
  62. package/dist/helpers/credential-refresh-proof.helpers.d.ts.map +1 -0
  63. package/dist/helpers/credential-refresh-proof.helpers.test.d.ts +2 -0
  64. package/dist/helpers/credential-refresh-proof.helpers.test.d.ts.map +1 -0
  65. package/dist/helpers/credential-refresh.helpers.d.ts +45 -2
  66. package/dist/helpers/credential-refresh.helpers.d.ts.map +1 -1
  67. package/dist/helpers/credential.helpers.d.ts +4 -2
  68. package/dist/helpers/credential.helpers.d.ts.map +1 -1
  69. package/dist/helpers/finalize-inbox.helpers.d.ts.map +1 -1
  70. package/dist/helpers/inbox-batch-http.helpers.d.ts +15 -0
  71. package/dist/helpers/inbox-batch-http.helpers.d.ts.map +1 -0
  72. package/dist/helpers/inbox-batch-http.helpers.test.d.ts +2 -0
  73. package/dist/helpers/inbox-batch-http.helpers.test.d.ts.map +1 -0
  74. package/dist/helpers/inbox-batch-status.helpers.d.ts +3 -0
  75. package/dist/helpers/inbox-batch-status.helpers.d.ts.map +1 -0
  76. package/dist/helpers/inbox-batch.helpers.d.ts +10 -0
  77. package/dist/helpers/inbox-batch.helpers.d.ts.map +1 -0
  78. package/dist/helpers/inbox-batch.helpers.test.d.ts +2 -0
  79. package/dist/helpers/inbox-batch.helpers.test.d.ts.map +1 -0
  80. package/dist/helpers/inbox-issuance-error.helpers.d.ts +13 -0
  81. package/dist/helpers/inbox-issuance-error.helpers.d.ts.map +1 -0
  82. package/dist/helpers/inbox-queue.helpers.d.ts +11 -0
  83. package/dist/helpers/inbox-queue.helpers.d.ts.map +1 -0
  84. package/dist/helpers/inbox-queue.helpers.test.d.ts +2 -0
  85. package/dist/helpers/inbox-queue.helpers.test.d.ts.map +1 -0
  86. package/dist/helpers/inbox-refresh.helpers.d.ts +186 -0
  87. package/dist/helpers/inbox-refresh.helpers.d.ts.map +1 -0
  88. package/dist/helpers/inbox.helpers.d.ts +9 -1
  89. package/dist/helpers/inbox.helpers.d.ts.map +1 -1
  90. package/dist/helpers/issuedCredentialStatus.helpers.d.ts +5 -0
  91. package/dist/helpers/issuedCredentialStatus.helpers.d.ts.map +1 -0
  92. package/dist/helpers/notifications.helpers.d.ts.map +1 -1
  93. package/dist/helpers/rateLimit.helpers.d.ts.map +1 -1
  94. package/dist/helpers/refresh-send-intent.helpers.d.ts +51 -0
  95. package/dist/helpers/refresh-send-intent.helpers.d.ts.map +1 -0
  96. package/dist/helpers/shim.d.ts +1 -1
  97. package/dist/helpers/shim.d.ts.map +1 -1
  98. package/dist/helpers/signingAuthority.helpers.d.ts +3 -2
  99. package/dist/helpers/signingAuthority.helpers.d.ts.map +1 -1
  100. package/dist/helpers/signingAuthority.helpers.test.d.ts +2 -0
  101. package/dist/helpers/signingAuthority.helpers.test.d.ts.map +1 -0
  102. package/dist/helpers/status-list.helpers.d.ts.map +1 -1
  103. package/dist/helpers/status-list.helpers.test.d.ts +2 -0
  104. package/dist/helpers/status-list.helpers.test.d.ts.map +1 -0
  105. package/dist/inbox-queue-local.d.ts +2 -0
  106. package/dist/inbox-queue-local.d.ts.map +1 -0
  107. package/dist/index.js +2 -2
  108. package/dist/models/AuthGrant.d.ts.map +1 -1
  109. package/dist/models/ConsentFlowTerms.d.ts.map +1 -1
  110. package/dist/models/ConsentFlowTransaction.d.ts.map +1 -1
  111. package/dist/models/Credential.d.ts +1 -0
  112. package/dist/models/Credential.d.ts.map +1 -1
  113. package/dist/models/CredentialActivity.d.ts +1 -0
  114. package/dist/models/CredentialActivity.d.ts.map +1 -1
  115. package/dist/models/CredentialRefresh.d.ts +5 -2
  116. package/dist/models/CredentialRefresh.d.ts.map +1 -1
  117. package/dist/models/InboxCredential.d.ts +1 -0
  118. package/dist/models/InboxCredential.d.ts.map +1 -1
  119. package/dist/models/credential-refresh-constraints.d.ts.map +1 -1
  120. package/dist/openapi.d.ts.map +1 -1
  121. package/dist/routes/act-as.test.d.ts +2 -0
  122. package/dist/routes/act-as.test.d.ts.map +1 -0
  123. package/dist/routes/activity.d.ts +3 -0
  124. package/dist/routes/activity.d.ts.map +1 -1
  125. package/dist/routes/app-store.d.ts.map +1 -1
  126. package/dist/routes/auth-grants.d.ts +10 -0
  127. package/dist/routes/auth-grants.d.ts.map +1 -1
  128. package/dist/routes/boosts.d.ts +506 -12
  129. package/dist/routes/boosts.d.ts.map +1 -1
  130. package/dist/routes/contracts.d.ts +48 -2
  131. package/dist/routes/contracts.d.ts.map +1 -1
  132. package/dist/routes/credential-refreshes.d.ts +4 -4
  133. package/dist/routes/credential-refreshes.d.ts.map +1 -1
  134. package/dist/routes/credentials.d.ts +8 -2
  135. package/dist/routes/credentials.d.ts.map +1 -1
  136. package/dist/routes/did-metadata.d.ts +8 -8
  137. package/dist/routes/federation.d.ts +2 -2
  138. package/dist/routes/inbox.d.ts +973 -31
  139. package/dist/routes/inbox.d.ts.map +1 -1
  140. package/dist/routes/index.d.ts +47 -127
  141. package/dist/routes/index.d.ts.map +1 -1
  142. package/dist/routes/presentations.d.ts +6 -6
  143. package/dist/routes/storage.d.ts +63 -63
  144. package/dist/routes/test.d.ts +6 -0
  145. package/dist/routes/test.d.ts.map +1 -1
  146. package/dist/routes/workflows.d.ts +14 -14
  147. package/dist/routes/workflows.d.ts.map +1 -1
  148. package/dist/services/delivery/adapters/postmark.adapter.d.ts.map +1 -1
  149. package/dist/services/delivery/adapters/postmark.adapter.test.d.ts +2 -0
  150. package/dist/services/delivery/adapters/postmark.adapter.test.d.ts.map +1 -0
  151. package/dist/services/registry/registry.factory.d.ts.map +1 -1
  152. package/dist/types/activity.d.ts +4 -0
  153. package/dist/types/activity.d.ts.map +1 -1
  154. package/dist/types/consentflowcontract.d.ts +22 -8
  155. package/dist/types/consentflowcontract.d.ts.map +1 -1
  156. package/dist/types/credential-refresh.d.ts +10 -1
  157. package/dist/types/credential-refresh.d.ts.map +1 -1
  158. package/dist/types/credential.d.ts +7 -0
  159. package/dist/types/credential.d.ts.map +1 -1
  160. package/dist/types/did-metadata.d.ts +2 -2
  161. package/dist/types/inbox-batch.d.ts +38 -0
  162. package/dist/types/inbox-batch.d.ts.map +1 -0
  163. package/inbox-e2e-setup.ts +53 -1
  164. package/inbox-elasticmq.conf +10 -0
  165. package/lambda.ts +61 -16
  166. package/package.json +18 -16
  167. package/scripts/setup-inbox-queues.ts +60 -0
  168. package/serverless-local.yml +2 -0
  169. package/serverless.yml +75 -0
  170. package/src/accesslayer/app-store-listing/relationships/read.ts +4 -3
  171. package/src/accesslayer/auth-grant/read.ts +2 -1
  172. package/src/accesslayer/consentflowcontract/relationships/create.ts +15 -5
  173. package/src/accesslayer/consentflowcontract/relationships/read.ts +161 -63
  174. package/src/accesslayer/consentflowcontract/relationships/update.ts +43 -10
  175. package/src/accesslayer/credential/create.test.ts +141 -0
  176. package/src/accesslayer/credential/create.ts +27 -3
  177. package/src/accesslayer/credential-activity/create.ts +1 -0
  178. package/src/accesslayer/credential-refresh/email-delivery.ts +116 -0
  179. package/src/accesslayer/credential-refresh/index.ts +1 -0
  180. package/src/accesslayer/credential-refresh/read.ts +8 -2
  181. package/src/accesslayer/credential-refresh/update.ts +4 -0
  182. package/src/accesslayer/inbox-batch/store.ts +469 -0
  183. package/src/accesslayer/inbox-credential/create.ts +51 -19
  184. package/src/accesslayer/inbox-credential/refresh.ts +221 -0
  185. package/src/accesslayer/inbox-credential/update.ts +1 -1
  186. package/src/cache/atomic-counter.test-helpers.ts +27 -0
  187. package/src/cache/atomic-counter.test.ts +6 -0
  188. package/src/cache/index.ts +25 -13
  189. package/src/config/environment.ts +26 -0
  190. package/src/credential-refresh.ts +5 -1
  191. package/src/docker-entry.ts +8 -0
  192. package/src/helpers/activity.helpers.ts +4 -0
  193. package/src/helpers/app-counter.helpers.ts +1 -1
  194. package/src/helpers/boost-storage.helpers.test.ts +242 -0
  195. package/src/helpers/boost.helpers.ts +58 -437
  196. package/src/helpers/contact-method.helpers.test.ts +43 -0
  197. package/src/helpers/contact-method.helpers.ts +3 -4
  198. package/src/helpers/credential-refresh-email-content.helpers.test.ts +222 -0
  199. package/src/helpers/credential-refresh-email-content.helpers.ts +155 -0
  200. package/src/helpers/credential-refresh-email.helpers.ts +201 -0
  201. package/src/helpers/credential-refresh-proof.helpers.test.ts +103 -0
  202. package/src/helpers/credential-refresh-proof.helpers.ts +43 -0
  203. package/src/helpers/credential-refresh.helpers.ts +385 -107
  204. package/src/helpers/credential.helpers.ts +6 -2
  205. package/src/helpers/finalize-inbox.helpers.ts +39 -22
  206. package/src/helpers/inbox-batch-http.helpers.test.ts +188 -0
  207. package/src/helpers/inbox-batch-http.helpers.ts +150 -0
  208. package/src/helpers/inbox-batch-status.helpers.ts +11 -0
  209. package/src/helpers/inbox-batch.helpers.test.ts +668 -0
  210. package/src/helpers/inbox-batch.helpers.ts +374 -0
  211. package/src/helpers/inbox-issuance-error.helpers.ts +25 -0
  212. package/src/helpers/inbox-queue.helpers.test.ts +253 -0
  213. package/src/helpers/inbox-queue.helpers.ts +372 -0
  214. package/src/helpers/inbox-refresh.helpers.ts +432 -0
  215. package/src/helpers/inbox.helpers.ts +301 -50
  216. package/src/helpers/issuedCredentialStatus.helpers.ts +11 -0
  217. package/src/helpers/notifications.helpers.ts +4 -2
  218. package/src/helpers/rateLimit.helpers.ts +2 -5
  219. package/src/helpers/refresh-send-intent.helpers.ts +273 -0
  220. package/src/helpers/shim.ts +32 -31
  221. package/src/helpers/signingAuthority.helpers.test.ts +198 -0
  222. package/src/helpers/signingAuthority.helpers.ts +41 -18
  223. package/src/helpers/status-list.helpers.test.ts +77 -0
  224. package/src/helpers/status-list.helpers.ts +31 -8
  225. package/src/inbox-queue-local.ts +28 -0
  226. package/src/models/AuthGrant.ts +1 -0
  227. package/src/models/ConsentFlowTerms.ts +4 -0
  228. package/src/models/ConsentFlowTransaction.ts +4 -0
  229. package/src/models/Credential.ts +1 -0
  230. package/src/models/CredentialActivity.ts +3 -5
  231. package/src/models/CredentialRefresh.ts +4 -2
  232. package/src/models/InboxCredential.ts +2 -0
  233. package/src/models/credential-refresh-constraints.test.ts +8 -3
  234. package/src/models/credential-refresh-constraints.ts +5 -0
  235. package/src/openapi.ts +8 -0
  236. package/src/routes/act-as.test.ts +239 -0
  237. package/src/routes/app-store.ts +69 -52
  238. package/src/routes/auth-grants.ts +13 -11
  239. package/src/routes/boosts.ts +943 -120
  240. package/src/routes/contracts.ts +55 -7
  241. package/src/routes/credential-refreshes.test.ts +8 -2
  242. package/src/routes/credential-refreshes.ts +4 -1
  243. package/src/routes/credentials.ts +2 -1
  244. package/src/routes/inbox.ts +95 -49
  245. package/src/routes/index.ts +164 -88
  246. package/src/routes/skills.ts +1 -1
  247. package/src/routes/workflows.ts +80 -31
  248. package/src/services/delivery/adapters/postmark.adapter.test.ts +54 -0
  249. package/src/services/delivery/adapters/postmark.adapter.ts +12 -0
  250. package/src/services/registry/registry.factory.ts +2 -1
  251. package/src/types/activity.ts +2 -0
  252. package/src/types/consentflowcontract.ts +21 -3
  253. package/src/types/credential-refresh.ts +21 -2
  254. package/src/types/credential.ts +13 -1
  255. package/src/types/inbox-batch.ts +46 -0
  256. package/test/app-store-listing.spec.ts +66 -30
  257. package/test/atomic-counter.e2e.ts +12 -0
  258. package/test/auth-grant.spec.ts +21 -21
  259. package/test/bitstring-status-list.spec.ts +12 -9
  260. package/test/consentflow.spec.ts +453 -26
  261. package/test/credential-refresh-email.spec.ts +663 -0
  262. package/test/credential-refresh-publication.spec.ts +13 -5
  263. package/test/guardian-gated-routes.spec.ts +337 -180
  264. package/test/helpers/inbox-e2e-environment.ts +6 -0
  265. package/test/inbox-batch.spec.ts +1338 -0
  266. package/test/inbox-refresh.spec.ts +534 -0
  267. package/test/inbox-security.e2e.ts +48 -3
  268. package/test/openapi.spec.ts +41 -1
  269. package/test/refresh-send.spec.ts +1799 -0
  270. package/test/registry.spec.ts +56 -11
  271. package/test/revoke-suspend-per-instance.spec.ts +9 -1
  272. package/vitest.config.ts +13 -0
  273. package/vitest.inbox-e2e.config.ts +5 -1
  274. package/vitest.integration.config.ts +2 -0
@@ -0,0 +1,273 @@
1
+ import { createHash, randomUUID } from 'crypto';
2
+ import { TRPCError } from '@trpc/server';
3
+ import type { ManagedCredentialRefreshReceipt, SendBoostResponse } from '@learncard/types';
4
+ import { canonicalizeCredentialJson } from '@learncard/helpers';
5
+
6
+ import { neogma } from '@instance';
7
+
8
+ import { getInitialRefreshRoot, getManagedCredentialUri } from './credential-refresh.helpers';
9
+
10
+ /** A preparation not touched for this long is considered abandoned and may be taken over. */
11
+ export const REFRESH_SEND_INTENT_STALE_MS = 5 * 60_000;
12
+
13
+ export type RefreshSendIntentState = 'preparing' | 'prepared' | 'delivered';
14
+
15
+ /**
16
+ * Whole-call idempotency record for a managed refresh send (LC-2198 follow-up).
17
+ * Metadata only: never stores credential claims, subject bodies, plaintext VCs or JWEs.
18
+ */
19
+ export type RefreshSendIntent = {
20
+ intentKey: string;
21
+ issuerProfileId: string;
22
+ requestDigest: string;
23
+ state: RefreshSendIntentState;
24
+ claimToken: string;
25
+ boostUri?: string;
26
+ credentialId?: string;
27
+ refreshId?: string;
28
+ holderDid?: string;
29
+ pendingReceipt?: ManagedCredentialRefreshReceipt;
30
+ pendingActivityId?: string;
31
+ result?: SendBoostResponse;
32
+ updatedAt: string;
33
+ };
34
+
35
+ export type ClaimRefreshSendIntentResult =
36
+ | { kind: 'owned'; intent: RefreshSendIntent }
37
+ | { kind: 'prepared'; intent: RefreshSendIntent }
38
+ | { kind: 'delivered'; intent: RefreshSendIntent };
39
+
40
+ const getIntentKey = (issuerProfileId: string, idempotencyKey: string): string =>
41
+ `${issuerProfileId}:${idempotencyKey}`;
42
+
43
+ /** Digest of everything that defines a prepare request; a reused key must match it. */
44
+ export const computeRefreshSendRequestDigest = (request: {
45
+ recipientProfileId: string;
46
+ holderDid: string;
47
+ templateUri?: string;
48
+ template?: unknown;
49
+ contractUri?: string;
50
+ templateData?: Record<string, unknown>;
51
+ integrationId?: string;
52
+ credentialId?: string;
53
+ }): string =>
54
+ createHash('sha256')
55
+ .update(
56
+ canonicalizeCredentialJson({
57
+ recipientProfileId: request.recipientProfileId,
58
+ holderDid: request.holderDid,
59
+ templateUri: request.templateUri ?? null,
60
+ template: request.template ?? null,
61
+ contractUri: request.contractUri ?? null,
62
+ templateData: request.templateData ?? {},
63
+ integrationId: request.integrationId ?? null,
64
+ credentialId: request.credentialId ?? null,
65
+ })
66
+ )
67
+ .digest('base64url');
68
+
69
+ const parseIntent = (props: Record<string, unknown>): RefreshSendIntent => ({
70
+ intentKey: props.intentKey as string,
71
+ issuerProfileId: props.issuerProfileId as string,
72
+ requestDigest: props.requestDigest as string,
73
+ state: props.state as RefreshSendIntentState,
74
+ claimToken: props.claimToken as string,
75
+ boostUri: (props.boostUri as string | undefined) ?? undefined,
76
+ credentialId: (props.credentialId as string | undefined) ?? undefined,
77
+ refreshId: (props.refreshId as string | undefined) ?? undefined,
78
+ holderDid: (props.holderDid as string | undefined) ?? undefined,
79
+ pendingReceipt: props.pendingReceipt
80
+ ? (JSON.parse(props.pendingReceipt as string) as ManagedCredentialRefreshReceipt)
81
+ : undefined,
82
+ pendingActivityId: (props.pendingActivityId as string | undefined) ?? undefined,
83
+ result: props.result ? (JSON.parse(props.result as string) as SendBoostResponse) : undefined,
84
+ updatedAt: props.updatedAt as string,
85
+ });
86
+
87
+ export const getRefreshSendIntent = async (
88
+ issuerProfileId: string,
89
+ idempotencyKey: string
90
+ ): Promise<RefreshSendIntent | null> => {
91
+ const result = await neogma.queryRunner.run(
92
+ 'MATCH (i:RefreshSendIntent {intentKey: $intentKey}) RETURN i',
93
+ { intentKey: getIntentKey(issuerProfileId, idempotencyKey) }
94
+ );
95
+ const node = result.records[0]?.get('i');
96
+
97
+ return node ? parseIntent(node.properties) : null;
98
+ };
99
+
100
+ /**
101
+ * Creates or claims the intent for (issuer, idempotencyKey). Concurrency-safe through
102
+ * the `refresh_send_intent_key_unique` constraint. Throws CONFLICT when the key was used
103
+ * for a different request, or when another live request is still preparing it.
104
+ */
105
+ export const claimRefreshSendIntent = async (params: {
106
+ issuerProfileId: string;
107
+ idempotencyKey: string;
108
+ requestDigest: string;
109
+ }): Promise<ClaimRefreshSendIntentResult> => {
110
+ const { issuerProfileId, idempotencyKey, requestDigest } = params;
111
+ const claimToken = randomUUID();
112
+ const now = new Date();
113
+
114
+ const result = await neogma.queryRunner.run(
115
+ `MERGE (i:RefreshSendIntent {intentKey: $intentKey})
116
+ ON CREATE SET i.issuerProfileId = $issuerProfileId,
117
+ i.requestDigest = $requestDigest,
118
+ i.state = 'preparing',
119
+ i.claimToken = $claimToken,
120
+ i.createdAt = $now,
121
+ i.updatedAt = $now
122
+ SET i.updatedAt = i.updatedAt
123
+ WITH i
124
+ CALL {
125
+ WITH i
126
+ WITH i WHERE i.state = 'preparing'
127
+ AND i.claimToken <> $claimToken
128
+ AND i.requestDigest = $requestDigest
129
+ AND i.updatedAt < $staleBefore
130
+ SET i.claimToken = $claimToken, i.updatedAt = $now
131
+ }
132
+ RETURN i`,
133
+ {
134
+ intentKey: getIntentKey(issuerProfileId, idempotencyKey),
135
+ issuerProfileId,
136
+ requestDigest,
137
+ claimToken,
138
+ now: now.toISOString(),
139
+ staleBefore: new Date(now.getTime() - REFRESH_SEND_INTENT_STALE_MS).toISOString(),
140
+ }
141
+ );
142
+ const intent = parseIntent(result.records[0]!.get('i').properties);
143
+
144
+ if (intent.requestDigest !== requestDigest) {
145
+ throw new TRPCError({
146
+ code: 'CONFLICT',
147
+ message: 'This idempotencyKey was already used for a different send request.',
148
+ });
149
+ }
150
+
151
+ if (intent.state === 'delivered') return { kind: 'delivered', intent };
152
+ if (intent.state === 'prepared') return { kind: 'prepared', intent };
153
+ if (intent.claimToken === claimToken) return { kind: 'owned', intent };
154
+
155
+ throw new TRPCError({
156
+ code: 'CONFLICT',
157
+ message: 'A send with this idempotencyKey is still being prepared. Retry shortly.',
158
+ });
159
+ };
160
+
161
+ const lostIntentClaim = (): TRPCError =>
162
+ new TRPCError({
163
+ code: 'CONFLICT',
164
+ message:
165
+ 'This send preparation was taken over or completed. Retry with the same idempotencyKey.',
166
+ });
167
+
168
+ /**
169
+ * Records progress on an intent owned by `claimToken` (boost first, then allocation).
170
+ * The self-dependent SET takes the node write lock before checking ownership, so
171
+ * a concurrent takeover cannot pass a stale check and overwrite the new owner.
172
+ */
173
+ export const recordRefreshSendIntent = async (
174
+ intent: RefreshSendIntent,
175
+ fields: Partial<
176
+ Pick<RefreshSendIntent, 'boostUri' | 'credentialId' | 'refreshId' | 'holderDid'>
177
+ > & { state?: 'prepared' }
178
+ ): Promise<RefreshSendIntent> => {
179
+ const result = await neogma.queryRunner.run(
180
+ `MATCH (i:RefreshSendIntent {intentKey: $intentKey})
181
+ SET i.updatedAt = i.updatedAt
182
+ WITH i WHERE i.claimToken = $claimToken AND i.state = 'preparing'
183
+ SET i += $fields, i.updatedAt = $now
184
+ RETURN i`,
185
+ {
186
+ intentKey: intent.intentKey,
187
+ claimToken: intent.claimToken,
188
+ fields: Object.fromEntries(
189
+ Object.entries(fields).filter(([, value]) => value !== undefined)
190
+ ),
191
+ now: new Date().toISOString(),
192
+ }
193
+ );
194
+
195
+ if (!result.records.length) throw lostIntentClaim();
196
+ return parseIntent(result.records[0]!.get('i').properties);
197
+ };
198
+
199
+ /** Stores the metadata-only receipt (and activity) right before delivery is attempted. */
200
+ export const recordRefreshSendIntentPending = async (
201
+ intent: RefreshSendIntent,
202
+ pendingReceipt: ManagedCredentialRefreshReceipt,
203
+ pendingActivityId?: string
204
+ ): Promise<void> => {
205
+ const updated = await neogma.queryRunner.run(
206
+ `MATCH (i:RefreshSendIntent {intentKey: $intentKey})
207
+ SET i.updatedAt = i.updatedAt
208
+ WITH i WHERE i.claimToken = $claimToken AND i.state = 'prepared'
209
+ SET i.pendingReceipt = $pendingReceipt,
210
+ i.pendingActivityId = coalesce($pendingActivityId, i.pendingActivityId),
211
+ i.updatedAt = $now
212
+ RETURN i`,
213
+ {
214
+ intentKey: intent.intentKey,
215
+ claimToken: intent.claimToken,
216
+ pendingReceipt: JSON.stringify(pendingReceipt),
217
+ pendingActivityId: pendingActivityId ?? null,
218
+ now: new Date().toISOString(),
219
+ }
220
+ );
221
+ if (!updated.records.length) throw lostIntentClaim();
222
+ };
223
+
224
+ export const markRefreshSendIntentDelivered = async (
225
+ intent: RefreshSendIntent,
226
+ result: SendBoostResponse
227
+ ): Promise<void> => {
228
+ const updated = await neogma.queryRunner.run(
229
+ `MATCH (i:RefreshSendIntent {intentKey: $intentKey})
230
+ SET i.updatedAt = i.updatedAt
231
+ WITH i WHERE i.claimToken = $claimToken
232
+ AND (i.state = 'prepared' OR (i.state = 'delivered' AND i.result = $result))
233
+ SET i.state = 'delivered', i.result = $result, i.updatedAt = $now
234
+ RETURN i`,
235
+ {
236
+ intentKey: intent.intentKey,
237
+ claimToken: intent.claimToken,
238
+ // The atomic replay comparison must ignore object property insertion order.
239
+ result: canonicalizeCredentialJson(result),
240
+ now: new Date().toISOString(),
241
+ }
242
+ );
243
+ if (!updated.records.length) throw lostIntentClaim();
244
+ };
245
+
246
+ /**
247
+ * When an intent's refresh is already bound (delivery succeeded but the result was never
248
+ * recorded), rebuild the result from the pending receipt and mark it delivered. Returns
249
+ * null when nothing is bound yet.
250
+ */
251
+ export const reconcileBoundRefreshSendIntent = async (
252
+ intent: RefreshSendIntent,
253
+ domain: string
254
+ ): Promise<SendBoostResponse | null> => {
255
+ if (intent.result) return intent.result;
256
+ if (!intent.refreshId || !intent.boostUri || !intent.pendingReceipt) return null;
257
+
258
+ const root = await getInitialRefreshRoot(intent.refreshId);
259
+
260
+ if (!root) return null;
261
+
262
+ const result: SendBoostResponse = {
263
+ type: 'boost',
264
+ uri: intent.boostUri,
265
+ credentialUri: getManagedCredentialUri(root.rootId, domain),
266
+ activityId: intent.pendingActivityId ?? '',
267
+ refresh: intent.pendingReceipt,
268
+ };
269
+
270
+ await markRefreshSendIntentDelivered(intent, result);
271
+
272
+ return result;
273
+ };
@@ -5,6 +5,7 @@ import {
5
5
  type Context as AWSContext,
6
6
  } from 'aws-lambda';
7
7
  import { TRPCError } from '@trpc/server';
8
+ import { withInboxBatchBodyLimit } from './inbox-batch-http.helpers';
8
9
  import { type IncomingMessage, type ServerResponse } from 'http';
9
10
 
10
11
  import { OpenApiRouter } from 'trpc-to-openapi';
@@ -183,36 +184,36 @@ export const createOpenApiAwsLambdaHandler = <TRouter extends OpenApiRouter>(
183
184
  maxBodySize: undefined, // AWS Lambda handles body size limits
184
185
  });
185
186
 
186
- return async (
187
- event: AWSAPIGatewayEvent,
188
- _context: AWSContext
189
- ): Promise<APIGatewayProxyResult> => {
190
- try {
191
- const req = createRequestFromEvent(event);
192
- const { res, promise } = createResponseHandler();
193
-
194
- // Execute the handler
195
- await openApiHttpHandler(req, res);
196
-
197
- return await promise;
198
- } catch (cause) {
199
- // Handle any uncaught errors
200
- const error = cause instanceof Error ? cause : new Error('Unknown error');
201
- const statusCode =
202
- error instanceof TRPCError ? (error.code === 'NOT_FOUND' ? 404 : 500) : 500;
203
-
204
- return {
205
- statusCode,
206
- headers: {
207
- 'Content-Type': 'application/json',
208
- },
209
- body: JSON.stringify({
210
- error: {
211
- message: error.message || 'Internal server error',
212
- code: error instanceof TRPCError ? error.code : 'INTERNAL_SERVER_ERROR',
187
+ return withInboxBatchBodyLimit(
188
+ async (event: AWSAPIGatewayEvent, _context: AWSContext): Promise<APIGatewayProxyResult> => {
189
+ try {
190
+ const req = createRequestFromEvent(event);
191
+ const { res, promise } = createResponseHandler();
192
+
193
+ // Execute the handler
194
+ await openApiHttpHandler(req, res);
195
+
196
+ return await promise;
197
+ } catch (cause) {
198
+ // Handle any uncaught errors
199
+ const error = cause instanceof Error ? cause : new Error('Unknown error');
200
+ const statusCode =
201
+ error instanceof TRPCError ? (error.code === 'NOT_FOUND' ? 404 : 500) : 500;
202
+
203
+ return {
204
+ statusCode,
205
+ headers: {
206
+ 'Content-Type': 'application/json',
213
207
  },
214
- }),
215
- } satisfies APIGatewayProxyResult;
216
- }
217
- };
208
+ body: JSON.stringify({
209
+ error: {
210
+ message: error.message || 'Internal server error',
211
+ code: error instanceof TRPCError ? error.code : 'INTERNAL_SERVER_ERROR',
212
+ },
213
+ }),
214
+ } satisfies APIGatewayProxyResult;
215
+ }
216
+ },
217
+ 'openapi'
218
+ );
218
219
  };
@@ -0,0 +1,198 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import type { JWE, UnsignedVC } from '@learncard/types';
3
+ import type { CredentialIssuer } from '../types/issuer';
4
+ import type { SigningAuthorityForUserType } from '../types/profile';
5
+
6
+ const mocks = vi.hoisted(() => ({
7
+ fetch: vi.fn(),
8
+ append: vi.fn(async credential => credential),
9
+ auth: vi.fn(async () => 'jwt'),
10
+ environment: { NODE_ENV: 'production' },
11
+ }));
12
+ vi.mock('@environment', () => ({ environment: mocks.environment }));
13
+ vi.mock('@helpers/did.helpers', () => ({
14
+ getDidWeb: (domain: string, profileId: string) => `did:web:${domain}:users:${profileId}`,
15
+ }));
16
+ vi.mock('undici', () => ({ Agent: class {}, fetch: mocks.fetch }));
17
+ vi.mock('@helpers/learnCard.helpers', () => ({
18
+ getDidWebLearnCard: async () => ({
19
+ id: { did: () => 'did:example:brain' },
20
+ invoke: { getDidAuthVp: mocks.auth },
21
+ }),
22
+ getLearnCard: vi.fn(),
23
+ }));
24
+ vi.mock('./status-list.helpers', () => ({ appendBitstringStatusListEntries: mocks.append }));
25
+ vi.mock('@tracing', () => ({
26
+ trace: async (_kind: string, _name: string, action: () => unknown) => action(),
27
+ traceCrypto: async (_name: string, action: () => unknown) => action(),
28
+ traceHttp: async (_name: string, action: () => unknown) => action(),
29
+ }));
30
+
31
+ import { issueCredentialWithSigningAuthority } from './signingAuthority.helpers';
32
+
33
+ const issuer = { type: 'profile', profile: { profileId: 'org' } } as unknown as CredentialIssuer;
34
+ const sa = {
35
+ signingAuthority: { endpoint: 'https://sa.example' },
36
+ relationship: { name: 'sa', did: 'did:example:sa' },
37
+ } as unknown as SigningAuthorityForUserType;
38
+ const credential = {
39
+ '@context': ['https://www.w3.org/2018/credentials/v1'],
40
+ type: ['VerifiableCredential'],
41
+ issuer: 'did:example:sa',
42
+ issuanceDate: '2026-01-01T00:00:00Z',
43
+ credentialSubject: { id: 'did:example:student' },
44
+ } as UnsignedVC;
45
+ const jwe: JWE = {
46
+ protected: 'header',
47
+ iv: 'iv',
48
+ ciphertext: 'ciphertext',
49
+ tag: 'tag',
50
+ recipients: [],
51
+ };
52
+
53
+ describe('SA encryption boundary', () => {
54
+ beforeEach(() => {
55
+ vi.clearAllMocks();
56
+ });
57
+
58
+ it.each([
59
+ { credentialSubject: {} },
60
+ { credentialSubject: [] },
61
+ { credentialSubject: [{ id: 'did:example:student' }, {}] },
62
+ ])(
63
+ 'rejects missing subject IDs before allocating status or authenticating (%j)',
64
+ async ({ credentialSubject }) => {
65
+ await expect(
66
+ issueCredentialWithSigningAuthority(
67
+ issuer,
68
+ { ...credential, credentialSubject },
69
+ sa,
70
+ 'network.example'
71
+ )
72
+ ).rejects.toMatchObject({ kind: 'validation_error', retryable: false });
73
+ expect(mocks.append).not.toHaveBeenCalled();
74
+ expect(mocks.auth).not.toHaveBeenCalled();
75
+ expect(mocks.fetch).not.toHaveBeenCalled();
76
+ }
77
+ );
78
+
79
+ it('includes every subject and the delegated contract owner without changing SA ownership', async () => {
80
+ mocks.fetch.mockResolvedValue({ ok: true, status: 200, json: async () => jwe });
81
+ await issueCredentialWithSigningAuthority(
82
+ issuer,
83
+ {
84
+ ...credential,
85
+ credentialSubject: [
86
+ { id: 'did:example:student' },
87
+ { id: 'did:example:second-student' },
88
+ { id: 'did:example:student' },
89
+ ],
90
+ },
91
+ sa,
92
+ 'network.example',
93
+ true,
94
+ undefined,
95
+ true,
96
+ ['did:example:contract-owner', 'did:web:network.example:users:org']
97
+ );
98
+ const body = JSON.parse(mocks.fetch.mock.calls[0]![1].body);
99
+ expect(body.signingAuthority.ownerDid).toBe('did:web:network.example:users:org');
100
+ expect(body.encryption.recipients).toEqual([
101
+ 'did:example:student',
102
+ 'did:example:second-student',
103
+ 'did:web:network.example:users:org',
104
+ 'did:example:contract-owner',
105
+ ]);
106
+ });
107
+
108
+ it.each([undefined, 'did:example:app-owner'])(
109
+ 'encrypts only for the subject and owner (%s)',
110
+ async override => {
111
+ mocks.fetch.mockResolvedValue({ ok: true, status: 200, json: async () => jwe });
112
+ await issueCredentialWithSigningAuthority(
113
+ issuer,
114
+ credential,
115
+ sa,
116
+ 'network.example',
117
+ true,
118
+ override
119
+ );
120
+ const body = JSON.parse(mocks.fetch.mock.calls[0]![1].body);
121
+ expect(body.encryption.recipients).toEqual([
122
+ 'did:example:student',
123
+ override ?? 'did:web:network.example:users:org',
124
+ ]);
125
+ }
126
+ );
127
+
128
+ it('allows subject-less plaintext inbox issuance without encryption recipients', async () => {
129
+ mocks.fetch.mockResolvedValue({
130
+ ok: true,
131
+ status: 200,
132
+ json: async () => ({
133
+ ...credential,
134
+ proof: {
135
+ type: 'Ed25519Signature2020',
136
+ created: '2026-01-01T00:00:00Z',
137
+ proofPurpose: 'assertionMethod',
138
+ verificationMethod: 'did:example:sa#key',
139
+ },
140
+ }),
141
+ });
142
+ await issueCredentialWithSigningAuthority(
143
+ issuer,
144
+ { ...credential, credentialSubject: {} },
145
+ sa,
146
+ 'network.example',
147
+ false
148
+ );
149
+ expect(JSON.parse(mocks.fetch.mock.calls[0]![1].body).encryption).toBeUndefined();
150
+ });
151
+
152
+ it('does not retry permanent recipient errors', async () => {
153
+ mocks.fetch.mockResolvedValue({
154
+ ok: false,
155
+ status: 400,
156
+ statusText: 'Bad Request',
157
+ text: async () => 'Recipient has no usable key',
158
+ });
159
+ await expect(
160
+ issueCredentialWithSigningAuthority(issuer, credential, sa, 'network.example')
161
+ ).rejects.toMatchObject({ status: 400, retryable: false, kind: 'http_4xx' });
162
+ expect(mocks.fetch).toHaveBeenCalledTimes(1);
163
+ });
164
+
165
+ it('keeps retry status metadata bound to the serialized request body', async () => {
166
+ const status = {
167
+ type: 'BitstringStatusListEntry' as const,
168
+ statusPurpose: 'revocation' as const,
169
+ statusListIndex: '42',
170
+ statusListCredential: 'https://network.example/status/1',
171
+ };
172
+ const unsigned = { ...credential, credentialStatus: status };
173
+ mocks.fetch
174
+ .mockImplementationOnce(async () => {
175
+ status.statusListIndex = '99';
176
+ return {
177
+ ok: false,
178
+ status: 503,
179
+ statusText: 'Unavailable',
180
+ text: async () => 'retry',
181
+ };
182
+ })
183
+ .mockResolvedValueOnce({ ok: true, status: 200, json: async () => jwe });
184
+ const issued = await issueCredentialWithSigningAuthority(
185
+ issuer,
186
+ unsigned,
187
+ sa,
188
+ 'network.example'
189
+ );
190
+ expect(mocks.append).toHaveBeenCalledTimes(1);
191
+ expect(mocks.fetch).toHaveBeenCalledTimes(2);
192
+ const bodies = mocks.fetch.mock.calls.map(call => JSON.parse(call[1].body));
193
+ expect(bodies[0].credential).toEqual(bodies[1].credential);
194
+ expect(issued.statusEntries).toEqual([bodies[1].credential.credentialStatus]);
195
+ expect(issued.statusEntries[0]?.statusListIndex).toBe('42');
196
+ expect(JSON.parse(JSON.stringify(issued)).credential).toEqual(jwe);
197
+ });
198
+ });
@@ -11,6 +11,8 @@ import { trace, traceCrypto, traceHttp } from '@tracing';
11
11
  import { PerfTracker } from '@helpers/perf';
12
12
  import { benchContextStorage } from '@helpers/bench-context.helpers';
13
13
  import { appendBitstringStatusListEntries } from './status-list.helpers';
14
+ import { getBitstringStatusListEntries } from '@learncard/helpers';
15
+ import type { IssuedCredential } from '../types/credential';
14
16
 
15
17
  const IS_TEST_ENVIRONMENT = environment.NODE_ENV === 'test';
16
18
 
@@ -127,17 +129,35 @@ export async function issueCredentialWithSigningAuthority(
127
129
  // credential. Allocating fresh Bitstring status entries per version would leak
128
130
  // writes and replace the descriptor the issuer supplied, so refresh publication
129
131
  // opts out. Defaults to true so every existing caller is preserved.
130
- appendCredentialStatus = true
131
- ): Promise<VC | JWE> {
132
+ appendCredentialStatus = true,
133
+ // Additional authorized readers (e.g. a contract owner delegating issuance).
134
+ additionalEncryptionRecipients: string[] = []
135
+ ): Promise<IssuedCredential> {
132
136
  const issuerEndpoint = `${signingAuthorityForUser.signingAuthority.endpoint}/credentials/issue`;
133
137
  const saName = signingAuthorityForUser.relationship.name;
134
138
  const saDid = signingAuthorityForUser.relationship.did;
135
139
  const ownerProfile = getIssuerOwnerProfile(issuer);
136
140
  const ownerDid =
137
141
  ownerDidOverride ?? getDidWeb(domain ?? 'network.learncard.com', ownerProfile.profileId);
142
+ const subjects = Array.isArray(credential.credentialSubject)
143
+ ? credential.credentialSubject
144
+ : [credential.credentialSubject];
145
+ const subjectIds = subjects.map(subject => subject?.id);
146
+ if (encrypt && (!subjectIds.length || subjectIds.some(id => !id))) {
147
+ throw new SaIssueError({
148
+ message: 'Encrypted credential issuance requires a DID for every subject',
149
+ status: 400,
150
+ kind: 'validation_error',
151
+ retryable: false,
152
+ });
153
+ }
138
154
  const credentialToIssue = appendCredentialStatus
139
155
  ? await appendBitstringStatusListEntries(credential, ownerProfile.profileId, domain)
140
156
  : credential;
157
+ // Capture the exact unsigned body once. Retries reuse both this body and its
158
+ // status coordinates, even if the caller later mutates its input object.
159
+ const serializedCredential = JSON.stringify(credentialToIssue);
160
+ const statusEntries = getBitstringStatusListEntries(JSON.parse(serializedCredential));
141
161
 
142
162
  const logContext = {
143
163
  issuer: getIssuerProfileId(issuer),
@@ -148,7 +168,7 @@ export async function issueCredentialWithSigningAuthority(
148
168
  encrypt,
149
169
  };
150
170
 
151
- return trace(
171
+ const issuedCredential = await trace(
152
172
  'signing-authority',
153
173
  'issueCredentialWithSigningAuthority',
154
174
  async () => {
@@ -156,7 +176,9 @@ export async function issueCredentialWithSigningAuthority(
156
176
 
157
177
  try {
158
178
  if (IS_TEST_ENVIRONMENT) {
159
- return await _mockIssueCredentialWithSigningAuthority(credentialToIssue);
179
+ return await _mockIssueCredentialWithSigningAuthority(
180
+ JSON.parse(serializedCredential)
181
+ );
160
182
  }
161
183
 
162
184
  console.log('[SA Helper] Initiating credential issuance', logContext);
@@ -166,8 +188,18 @@ export async function issueCredentialWithSigningAuthority(
166
188
  );
167
189
  perf.mark('initDid');
168
190
 
169
- const brainDid = learnCard.id.did();
170
- console.log('[SA Helper] Brain DID resolved:', brainDid);
191
+ // Brain authenticates the request but must not be able to decrypt the response.
192
+ const encryption = encrypt
193
+ ? {
194
+ recipients: [
195
+ ...new Set([
196
+ ...subjectIds,
197
+ ownerDid,
198
+ ...additionalEncryptionRecipients,
199
+ ]),
200
+ ],
201
+ }
202
+ : undefined;
171
203
 
172
204
  const didJwt = await traceCrypto('getDidAuthVp', () =>
173
205
  learnCard.invoke.getDidAuthVp({ proofFormat: 'jwt' })
@@ -178,24 +210,14 @@ export async function issueCredentialWithSigningAuthority(
178
210
  console.error('[SA Helper] Failed to generate DID Auth VP - got falsy value');
179
211
  }
180
212
 
181
- const subjectId = Array.isArray(credentialToIssue?.credentialSubject)
182
- ? credentialToIssue?.credentialSubject[0]?.id
183
- : credentialToIssue?.credentialSubject?.id;
184
-
185
- const encryption = encrypt
186
- ? {
187
- recipients: [brainDid, ...(subjectId ? [subjectId] : [])],
188
- }
189
- : undefined;
190
-
191
213
  console.log('[SA Helper] Request details:', {
192
- subjectId,
214
+ subjectIds,
193
215
  encryptionRecipients: encryption?.recipients,
194
216
  credentialType: credentialToIssue?.type,
195
217
  });
196
218
 
197
219
  const requestBody = JSON.stringify({
198
- credential: credentialToIssue,
220
+ credential: JSON.parse(serializedCredential),
199
221
  signingAuthority: {
200
222
  ownerDid,
201
223
  name: saName,
@@ -402,4 +424,5 @@ export async function issueCredentialWithSigningAuthority(
402
424
  saEndpoint: signingAuthorityForUser.signingAuthority.endpoint,
403
425
  }
404
426
  );
427
+ return { kind: 'issued-credential', credential: issuedCredential, statusEntries };
405
428
  }