@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,668 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import { TRPCError } from '@trpc/server';
3
+ import { IssueInboxCredentialBatchValidator } from '@learncard/types';
4
+ import type { IssueInboxCredentialBatch } from '@learncard/types';
5
+ import type { ProfileType } from 'types/profile';
6
+ import type { Context } from '@routes';
7
+ import { InboxIssuancePreflightError } from './inbox-issuance-error.helpers';
8
+ import { getInboxBatchState } from './inbox-batch-status.helpers';
9
+
10
+ const mocks = vi.hoisted(() => ({
11
+ get: vi.fn(),
12
+ setIfAbsent: vi.fn(),
13
+ compareAndSet: vi.fn(),
14
+ beforeIssue: vi.fn(),
15
+ issue: vi.fn(),
16
+ resolve: vi.fn(),
17
+ assertRefresh: vi.fn(),
18
+ refreshDigest: vi.fn(),
19
+ skipCheckpoint: false,
20
+ }));
21
+ vi.mock('./inbox-refresh.helpers', () => ({
22
+ assertInboxRefreshEnabled: mocks.assertRefresh,
23
+ inboxRefreshRequestDigest: mocks.refreshDigest,
24
+ }));
25
+ vi.mock('./inbox.helpers', () => ({
26
+ issueToInbox: async (...args: Parameters<typeof import('./inbox.helpers').issueToInbox>) => {
27
+ // This fake models delivery after the checkpoint. The container test "retries a transient
28
+ // signing failure before delivery" pins the real helper's signing/checkpoint ordering.
29
+ if (!mocks.skipCheckpoint) await args[5]?.();
30
+ const result = await mocks.issue(...args);
31
+ if (mocks.skipCheckpoint) {
32
+ const { InboxDeliveryCheckpointError } = await import('./inbox-issuance-error.helpers');
33
+ throw new InboxDeliveryCheckpointError({
34
+ issuanceId: result.inboxCredential.id,
35
+ status: result.status,
36
+ claimUrl: result.claimUrl,
37
+ recipientDid: result.recipientDid,
38
+ guardianStatus: result.guardianStatus,
39
+ });
40
+ }
41
+ return result;
42
+ },
43
+ resolveInboxCredentialInput: mocks.resolve,
44
+ }));
45
+ import { issueInboxBatch } from './inbox-batch.helpers';
46
+ import Fastify from 'fastify';
47
+ import { configureInboxBatchBodyLimit, INBOX_BATCH_MAX_BYTES } from './inbox-batch-http.helpers';
48
+
49
+ const profile = { profileId: 'issuer' } as ProfileType;
50
+ const ctx = { domain: 'example.test' } as Context;
51
+ const credential = {
52
+ '@context': ['https://www.w3.org/2018/credentials/v1'],
53
+ type: ['VerifiableCredential'],
54
+ issuer: 'did:example:issuer',
55
+ issuanceDate: '2026-01-01T00:00:00Z',
56
+ credentialSubject: { id: 'did:example:holder' },
57
+ };
58
+ const item = (id = 'a') => ({
59
+ recipient: { type: 'email' as const, value: `${id}@example.test` },
60
+ credential,
61
+ });
62
+ const run = (batch: IssueInboxCredentialBatch) =>
63
+ issueInboxBatch(profile, batch, ctx, {
64
+ cache: mocks,
65
+ beforeIssue: mocks.beforeIssue,
66
+ });
67
+
68
+ describe('getInboxBatchState', () => {
69
+ it('does not classify an empty state list as completed', () => {
70
+ expect(getInboxBatchState([])).toBe('QUEUED');
71
+ });
72
+
73
+ it('uses reconciliation, completion, and progress precedence', () => {
74
+ expect(getInboxBatchState(['QUEUED', 'NEEDS_RECONCILIATION'])).toBe('NEEDS_RECONCILIATION');
75
+ expect(getInboxBatchState(['COMPLETED', 'COMPLETED'])).toBe('COMPLETED');
76
+ expect(getInboxBatchState(['QUEUED', 'PROCESSING'])).toBe('PROCESSING');
77
+ });
78
+ });
79
+
80
+ describe('inbox batch worker processing', () => {
81
+ beforeEach(() => {
82
+ vi.resetAllMocks();
83
+ mocks.get.mockResolvedValue(null);
84
+ mocks.setIfAbsent.mockResolvedValue('OK');
85
+ mocks.compareAndSet.mockResolvedValue(true);
86
+ mocks.resolve.mockImplementation(async input => ({ credential: input.credential }));
87
+ mocks.skipCheckpoint = false;
88
+ mocks.refreshDigest.mockReturnValue('refresh-digest');
89
+ mocks.issue.mockImplementation(async (_profile, recipient) => ({
90
+ inboxCredential: { id: recipient.value },
91
+ status: 'PENDING',
92
+ claimUrl: 'https://example.test/claim',
93
+ }));
94
+ });
95
+
96
+ it('inherits refresh and honors item overrides including false and the top-level alias', async () => {
97
+ const batch = IssueInboxCredentialBatchValidator.parse({
98
+ configuration: { refresh: true },
99
+ items: [
100
+ item('inherit'),
101
+ { ...item('off'), configuration: { refresh: false } },
102
+ { ...item('alias'), refresh: false },
103
+ { ...item('explicit'), refresh: false, configuration: { refresh: true } },
104
+ ],
105
+ });
106
+ await run(batch);
107
+ const flags = Object.fromEntries(
108
+ mocks.issue.mock.calls.map(call => [call[1].value, call[3].refresh])
109
+ );
110
+ expect(flags).toEqual({
111
+ 'inherit@example.test': true,
112
+ 'off@example.test': false,
113
+ 'alias@example.test': false,
114
+ 'explicit@example.test': true,
115
+ });
116
+ expect(mocks.assertRefresh).toHaveBeenCalledTimes(2);
117
+ await run(
118
+ IssueInboxCredentialBatchValidator.parse({
119
+ configuration: { refresh: false },
120
+ items: [{ ...item('on'), configuration: { refresh: true } }],
121
+ })
122
+ );
123
+ expect(mocks.issue.mock.calls.at(-1)?.[3].refresh).toBe(true);
124
+ });
125
+
126
+ it('returns and replays refresh receipts and forwards the template anchor and digest', async () => {
127
+ const refresh = {
128
+ refreshId: 'refresh-id',
129
+ credentialId: 'credential-id',
130
+ issuerDid: 'did:example:issuer',
131
+ refreshService: {
132
+ id: 'https://example.test/refresh',
133
+ type: 'LearnCardCredentialRefresh2026',
134
+ },
135
+ };
136
+ mocks.resolve.mockResolvedValue({ credential, resolvedBoostUri: 'boost:template' });
137
+ mocks.issue.mockResolvedValue({
138
+ inboxCredential: { id: 'issued' },
139
+ status: 'PENDING',
140
+ refresh,
141
+ });
142
+ const batch = IssueInboxCredentialBatchValidator.parse({
143
+ configuration: { refresh: true },
144
+ items: [{ ...item(), idempotencyKey: 'refresh-key' }],
145
+ });
146
+ const result = await run(batch);
147
+ expect(result.results[0]).toMatchObject({ success: true, refresh });
148
+ expect(mocks.issue.mock.calls[0]?.[3]).toMatchObject({
149
+ refresh: true,
150
+ boostUri: 'boost:template',
151
+ refreshRequestDigest: 'refresh-digest',
152
+ });
153
+ mocks.get.mockResolvedValue(mocks.compareAndSet.mock.calls[0]![2]);
154
+ const replay = await run(batch);
155
+ expect(replay.results[0]).toMatchObject({ success: true, refresh, deduplicated: true });
156
+ expect(mocks.issue).toHaveBeenCalledTimes(1);
157
+ const changed = await run({ ...batch, configuration: { refresh: false } });
158
+ expect(changed.results[0]).toMatchObject({
159
+ success: false,
160
+ error: { reason: 'IDEMPOTENCY_MISMATCH' },
161
+ });
162
+ });
163
+
164
+ it('rejects unavailable refresh before resolving or issuing an item', async () => {
165
+ mocks.assertRefresh.mockRejectedValue(new TRPCError({ code: 'UNAUTHORIZED' }));
166
+ const result = await run({ configuration: { refresh: true }, items: [item()] });
167
+ expect(result.results[0]).toMatchObject({
168
+ success: false,
169
+ error: { code: 'UNAUTHORIZED' },
170
+ });
171
+ expect(mocks.resolve).not.toHaveBeenCalled();
172
+ expect(mocks.issue).not.toHaveBeenCalled();
173
+ });
174
+
175
+ it('isolates disabled refresh failures while issuing opted-out items', async () => {
176
+ mocks.assertRefresh.mockRejectedValue(new TRPCError({ code: 'NOT_FOUND' }));
177
+ const result = await run(
178
+ IssueInboxCredentialBatchValidator.parse({
179
+ configuration: { refresh: true },
180
+ items: [item('refresh'), { ...item('normal'), configuration: { refresh: false } }],
181
+ })
182
+ );
183
+ expect(result.results).toMatchObject([
184
+ { success: false, error: { code: 'NOT_FOUND' } },
185
+ { success: true, recipient: item('normal').recipient },
186
+ ]);
187
+ expect(mocks.issue).toHaveBeenCalledTimes(1);
188
+ expect(mocks.issue.mock.lastCall![3].refresh).toBe(false);
189
+ });
190
+
191
+ it.each(['true', 1, null])(
192
+ 'rejects invalid refresh value %s at both configuration levels',
193
+ refresh => {
194
+ expect(
195
+ IssueInboxCredentialBatchValidator.safeParse({
196
+ configuration: { refresh },
197
+ items: [item()],
198
+ }).success
199
+ ).toBe(false);
200
+ expect(
201
+ IssueInboxCredentialBatchValidator.safeParse({
202
+ items: [{ ...item(), configuration: { refresh } }],
203
+ }).success
204
+ ).toBe(false);
205
+ }
206
+ );
207
+
208
+ it('bounds concurrency and preserves order when work completes out of order', async () => {
209
+ let active = 0;
210
+ let peak = 0;
211
+ mocks.issue.mockImplementation(async (_profile, recipient) => {
212
+ active++;
213
+ peak = Math.max(peak, active);
214
+ await new Promise(resolve =>
215
+ setTimeout(resolve, recipient.value.startsWith('0') ? 20 : 1)
216
+ );
217
+ active--;
218
+ return { inboxCredential: { id: recipient.value }, status: 'PENDING' };
219
+ });
220
+ const batch = await run({ items: Array.from({ length: 12 }, (_, i) => item(String(i))) });
221
+ expect(peak).toBe(10);
222
+ expect(batch.results.map(r => r.success && r.issuanceId)).toEqual(
223
+ Array.from({ length: 12 }, (_, i) => `${i}@example.test`)
224
+ );
225
+ });
226
+
227
+ it('isolates missing credentials, TRPC errors and unknown exceptions without leaking internals', async () => {
228
+ mocks.issue
229
+ .mockRejectedValueOnce(
230
+ new TRPCError({ code: 'NOT_FOUND', message: 'Template not found' })
231
+ )
232
+ .mockRejectedValueOnce(new Error('private database connection'))
233
+ .mockResolvedValueOnce({ inboxCredential: { id: 'success' }, status: 'PENDING' });
234
+ const result = await run({
235
+ items: [{ recipient: item().recipient }, item('b'), item('c'), item('d')],
236
+ });
237
+ expect(result.results).toMatchObject([
238
+ { success: false, index: 0, error: { code: 'BAD_REQUEST' } },
239
+ {
240
+ success: false,
241
+ index: 1,
242
+ error: { code: 'NOT_FOUND', message: 'Template not found' },
243
+ },
244
+ {
245
+ success: false,
246
+ index: 2,
247
+ error: { code: 'INTERNAL_SERVER_ERROR', message: 'Failed to issue credential' },
248
+ },
249
+ { success: true, index: 3 },
250
+ ]);
251
+ expect(result.summary).toEqual({ total: 4, succeeded: 1, failed: 3, deduplicated: 0 });
252
+ });
253
+
254
+ it('deep-merges before applying defaults, replaces arrays, and leaves inputs untouched', async () => {
255
+ const batch: IssueInboxCredentialBatch = {
256
+ configuration: {
257
+ signingAuthority: { endpoint: 'https://sa.test', name: 'UPPER' },
258
+ delivery: { suppress: true, template: { model: { issuer: { name: 'Issuer' } } } },
259
+ templateData: { student: { course: 'Math' }, grades: [1, 2] },
260
+ },
261
+ items: [
262
+ {
263
+ ...item(),
264
+ configuration: {
265
+ delivery: { template: { model: { recipient: { name: 'Ada' } } } },
266
+ templateData: { student: { name: 'Ada' }, grades: [3] },
267
+ },
268
+ },
269
+ ],
270
+ };
271
+ const original = structuredClone(batch);
272
+ await run(batch);
273
+ expect(mocks.issue.mock.calls[0]?.[3]).toMatchObject({
274
+ signingAuthority: { endpoint: 'https://sa.test', name: 'upper' },
275
+ delivery: {
276
+ suppress: true,
277
+ template: { model: { issuer: { name: 'Issuer' }, recipient: { name: 'Ada' } } },
278
+ },
279
+ templateData: { student: { course: 'Math', name: 'Ada' }, grades: [3] },
280
+ });
281
+ expect(batch).toEqual(original);
282
+ });
283
+
284
+ it('caches only successes for 24 hours and replays using the current index', async () => {
285
+ const batch = await run({
286
+ items: [
287
+ { ...item(), idempotencyKey: 'key' },
288
+ { recipient: item('b').recipient, idempotencyKey: 'failed' },
289
+ ],
290
+ });
291
+ expect(mocks.compareAndSet).toHaveBeenCalledTimes(1);
292
+ expect(mocks.compareAndSet).toHaveBeenCalledWith(
293
+ 'inbox-batch-idem:issuer:key',
294
+ expect.any(String),
295
+ expect.any(String),
296
+ 86400
297
+ );
298
+ expect(JSON.parse(mocks.compareAndSet.mock.calls[0]![2])).toMatchObject(
299
+ JSON.parse(JSON.stringify(batch.results[0]))
300
+ );
301
+ mocks.get.mockResolvedValueOnce(
302
+ JSON.stringify({
303
+ ...JSON.parse(mocks.compareAndSet.mock.calls[0]![2]),
304
+ index: 99,
305
+ })
306
+ );
307
+ mocks.issue.mockClear();
308
+ const replay = await run({ items: [{ ...item(), idempotencyKey: 'key' }] });
309
+ expect(replay.results[0]).toMatchObject({ success: true, index: 0, deduplicated: true });
310
+ expect(mocks.issue).not.toHaveBeenCalled();
311
+ });
312
+
313
+ it('does not deduplicate unkeyed items', async () => {
314
+ await run({ items: [item(), item()] });
315
+ expect(mocks.get).not.toHaveBeenCalled();
316
+ expect(mocks.setIfAbsent).not.toHaveBeenCalled();
317
+ expect(mocks.issue).toHaveBeenCalledTimes(2);
318
+ });
319
+
320
+ it('fails closed on cache read/reservation errors and invalid replay records', async () => {
321
+ mocks.get.mockResolvedValueOnce(undefined).mockResolvedValueOnce('invalid json');
322
+ const items = [{ ...item(), idempotencyKey: 'a' }];
323
+ expect((await run({ items })).summary.failed).toBe(1);
324
+ expect((await run({ items })).summary.failed).toBe(1);
325
+ mocks.setIfAbsent.mockResolvedValueOnce(undefined);
326
+ expect((await run({ items })).summary.failed).toBe(1);
327
+ expect(mocks.issue).not.toHaveBeenCalled();
328
+ });
329
+
330
+ it('releases reservations on preparation failure but retains ambiguous issuance outcomes', async () => {
331
+ const items = [{ ...item(), idempotencyKey: 'a' }];
332
+ mocks.resolve.mockRejectedValueOnce(new TRPCError({ code: 'NOT_FOUND' }));
333
+ expect((await run({ items })).results[0]).toMatchObject({
334
+ success: false,
335
+ error: { code: 'NOT_FOUND' },
336
+ });
337
+ expect(mocks.compareAndSet).toHaveBeenCalledWith(
338
+ expect.any(String),
339
+ expect.any(String),
340
+ null,
341
+ 86400
342
+ );
343
+ mocks.compareAndSet.mockClear();
344
+ mocks.issue.mockRejectedValueOnce(new Error('connection lost after write'));
345
+ expect((await run({ items })).results[0]).toMatchObject({
346
+ success: false,
347
+ error: { code: 'CONFLICT' },
348
+ });
349
+ expect(mocks.compareAndSet).not.toHaveBeenCalled();
350
+ });
351
+
352
+ it.each([undefined, false])(
353
+ 'preserves issuance metadata when replay commit returns %s',
354
+ async saved => {
355
+ mocks.compareAndSet.mockResolvedValue(saved);
356
+ const result = await run({ items: [{ ...item(), idempotencyKey: 'a' }] });
357
+ expect(result.results[0]).toMatchObject({
358
+ success: false,
359
+ error: { code: 'CONFLICT', message: expect.stringContaining('Credential issued') },
360
+ issuanceId: 'a@example.test',
361
+ claimUrl: 'https://example.test/claim',
362
+ });
363
+ expect(mocks.compareAndSet).toHaveBeenCalledTimes(saved === undefined ? 2 : 1);
364
+ expect(mocks.issue).toHaveBeenCalledTimes(1);
365
+ }
366
+ );
367
+
368
+ it('retries a transient commit failure once without issuing again', async () => {
369
+ mocks.compareAndSet.mockResolvedValueOnce(undefined).mockResolvedValueOnce(true);
370
+ expect((await run({ items: [{ ...item(), idempotencyKey: 'a' }] })).summary.succeeded).toBe(
371
+ 1
372
+ );
373
+ expect(mocks.compareAndSet).toHaveBeenCalledTimes(2);
374
+ expect(mocks.issue).toHaveBeenCalledTimes(1);
375
+ });
376
+
377
+ it('recognizes a committed result when the CAS reply was lost', async () => {
378
+ mocks.compareAndSet.mockImplementation(async (_key, _owner, value) => {
379
+ mocks.get.mockResolvedValue(value);
380
+ return undefined;
381
+ });
382
+ expect((await run({ items: [{ ...item(), idempotencyKey: 'a' }] })).summary.succeeded).toBe(
383
+ 1
384
+ );
385
+ expect(mocks.issue).toHaveBeenCalledTimes(1);
386
+ });
387
+
388
+ it.each(['FORBIDDEN', 'BAD_REQUEST', 'NOT_FOUND'] as const)(
389
+ 'releases an explicitly side-effect-free %s and allows the corrected item to retry',
390
+ async code => {
391
+ const items = [{ ...item(), idempotencyKey: 'preflight' }];
392
+ mocks.issue.mockRejectedValueOnce(
393
+ new InboxIssuancePreflightError({ code, message: 'Invalid preflight input' })
394
+ );
395
+ expect((await run({ items })).results[0]).toMatchObject({
396
+ success: false,
397
+ error: { code },
398
+ });
399
+ expect(mocks.compareAndSet).toHaveBeenCalledWith(
400
+ expect.any(String),
401
+ expect.any(String),
402
+ null,
403
+ 86400
404
+ );
405
+ expect(
406
+ (
407
+ await run({
408
+ items: [{ ...items[0]!, configuration: { delivery: { suppress: true } } }],
409
+ })
410
+ ).summary.succeeded
411
+ ).toBe(1);
412
+ }
413
+ );
414
+
415
+ it('retains reservations for ordinary 4xx failures that may follow a write', async () => {
416
+ mocks.issue.mockRejectedValueOnce(new TRPCError({ code: 'BAD_REQUEST' }));
417
+ expect(
418
+ (await run({ items: [{ ...item(), idempotencyKey: 'a' }] })).results[0]
419
+ ).toMatchObject({
420
+ success: false,
421
+ error: { code: 'CONFLICT' },
422
+ });
423
+ expect(mocks.compareAndSet).not.toHaveBeenCalled();
424
+ });
425
+
426
+ it('rejects later duplicate keys deterministically', async () => {
427
+ const result = await run({
428
+ items: [
429
+ { ...item(), idempotencyKey: 'same' },
430
+ { ...item(), idempotencyKey: 'same' },
431
+ { ...item('different'), idempotencyKey: 'same' },
432
+ ],
433
+ });
434
+ expect(result.results).toMatchObject([
435
+ { index: 0, success: true },
436
+ { index: 1, success: false, error: { code: 'CONFLICT' } },
437
+ { index: 2, success: false, error: { code: 'CONFLICT' } },
438
+ ]);
439
+ expect(mocks.issue).toHaveBeenCalledTimes(1);
440
+ expect(mocks.setIfAbsent).toHaveBeenCalledTimes(1);
441
+ });
442
+
443
+ it('does not attempt later duplicate keys even if the first item fails validation', async () => {
444
+ const result = await run({
445
+ items: [
446
+ { recipient: item().recipient, idempotencyKey: 'same' },
447
+ { ...item(), idempotencyKey: 'same' },
448
+ ],
449
+ });
450
+ expect(result.results).toMatchObject([
451
+ { success: false, error: { code: 'BAD_REQUEST' } },
452
+ { success: false, error: { code: 'CONFLICT' } },
453
+ ]);
454
+ expect(mocks.issue).not.toHaveBeenCalled();
455
+ expect(mocks.setIfAbsent).not.toHaveBeenCalled();
456
+ });
457
+
458
+ it('reports a retryable conflict if the reservation disappears after SET NX contention', async () => {
459
+ mocks.setIfAbsent.mockResolvedValueOnce(null);
460
+ const result = await run({ items: [{ ...item(), idempotencyKey: 'a' }] });
461
+ expect(result.results[0]).toMatchObject({
462
+ success: false,
463
+ error: { code: 'CONFLICT', message: expect.stringContaining('Retry this same key') },
464
+ });
465
+ expect(mocks.issue).not.toHaveBeenCalled();
466
+ });
467
+
468
+ it('rejects replay records missing the request fingerprint', async () => {
469
+ mocks.get.mockResolvedValueOnce(
470
+ JSON.stringify({
471
+ success: true,
472
+ index: 0,
473
+ issuanceId: 'old',
474
+ status: 'PENDING',
475
+ recipient: item().recipient,
476
+ })
477
+ );
478
+ expect((await run({ items: [{ ...item(), idempotencyKey: 'a' }] })).summary.failed).toBe(1);
479
+ expect(mocks.issue).not.toHaveBeenCalled();
480
+ });
481
+
482
+ it('validates guardian self-approval after merging overrides and allows null to clear it', async () => {
483
+ const result = await run({
484
+ configuration: { guardianEmail: 'A@example.test' },
485
+ items: [
486
+ item(),
487
+ { ...item(), configuration: { guardianEmail: 'guardian@example.test' } },
488
+ { ...item('clear'), configuration: { guardianEmail: null } },
489
+ ],
490
+ });
491
+ expect(result.results).toMatchObject([
492
+ {
493
+ success: false,
494
+ error: { code: 'BAD_REQUEST', message: expect.stringContaining('self-approval') },
495
+ },
496
+ { success: true },
497
+ { success: true },
498
+ ]);
499
+ expect(mocks.issue).toHaveBeenCalledTimes(2);
500
+ expect(mocks.issue.mock.calls[1]?.[3]).not.toHaveProperty('guardianEmail');
501
+ });
502
+
503
+ it('checks the durable worker lease before entering issuance', async () => {
504
+ mocks.beforeIssue.mockRejectedValueOnce(new Error('Lease lost'));
505
+ expect(
506
+ (await run({ items: [{ ...item(), idempotencyKey: 'lease' }] })).results[0]
507
+ ).toMatchObject({ success: false });
508
+ expect(mocks.resolve).toHaveBeenCalledTimes(1);
509
+ expect(mocks.issue).not.toHaveBeenCalled();
510
+ expect(mocks.compareAndSet).toHaveBeenCalledWith(
511
+ expect.any(String),
512
+ expect.any(String),
513
+ null,
514
+ 86400
515
+ );
516
+ });
517
+
518
+ it.each([false, true])(
519
+ 'preserves checkpoint reconciliation details when phase persistence fails: %s',
520
+ async fails => {
521
+ mocks.skipCheckpoint = true;
522
+ if (fails) mocks.beforeIssue.mockRejectedValueOnce(new Error('Storage unavailable'));
523
+ const result = await run({
524
+ items: [{ ...item(), idempotencyKey: 'missing-checkpoint' }],
525
+ });
526
+
527
+ expect(result.results[0]).toMatchObject({
528
+ success: false,
529
+ error: {
530
+ code: 'CONFLICT',
531
+ message: expect.stringContaining('Reconcile using issuanceId'),
532
+ },
533
+ issuanceId: 'a@example.test',
534
+ claimUrl: 'https://example.test/claim',
535
+ });
536
+ expect(mocks.beforeIssue).toHaveBeenCalledTimes(1);
537
+ expect(mocks.compareAndSet).not.toHaveBeenCalled();
538
+ }
539
+ );
540
+
541
+ it('preserves preparation errors when releasing a reservation throws', async () => {
542
+ mocks.resolve.mockRejectedValueOnce(
543
+ new TRPCError({ code: 'NOT_FOUND', message: 'Missing template' })
544
+ );
545
+ mocks.compareAndSet.mockRejectedValueOnce(new Error('Storage unavailable'));
546
+ const result = await run({ items: [{ ...item(), idempotencyKey: 'cleanup' }] });
547
+ expect(result.results[0]).toMatchObject({
548
+ success: false,
549
+ error: { code: 'NOT_FOUND', message: 'Missing template' },
550
+ });
551
+ expect(mocks.issue).not.toHaveBeenCalled();
552
+ });
553
+
554
+ it('returns the item outcome when the lease checkpoint and cleanup both fail', async () => {
555
+ mocks.beforeIssue.mockRejectedValueOnce(new Error('Inbox item lease lost'));
556
+ mocks.compareAndSet.mockRejectedValueOnce(new Error('Inbox item lease lost'));
557
+ const result = await run({ items: [{ ...item(), idempotencyKey: 'lost' }] });
558
+ expect(result.results[0]).toMatchObject({
559
+ success: false,
560
+ error: { code: 'INTERNAL_SERVER_ERROR' },
561
+ });
562
+ expect(mocks.issue).not.toHaveBeenCalled();
563
+ });
564
+
565
+ it('serializes overlapping keys across requests and rejects changed payloads', async () => {
566
+ const values = new Map<string, string>();
567
+ mocks.get.mockImplementation(async key => values.get(key) ?? null);
568
+ mocks.setIfAbsent.mockImplementation(async (key, value) => {
569
+ if (values.has(key)) return null;
570
+ values.set(key, value);
571
+ return 'OK';
572
+ });
573
+ mocks.compareAndSet.mockImplementation(async (key, expected, value) => {
574
+ if (values.get(key) !== expected) return false;
575
+ values.set(key, value);
576
+ return true;
577
+ });
578
+ const items = [{ ...item(), idempotencyKey: 'a' }];
579
+ await Promise.all([run({ items }), run({ items })]);
580
+ expect(mocks.issue).toHaveBeenCalledTimes(1);
581
+ expect((await run({ items })).summary.deduplicated).toBe(1);
582
+ expect(
583
+ (await run({ items: [{ ...item('different'), idempotencyKey: 'a' }] })).results[0]
584
+ ).toMatchObject({ success: false, error: { code: 'CONFLICT' } });
585
+ expect(mocks.issue).toHaveBeenCalledTimes(1);
586
+ });
587
+
588
+ it('masks internal TRPC error details', async () => {
589
+ mocks.issue.mockRejectedValueOnce(
590
+ new TRPCError({
591
+ code: 'INTERNAL_SERVER_ERROR',
592
+ message: 'private signing service token',
593
+ })
594
+ );
595
+ expect((await run({ items: [item()] })).results[0]).toMatchObject({
596
+ error: { code: 'INTERNAL_SERVER_ERROR', message: 'Failed to issue credential' },
597
+ });
598
+ });
599
+
600
+ it('applies the tested HTTP parser budget to batch routes only', async () => {
601
+ const server = Fastify();
602
+ configureInboxBatchBodyLimit(server);
603
+ server.post('/api/inbox/issue-batch', async () => ({ accepted: true }));
604
+ server.post('/api/inbox/issue', async () => ({ accepted: true }));
605
+ server.post('/trpc/:path', async () => ({ accepted: true }));
606
+ const payload = {
607
+ padding: 'x'.repeat(
608
+ INBOX_BATCH_MAX_BYTES - Buffer.byteLength(JSON.stringify({ padding: '' }))
609
+ ),
610
+ };
611
+ try {
612
+ expect(
613
+ (await server.inject({ method: 'POST', url: '/api/inbox/issue-batch', payload }))
614
+ .statusCode
615
+ ).toBe(200);
616
+ expect(
617
+ (await server.inject({ method: 'POST', url: '/trpc/inbox.issueBatch', payload }))
618
+ .statusCode
619
+ ).toBe(200);
620
+ expect(
621
+ (await server.inject({ method: 'POST', url: '/api/inbox/issue', payload }))
622
+ .statusCode
623
+ ).toBe(413);
624
+ payload.padding += 'x';
625
+ expect(
626
+ (await server.inject({ method: 'POST', url: '/api/inbox/issue-batch', payload }))
627
+ .statusCode
628
+ ).toBe(413);
629
+ } finally {
630
+ await server.close();
631
+ }
632
+ });
633
+
634
+ it('validates item count and key length', () => {
635
+ expect(IssueInboxCredentialBatchValidator.safeParse({ items: [] }).success).toBe(false);
636
+ expect(
637
+ IssueInboxCredentialBatchValidator.safeParse({ items: Array(101).fill(item()) }).success
638
+ ).toBe(false);
639
+ expect(
640
+ IssueInboxCredentialBatchValidator.safeParse({ items: Array(100).fill(item()) }).success
641
+ ).toBe(true);
642
+ expect(
643
+ IssueInboxCredentialBatchValidator.safeParse({
644
+ items: [{ ...item(), idempotencyKey: 'x'.repeat(257) }],
645
+ }).success
646
+ ).toBe(false);
647
+ });
648
+ });
649
+
650
+ describe('batch admission validation', () => {
651
+ it('reports every invalid item index before processing and honors guardian overrides', () => {
652
+ const result = IssueInboxCredentialBatchValidator.safeParse({
653
+ configuration: { guardianEmail: 'A@example.test' },
654
+ items: [
655
+ { recipient: item('b').recipient },
656
+ item(),
657
+ { ...item(), configuration: { guardianEmail: 'guardian@example.test' } },
658
+ { ...item('clear'), configuration: { guardianEmail: null } },
659
+ ],
660
+ });
661
+ expect(result.success).toBe(false);
662
+ if (!result.success)
663
+ expect(result.error.issues.map(issue => issue.path)).toEqual([
664
+ ['items', 0, 'credential'],
665
+ ['items', 1, 'configuration', 'guardianEmail'],
666
+ ]);
667
+ });
668
+ });