@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,221 @@
1
+ import { CredentialRefreshRecordValidator } from 'types/credential-refresh';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { TRPCError } from '@trpc/server';
4
+ import { neogma } from '@instance';
5
+ import type { InboxCredentialType, PublishCredentialRefreshResult } from '@learncard/types';
6
+ import { inflateObject } from '@helpers/objects.helpers';
7
+
8
+ export const getInboxRefreshIssue = async (
9
+ issueKey: string
10
+ ): Promise<{
11
+ inbox: InboxCredentialType;
12
+ requestDigest: string;
13
+ claimUrl?: string;
14
+ } | null> => {
15
+ const result = await neogma.queryRunner.run(
16
+ 'MATCH (ic:InboxCredential {refreshIssueKey: $issueKey}) RETURN ic LIMIT 1',
17
+ { issueKey }
18
+ );
19
+ const props = result.records[0]?.get('ic')?.properties;
20
+ return props
21
+ ? {
22
+ inbox: inflateObject(props),
23
+ requestDigest: props.refreshRequestDigest,
24
+ claimUrl: props.refreshClaimUrl,
25
+ }
26
+ : null;
27
+ };
28
+
29
+ /** Creates the inbox row and unbound aggregate together; a retry cannot allocate a second root. */
30
+ export const createPendingInboxRefresh = async (params: {
31
+ inbox: Record<string, unknown>;
32
+ aggregate: Record<string, unknown>;
33
+ issueKey: string;
34
+ requestDigest: string;
35
+ }): Promise<InboxCredentialType> => {
36
+ const result = await neogma.queryRunner.run(
37
+ `MATCH (issuer:Profile {profileId: $issuerProfileId})
38
+ MERGE (ic:InboxCredential {refreshIssueKey: $issueKey})
39
+ ON CREATE SET ic += $inbox, ic.refreshRequestDigest = $requestDigest
40
+ WITH issuer, ic WHERE ic.refreshRequestDigest = $requestDigest
41
+ MERGE (refresh:CredentialRefresh {refreshId: ic.refreshId})
42
+ ON CREATE SET refresh += $aggregate
43
+ MERGE (issuer)-[:ISSUED_REFRESH]->(refresh)
44
+ MERGE (refresh)-[:INBOX]->(ic)
45
+ MERGE (p:InboxRefreshPublication {refreshVersionKey: ic.refreshId + ':1'})
46
+ ON CREATE SET p.id = ic.refreshId, p.refreshId = ic.refreshId, p.version = 1,
47
+ p.publishedAt = ic.createdAt, p.signingMode = 'signing-authority'
48
+ RETURN ic`,
49
+ { ...params, issuerProfileId: params.aggregate.issuerProfileId }
50
+ );
51
+ const props = result.records[0]?.get('ic')?.properties;
52
+ if (!props)
53
+ throw new TRPCError({
54
+ code: 'CONFLICT',
55
+ message: 'This idempotencyKey was used for a different inbox issuance.',
56
+ });
57
+ return inflateObject<InboxCredentialType>(props);
58
+ };
59
+
60
+ /** Metadata only. Pending publication bodies live exclusively in the expiring inbox escrow. */
61
+ export const getPendingInboxPublication = async (
62
+ refreshId: string,
63
+ idempotencyKey?: string
64
+ ): Promise<PublishCredentialRefreshResult | null> => {
65
+ if (!idempotencyKey) return null;
66
+ const result = await neogma.queryRunner.run(
67
+ `MATCH (p:InboxRefreshPublication {refreshIdempotencyKey: $key}) RETURN p LIMIT 1`,
68
+ { key: `${refreshId}:${idempotencyKey}` }
69
+ );
70
+ const props = result.records[0]?.get('p')?.properties;
71
+ return props
72
+ ? {
73
+ refreshId,
74
+ version: Number(props.version),
75
+ publishedAt: props.publishedAt,
76
+ notification: 'not-applicable',
77
+ }
78
+ : null;
79
+ };
80
+
81
+ /** Publication and claim both lock the inbox row, then compare the version they prepared. */
82
+ export const advancePendingInboxRefresh = async (params: {
83
+ refreshId: string;
84
+ expectedVersion: number;
85
+ credential: string;
86
+ credentialName?: string;
87
+ materialDigest: string;
88
+ idempotencyKey?: string;
89
+ updateSummary?: string;
90
+ }): Promise<PublishCredentialRefreshResult | null> => {
91
+ const { refreshId, expectedVersion, idempotencyKey } = params;
92
+ const replay = await getPendingInboxPublication(refreshId, idempotencyKey);
93
+ if (replay) return replay;
94
+ const version = expectedVersion + 1;
95
+ const publishedAt = new Date().toISOString();
96
+ const result = await neogma.queryRunner.run(
97
+ `MATCH (refresh:CredentialRefresh {refreshId: $refreshId})-[:INBOX]->(ic:InboxCredential)
98
+ SET ic._escrowLock = true REMOVE ic._escrowLock
99
+ WITH refresh, ic
100
+ WHERE refresh.state = 'pending_holder' AND refresh.currentVersion = $expectedVersion
101
+ AND ic.currentStatus = 'PENDING' AND datetime(ic.expiresAt) > datetime()
102
+ AND ($key IS NULL OR NOT EXISTS { MATCH (:InboxRefreshPublication {refreshIdempotencyKey: $key}) })
103
+ CREATE (p:InboxRefreshPublication $publication)
104
+ SET ic.credential = $credential, ic.credentialName = $credentialName,
105
+ refresh.currentVersion = $version, refresh.materialDigest = $materialDigest,
106
+ refresh.credentialDisplayName = coalesce($credentialName, refresh.credentialDisplayName),
107
+ refresh.lastPublishedAt = $publishedAt, refresh.updatedAt = $publishedAt,
108
+ refresh.updateSummary = $updateSummary
109
+ RETURN p`,
110
+ {
111
+ ...params,
112
+ version,
113
+ publishedAt,
114
+ credentialName: params.credentialName ?? null,
115
+ updateSummary: params.updateSummary ?? null,
116
+ key: idempotencyKey ? `${refreshId}:${idempotencyKey}` : null,
117
+ publication: Object.fromEntries(
118
+ Object.entries({
119
+ id: randomUUID(),
120
+ refreshId,
121
+ version,
122
+ refreshVersionKey: `${refreshId}:${version}`,
123
+ publishedAt,
124
+ signingMode: 'signing-authority',
125
+ updateSummary: params.updateSummary,
126
+ refreshIdempotencyKey: idempotencyKey
127
+ ? `${refreshId}:${idempotencyKey}`
128
+ : undefined,
129
+ }).filter(([, value]) => value !== undefined)
130
+ ),
131
+ }
132
+ );
133
+ if (!result.records.length) return getPendingInboxPublication(refreshId, idempotencyKey);
134
+ return { refreshId, version, publishedAt, notification: 'not-applicable' };
135
+ };
136
+
137
+ /** Single commit: holder binding, canonical delivery, current version, and escrow erasure. */
138
+ export const bindPendingInboxRefresh = async (params: {
139
+ inboxId: string;
140
+ refreshId: string;
141
+ expectedVersion: number;
142
+ holderDid: string;
143
+ holderProfileId?: string;
144
+ issuerProfileId: string;
145
+ root: Record<string, unknown>;
146
+ aggregate: Record<string, unknown>;
147
+ deliveryCredential: string;
148
+ deliveryExpiresAt: string;
149
+ accepted: boolean;
150
+ boostId?: string;
151
+ }): Promise<boolean> => {
152
+ const now = new Date().toISOString();
153
+ const result = await neogma.queryRunner.run(
154
+ `MATCH (refresh:CredentialRefresh {refreshId: $refreshId})-[:INBOX]->(ic:InboxCredential {id: $inboxId})
155
+ SET ic._escrowLock = true REMOVE ic._escrowLock
156
+ WITH refresh, ic
157
+ WHERE refresh.state = 'pending_holder' AND refresh.currentVersion = $expectedVersion
158
+ AND ic.currentStatus = 'PENDING' AND datetime(ic.expiresAt) > datetime()
159
+ AND (ic.guardianStatus IS NULL OR ic.guardianStatus = 'GUARDIAN_APPROVED')
160
+ MATCH (issuer:Profile {profileId: $issuerProfileId})
161
+ OPTIONAL MATCH (holder:Profile {profileId: $holderProfileId})
162
+ OPTIONAL MATCH (boost:Boost {id: $boostId})
163
+ CREATE (root:Credential $root)
164
+ CREATE (refresh)-[:ROOT]->(root)
165
+ CREATE (refresh)-[:HEAD]->(root)
166
+ CREATE (issuer)-[:CREDENTIAL_SENT {to: $deliveryTarget, date: $now, activityId: ic.activityId, integrationId: ic.integrationId}]->(root)
167
+ FOREACH (_ IN CASE WHEN holder IS NULL THEN [] ELSE [1] END |
168
+ CREATE (holder)-[:HELD_REFRESH]->(refresh))
169
+ FOREACH (_ IN CASE WHEN holder IS NOT NULL AND $accepted THEN [1] ELSE [] END |
170
+ CREATE (root)-[:CREDENTIAL_RECEIVED {date: $now}]->(holder))
171
+ FOREACH (_ IN CASE WHEN boost IS NULL THEN [] ELSE [1] END |
172
+ CREATE (root)-[:INSTANCE_OF]->(boost))
173
+ SET refresh += $aggregate,
174
+ refresh.holderDid = $holderDid, refresh.holderProfileId = $holderProfileId,
175
+ refresh.state = CASE WHEN $accepted THEN 'active' ELSE 'awaiting_claim' END,
176
+ refresh.updatedAt = $now,
177
+ ic.currentStatus = 'ISSUED', ic.isAccepted = $accepted, ic.finalizedAt = $now,
178
+ ic.credential = null, ic.credentialName = null, ic.achievementType = null,
179
+ ic.deliveryCredential = $deliveryCredential, ic.deliveryRecipientDid = $holderDid,
180
+ ic.deliveryExpiresAt = $deliveryExpiresAt
181
+ RETURN root.id AS id`,
182
+ {
183
+ ...params,
184
+ now,
185
+ holderProfileId: params.holderProfileId ?? null,
186
+ boostId: params.boostId ?? null,
187
+ deliveryTarget: params.holderProfileId ?? params.holderDid,
188
+ }
189
+ );
190
+ return result.records.length > 0;
191
+ };
192
+
193
+ /** A read snapshot under the same inbox lock used by publish/claim, preventing mixed revisions. */
194
+ export const getInboxRefreshSnapshot = async (inboxId: string) => {
195
+ const result = await neogma.queryRunner.run(
196
+ `MATCH (refresh:CredentialRefresh)-[:INBOX]->(ic:InboxCredential {id: $inboxId})
197
+ SET ic._escrowLock = true REMOVE ic._escrowLock
198
+ RETURN ic, refresh LIMIT 1`,
199
+ { inboxId }
200
+ );
201
+ const row = result.records[0];
202
+ if (!row) return null;
203
+ const props = row.get('refresh').properties;
204
+ return {
205
+ inbox: inflateObject<InboxCredentialType>(row.get('ic').properties),
206
+ aggregate: CredentialRefreshRecordValidator.parse({
207
+ ...props,
208
+ currentVersion: Number(props.currentVersion),
209
+ }),
210
+ };
211
+ };
212
+
213
+ export const recordInboxRefreshClaimUrl = async (id: string, claimUrl: string): Promise<string> => {
214
+ const result = await neogma.queryRunner.run(
215
+ `MATCH (ic:InboxCredential {id: $id})
216
+ SET ic.refreshClaimUrl = coalesce(ic.refreshClaimUrl, $claimUrl)
217
+ RETURN ic.refreshClaimUrl AS claimUrl`,
218
+ { id, claimUrl }
219
+ );
220
+ return result.records[0]!.get('claimUrl');
221
+ };
@@ -10,7 +10,7 @@ import {
10
10
 
11
11
  export const updateInboxCredential = async (
12
12
  id: string,
13
- updates: Partial<Omit<InboxCredentialType, 'id' | 'createdAt' | 'credential'>>
13
+ updates: Partial<Omit<InboxCredentialType, 'id' | 'createdAt' | 'credential' | 'refresh'>>
14
14
  ): Promise<InboxCredentialType | null> => {
15
15
  const result = await new QueryBuilder(new BindParam({ id, updates: flattenObject(updates) }))
16
16
  .match({ model: InboxCredential, identifier: 'inboxCredential' })
@@ -0,0 +1,27 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import type { Cache } from './index';
3
+
4
+ /** Run the same contract against memory in unit tests and Redis 7 in the CI container suite. */
5
+ export const testAtomicCacheCounter = (cache: Cache): void => {
6
+ describe('atomic cache counter', () => {
7
+ it('increments atomically and repairs missing expiry', async () => {
8
+ const key = `atomic-increment-${crypto.randomUUID()}`;
9
+ try {
10
+ const counts = await Promise.all(
11
+ Array.from({ length: 20 }, () => cache.incr(key, 3600, 5))
12
+ );
13
+ expect(counts.slice().sort((a, b) => a! - b!)).toEqual(
14
+ Array.from({ length: 20 }, (_, i) => (i + 1) * 5)
15
+ );
16
+ expect(await cache.ttl(key)).toBeGreaterThan(3590);
17
+ // ttl=false deliberately writes without expiry; keepTtl would preserve it.
18
+ await cache.set(key, 200, false);
19
+ expect(await cache.ttl(key)).toBe(-1);
20
+ expect(await cache.incr(key, 3600, 2)).toBe(202);
21
+ expect(await cache.ttl(key)).toBeGreaterThan(3590);
22
+ } finally {
23
+ await cache.delete([key]);
24
+ }
25
+ });
26
+ });
27
+ };
@@ -0,0 +1,6 @@
1
+ import { vi } from 'vitest';
2
+ vi.mock('@environment', () => ({ environment: {} }));
3
+ import cache from './index';
4
+ import { testAtomicCacheCounter } from './atomic-counter.test-helpers';
5
+
6
+ testAtomicCacheCounter(cache);
@@ -85,11 +85,11 @@ export type Cache = {
85
85
  lrange: (key: RedisKey, start: number, stop: number) => Promise<string[] | undefined>;
86
86
 
87
87
  /**
88
- * Atomically increments a key by 1 and returns the new value.
89
- * If the key does not exist it is created with value 1.
90
- * When ttl is provided and this is the first increment (result === 1), EXPIRE is set.
88
+ * Atomically increments a key by amount (default 1) and returns the new value.
89
+ * If the key does not exist it is created with value amount.
90
+ * When ttl is provided, increment and missing-expiry repair happen atomically.
91
91
  */
92
- incr: (key: RedisKey, ttl?: number) => Promise<number | undefined>;
92
+ incr: (key: RedisKey, ttl?: number, amount?: number) => Promise<number | undefined>;
93
93
  };
94
94
 
95
95
  /** Evict all keys after one hour by default */
@@ -239,19 +239,31 @@ export const getCache = (): Cache => {
239
239
 
240
240
  return undefined;
241
241
  },
242
- incr: async (key, ttl) => {
242
+ incr: async (key, ttl, amount = 1) => {
243
243
  try {
244
244
  const redis = cache?.redis ?? cache?.node;
245
245
  if (!redis) return undefined;
246
246
 
247
- const newVal = await redis.incr(key);
248
-
249
- // Set TTL on first increment so the window auto-expires
250
- if (newVal === 1 && ttl) {
251
- await redis.expire(key, ttl);
252
- }
253
-
254
- return newVal;
247
+ if (!Number.isSafeInteger(amount) || amount <= 0) return undefined;
248
+ // Increment and expiry must be atomic: a crash between commands must not
249
+ // leave a permanent quota. Also repair counters created without an expiry.
250
+ if (ttl)
251
+ return Number(
252
+ await redis.eval(
253
+ `
254
+ local count = redis.call('INCRBY', KEYS[1], ARGV[1])
255
+ if redis.call('TTL', KEYS[1]) < 0 then
256
+ redis.call('EXPIRE', KEYS[1], ARGV[2])
257
+ end
258
+ return count
259
+ `,
260
+ 1,
261
+ key,
262
+ amount,
263
+ ttl
264
+ )
265
+ );
266
+ return await redis.incrby(key, amount);
255
267
  } catch (e) {
256
268
  console.error('Cache incr error', e);
257
269
  }
@@ -36,6 +36,9 @@ export const brainServiceEnvironmentShape = {
36
36
  OIDC_EXPECTED_AUDIENCE: optionalEnvironmentString,
37
37
  AWS_REGION: optionalEnvironmentString,
38
38
  NOTIFICATIONS_QUEUE_URL: optionalEnvironmentUrl,
39
+ INBOX_QUEUE_URL: optionalEnvironmentUrl,
40
+ INBOX_DEAD_LETTER_QUEUE_URL: optionalEnvironmentUrl,
41
+ INBOX_QUEUE_ENDPOINT: optionalEnvironmentUrl,
39
42
  NOTIFICATIONS_QUEUE_POLL_URL: optionalEnvironmentUrl,
40
43
  NOTIFICATIONS_SERVICE_WEBHOOK_URL: optionalEnvironmentUrl.or(z.literal('false')),
41
44
  NOTIFICATIONS_SERVICE_PORT: optionalEnvironmentPort,
@@ -80,6 +83,8 @@ export const brainServiceEnvironmentShape = {
80
83
  IS_CI: optionalEnvironmentBoolean.default(false),
81
84
  IS_E2E_TEST: optionalEnvironmentBoolean.default(false),
82
85
  INBOX_DELETE_EXPIRED_RECORDS: optionalEnvironmentBoolean.default(false),
86
+ INBOX_BATCH_ITEMS_PER_HOUR: optionalEnvironmentString,
87
+ AWS_LAMBDA_FUNCTION_NAME: optionalEnvironmentString,
83
88
  ENABLE_BENCH_ROUTES: optionalEnvironmentBoolean.default(false),
84
89
  ENABLE_SEND_CREDENTIAL_TELEMETRY: optionalEnvironmentBoolean.default(false),
85
90
  LC_PERF_LOG: optionalEnvironmentBoolean.default(false),
@@ -206,6 +211,27 @@ export const getCredentialRefreshRuntimeEnvironment = (): CredentialRefreshRunti
206
211
  examplePath: 'services/learn-card-network/brain-service/.env.example',
207
212
  });
208
213
 
214
+ const inboxBatchRuntimeEnvironmentSchema = z.object({
215
+ INBOX_QUEUE_URL: brainServiceEnvironmentShape.INBOX_QUEUE_URL,
216
+ INBOX_DEAD_LETTER_QUEUE_URL: brainServiceEnvironmentShape.INBOX_DEAD_LETTER_QUEUE_URL,
217
+ INBOX_QUEUE_ENDPOINT: brainServiceEnvironmentShape.INBOX_QUEUE_ENDPOINT,
218
+ AWS_REGION: brainServiceEnvironmentShape.AWS_REGION,
219
+ NODE_ENV: brainServiceEnvironmentShape.NODE_ENV,
220
+ IS_OFFLINE: brainServiceEnvironmentShape.IS_OFFLINE,
221
+ AWS_LAMBDA_FUNCTION_NAME: brainServiceEnvironmentShape.AWS_LAMBDA_FUNCTION_NAME,
222
+ INBOX_BATCH_ITEMS_PER_HOUR: brainServiceEnvironmentShape.INBOX_BATCH_ITEMS_PER_HOUR,
223
+ });
224
+
225
+ export type InboxBatchRuntimeEnvironment = z.output<typeof inboxBatchRuntimeEnvironmentSchema>;
226
+
227
+ /** Reads batch settings at call time so local invocations and tests can override them safely. */
228
+ export const getInboxBatchRuntimeEnvironment = (): InboxBatchRuntimeEnvironment =>
229
+ parseEnvironment(inboxBatchRuntimeEnvironmentSchema, process.env, {
230
+ project: 'brain-service inbox batch',
231
+ source: 'process environment',
232
+ examplePath: 'services/learn-card-network/brain-service/.env.example',
233
+ });
234
+
209
235
  export type BrainServiceEnvironment = z.output<typeof brainServiceEnvironmentSchema>;
210
236
 
211
237
  export const parseBrainServiceEnvironment = (
@@ -172,7 +172,11 @@ const authenticateRefreshRequest = async (
172
172
 
173
173
  // Missing aggregates, wrong holders, and awaiting-claim aggregates deliberately
174
174
  // share one response so a valid DID cannot use the endpoint as an existence oracle.
175
- if (aggregate.holderDid !== auth.holderDid) {
175
+ if (
176
+ !aggregate.holderDid ||
177
+ aggregate.state === 'pending_holder' ||
178
+ aggregate.holderDid !== auth.holderDid
179
+ ) {
176
180
  logRefreshRequest(refreshId, 'unauthorized', startedAt);
177
181
  await reply.status(403).send({ code: 'CREDENTIAL_REFRESH_UNAUTHORIZED' });
178
182
  return null;
@@ -1,3 +1,4 @@
1
+ import { configureInboxBatchBodyLimit } from '@helpers/inbox-batch-http.helpers';
1
2
  import { environment } from '@environment';
2
3
  import Fastify from 'fastify';
3
4
  import fastifyCors from '@fastify/cors';
@@ -15,6 +16,7 @@ import {
15
16
  import { neogma } from '@instance';
16
17
  import { appRouter, type AppRouter, createContext } from './app';
17
18
  import { openApiDocument } from './openapi';
19
+ import { inboxBatchResponseMeta } from './helpers/inbox-batch-http.helpers';
18
20
  import { didFastifyPlugin } from './dids';
19
21
  import { skillsViewerFastifyPlugin } from './skills-viewer';
20
22
  import { statusListsFastifyPlugin } from './status-lists';
@@ -25,6 +27,11 @@ import { maybeAutoSeedSkillFrameworks } from './seed/seedSkillFrameworks';
25
27
 
26
28
  const server = Fastify({ routerOptions: { maxParamLength: 5000 } });
27
29
 
30
+ // Register before either OpenAPI or tRPC registers its wildcard route. The hook raises the
31
+ // parser ceiling for those shared routes, then narrows it back to 4 MiB only for batch issuance.
32
+ // Registering this later would leave the already-created adapter routes at Fastify's 1 MiB limit.
33
+ configureInboxBatchBodyLimit(server);
34
+
28
35
  server.addHook('onRequest', (request, _reply, done) => {
29
36
  type RawWithEmitter = typeof request.raw & {
30
37
  on?: (...args: unknown[]) => unknown;
@@ -79,6 +86,7 @@ server.register(fastifyTRPCPlugin, {
79
86
  });
80
87
 
81
88
  server.register(fastifyTRPCOpenApiPlugin, {
89
+ responseMeta: inboxBatchResponseMeta,
82
90
  basePath: '/api',
83
91
  router: appRouter,
84
92
  createContext,
@@ -20,6 +20,7 @@ export const logCredentialActivity = async (
20
20
  };
21
21
 
22
22
  export const logCredentialSent = async (params: {
23
+ onBehalfOf?: string;
23
24
  actorProfileId?: string;
24
25
  recipientType: CredentialActivityRecipientType;
25
26
  recipientIdentifier: string;
@@ -45,6 +46,7 @@ export const logCredentialSent = async (params: {
45
46
  };
46
47
 
47
48
  export const logCredentialDelivered = async (params: {
49
+ onBehalfOf?: string;
48
50
  activityId: string;
49
51
  actorProfileId: string;
50
52
  recipientType: CredentialActivityRecipientType;
@@ -64,6 +66,7 @@ export const logCredentialDelivered = async (params: {
64
66
  };
65
67
 
66
68
  export const logCredentialClaimed = async (params: {
69
+ onBehalfOf?: string;
67
70
  activityId?: string;
68
71
  actorProfileId: string;
69
72
  recipientType: CredentialActivityRecipientType;
@@ -98,6 +101,7 @@ export const logCredentialExpired = async (params: {
98
101
  };
99
102
 
100
103
  export const logCredentialFailed = async (params: {
104
+ onBehalfOf?: string;
101
105
  activityId?: string;
102
106
  actorProfileId: string;
103
107
  recipientType: CredentialActivityRecipientType;
@@ -44,7 +44,7 @@ export const handleIncrementCounterEvent = async (
44
44
  // tRPC does not support HTTP 429 natively, so we cast to BAD_REQUEST
45
45
  // while keeping the semantic code in the message for clients.
46
46
  throw new TRPCError({
47
- code: 'TOO_MANY_REQUESTS' as 'BAD_REQUEST',
47
+ code: 'TOO_MANY_REQUESTS',
48
48
  message: 'Rate limit exceeded: max 100 counter writes per user per app per minute',
49
49
  });
50
50
  }
@@ -0,0 +1,43 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+
3
+ const environment = vi.hoisted(() => ({
4
+ CLIENT_APP_DOMAIN_NAME: undefined as string | undefined,
5
+ CLIENT_APP_PORT: undefined as number | undefined,
6
+ PORT: 4000,
7
+ IS_OFFLINE: true,
8
+ }));
9
+ vi.mock('@environment', () => ({ environment }));
10
+ vi.mock('@cache', () => ({ default: {} }));
11
+
12
+ import { generateClaimUrl } from './contact-method.helpers';
13
+
14
+ describe('inbox claim links', () => {
15
+ it('opens the default frontend instead of the backend port', () => {
16
+ expect(generateClaimUrl('test-token')).toBe(
17
+ 'http://localhost:3000/interactions/inbox-claim/test-token?iuv=1'
18
+ );
19
+ });
20
+
21
+ it('uses the configured frontend port locally', () => {
22
+ environment.CLIENT_APP_PORT = 3100;
23
+ try {
24
+ expect(generateClaimUrl('test-token')).toContain('http://localhost:3100/');
25
+ } finally {
26
+ environment.CLIENT_APP_PORT = undefined;
27
+ }
28
+ });
29
+
30
+ it('honors a configured app domain locally and keeps production HTTPS', () => {
31
+ environment.CLIENT_APP_DOMAIN_NAME = 'app.example.org';
32
+ try {
33
+ expect(generateClaimUrl('test-token')).toContain('http://app.example.org/');
34
+ environment.IS_OFFLINE = false;
35
+ expect(generateClaimUrl('test-token')).toBe(
36
+ 'https://app.example.org/interactions/inbox-claim/test-token?iuv=1'
37
+ );
38
+ } finally {
39
+ environment.IS_OFFLINE = true;
40
+ environment.CLIENT_APP_DOMAIN_NAME = undefined;
41
+ }
42
+ });
43
+ });
@@ -110,10 +110,9 @@ export const markInboxClaimTokenAsUsed = async (token: string): Promise<boolean>
110
110
 
111
111
  export const generateClaimUrl = (token: string): string => {
112
112
  const domainName = environment.CLIENT_APP_DOMAIN_NAME;
113
- const domain =
114
- !domainName || environment.IS_OFFLINE
115
- ? `localhost:${environment.PORT || 3000}`
116
- : domainName;
113
+ // Claim links open the frontend, not the brain-service HTTP port. Honor an
114
+ // explicitly configured app domain in local development as well.
115
+ const domain = domainName || `localhost:${environment.CLIENT_APP_PORT || 3000}`;
117
116
 
118
117
  const protocol = environment.IS_OFFLINE ? 'http' : 'https';
119
118