@learncard/network-brain-service 3.18.0 → 3.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (199) hide show
  1. package/.env.example +4 -0
  2. package/CHANGELOG.md +80 -0
  3. package/compose.inbox.yml +7 -0
  4. package/dist/accesslayer/auth-grant/read.d.ts +1 -0
  5. package/dist/accesslayer/auth-grant/read.d.ts.map +1 -1
  6. package/dist/accesslayer/credential-activity/create.d.ts.map +1 -1
  7. package/dist/accesslayer/credential-refresh/email-delivery.d.ts +18 -0
  8. package/dist/accesslayer/credential-refresh/email-delivery.d.ts.map +1 -0
  9. package/dist/accesslayer/credential-refresh/index.d.ts +1 -0
  10. package/dist/accesslayer/credential-refresh/index.d.ts.map +1 -1
  11. package/dist/accesslayer/credential-refresh/read.d.ts.map +1 -1
  12. package/dist/accesslayer/credential-refresh/update.d.ts.map +1 -1
  13. package/dist/accesslayer/inbox-batch/store.d.ts +33 -0
  14. package/dist/accesslayer/inbox-batch/store.d.ts.map +1 -0
  15. package/dist/accesslayer/inbox-credential/create.d.ts +5 -0
  16. package/dist/accesslayer/inbox-credential/create.d.ts.map +1 -1
  17. package/dist/accesslayer/inbox-credential/refresh.d.ts +71 -0
  18. package/dist/accesslayer/inbox-credential/refresh.d.ts.map +1 -0
  19. package/dist/accesslayer/inbox-credential/update.d.ts +1 -1
  20. package/dist/accesslayer/inbox-credential/update.d.ts.map +1 -1
  21. package/dist/cache/atomic-counter.test-helpers.d.ts +3 -0
  22. package/dist/cache/atomic-counter.test-helpers.d.ts.map +1 -0
  23. package/dist/cache/atomic-counter.test.d.ts +2 -0
  24. package/dist/cache/atomic-counter.test.d.ts.map +1 -0
  25. package/dist/cache/index.d.ts +1 -1
  26. package/dist/cache/index.d.ts.map +1 -1
  27. package/dist/config/environment.d.ts +46 -0
  28. package/dist/config/environment.d.ts.map +1 -1
  29. package/dist/credential-refresh.d.ts.map +1 -1
  30. package/dist/helpers/activity.helpers.d.ts +4 -0
  31. package/dist/helpers/activity.helpers.d.ts.map +1 -1
  32. package/dist/helpers/contact-method.helpers.d.ts.map +1 -1
  33. package/dist/helpers/contact-method.helpers.test.d.ts +2 -0
  34. package/dist/helpers/contact-method.helpers.test.d.ts.map +1 -0
  35. package/dist/helpers/credential-refresh-email-content.helpers.d.ts +44 -0
  36. package/dist/helpers/credential-refresh-email-content.helpers.d.ts.map +1 -0
  37. package/dist/helpers/credential-refresh-email-content.helpers.test.d.ts +2 -0
  38. package/dist/helpers/credential-refresh-email-content.helpers.test.d.ts.map +1 -0
  39. package/dist/helpers/credential-refresh-email.helpers.d.ts +17 -0
  40. package/dist/helpers/credential-refresh-email.helpers.d.ts.map +1 -0
  41. package/dist/helpers/credential-refresh-proof.helpers.d.ts +4 -0
  42. package/dist/helpers/credential-refresh-proof.helpers.d.ts.map +1 -0
  43. package/dist/helpers/credential-refresh-proof.helpers.test.d.ts +2 -0
  44. package/dist/helpers/credential-refresh-proof.helpers.test.d.ts.map +1 -0
  45. package/dist/helpers/credential-refresh.helpers.d.ts +45 -2
  46. package/dist/helpers/credential-refresh.helpers.d.ts.map +1 -1
  47. package/dist/helpers/credential.helpers.d.ts +2 -1
  48. package/dist/helpers/credential.helpers.d.ts.map +1 -1
  49. package/dist/helpers/finalize-inbox.helpers.d.ts.map +1 -1
  50. package/dist/helpers/inbox-batch-http.helpers.d.ts +15 -0
  51. package/dist/helpers/inbox-batch-http.helpers.d.ts.map +1 -0
  52. package/dist/helpers/inbox-batch-http.helpers.test.d.ts +2 -0
  53. package/dist/helpers/inbox-batch-http.helpers.test.d.ts.map +1 -0
  54. package/dist/helpers/inbox-batch-status.helpers.d.ts +3 -0
  55. package/dist/helpers/inbox-batch-status.helpers.d.ts.map +1 -0
  56. package/dist/helpers/inbox-batch.helpers.d.ts +10 -0
  57. package/dist/helpers/inbox-batch.helpers.d.ts.map +1 -0
  58. package/dist/helpers/inbox-batch.helpers.test.d.ts +2 -0
  59. package/dist/helpers/inbox-batch.helpers.test.d.ts.map +1 -0
  60. package/dist/helpers/inbox-issuance-error.helpers.d.ts +13 -0
  61. package/dist/helpers/inbox-issuance-error.helpers.d.ts.map +1 -0
  62. package/dist/helpers/inbox-queue.helpers.d.ts +11 -0
  63. package/dist/helpers/inbox-queue.helpers.d.ts.map +1 -0
  64. package/dist/helpers/inbox-queue.helpers.test.d.ts +2 -0
  65. package/dist/helpers/inbox-queue.helpers.test.d.ts.map +1 -0
  66. package/dist/helpers/inbox-refresh.helpers.d.ts +186 -0
  67. package/dist/helpers/inbox-refresh.helpers.d.ts.map +1 -0
  68. package/dist/helpers/inbox.helpers.d.ts +9 -1
  69. package/dist/helpers/inbox.helpers.d.ts.map +1 -1
  70. package/dist/helpers/notifications.helpers.d.ts.map +1 -1
  71. package/dist/helpers/rateLimit.helpers.d.ts.map +1 -1
  72. package/dist/helpers/refresh-send-intent.helpers.d.ts +51 -0
  73. package/dist/helpers/refresh-send-intent.helpers.d.ts.map +1 -0
  74. package/dist/helpers/shim.d.ts +1 -1
  75. package/dist/helpers/shim.d.ts.map +1 -1
  76. package/dist/inbox-queue-local.d.ts +2 -0
  77. package/dist/inbox-queue-local.d.ts.map +1 -0
  78. package/dist/index.js +2 -2
  79. package/dist/models/AuthGrant.d.ts.map +1 -1
  80. package/dist/models/CredentialActivity.d.ts +1 -0
  81. package/dist/models/CredentialActivity.d.ts.map +1 -1
  82. package/dist/models/CredentialRefresh.d.ts +5 -2
  83. package/dist/models/CredentialRefresh.d.ts.map +1 -1
  84. package/dist/models/InboxCredential.d.ts +1 -0
  85. package/dist/models/InboxCredential.d.ts.map +1 -1
  86. package/dist/models/credential-refresh-constraints.d.ts.map +1 -1
  87. package/dist/openapi.d.ts.map +1 -1
  88. package/dist/routes/act-as.test.d.ts +2 -0
  89. package/dist/routes/act-as.test.d.ts.map +1 -0
  90. package/dist/routes/activity.d.ts +3 -0
  91. package/dist/routes/activity.d.ts.map +1 -1
  92. package/dist/routes/auth-grants.d.ts +10 -0
  93. package/dist/routes/auth-grants.d.ts.map +1 -1
  94. package/dist/routes/boosts.d.ts +494 -0
  95. package/dist/routes/boosts.d.ts.map +1 -1
  96. package/dist/routes/credential-refreshes.d.ts.map +1 -1
  97. package/dist/routes/credentials.d.ts.map +1 -1
  98. package/dist/routes/inbox.d.ts +953 -11
  99. package/dist/routes/inbox.d.ts.map +1 -1
  100. package/dist/routes/index.d.ts +42 -126
  101. package/dist/routes/index.d.ts.map +1 -1
  102. package/dist/routes/workflows.d.ts.map +1 -1
  103. package/dist/services/delivery/adapters/postmark.adapter.d.ts.map +1 -1
  104. package/dist/services/delivery/adapters/postmark.adapter.test.d.ts +2 -0
  105. package/dist/services/delivery/adapters/postmark.adapter.test.d.ts.map +1 -0
  106. package/dist/services/registry/registry.factory.d.ts.map +1 -1
  107. package/dist/types/activity.d.ts +4 -0
  108. package/dist/types/activity.d.ts.map +1 -1
  109. package/dist/types/credential-refresh.d.ts +10 -1
  110. package/dist/types/credential-refresh.d.ts.map +1 -1
  111. package/dist/types/inbox-batch.d.ts +38 -0
  112. package/dist/types/inbox-batch.d.ts.map +1 -0
  113. package/inbox-e2e-setup.ts +53 -1
  114. package/inbox-elasticmq.conf +10 -0
  115. package/lambda.ts +61 -16
  116. package/package.json +18 -16
  117. package/scripts/setup-inbox-queues.ts +60 -0
  118. package/serverless-local.yml +2 -0
  119. package/serverless.yml +75 -0
  120. package/src/accesslayer/auth-grant/read.ts +2 -1
  121. package/src/accesslayer/credential-activity/create.ts +1 -0
  122. package/src/accesslayer/credential-refresh/email-delivery.ts +116 -0
  123. package/src/accesslayer/credential-refresh/index.ts +1 -0
  124. package/src/accesslayer/credential-refresh/read.ts +8 -2
  125. package/src/accesslayer/credential-refresh/update.ts +4 -0
  126. package/src/accesslayer/inbox-batch/store.ts +469 -0
  127. package/src/accesslayer/inbox-credential/create.ts +51 -19
  128. package/src/accesslayer/inbox-credential/refresh.ts +221 -0
  129. package/src/accesslayer/inbox-credential/update.ts +1 -1
  130. package/src/cache/atomic-counter.test-helpers.ts +27 -0
  131. package/src/cache/atomic-counter.test.ts +6 -0
  132. package/src/cache/index.ts +25 -13
  133. package/src/config/environment.ts +26 -0
  134. package/src/credential-refresh.ts +5 -1
  135. package/src/docker-entry.ts +8 -0
  136. package/src/helpers/activity.helpers.ts +4 -0
  137. package/src/helpers/app-counter.helpers.ts +1 -1
  138. package/src/helpers/contact-method.helpers.test.ts +43 -0
  139. package/src/helpers/contact-method.helpers.ts +3 -4
  140. package/src/helpers/credential-refresh-email-content.helpers.test.ts +222 -0
  141. package/src/helpers/credential-refresh-email-content.helpers.ts +155 -0
  142. package/src/helpers/credential-refresh-email.helpers.ts +201 -0
  143. package/src/helpers/credential-refresh-proof.helpers.test.ts +103 -0
  144. package/src/helpers/credential-refresh-proof.helpers.ts +43 -0
  145. package/src/helpers/credential-refresh.helpers.ts +375 -99
  146. package/src/helpers/credential.helpers.ts +4 -1
  147. package/src/helpers/finalize-inbox.helpers.ts +29 -14
  148. package/src/helpers/inbox-batch-http.helpers.test.ts +188 -0
  149. package/src/helpers/inbox-batch-http.helpers.ts +150 -0
  150. package/src/helpers/inbox-batch-status.helpers.ts +11 -0
  151. package/src/helpers/inbox-batch.helpers.test.ts +668 -0
  152. package/src/helpers/inbox-batch.helpers.ts +374 -0
  153. package/src/helpers/inbox-issuance-error.helpers.ts +25 -0
  154. package/src/helpers/inbox-queue.helpers.test.ts +253 -0
  155. package/src/helpers/inbox-queue.helpers.ts +372 -0
  156. package/src/helpers/inbox-refresh.helpers.ts +432 -0
  157. package/src/helpers/inbox.helpers.ts +268 -32
  158. package/src/helpers/notifications.helpers.ts +4 -2
  159. package/src/helpers/rateLimit.helpers.ts +2 -5
  160. package/src/helpers/refresh-send-intent.helpers.ts +273 -0
  161. package/src/helpers/shim.ts +32 -31
  162. package/src/inbox-queue-local.ts +28 -0
  163. package/src/models/AuthGrant.ts +1 -0
  164. package/src/models/CredentialActivity.ts +3 -5
  165. package/src/models/CredentialRefresh.ts +4 -2
  166. package/src/models/InboxCredential.ts +2 -0
  167. package/src/models/credential-refresh-constraints.test.ts +8 -3
  168. package/src/models/credential-refresh-constraints.ts +5 -0
  169. package/src/openapi.ts +8 -0
  170. package/src/routes/act-as.test.ts +239 -0
  171. package/src/routes/app-store.ts +2 -2
  172. package/src/routes/auth-grants.ts +13 -11
  173. package/src/routes/boosts.ts +914 -92
  174. package/src/routes/credential-refreshes.test.ts +8 -2
  175. package/src/routes/credential-refreshes.ts +4 -1
  176. package/src/routes/credentials.ts +2 -1
  177. package/src/routes/inbox.ts +95 -49
  178. package/src/routes/index.ts +82 -7
  179. package/src/routes/skills.ts +1 -1
  180. package/src/routes/workflows.ts +43 -24
  181. package/src/services/delivery/adapters/postmark.adapter.test.ts +54 -0
  182. package/src/services/delivery/adapters/postmark.adapter.ts +12 -0
  183. package/src/services/registry/registry.factory.ts +2 -1
  184. package/src/types/activity.ts +2 -0
  185. package/src/types/credential-refresh.ts +21 -2
  186. package/src/types/inbox-batch.ts +46 -0
  187. package/test/atomic-counter.e2e.ts +12 -0
  188. package/test/auth-grant.spec.ts +21 -21
  189. package/test/credential-refresh-email.spec.ts +663 -0
  190. package/test/credential-refresh-publication.spec.ts +2 -0
  191. package/test/helpers/inbox-e2e-environment.ts +6 -0
  192. package/test/inbox-batch.spec.ts +1338 -0
  193. package/test/inbox-refresh.spec.ts +534 -0
  194. package/test/openapi.spec.ts +41 -1
  195. package/test/refresh-send.spec.ts +1799 -0
  196. package/test/registry.spec.ts +56 -11
  197. package/vitest.config.ts +9 -0
  198. package/vitest.inbox-e2e.config.ts +5 -1
  199. package/vitest.integration.config.ts +2 -0
