@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,77 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
2
+
3
+ const mocks = vi.hoisted(() => ({ findOne: vi.fn(), session: vi.fn(), transaction: vi.fn() }));
4
+ vi.mock('@environment', () => ({ environment: {} }));
5
+ vi.mock('@models', () => ({ Credential: { findOne: mocks.findOne } }));
6
+ vi.mock('@instance', () => ({
7
+ neogma: { driver: { session: mocks.session }, getTransaction: mocks.transaction },
8
+ }));
9
+ vi.mock('@helpers/learnCard.helpers', () => ({
10
+ getLearnCard: vi.fn(),
11
+ getDidWebLearnCard: vi.fn(),
12
+ }));
13
+
14
+ import { setCredentialBitstringStatus } from './status-list.helpers';
15
+
16
+ describe('stored credential status validation', () => {
17
+ beforeEach(() => {
18
+ vi.clearAllMocks();
19
+ });
20
+
21
+ it.each(['{truncated', 'null', '{}', '[null]', '[{}]', '"not an array"'])(
22
+ 'returns false for malformed status metadata (%s) without starting a status write',
23
+ async statusEntries => {
24
+ mocks.findOne.mockResolvedValue({ statusEntries, credential: '{}' });
25
+ await expect(
26
+ setCredentialBitstringStatus('credential', 'revocation', true)
27
+ ).resolves.toBe(false);
28
+ expect(mocks.session).not.toHaveBeenCalled();
29
+ }
30
+ );
31
+
32
+ it('returns false when legacy credential JSON cannot be parsed', async () => {
33
+ mocks.findOne.mockResolvedValue({ credential: '{truncated' });
34
+ await expect(setCredentialBitstringStatus('credential', 'suspension', true)).resolves.toBe(
35
+ false
36
+ );
37
+ expect(mocks.session).not.toHaveBeenCalled();
38
+ });
39
+
40
+ it('returns false for valid empty metadata or a missing credential', async () => {
41
+ mocks.findOne
42
+ .mockResolvedValueOnce({ statusEntries: '[]', credential: '{}' })
43
+ .mockResolvedValueOnce(null);
44
+ expect(await setCredentialBitstringStatus('credential', 'revocation', true)).toBe(false);
45
+ expect(await setCredentialBitstringStatus('missing', 'revocation', true)).toBe(false);
46
+ });
47
+
48
+ it('returns false when a valid entry cannot be updated', async () => {
49
+ mocks.findOne.mockResolvedValue({
50
+ statusEntries: JSON.stringify([
51
+ {
52
+ type: 'BitstringStatusListEntry',
53
+ statusPurpose: 'revocation',
54
+ statusListIndex: '42',
55
+ statusListCredential: 'https://network.example/status/1',
56
+ },
57
+ ]),
58
+ });
59
+ mocks.transaction.mockResolvedValue(false);
60
+ expect(await setCredentialBitstringStatus('credential', 'revocation', true)).toBe(false);
61
+ });
62
+
63
+ it('rejects malformed numeric coordinates before any status write', async () => {
64
+ mocks.findOne.mockResolvedValue({
65
+ statusEntries: JSON.stringify([
66
+ {
67
+ type: 'BitstringStatusListEntry',
68
+ statusPurpose: 'revocation',
69
+ statusListIndex: 'not-a-number',
70
+ statusListCredential: 'https://network.example/status/1',
71
+ },
72
+ ]),
73
+ });
74
+ expect(await setCredentialBitstringStatus('credential', 'revocation', true)).toBe(false);
75
+ expect(mocks.transaction).not.toHaveBeenCalled();
76
+ });
77
+ });
@@ -8,7 +8,10 @@ import type {
8
8
  UnsignedVC,
9
9
  VC,
10
10
  } from '@learncard/types';
