@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,372 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import {
3
+ SQSClient,
4
+ SendMessageBatchCommand,
5
+ ReceiveMessageCommand,
6
+ DeleteMessageCommand,
7
+ } from '@aws-sdk/client-sqs';
8
+ import { TRPCError } from '@trpc/server';
9
+ import type {
10
+ InboxBatchReceipt,
11
+ InboxBatchStatus,
12
+ IssueInboxCredentialBatch,
13
+ IssueInboxCredentialBatchItemResult,
14
+ } from '@learncard/types';
15
+ import { getInboxBatchRuntimeEnvironment } from '@environment';
16
+ import type { Context } from '@routes';
17
+ import type { ProfileType } from 'types/profile';
18
+ import type { BatchJobPayload } from 'types/inbox-batch';
19
+ import { getProfileByProfileId } from '@accesslayer/profile/read';
20
+ import {
21
+ batchReplayStore,
22
+ claimBatchItem,
23
+ createBatchJob,
24
+ finishBatchItem,
25
+ markBatchIssuanceStarted,
26
+ readBatchJob,
27
+ recoverInboxJobs,
28
+ takeInboxDispatches,
29
+ } from '@accesslayer/inbox-batch/store';
30
+ import { acknowledgeInboxDispatches, deadLetterBatchItem } from '@accesslayer/inbox-batch/store';
31
+ import { encryptInboxCredential, decryptInboxCredential } from './inbox-encryption.helpers';
32
+ import { fingerprint, issueInboxBatch } from './inbox-batch.helpers';
33
+ import { INBOX_BATCH_MAX_BYTES } from './inbox-batch-http.helpers';
34
+ import { getInboxBatchState } from './inbox-batch-status.helpers';
35
+
36
+ const queue = (): { client: SQSClient; url: string } => {
37
+ const env = getInboxBatchRuntimeEnvironment();
38
+ if (!env.INBOX_QUEUE_URL)
39
+ throw new TRPCError({
40
+ code: 'INTERNAL_SERVER_ERROR',
41
+ message: 'Inbox queue is not configured.',
42
+ });
43
+ return {
44
+ url: env.INBOX_QUEUE_URL,
45
+ client: new SQSClient({
46
+ region: env.AWS_REGION ?? 'us-east-1',
47
+ ...(env.INBOX_QUEUE_ENDPOINT
48
+ ? {
49
+ endpoint: env.INBOX_QUEUE_ENDPOINT,
50
+ credentials: { accessKeyId: 'local', secretAccessKey: 'local' },
51
+ }
52
+ : {}),
53
+ }),
54
+ };
55
+ };
56
+
57
+ /** Only durable admission happens here. The dispatcher owns publication, including retries. */
58
+ export const submitInboxBatch = async (
59
+ profile: ProfileType,
60
+ batch: IssueInboxCredentialBatch,
61
+ ctx: Context
62
+ ): Promise<InboxBatchReceipt> => {
63
+ if (Buffer.byteLength(JSON.stringify(batch), 'utf8') > INBOX_BATCH_MAX_BYTES)
64
+ throw new TRPCError({
65
+ code: 'PAYLOAD_TOO_LARGE',
66
+ message: 'Inbox batch exceeds the 4 MiB JSON payload limit',
67
+ });
68
+ if (!getInboxBatchRuntimeEnvironment().INBOX_QUEUE_URL)
69
+ throw new TRPCError({
70
+ code: 'INTERNAL_SERVER_ERROR',
71
+ message: 'Inbox queue is not configured.',
72
+ });
73
+ const seen = new Set<string>();
74
+ const items = await Promise.all(
75
+ batch.items.map(async item => {
76
+ const duplicate = item.idempotencyKey !== undefined && seen.has(item.idempotencyKey);
77
+ if (item.idempotencyKey !== undefined) seen.add(item.idempotencyKey);
78
+ return {
79
+ duplicate,
80
+ correlation: await encryptInboxCredential(
81
+ JSON.stringify({
82
+ idempotencyKey: item.idempotencyKey,
83
+ recipient: item.recipient,
84
+ })
85
+ ),
86
+ replayKey:
87
+ item.idempotencyKey === undefined
88
+ ? `internal:${randomUUID()}`
89
+ : `client:${fingerprint([profile.profileId, item.idempotencyKey])}`,
90
+ };
91
+ })
92
+ );
93
+ const configuredLimit = Number(getInboxBatchRuntimeEnvironment().INBOX_BATCH_ITEMS_PER_HOUR);
94
+ return createBatchJob({
95
+ issuer: profile.profileId,
96
+ requestId: batch.requestId,
97
+ requestHash: fingerprint({ batch, domain: ctx.domain, tenant: ctx.tenant?.id }),
98
+ payload: await encryptInboxCredential(
99
+ JSON.stringify({
100
+ batch,
101
+ context: { domain: ctx.domain, tenant: ctx.tenant },
102
+ scope: ctx.user?.scope,
103
+ } satisfies BatchJobPayload)
104
+ ),
105
+ items,
106
+ limit:
107
+ Number.isSafeInteger(configuredLimit) && configuredLimit > 0 ? configuredLimit : 10_000,
108
+ });
109
+ };
110
+
111
+ export const getInboxBatch = async (issuer: string, batchId: string): Promise<InboxBatchStatus> => {
112
+ const { job, items } = await readBatchJob(batchId, issuer);
113
+ const entries = await Promise.all(
114
+ items.map(async item => {
115
+ const state = item.state as InboxBatchStatus['items'][number]['state'];
116
+ let result: IssueInboxCredentialBatchItemResult | undefined;
117
+ if (item.result)
118
+ result = {
119
+ ...JSON.parse(await decryptInboxCredential(item.result)),
120
+ index: Number(item.index),
121
+ };
122
+ else if (state === 'NEEDS_RECONCILIATION' || state === 'COMPLETED')
123
+ result = {
124
+ success: false,
125
+ index: Number(item.index),
126
+ error: {
127
+ code:
128
+ state === 'NEEDS_RECONCILIATION' ? 'CONFLICT' : 'INTERNAL_SERVER_ERROR',
129
+ ...(state === 'NEEDS_RECONCILIATION'
130
+ ? { reason: 'UNCONFIRMED' as const }
131
+ : {}),
132
+ message:
133
+ state === 'NEEDS_RECONCILIATION'
134
+ ? 'Issuance outcome is unconfirmed. Keep this key and contact support for reconciliation.'
135
+ : 'Processing could not be completed after worker failures or queue delivery exhaustion.',
136
+ },
137
+ };
138
+ if (result && item.correlation) {
139
+ const correlation = JSON.parse(await decryptInboxCredential(item.correlation));
140
+ result = {
141
+ ...result,
142
+ recipient: correlation.recipient,
143
+ // Recovery may read a replay containing an internal key for an unkeyed item.
144
+ idempotencyKey: correlation.idempotencyKey,
145
+ };
146
+ }
147
+ return { index: Number(item.index), state, ...(result ? { result } : {}) };
148
+ })
149
+ );
150
+ const completed = entries.filter(
151
+ i => i.state === 'COMPLETED' || i.state === 'NEEDS_RECONCILIATION'
152
+ ).length;
153
+ const unconfirmed = entries.filter(i => i.state === 'NEEDS_RECONCILIATION').length;
154
+ const succeeded = entries.filter(i => i.result?.success).length;
155
+ return {
156
+ batchId,
157
+ createdAt: new Date(Number(job.createdAt)).toISOString(),
158
+ status: getInboxBatchState(entries.map(item => item.state)),
159
+ done: completed === entries.length,
160
+ items: entries,
161
+ summary: {
162
+ total: entries.length,
163
+ succeeded,
164
+ failed: entries.filter(i => i.state === 'COMPLETED' && i.result && !i.result.success)
165
+ .length,
166
+ deduplicated: entries.filter(i => i.result?.success && i.result.deduplicated).length,
167
+ completed,
168
+ pending: entries.length - completed,
169
+ unconfirmed,
170
+ },
171
+ };
172
+ };
173
+
174
+ /** SQS is at-least-once. Ownership and issuance checkpoints, not delivery count, control execution. */
175
+ export const processInboxQueueMessage = async (body: string): Promise<void> => {
176
+ const message: unknown = JSON.parse(body);
177
+ if (
178
+ !message ||
179
+ typeof message !== 'object' ||
180
+ !('itemId' in message) ||
181
+ typeof message.itemId !== 'string'
182
+ )
183
+ throw new Error('Invalid inbox queue message');
184
+ const owner = randomUUID();
185
+ const claimed = await claimBatchItem(message.itemId, owner);
186
+ if (!claimed) return;
187
+ const { job, item } = claimed;
188
+ const fail = (code: string, text: string): IssueInboxCredentialBatchItemResult => ({
189
+ success: false,
190
+ index: Number(item.index),
191
+ error: {
192
+ code,
193
+ message: text,
194
+ ...(item.duplicate ? { reason: 'DUPLICATE_KEY' as const } : {}),
195
+ },
196
+ });
197
+ if (item.duplicate) {
198
+ await finishBatchItem(
199
+ item.id,
200
+ owner,
201
+ fail(
202
+ 'CONFLICT',
203
+ 'Duplicate idempotency key in this batch. Only its first occurrence is attempted.'
204
+ )
205
+ );
206
+ return;
207
+ }
208
+ const payload = JSON.parse(await decryptInboxCredential(job.payload)) as BatchJobPayload;
209
+ const profile = await getProfileByProfileId(job.issuer);
210
+ if (!profile) {
211
+ await finishBatchItem(
212
+ item.id,
213
+ owner,
214
+ fail('NOT_FOUND', 'Issuer profile no longer exists.')
215
+ );
216
+ return;
217
+ }
218
+ const input = payload.batch.items[Number(item.index)]!;
219
+ const result = await issueInboxBatch(
220
+ profile,
221
+ {
222
+ configuration: payload.batch.configuration,
223
+ // Internal keys protect unkeyed items against SQS redelivery without deduplicating new submissions.
224
+ items: [{ ...input, idempotencyKey: input.idempotencyKey ?? item.id }],
225
+ },
226
+ {
227
+ ...payload.context,
228
+ user: { did: profile.did, isChallengeValid: true, scope: payload.scope },
229
+ },
230
+ {
231
+ cache: batchReplayStore(item.id, owner, item.replayKey),
232
+ beforeIssue: () => markBatchIssuanceStarted(item.id, owner),
233
+ }
234
+ );
235
+ await finishBatchItem(item.id, owner, {
236
+ ...result.results[0]!,
237
+ index: Number(item.index),
238
+ idempotencyKey: input.idempotencyKey,
239
+ });
240
+ console.info('Inbox item processed', {
241
+ batchId: job.id,
242
+ index: Number(item.index),
243
+ success: result.results[0]!.success,
244
+ queueAgeMs: Date.now() - Number(job.createdAt),
245
+ });
246
+ };
247
+
248
+ export const processInboxDeadLetter = async (body: string): Promise<void> => {
249
+ let message: unknown;
250
+ try {
251
+ message = JSON.parse(body);
252
+ } catch {
253
+ console.error('Discarding malformed inbox dead-letter JSON');
254
+ return;
255
+ }
256
+ if (
257
+ !message ||
258
+ typeof message !== 'object' ||
259
+ !('itemId' in message) ||
260
+ typeof message.itemId !== 'string'
261
+ ) {
262
+ console.error('Discarding malformed inbox dead-letter message');
263
+ return;
264
+ }
265
+ await deadLetterBatchItem(message.itemId);
266
+ console.error('Inbox dead-letter recorded', { itemId: message.itemId });
267
+ };
268
+
269
+ export const dispatchInboxJobs = async (
270
+ maintenanceDeadline = Date.now() + 20_000
271
+ ): Promise<void> => {
272
+ const { client, url } = queue();
273
+ try {
274
+ // Publish before maintenance: an unavailable recovery path must not stall new work.
275
+ // Recovered items become dispatchable here but wait for the next one-minute invocation,
276
+ // because this run has already claimed its publication set.
277
+ const leases = await takeInboxDispatches();
278
+ const failures: unknown[] = [];
279
+ for (let offset = 0; offset < leases.length; offset += 10) {
280
+ try {
281
+ const response = await client.send(
282
+ new SendMessageBatchCommand({
283
+ QueueUrl: url,
284
+ Entries: leases.slice(offset, offset + 10).map(({ id: itemId }, index) => ({
285
+ Id: String(index),
286
+ MessageBody: JSON.stringify({ itemId }),
287
+ })),
288
+ })
289
+ );
290
+ await acknowledgeInboxDispatches(
291
+ (response.Successful ?? []).map(entry => leases[offset + Number(entry.Id)]!)
292
+ );
293
+ if (response.Failed?.length) {
294
+ console.error('Inbox dispatch entries failed', {
295
+ count: response.Failed.length,
296
+ });
297
+ failures.push(
298
+ new Error('Inbox dispatch partially failed; durable outbox will retry')
299
+ );
300
+ }
301
+ } catch (error) {
302
+ console.error('Inbox dispatch chunk failed', { offset });
303
+ failures.push(error);
304
+ }
305
+ }
306
+ if (leases.length) console.info('Inbox dispatch attempted', { count: leases.length });
307
+ try {
308
+ await recoverInboxJobs(maintenanceDeadline);
309
+ } catch (error) {
310
+ console.error('Inbox maintenance failed; publication was attempted independently');
311
+ failures.push(error);
312
+ }
313
+ // Report failures after attempting every chunk; alarms require sustained failures.
314
+ if (failures.length) throw failures[0];
315
+ } finally {
316
+ client.destroy();
317
+ }
318
+ };
319
+
320
+ /** Local/E2E consumer uses the same SQS protocol and processing entry point as Lambda. */
321
+ export const consumeInboxQueueOnce = async (waitSeconds = 1): Promise<void> => {
322
+ const { client, url } = queue();
323
+ try {
324
+ const response = await client.send(
325
+ new ReceiveMessageCommand({
326
+ QueueUrl: url,
327
+ MaxNumberOfMessages: 10,
328
+ WaitTimeSeconds: waitSeconds,
329
+ VisibilityTimeout: 1800,
330
+ })
331
+ );
332
+ const results = await Promise.allSettled(
333
+ (response.Messages ?? []).map(async message => {
334
+ await processInboxQueueMessage(message.Body ?? '');
335
+ await client.send(
336
+ new DeleteMessageCommand({
337
+ QueueUrl: url,
338
+ ReceiptHandle: message.ReceiptHandle!,
339
+ })
340
+ );
341
+ })
342
+ );
343
+ // Wait for every owned item before destroying the SQS client or starting another wave.
344
+ const failed = results.find(result => result.status === 'rejected');
345
+ if (failed?.status === 'rejected') throw failed.reason;
346
+ } finally {
347
+ client.destroy();
348
+ }
349
+ };
350
+
351
+ export const consumeInboxDeadLettersOnce = async (): Promise<void> => {
352
+ const url = getInboxBatchRuntimeEnvironment().INBOX_DEAD_LETTER_QUEUE_URL;
353
+ if (!url) return;
354
+ const { client } = queue();
355
+ try {
356
+ const response = await client.send(
357
+ new ReceiveMessageCommand({
358
+ QueueUrl: url,
359
+ MaxNumberOfMessages: 10,
360
+ WaitTimeSeconds: 0,
361
+ })
362
+ );
363
+ for (const message of response.Messages ?? []) {
364
+ await processInboxDeadLetter(message.Body ?? '');
365
+ await client.send(
366
+ new DeleteMessageCommand({ QueueUrl: url, ReceiptHandle: message.ReceiptHandle! })
367
+ );
368
+ }
369
+ } finally {
370
+ client.destroy();
371
+ }
372
+ };