@@ -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,28 @@
1
+ import {
2
+ consumeInboxQueueOnce,
3
+ consumeInboxDeadLettersOnce,
4
+ dispatchInboxJobs,
5
+ } from './helpers/inbox-queue.helpers';
6
+
7
+ let running = true;
8
+ process.on('SIGINT', () => {
9
+ running = false;
10
+ });
11
+ process.on('SIGTERM', () => {
12
+ running = false;
13
+ });
14
+
15
+ /** Run separately from the HTTP process; restarting either process leaves durable jobs intact. */
16
+ const run = async (): Promise<void> => {
17
+ while (running) {
18
+ try {
19
+ await dispatchInboxJobs();
20
+ await consumeInboxQueueOnce(10);
21
+ await consumeInboxDeadLettersOnce();
22
+ } catch {
23
+ console.error('Inbox queue iteration failed; durable jobs will be retried.');
24
+ await new Promise(resolve => setTimeout(resolve, 1000));
25
+ }
26
+ }
27
+ };
28
+ void run();
@@ -21,6 +21,7 @@ export const AuthGrant = ModelFactory<FlatAuthGrantType, AuthGrantRelationships>
21
21
  challenge: { type: 'string', required: true, uniqueItems: true },
22
22
  status: { type: 'string', required: true },