11
- import { DEFAULT_BITSTRING_STATUS_LIST_SIZE } from '@learncard/types';
11
+ import {
12
+ BitstringStatusListEntryValidator,
13
+ DEFAULT_BITSTRING_STATUS_LIST_SIZE,
14
+ } from '@learncard/types';
12
15
  import {
13
16
  getBitstringStatusListEntries,
14
17
  getCredentialStatusArray,
@@ -397,16 +400,36 @@ export const setCredentialBitstringStatus = async (
397
400
  const credential = await Credential.findOne({ where: { id: credentialId } });
398
401
  if (!credential) return false;
399
402
 
400
- const parsedCredential = JSON.parse(credential.credential);
401
- const entries = getBitstringStatusListEntries(parsedCredential).filter(
402
- entry => entry.statusPurpose === statusPurpose
403
- );
403
+ // Encrypted SA credentials retain only public status coordinates separately.
404
+ // Legacy plaintext/wrapped credentials continue using their embedded entries.
405
+ let statusEntries: BitstringStatusListEntry[];
406
+ try {
407
+ statusEntries = BitstringStatusListEntryValidator.array().parse(
408
+ credential.statusEntries
409
+ ? JSON.parse(credential.statusEntries)
410
+ : getBitstringStatusListEntries(JSON.parse(credential.credential))
411
+ );
412
+ if (
413
+ statusEntries.some(
414
+ entry =>
415
+ !Number.isSafeInteger(Number(entry.statusListIndex)) ||
416
+ Number(entry.statusListIndex) < 0
417
+ )
418
+ ) {
419
+ throw new Error('Invalid status list index');
420
+ }
421
+ } catch {
422
+ console.error('[setCredentialBitstringStatus] Invalid stored status entries', {
423
+ credentialId,
424
+ });
425
+ return false;
426
+ }
427
+ const entries = statusEntries.filter(entry => entry.statusPurpose === statusPurpose);
404
428
 
405
429
  if (entries.length === 0) return false;
406
430
 
407
- await Promise.all(entries.map(entry => setStatusListEntryBit(entry, value)));
408
-
409
- return true;
431
+ const updated = await Promise.all(entries.map(entry => setStatusListEntryBit(entry, value)));
432
+ return updated.every(Boolean);
410
433
  };
411
434
 
412
435
  export const getSignedStatusListCredential = async (id: string): Promise<VC | null> => {
@@ -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
  },
@@ -26,6 +26,10 @@ export const ConsentFlowTerms = ModelFactory<FlatDbTermsType, ConsentFlowTermsRe
26
26
  expiresAt: { type: 'string', required: false },
27
27
  oneTime: { type: 'boolean', required: false },
28
28
  deniedWriters: { type: 'string[]', required: false },
29
+ 'guardianApproval.guardianProfileId': { type: 'string', required: false },
30
+ 'guardianApproval.guardianDid': { type: 'string', required: false },
31
+ 'guardianApproval.approvedAt': { type: 'string', required: false },
32
+ 'guardianApproval.contractUpdatedAt': { type: 'string', required: false },
29
33
  } as any,
30
34
  relationships: {
31
35
  createdBy: { model: Profile, direction: 'out', name: 'CREATED_BY' },
@@ -24,6 +24,10 @@ export const ConsentFlowTransaction = ModelFactory<
24
24
  status: { type: 'string', required: true },
25
25
  expiresAt: { type: 'string', required: false },
26
26
  oneTime: { type: 'boolean', required: false },
27
+ 'guardianApproval.guardianProfileId': { type: 'string', required: false },
28
+ 'guardianApproval.guardianDid': { type: 'string', required: false },
29
+ 'guardianApproval.approvedAt': { type: 'string', required: false },
30
+ 'guardianApproval.contractUpdatedAt': { type: 'string', required: false },
27
31
  } as any,
28
32
  relationships: { isFor: { model: ConsentFlowTerms, direction: 'out', name: 'IS_FOR' } },
29
33
  primaryKeyField: 'id',
@@ -33,6 +33,7 @@ export const Credential = ModelFactory<CredentialType, CredentialRelationships>(
33
33
  schema: {
34
34
  id: { type: 'string', required: true, uniqueItems: true },
35
35
  credential: { type: 'string', required: true },
36
+ statusEntries: { type: 'string', required: false },
36
37
  },
37
38
  primaryKeyField: 'id',
38
39
  },