23
23
  scope: { type: 'string', required: true },
24
+ actAs: { type: 'string', required: false },
24
25
  createdAt: { type: 'string', required: true },
25
26
  expiresAt: { type: 'string', required: false },
26
27
  },
@@ -7,11 +7,7 @@ import { Boost, BoostInstance } from './Boost';
7
7
  import { AppStoreListing, AppStoreListingInstance } from './AppStoreListing';
8
8
 
9
9
  export type CredentialActivityEventType =
10
- | 'CREATED'
11
- | 'DELIVERED'
12
- | 'CLAIMED'
13
- | 'EXPIRED'
14
- | 'FAILED';
10
+ 'CREATED' | 'DELIVERED' | 'CLAIMED' | 'EXPIRED' | 'FAILED';
15
11
 
16
12
  export type CredentialActivityRecipientType = 'profile' | 'email' | 'phone';
17
13
 
@@ -32,6 +28,7 @@ export type CredentialActivityType = {
32
28
  eventType: CredentialActivityEventType;
33
29
  timestamp: string;
34
30
  actorProfileId?: string;
31
+ onBehalfOf?: string;
35
32
  recipientType: CredentialActivityRecipientType;
36
33
  recipientIdentifier: string;
37
34
  boostUri?: string;
@@ -70,6 +67,7 @@ export const CredentialActivity = ModelFactory<
70
67
  },