@@ -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.
@@ -0,0 +1,239 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import { resolveTenantFromRequest } from '@learncard/email-templates';
3
+ import { ACT_AS_HEADER, AUTH_GRANT_AUDIENCE_DOMAIN_PREFIX } from '@learncard/types';
4
+ import type { ProfileType } from 'types/profile';
5
+
6
+ const mocks = vi.hoisted(() => ({
7
+ getProfileByDid: vi.fn(),
8
+ getProfileByProfileId: vi.fn(),
9
+ getProfilesThatManageAProfile: vi.fn(),
10
+ grant: vi.fn(),
11
+ verifyPresentation: vi.fn(),
12
+ isAuthGrantAssociatedWithProfile: vi.fn(),
13
+ getAuthGrantById: vi.fn(),
14
+ updateAuthGrant: vi.fn(),
15
+ }));
16
+
17
+ vi.mock('@accesslayer/profile/read', () => ({
18
+ getProfileByDid: mocks.getProfileByDid,
19
+ getProfileByProfileId: mocks.getProfileByProfileId,
20
+ }));
21
+ vi.mock('@accesslayer/profile/relationships/read', () => ({
22
+ getProfilesThatManageAProfile: mocks.getProfilesThatManageAProfile,
23
+ }));
24
+ vi.mock('@accesslayer/auth-grant/read', () => ({
25
+ isAuthGrantChallengeValidForDID: mocks.grant,
26
+ isAuthGrantAssociatedWithProfile: mocks.isAuthGrantAssociatedWithProfile,
27
+ getAuthGrantById: mocks.getAuthGrantById,
28
+ }));
29
+ vi.mock('@accesslayer/auth-grant/update', () => ({ updateAuthGrant: mocks.updateAuthGrant }));
30
+ vi.mock('@helpers/learnCard.helpers', () => ({
31
+ getEmptyLearnCard: async () => ({ invoke: { verifyPresentation: mocks.verifyPresentation } }),
32
+ }));
33
+
34
+ import { createContext, didRoute, profileRoute, t, type Context } from './index';
35
+ import { profilesRouter } from './profiles';
36
+ import { authGrantsRouter } from './auth-grants';
37
+
38
+ const domain = 'network.example.com';
39
+ const manager: ProfileType = {
40
+ profileId: 'manager',
41
+ did: 'did:key:manager',
42
+ displayName: 'Manager',
43
+ shortBio: '',
44
+ bio: '',
45
+ };
46
+ const target: ProfileType = {
47
+ profileId: 'the-id',
48
+ did: `did:web:${domain}:users:the-id`,
49
+ displayName: 'District',
50
+ shortBio: '',
51
+ bio: '',
52
+ };
53
+ const inspectionRouter = t.router({
54
+ context: didRoute.query(({ ctx }) => ctx.user),
55
+ writeInbox: profileRoute.meta({ requiredScope: 'inbox:write' }).query(() => true),
56
+ writeBoost: profileRoute.meta({ requiredScope: 'boosts:write' }).query(() => true),
57
+ });
58
+ const context = (overrides: Partial<Context> = {}): Context => ({
59
+ domain,
60
+ tenant: resolveTenantFromRequest({}),
61
+ actAs: target.profileId,
62
+ user: { did: manager.did, isChallengeValid: true, scope: '*:*' },
63
+ ...overrides,
64
+ });
65
+
66
+ beforeEach(() => {
67
+ vi.resetAllMocks();
68
+ mocks.getProfileByDid.mockResolvedValue(manager);
69
+ mocks.getProfileByProfileId.mockImplementation(async (id: string) =>
70
+ id === target.profileId ? target : null
71
+ );
72
+ mocks.getProfilesThatManageAProfile.mockResolvedValue([manager]);
73
+ mocks.verifyPresentation.mockResolvedValue({ warnings: [], errors: [], checks: ['JWS'] });
74
+ });
75
+
76
+ describe('act as a managed profile', () => {
77
+ it.each([target.profileId, target.did])('accepts seed-auth delegation to %s', async actAs => {
78
+ const ctx = context({ actAs });
79
+ await expect(profilesRouter.createCaller(ctx).getProfile()).resolves.toMatchObject(target);
80
+ await expect(inspectionRouter.createCaller(ctx).context()).resolves.toMatchObject({
81
+ did: target.did,
82
+ profile: target,
83
+ onBehalfOf: manager.profileId,
84
+ scope: '*:*',
85
+ });
86
+ expect(ctx.user?.did).toBe(manager.did);
87
+ expect(mocks.getProfilesThatManageAProfile).toHaveBeenCalledWith(target.profileId);
88
+ });
89
+
90
+ it('rejects a profile the caller does not manage', async () => {
91
+ mocks.getProfilesThatManageAProfile.mockResolvedValue([]);
92
+ await expect(inspectionRouter.createCaller(context()).context()).rejects.toMatchObject({
93
+ code: 'FORBIDDEN',
94
+ message: 'You do not manage profile "the-id".',
95
+ });
96
+ });
97
+
98
+ it('rejects an unknown profile', async () => {
99
+ await expect(
100
+ inspectionRouter.createCaller(context({ actAs: 'missing' })).context()
101
+ ).rejects.toMatchObject({ code: 'NOT_FOUND' });
102
+ });
103
+
104
+ it.each([
105
+ 'did:key:other',
106
+ 'did:web:elsewhere:users:the-id',
107
+ `did:web:${domain}:manager:the-id`,
108
+ `${target.did}:extra`,
109
+ ])('rejects an unsupported DID: %s', async actAs => {
110
+ await expect(
111
+ inspectionRouter.createCaller(context({ actAs })).context()
112
+ ).rejects.toMatchObject({ code: 'BAD_REQUEST' });
113
+ });
114
+
115
+ it.each([
116
+ [undefined, false],
117
+ ['', false],
118
+ ['*', true],
119
+ ['other-id', false],
120
+ ['the-id,other', true],
121
+ [' other, the-id ', true],
122
+ ] as const)('enforces token policy %s (allowed: %s)', async (actAs, allowed) => {
123
+ mocks.grant.mockResolvedValue({ isChallengeValid: true, scope: 'inbox:write', actAs });
124
+ const payload = Buffer.from(
125
+ JSON.stringify({
126
+ vp: { holder: manager.did },
127
+ nonce: `${AUTH_GRANT_AUDIENCE_DOMAIN_PREFIX}test`,
128
+ })
129
+ ).toString('base64url');
130
+ const ctx = await createContext({
131
+ req: {
132
+ headers: new Map([
133
+ ['authorization', `Bearer e30.${payload}.signature`],
134
+ [ACT_AS_HEADER, target.profileId],
135
+ ]),
136
+ },
137
+ });
138
+ const caller = inspectionRouter.createCaller(ctx);
139
+ if (!allowed) {
140
+ await expect(caller.context()).rejects.toMatchObject({
141
+ code: 'FORBIDDEN',
142
+ message: 'This API token may not act as "the-id". Grant actAs on the token.',
143
+ });
144
+ return;
145
+ }
146
+ await expect(caller.context()).resolves.toMatchObject({
147
+ profile: target,
148
+ did: target.did,
149
+ onBehalfOf: manager.profileId,
150
+ scope: 'inbox:write',
151
+ isAuthGrant: true,
152
+ actAsPolicy: actAs,
153
+ });
154
+ await expect(caller.writeInbox()).resolves.toBe(true);
155
+ await expect(caller.writeBoost()).rejects.toMatchObject({ code: 'UNAUTHORIZED' });
156
+ });
157
+
158
+ it('leaves the authenticated identity unchanged without a header', async () => {
159
+ const result = await inspectionRouter.createCaller(context({ actAs: undefined })).context();
160
+ expect(result).toMatchObject({ profile: manager, did: manager.did, scope: '*:*' });
161
+ expect(result.onBehalfOf).toBeUndefined();
162
+ });
163
+
164
+ it.each([ACT_AS_HEADER, ACT_AS_HEADER.toLowerCase(), ACT_AS_HEADER.toUpperCase()])(
165
+ 'reads header %s without resolving a profile in createContext',
166
+ async name => {
167
+ const ctx = await createContext({ req: { headers: new Map([[name, target.did]]) } });
168
+ expect(ctx.actAs).toBe(target.did);
169
+ expect(mocks.getProfileByProfileId).not.toHaveBeenCalled();
170
+ }
171
+ );
172
+ });
173
+
174
+ describe('act-as ordering', () => {
175
+ it('does not resolve the target when the challenge is not verified', async () => {
176
+ const ctx = context({ user: { did: manager.did, isChallengeValid: false, scope: '*:*' } });
177
+ await expect(inspectionRouter.createCaller(ctx).context()).resolves.toMatchObject({
178
+ did: manager.did,
179
+ });
180
+ expect(mocks.getProfileByProfileId).not.toHaveBeenCalled();
181
+ expect(mocks.getProfilesThatManageAProfile).not.toHaveBeenCalled();
182
+ });
183
+
184
+ it('treats a blank header as no delegation', async () => {
185
+ const ctx = context({ actAs: ' ' });
186
+ await expect(inspectionRouter.createCaller(ctx).context()).resolves.toMatchObject({
187
+ did: manager.did,
188
+ });
189
+ expect(mocks.getProfileByProfileId).not.toHaveBeenCalled();
190
+ });
191
+ });
192
+
193
+ describe('auth grant actAs is immutable', () => {
194
+ const grant = {
195
+ id: 'grant-1',
196
+ name: 'svc',
197
+ challenge: `${AUTH_GRANT_AUDIENCE_DOMAIN_PREFIX}test`,
198
+ status: 'active',
199
+ scope: 'inbox:write',
200
+ createdAt: '2026-01-01T00:00:00.000Z',
201
+ actAs: '*',
202
+ };
203
+
204
+ beforeEach(() => {
205
+ mocks.isAuthGrantAssociatedWithProfile.mockResolvedValue(true);
206
+ mocks.getAuthGrantById.mockResolvedValue(grant);
207
+ mocks.updateAuthGrant.mockResolvedValue(true);
208
+ });
209
+
210
+ it.each([{ actAs: 'the-id' }, { actAs: '*' }, { scope: '*:*' }, { status: 'revoked' }])(
211
+ 'rejects %j with BAD_REQUEST instead of silently dropping it',
212
+ async updates => {
213
+ const caller = authGrantsRouter.createCaller(context({ actAs: undefined }));
214
+ await expect(
215
+ caller.updateAuthGrant({ id: grant.id, updates: updates as never })
216
+ ).rejects.toMatchObject({
217
+ code: 'BAD_REQUEST',
218
+ message: expect.stringContaining('Cannot update id, scope, actAs'),
219
+ });
220
+ expect(mocks.updateAuthGrant).not.toHaveBeenCalled();
221
+ }
222
+ );
223
+
224
+ it('rejects unknown keys', async () => {
225
+ const caller = authGrantsRouter.createCaller(context({ actAs: undefined }));
226
+ await expect(
227
+ caller.updateAuthGrant({ id: grant.id, updates: { bogus: true } as never })
228
+ ).rejects.toMatchObject({ code: 'BAD_REQUEST' });
229
+ expect(mocks.updateAuthGrant).not.toHaveBeenCalled();
230
+ });
231
+
232
+ it('still allows mutable fields', async () => {
233
+ const caller = authGrantsRouter.createCaller(context({ actAs: undefined }));
234
+ await expect(
235
+ caller.updateAuthGrant({ id: grant.id, updates: { description: 'renamed' } })
236
+ ).resolves.toBe(true);
237
+ expect(mocks.updateAuthGrant).toHaveBeenCalledWith(grant, { description: 'renamed' });
238
+ });
239
+ });