71
68
  timestamp: { type: 'string', required: true },
72
69
  actorProfileId: { type: 'string', required: false },
70
+ onBehalfOf: { type: 'string', required: false },
73
71
  recipientType: {
74
72
  type: 'string',
75
73
  required: true,
@@ -36,16 +36,18 @@ export const CredentialRefresh = ModelFactory<
36
36
  {
37
37
  label: 'CredentialRefresh',
38
38
  schema: {
39
+ inboxCredentialId: { type: 'string', required: false },
40
+ inboxCredentialStatus: { type: 'string', required: false },
39
41
  refreshId: { type: 'string', required: true },
40
42
  issuerProfileId: { type: 'string', required: true },
41
43
  issuerDid: { type: 'string', required: true },
42
44
  holderProfileId: { type: 'string', required: false },
43
- holderDid: { type: 'string', required: true },
45
+ holderDid: { type: 'string', required: false },
44
46
  credentialId: { type: 'string', required: true },
45
47
  state: {
46
48
  type: 'string',
47
49
  required: true,
48
- enum: ['awaiting_claim', 'active', 'revoked'],
50
+ enum: ['pending_holder', 'awaiting_claim', 'active', 'revoked'],
49
51
  },
50
52
  currentVersion: { type: 'number', required: true },
51
53
  etag: { type: 'string', required: false },
@@ -6,6 +6,7 @@ import { Profile, ProfileInstance } from './Profile';
6
6
  import ContactMethod, { ContactMethodInstance } from './ContactMethod';
7
7
 
8
8
  export type InboxCredentialType = {
9
+ refreshId?: string;
9
10
  id: string;
10
11
  credential?: string; // Versioned JWE; removed immediately after successful finalization
11
12
  isSigned: boolean;
@@ -86,6 +87,7 @@ export const InboxCredential = ModelFactory<InboxCredentialType, InboxCredential
86
87
  {
87
88
  label: 'InboxCredential',
88
89
  schema: {
90
+ refreshId: { type: 'string', required: false },
89
91
  id: { type: 'string', required: true, uniqueItems: true },
90
92
  credential: { type: 'string', required: false },
91
93
  isSigned: { type: 'boolean', required: true },
@@ -38,11 +38,16 @@ describe('credential refresh constraint readiness', () => {
38
38
  releaseFirstQuery();
39
39
  await Promise.all([first, second]);
40
40
 
41
- expect(mocks.run).toHaveBeenCalledTimes(3);
41
+ expect(mocks.run).toHaveBeenCalledTimes(8);
42
42
  expect(mocks.run.mock.calls.map(([query]) => query)).toEqual([
43
+ 'CREATE CONSTRAINT inbox_refresh_issue_key_unique IF NOT EXISTS FOR (i:InboxCredential) REQUIRE (i.refreshIssueKey) IS UNIQUE',
44
+ 'CREATE CONSTRAINT inbox_refresh_publication_key_unique IF NOT EXISTS FOR (p:InboxRefreshPublication) REQUIRE (p.refreshIdempotencyKey) IS UNIQUE',
45
+ 'CREATE CONSTRAINT inbox_refresh_version_key_unique IF NOT EXISTS FOR (p:InboxRefreshPublication) REQUIRE (p.refreshVersionKey) IS UNIQUE',
43
46
  'CREATE CONSTRAINT credential_refresh_id_unique IF NOT EXISTS FOR (r:CredentialRefresh) REQUIRE (r.refreshId) IS UNIQUE',
44
47
  'CREATE CONSTRAINT credential_refresh_version_key_unique IF NOT EXISTS FOR (c:Credential) REQUIRE (c.refreshVersionKey) IS UNIQUE',
45
48
  'CREATE CONSTRAINT credential_refresh_idempotency_key_unique IF NOT EXISTS FOR (c:Credential) REQUIRE (c.refreshIdempotencyKey) IS UNIQUE',
49
+ 'CREATE CONSTRAINT credential_refresh_email_delivery_key_unique IF NOT EXISTS FOR (d:CredentialRefreshEmailDelivery) REQUIRE (d.deliveryKey) IS UNIQUE',
50
+ 'CREATE CONSTRAINT refresh_send_intent_key_unique IF NOT EXISTS FOR (i:RefreshSendIntent) REQUIRE (i.intentKey) IS UNIQUE',
46
51
  ]);
47
52
  });
48
53
 
@@ -57,7 +62,7 @@ describe('credential refresh constraint readiness', () => {
57
62
  await import('./credential-refresh-constraints');
58
63
 
59
64
  await expect(ensureCredentialRefreshConstraints()).resolves.toBeUndefined();
60
- expect(mocks.run).toHaveBeenCalledTimes(3);
65
+ expect(mocks.run).toHaveBeenCalledTimes(8);
61
66
  });
62
67
 
63
68
  it('clears failed readiness so a later request can retry', async () => {
@@ -70,6 +75,6 @@ describe('credential refresh constraint readiness', () => {
70
75
  await expect(ensureCredentialRefreshConstraints()).rejects.toBe(setupFailure);
71
76
  await expect(ensureCredentialRefreshConstraints()).resolves.toBeUndefined();
72
77
 
73
- expect(mocks.run).toHaveBeenCalledTimes(4);
78
+ expect(mocks.run).toHaveBeenCalledTimes(9);
74
79
  });
75
80
  });
@@ -1,9 +1,14 @@
1
1
  import { neogma } from '@instance';
2
2
 
3
3
  const CREDENTIAL_REFRESH_CONSTRAINT_QUERIES = [
4
+ 'CREATE CONSTRAINT inbox_refresh_issue_key_unique IF NOT EXISTS FOR (i:InboxCredential) REQUIRE (i.refreshIssueKey) IS UNIQUE',
5
+ 'CREATE CONSTRAINT inbox_refresh_publication_key_unique IF NOT EXISTS FOR (p:InboxRefreshPublication) REQUIRE (p.refreshIdempotencyKey) IS UNIQUE',
6
+ 'CREATE CONSTRAINT inbox_refresh_version_key_unique IF NOT EXISTS FOR (p:InboxRefreshPublication) REQUIRE (p.refreshVersionKey) IS UNIQUE',
4
7
  'CREATE CONSTRAINT credential_refresh_id_unique IF NOT EXISTS FOR (r:CredentialRefresh) REQUIRE (r.refreshId) IS UNIQUE',
5
8
  'CREATE CONSTRAINT credential_refresh_version_key_unique IF NOT EXISTS FOR (c:Credential) REQUIRE (c.refreshVersionKey) IS UNIQUE',
6
9
  'CREATE CONSTRAINT credential_refresh_idempotency_key_unique IF NOT EXISTS FOR (c:Credential) REQUIRE (c.refreshIdempotencyKey) IS UNIQUE',
10
+ 'CREATE CONSTRAINT credential_refresh_email_delivery_key_unique IF NOT EXISTS FOR (d:CredentialRefreshEmailDelivery) REQUIRE (d.deliveryKey) IS UNIQUE',
11
+ 'CREATE CONSTRAINT refresh_send_intent_key_unique IF NOT EXISTS FOR (i:RefreshSendIntent) REQUIRE (i.intentKey) IS UNIQUE',
7
12
  ];
8
13
 
9
14
  type Neo4jSchemaError = {
package/src/openapi.ts CHANGED
@@ -25,6 +25,14 @@ export const openApiDocument = generateOpenApiDocument(appRouter, {
25
25
  ],
26
26
  });
27
27
 
28
+ // Keep this path/status aligned with inboxBatchResponseMeta in helpers/inbox-batch-http.helpers.ts.
29
+ // The adapter's responseMeta returns 202 for durable batch acceptance.
30
+ const batchResponses = openApiDocument.paths?.['/inbox/issue-batch']?.post?.responses;
31
+ if (batchResponses?.['200']) {
32
+ batchResponses['202'] = batchResponses['200'];
33
+ delete batchResponses['200'];
34
+ }
35
+
28
36
  // trpc-to-openapi requires a top-level ZodObject and cannot render the validator's
29
37
  // cross-field refinement. Preserve the runtime-safe object parser while documenting
30
38
  // the two mutually exclusive publication shapes for generated OpenAPI clients.