@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,1799 @@
1
+ import { vi } from 'vitest';
2
+ import { JWEValidator, VC, UnsignedVC } from '@learncard/types';
3
+ import { injectManagedRefreshService } from '@learncard/helpers';
4
+
5
+ const MANAGED_REFRESH_TYPE_TERM = 'LearnCardCredentialRefresh2026';
6
+
7
+ /**
8
+ * The signing-authority helper signs as the brain identity under the unit-test mock,
9
+ * which managed binding would (correctly) reject as a foreign issuer. Replace it with
10
+ * an issuer-controlled authority: the credential is signed with the issuer's own key
11
+ * after the server-driven status allocation, mirroring a real registered SA.
12
+ */
13
+ const signingAuthorityMocks = vi.hoisted(() => ({
14
+ issueCredential: vi.fn(),
15
+ }));
16
+
17
+ vi.mock('@helpers/signingAuthority.helpers', async importOriginal => ({
18
+ ...(await importOriginal<Record<string, unknown>>()),
19
+ issueCredentialWithSigningAuthority: signingAuthorityMocks.issueCredential,
20
+ }));
21
+
22
+ import { neogma } from '@instance';
23
+ import {
24
+ Boost,
25
+ ConsentFlowContract,
26
+ Credential,
27
+ CredentialActivity,
28
+ CredentialRefresh,
29
+ InboxCredential,
30
+ Profile,
31
+ ProfileManager,
32
+ SigningAuthority,
33
+ } from '@models';
34
+
35
+ import { getClient, getUser } from './helpers/getClient';
36
+ import { addNotificationToQueueSpy } from './helpers/spies';
37
+ import * as Notifications from '@helpers/notifications.helpers';
38
+ import { getLearnCard, SeedLearnCard } from '@helpers/learnCard.helpers';
39
+ import { blockProfile } from '@helpers/connection.helpers';
40
+ import { appendBitstringStatusListEntries } from '@helpers/status-list.helpers';
41
+ import { getDidWeb } from '@helpers/did.helpers';
42
+ import { getCredentialRefresh, getCredentialRefreshHead } from '@accesslayer/credential-refresh';
43
+ import { testUnsignedBoost } from './helpers/send';
44
+ import {
45
+ claimRefreshSendIntent,
46
+ getRefreshSendIntent,
47
+ recordRefreshSendIntent,
48
+ recordRefreshSendIntentPending,
49
+ markRefreshSendIntentDelivered,
50
+ } from '@helpers/refresh-send-intent.helpers';
51
+ import { extractManagedRefreshHandoff } from '@helpers/credential-refresh.helpers';
52
+
53
+ // Minimal VC 2.0 isolates the managed send/status behavior here. The real-signing
54
+ // E2E matrix also covers VCDM 2.0 + OBv3 3.0.3: only older OB contexts (through
55
+ // 3.0.1) conflict with VC2 protected terms, not OBv3 as a whole.
56
+ const testUnsignedVcV2: UnsignedVC = {
57
+ '@context': ['https://www.w3.org/ns/credentials/v2'],
58
+ type: ['VerifiableCredential'],
59
+ issuer: 'did:web:localhost%3A3000:users:refresh-send-issuer',
60
+ name: 'Refreshable VC',
61
+ credentialSubject: { id: 'did:example:recipient' },
62
+ } as UnsignedVC;
63
+
64
+ let brain: SeedLearnCard;
65
+ let issuer: Awaited<ReturnType<typeof getUser>>;
66
+ let holder: Awaited<ReturnType<typeof getUser>>;
67
+ let outsider: Awaited<ReturnType<typeof getUser>>;
68
+
69
+ const ISSUER_PROFILE_ID = 'refresh-send-issuer';
70
+ const HOLDER_PROFILE_ID = 'refresh-send-holder';
71
+ const OUTSIDER_PROFILE_ID = 'refresh-send-outsider';
72
+ const MANAGED_PROFILE_ID = 'refresh-send-managed';
73
+ const DOMAIN = 'localhost%3A3000';
74
+
75
+ type SendResult = {
76
+ type: 'boost';
77
+ uri: string;
78
+ credentialUri: string;
79
+ activityId: string;
80
+ refresh?: {
81
+ refreshId: string;
82
+ refreshService: { id: string; type: string; authorization?: { type: string } };
83
+ credentialId: string;
84
+ issuerDid: string;
85
+ holderDid: string;
86
+ credentialStatus?: unknown;
87
+ };
88
+ };
89
+
90
+ const toNum = (value: unknown): number =>
91
+ value && typeof (value as { toNumber?: () => number }).toNumber === 'function'
92
+ ? (value as { toNumber: () => number }).toNumber()
93
+ : Number(value ?? 0);
94
+
95
+ const runQuery = async (cypher: string, params: Record<string, unknown> = {}) =>
96
+ neogma.queryRunner.run(cypher, params);
97
+
98
+ const countNodes = async (label: string): Promise<number> =>
99
+ toNum((await runQuery(`MATCH (n:${label}) RETURN count(n) AS count`)).records[0]?.get('count'));
100
+
101
+ const countRelationships = async (type: string): Promise<number> =>
102
+ toNum(
103
+ (await runQuery(`MATCH ()-[r:${type}]->() RETURN count(r) AS count`)).records[0]?.get(
104
+ 'count'
105
+ )
106
+ );
107
+
108
+ /** The stored root credential body must be a holder-only JWE, never plaintext. */
109
+ const getRootCredentialBody = async (refreshId: string): Promise<string> => {
110
+ const result = await runQuery(
111
+ `MATCH (:CredentialRefresh {refreshId: $refreshId})-[:ROOT]->(root:Credential)
112
+ RETURN root.credential AS credential LIMIT 1`,
113
+ { refreshId }
114
+ );
115
+
116
+ return result.records[0]?.get('credential');
117
+ };
118
+
119
+ const expectsNoMutation = async (baseline: Record<string, number>) => {
120
+ const counts: Record<string, number> = {
121
+ credential: await countNodes('Credential'),
122
+ boost: await countNodes('Boost'),
123
+ refresh: await countNodes('CredentialRefresh'),
124
+ activity: await countNodes('CredentialActivity'),
125
+ inbox: await countNodes('InboxCredential'),
126
+ };
127
+
128
+ expect(counts).toEqual(baseline);
129
+ };
130
+
131
+ const getMutationBaseline = async (): Promise<Record<string, number>> => ({
132
+ credential: await countNodes('Credential'),
133
+ boost: await countNodes('Boost'),
134
+ refresh: await countNodes('CredentialRefresh'),
135
+ activity: await countNodes('CredentialActivity'),
136
+ inbox: await countNodes('InboxCredential'),
137
+ });
138
+
139
+ describe('Unified send with managed refresh (LC-2198)', () => {
140
+ beforeAll(async () => {
141
+ process.env.CREDENTIAL_REFRESH_ENABLED = 'true';
142
+
143
+ brain = await getLearnCard();
144
+ issuer = await getUser('e'.repeat(64));
145
+ holder = await getUser('9'.repeat(64));
146
+ outsider = await getUser('1'.repeat(64));
147
+
148
+ vi.spyOn(Notifications, 'addNotificationToQueue').mockImplementation(
149
+ addNotificationToQueueSpy
150
+ );
151
+
152
+ await runQuery(
153
+ 'CREATE CONSTRAINT credential_refresh_id_unique IF NOT EXISTS FOR (r:CredentialRefresh) REQUIRE (r.refreshId) IS UNIQUE'
154
+ );
155
+ await runQuery(
156
+ 'CREATE CONSTRAINT credential_refresh_version_key_unique IF NOT EXISTS FOR (c:Credential) REQUIRE (c.refreshVersionKey) IS UNIQUE'
157
+ );
158
+ await runQuery(
159
+ 'CREATE CONSTRAINT refresh_send_intent_key_unique IF NOT EXISTS FOR (i:RefreshSendIntent) REQUIRE (i.intentKey) IS UNIQUE'
160
+ );
161
+
162
+ signingAuthorityMocks.issueCredential.mockImplementation(
163
+ async (
164
+ _issuer: unknown,
165
+ credential: UnsignedVC,
166
+ _signingAuthority: unknown,
167
+ domain: string,
168
+ _encrypt = true,
169
+ _ownerDidOverride: unknown,
170
+ appendCredentialStatus = true
171
+ ) => {
172
+ const body = appendCredentialStatus
173
+ ? await appendBitstringStatusListEntries(credential, ISSUER_PROFILE_ID, domain)
174
+ : credential;
175
+
176
+ const vc = await issuer.learnCard.invoke.issueCredential({
177
+ ...body,
178
+ issuer: issuer.learnCard.id.did(),
179
+ });
180
+
181
+ return { kind: 'issued-credential', credential: vc, statusEntries: [] };
182
+ }
183
+ );
184
+ });
185
+
186
+ afterAll(() => {
187
+ signingAuthorityMocks.issueCredential.mockRestore();
188
+ });
189
+
190
+ beforeEach(async () => {
191
+ await runQuery('MATCH (r:CredentialRefresh) DETACH DELETE r');
192
+ await runQuery('MATCH (i:RefreshSendIntent) DETACH DELETE i');
193
+ await runQuery('MATCH (a:CredentialActivity) DETACH DELETE a');
194
+ await runQuery('MATCH (t:ConsentFlowTransaction) DETACH DELETE t');
195
+ await runQuery('MATCH (c:Credential) DETACH DELETE c');
196
+ await Boost.delete({ detach: true, where: {} });
197
+ await InboxCredential.delete({ detach: true, where: {} });
198
+ await ConsentFlowContract.delete({ detach: true, where: {} });
199
+ await SigningAuthority.delete({ detach: true, where: {} });
200
+ await runQuery('MATCH (m:ProfileManager) DETACH DELETE m');
201
+ await runQuery('MATCH (p:Profile) DETACH DELETE p');
202
+
203
+ await issuer.clients.fullAuth.profile.createProfile({ profileId: ISSUER_PROFILE_ID });
204
+ await holder.clients.fullAuth.profile.createProfile({ profileId: HOLDER_PROFILE_ID });
205
+ await outsider.clients.fullAuth.profile.createProfile({ profileId: OUTSIDER_PROFILE_ID });
206
+
207
+ await issuer.clients.fullAuth.profile.registerSigningAuthority({
208
+ endpoint: 'https://sa.example.com',
209
+ name: 'refresh-send-sa',
210
+ did: issuer.learnCard.id.did(),
211
+ });
212
+
213
+ addNotificationToQueueSpy.mockReset();
214
+ signingAuthorityMocks.issueCredential.mockClear();
215
+ });
216
+
217
+ describe('inline refresh preparation', () => {
218
+ it('preserves metadata, permissions, skills and contract linkage, then reuses one boost', async () => {
219
+ const contractUri = await issuer.clients.fullAuth.contracts.createConsentFlowContract({
220
+ contract: {
221
+ read: { anonymize: false },
222
+ write: { credentials: { categories: { TestBoosts: { required: false } } } },
223
+ },
224
+ name: 'Prepared Refresh Contract',
225
+ writers: [ISSUER_PROFILE_ID],
226
+ });
227
+ await holder.clients.fullAuth.contracts.consentToContract({
228
+ contractUri,
229
+ terms: {
230
+ read: { anonymize: false },
231
+ write: { credentials: { categories: { TestBoosts: true } } },
232
+ },
233
+ });
234
+ const frameworkId = `refresh-prep-${crypto.randomUUID()}`;
235
+ await issuer.clients.fullAuth.skillFrameworks.createManaged({
236
+ id: frameworkId,
237
+ name: 'Refresh skills',
238
+ });
239
+ await issuer.clients.fullAuth.skills.create({
240
+ frameworkId,
241
+ skill: { id: 'skill-1', statement: 'Refresh skill' },
242
+ });
243
+ const baseline = await getMutationBaseline();
244
+ const prepared = await issuer.clients.fullAuth.boost.prepareRefreshableSend({
245
+ recipient: HOLDER_PROFILE_ID,
246
+ contractUri,
247
+ template: {
248
+ credential: testUnsignedBoost,
249
+ name: 'Prepared Boost',
250
+ category: 'TestBoosts',
251
+ claimPermissions: { canView: true },
252
+ skills: [{ frameworkId, id: 'skill-1', proficiencyLevel: 2 }],
253
+ },
254
+ });
255
+ const boost = await issuer.clients.fullAuth.boost.getBoost({
256
+ uri: prepared.boostUri,
257
+ });
258
+ expect(boost).toMatchObject({
259
+ name: 'Prepared Boost',
260
+ category: 'TestBoosts',
261
+ claimPermissions: { canView: true },
262
+ });
263
+ expect(
264
+ await issuer.clients.fullAuth.boost.getBoostSkills({ uri: prepared.boostUri })
265
+ ).toEqual([expect.objectContaining({ id: 'skill-1', proficiencyLevel: 2 })]);
266
+ const links = await runQuery(
267
+ 'MATCH (:ConsentFlowContract)-[:RELATED_TO]->(b:Boost {name: $name}) RETURN count(b) AS count',
268
+ { name: 'Prepared Boost' }
269
+ );
270
+ expect(toNum(links.records[0].get('count'))).toBe(1);
271
+ // Preparation creates the anchor AND the refresh allocation, but never a
272
+ // credential, activity or delivery.
273
+ await expectsNoMutation({
274
+ ...baseline,
275
+ boost: baseline.boost + 1,
276
+ refresh: baseline.refresh + 1,
277
+ });
278
+ expect(prepared.refreshId).toBeDefined();
279
+ const signedCredential = await issuer.learnCard.invoke.issueCredential(
280
+ injectManagedRefreshService(
281
+ {
282
+ ...testUnsignedBoost,
283
+ id: prepared.credentialId,
284
+ issuer: issuer.learnCard.id.did(),
285
+ credentialSubject: {
286
+ ...testUnsignedBoost.credentialSubject,
287
+ id: prepared.holderDid,
288
+ },
289
+ boostId: prepared.boostUri,
290
+ },
291
+ prepared.refreshService
292
+ )
293
+ );
294
+ const result = await issuer.clients.fullAuth.boost.send({
295
+ type: 'boost',
296
+ recipient: HOLDER_PROFILE_ID,
297
+ templateUri: prepared.boostUri,
298
+ signedCredential,
299
+ contractUri,
300
+ refresh: true,
301
+ });
302
+ expect(result.uri).toBe(prepared.boostUri);
303
+ expect(await countNodes('Boost')).toBe(baseline.boost + 1);
304
+ const rows = await runQuery(
305
+ `MATCH (root:Credential {refreshVersionKey: $key})-[:ISSUED_VIA_TRANSACTION]->(:ConsentFlowTransaction)-[:IS_FOR]->(:ConsentFlowTerms)
306
+ RETURN count(*) AS count`,
307
+ { key: `${prepared.refreshId}:1` }
308
+ );
309
+ expect(toNum(rows.records[0].get('count'))).toBe(1);
310
+ });
311
+
312
+ it.each([
313
+ ['holder@example.com', 'BAD_REQUEST'],
314
+ ['+15551234567', 'BAD_REQUEST'],
315
+ ['did:web:example.com:users:remote-holder', 'NOT_FOUND'],
316
+ ['did:key:z6Mkunknown', 'NOT_FOUND'],
317
+ ['unknown-profile', 'NOT_FOUND'],
318
+ ])('rejects unsupported recipient %s before creating anything', async (recipient, code) => {
319
+ const baseline = await getMutationBaseline();
320
+ await expect(
321
+ issuer.clients.fullAuth.boost.prepareRefreshableSend({
322
+ recipient,
323
+ template: { credential: testUnsignedBoost },
324
+ })
325
+ ).rejects.toMatchObject({ code });
326
+ await expectsNoMutation(baseline);
327
+ });
328
+
329
+ it.each(['boosts:write', 'credentials:write'])(
330
+ 'requires both scopes, not just %s',
331
+ async scope => {
332
+ const client = getClient({
333
+ did: issuer.learnCard.id.did(),
334
+ isChallengeValid: true,
335
+ scope,
336
+ });
337
+ const baseline = await getMutationBaseline();
338
+ await expect(
339
+ client.boost.prepareRefreshableSend({
340
+ recipient: HOLDER_PROFILE_ID,
341
+ template: { credential: testUnsignedBoost },
342
+ })
343
+ ).rejects.toMatchObject({ code: 'UNAUTHORIZED' });
344
+ await expectsNoMutation(baseline);
345
+ }
346
+ );
347
+
348
+ it('rejects disabled refresh before creating anything', async () => {
349
+ const baseline = await getMutationBaseline();
350
+ const previous = process.env.CREDENTIAL_REFRESH_ENABLED;
351
+ process.env.CREDENTIAL_REFRESH_ENABLED = 'false';
352
+ try {
353
+ await expect(
354
+ issuer.clients.fullAuth.boost.prepareRefreshableSend({
355
+ recipient: HOLDER_PROFILE_ID,
356
+ template: { credential: testUnsignedBoost },
357
+ })
358
+ ).rejects.toMatchObject({ code: 'NOT_FOUND' });
359
+ } finally {
360
+ process.env.CREDENTIAL_REFRESH_ENABLED = previous;
361
+ }
362
+ await expectsNoMutation(baseline);
363
+ });
364
+
365
+ it('rejects blocked recipients before creating anything', async () => {
366
+ await blockProfile(
367
+ await issuer.clients.fullAuth.profile.getProfile(),
368
+ await holder.clients.fullAuth.profile.getProfile()
369
+ );
370
+ const baseline = await getMutationBaseline();
371
+ await expect(
372
+ issuer.clients.fullAuth.boost.prepareRefreshableSend({
373
+ recipient: HOLDER_PROFILE_ID,
374
+ template: { credential: testUnsignedBoost },
375
+ })
376
+ ).rejects.toMatchObject({ code: 'NOT_FOUND' });
377
+ await expectsNoMutation(baseline);
378
+ });
379
+
380
+ it('rejects draft templates before creating anything', async () => {
381
+ const baseline = await getMutationBaseline();
382
+ await expect(
383
+ issuer.clients.fullAuth.boost.prepareRefreshableSend({
384
+ recipient: HOLDER_PROFILE_ID,
385
+ template: { credential: testUnsignedBoost, status: 'DRAFT' },
386
+ })
387
+ ).rejects.toMatchObject({ code: 'FORBIDDEN' });
388
+ await expectsNoMutation(baseline);
389
+ });
390
+ });
391
+
392
+ describe('whole-call idempotency (idempotencyKey)', () => {
393
+ const inlineTemplate = () => ({
394
+ credential: testUnsignedVcV2,
395
+ name: 'Idempotent Refreshable',
396
+ });
397
+
398
+ it('prepares a templateUri send with the allocation and full guards', async () => {
399
+ const boostUri = await issuer.clients.fullAuth.boost.createBoost({
400
+ credential: testUnsignedVcV2,
401
+ });
402
+
403
+ const prepared = await issuer.clients.fullAuth.boost.prepareRefreshableSend({
404
+ recipient: HOLDER_PROFILE_ID,
405
+ templateUri: boostUri,
406
+ });
407
+
408
+ expect(prepared.boostUri).toBe(boostUri);
409
+ expect(prepared.refreshService.id).toContain(prepared.refreshId);
410
+ expect(prepared.holderDid).toBe(getDidWeb(DOMAIN, HOLDER_PROFILE_ID));
411
+ expect(prepared.completed).toBeUndefined();
412
+ expect(await countNodes('CredentialRefresh')).toBe(1);
413
+ });
414
+
415
+ it('reuses the same boost and allocation when prepare is retried with the same key', async () => {
416
+ const input = {
417
+ recipient: HOLDER_PROFILE_ID,
418
+ template: inlineTemplate(),
419
+ idempotencyKey: 'retry-prepare-1',
420
+ };
421
+
422
+ const first = await issuer.clients.fullAuth.boost.prepareRefreshableSend(input);
423
+ const second = await issuer.clients.fullAuth.boost.prepareRefreshableSend(input);
424
+
425
+ expect(second).toEqual(first);
426
+ expect(await countNodes('Boost')).toBe(1);
427
+ expect(await countNodes('CredentialRefresh')).toBe(1);
428
+ });
429
+
430
+ it('rejects reusing a key for a different request', async () => {
431
+ await issuer.clients.fullAuth.boost.prepareRefreshableSend({
432
+ recipient: HOLDER_PROFILE_ID,
433
+ template: inlineTemplate(),
434
+ idempotencyKey: 'retry-prepare-2',
435
+ });
436
+
437
+ await expect(
438
+ issuer.clients.fullAuth.boost.prepareRefreshableSend({
439
+ recipient: OUTSIDER_PROFILE_ID,
440
+ template: inlineTemplate(),
441
+ idempotencyKey: 'retry-prepare-2',
442
+ })
443
+ ).rejects.toMatchObject({ code: 'CONFLICT' });
444
+ });
445
+
446
+ it('scopes keys per issuer', async () => {
447
+ const a = await issuer.clients.fullAuth.boost.prepareRefreshableSend({
448
+ recipient: HOLDER_PROFILE_ID,
449
+ template: inlineTemplate(),
450
+ idempotencyKey: 'shared-key',
451
+ });
452
+ const b = await outsider.clients.fullAuth.boost.prepareRefreshableSend({
453
+ recipient: HOLDER_PROFILE_ID,
454
+ template: inlineTemplate(),
455
+ idempotencyKey: 'shared-key',
456
+ });
457
+
458
+ expect(b.refreshId).not.toBe(a.refreshId);
459
+ });
460
+
461
+ it('retries a signing-authority send with the same key without duplicating anything', async () => {
462
+ const input = {
463
+ type: 'boost' as const,
464
+ recipient: HOLDER_PROFILE_ID,
465
+ template: inlineTemplate(),
466
+ refresh: true,
467
+ idempotencyKey: 'sa-send-1',
468
+ };
469
+
470
+ const first = (await issuer.clients.fullAuth.boost.send(input)) as SendResult;
471
+ const second = (await issuer.clients.fullAuth.boost.send(input)) as SendResult;
472
+
473
+ expect(second).toEqual(first);
474
+ expect(await countNodes('Boost')).toBe(1);
475
+ expect(await countNodes('CredentialRefresh')).toBe(1);
476
+ expect(await countNodes('CredentialActivity')).toBe(1);
477
+ expect(await countRelationships('CREDENTIAL_SENT')).toBe(1);
478
+ expect(signingAuthorityMocks.issueCredential).toHaveBeenCalledTimes(1);
479
+ });
480
+
481
+ it('completes an abandoned prepare on the next send with the same key', async () => {
482
+ // Simulates the SDK failing after prepare (e.g. while signing).
483
+ const prepared = await issuer.clients.fullAuth.boost.prepareRefreshableSend({
484
+ recipient: HOLDER_PROFILE_ID,
485
+ template: inlineTemplate(),
486
+ idempotencyKey: 'abandoned-1',
487
+ });
488
+
489
+ const signed = await issuer.learnCard.invoke.issueCredential(
490
+ injectManagedRefreshService(
491
+ {
492
+ ...testUnsignedVcV2,
493
+ id: prepared.credentialId,
494
+ issuer: issuer.learnCard.id.did(),
495
+ validFrom: '2026-01-01T00:00:00Z',
496
+ credentialSubject: { id: prepared.holderDid },
497
+ } as UnsignedVC,
498
+ prepared.refreshService
499
+ )
500
+ );
501
+
502
+ const result = (await issuer.clients.fullAuth.boost.send({
503
+ type: 'boost',
504
+ recipient: HOLDER_PROFILE_ID,
505
+ templateUri: prepared.boostUri,
506
+ signedCredential: signed,
507
+ refresh: true,
508
+ idempotencyKey: 'abandoned-1',
509
+ })) as SendResult;
510
+
511
+ expect(result.refresh!.refreshId).toBe(prepared.refreshId);
512
+ expect(await countNodes('Boost')).toBe(1);
513
+ expect(await countNodes('CredentialRefresh')).toBe(1);
514
+
515
+ // A later prepare with the same key reports the completed result: the SDK
516
+ // returns it without signing again.
517
+ const again = await issuer.clients.fullAuth.boost.prepareRefreshableSend({
518
+ recipient: HOLDER_PROFILE_ID,
519
+ template: inlineTemplate(),
520
+ idempotencyKey: 'abandoned-1',
521
+ });
522
+ expect(again.completed).toEqual(result);
523
+ });
524
+
525
+ it.each([false, true])(
526
+ 'reconciles a keyed pre-signed delivery (material retry change: %s)',
527
+ async materialChange => {
528
+ const idempotencyKey = 'pre-signed-post-bind';
529
+ const prepared = await issuer.clients.fullAuth.boost.prepareRefreshableSend({
530
+ recipient: HOLDER_PROFILE_ID,
531
+ template: inlineTemplate(),
532
+ idempotencyKey,
533
+ });
534
+ const unsigned = injectManagedRefreshService(
535
+ {
536
+ ...testUnsignedVcV2,
537
+ id: prepared.credentialId,
538
+ issuer: issuer.learnCard.id.did(),
539
+ validFrom: '2026-01-01T00:00:00Z',
540
+ credentialSubject: { id: prepared.holderDid },
541
+ } as UnsignedVC,
542
+ prepared.refreshService
543
+ );
544
+ const input = {
545
+ type: 'boost' as const,
546
+ recipient: HOLDER_PROFILE_ID,
547
+ templateUri: prepared.boostUri,
548
+ signedCredential: await issuer.learnCard.invoke.issueCredential(unsigned),
549
+ refresh: true,
550
+ idempotencyKey,
551
+ };
552
+ const first = await issuer.clients.fullAuth.boost.send(input);
553
+ // Model a crash after binding/delivery but before storing the intent result.
554
+ await runQuery(
555
+ `MATCH (i:RefreshSendIntent {intentKey: $key})
556
+ SET i.state = 'prepared' REMOVE i.result`,
557
+ { key: `${ISSUER_PROFILE_ID}:${idempotencyKey}` }
558
+ );
559
+ const resigned = await issuer.learnCard.invoke.issueCredential({
560
+ ...unsigned,
561
+ validFrom: '2026-01-02T00:00:00Z',
562
+ ...(materialChange ? { name: 'Rebuilt credential on retry' } : {}),
563
+ });
564
+ const baseline = await getMutationBaseline();
565
+ const deliveries = await countRelationships('CREDENTIAL_SENT');
566
+ const notifications = addNotificationToQueueSpy.mock.calls.length;
567
+ // No second prepare call: a direct client retries only the send leg.
568
+ const retry = await issuer.clients.fullAuth.boost.send({
569
+ ...input,
570
+ signedCredential: resigned,
571
+ });
572
+ expect(retry).toEqual(first);
573
+ expect((await getRefreshSendIntent(ISSUER_PROFILE_ID, idempotencyKey))?.state).toBe(
574
+ 'delivered'
575
+ );
576
+ await expectsNoMutation(baseline);
577
+ expect(await countRelationships('CREDENTIAL_SENT')).toBe(deliveries);
578
+ expect(addNotificationToQueueSpy.mock.calls.length).toBe(notifications);
579
+ // Without a key, material changes must still fail the exact-replay guard.
580
+ if (materialChange) {
581
+ await expect(
582
+ issuer.clients.fullAuth.boost.send({
583
+ ...input,
584
+ signedCredential: resigned,
585
+ idempotencyKey: undefined,
586
+ })
587
+ ).rejects.toMatchObject({ code: 'CONFLICT' });
588
+ }
589
+ }
590
+ );
591
+
592
+ it('reconciles a delivery that was bound but never recorded', async () => {
593
+ const input = {
594
+ type: 'boost' as const,
595
+ recipient: HOLDER_PROFILE_ID,
596
+ template: inlineTemplate(),
597
+ refresh: true,
598
+ idempotencyKey: 'crash-after-bind',
599
+ };
600
+
601
+ const first = (await issuer.clients.fullAuth.boost.send(input)) as SendResult;
602
+
603
+ // Simulate a crash between binding and recording the result.
604
+ await runQuery(
605
+ `MATCH (i:RefreshSendIntent {intentKey: $key})
606
+ SET i.state = 'prepared' REMOVE i.result`,
607
+ { key: `${ISSUER_PROFILE_ID}:crash-after-bind` }
608
+ );
609
+
610
+ const second = (await issuer.clients.fullAuth.boost.send(input)) as SendResult;
611
+
612
+ expect(second).toEqual(first);
613
+ expect(signingAuthorityMocks.issueCredential).toHaveBeenCalledTimes(1);
614
+ expect(await countNodes('CredentialActivity')).toBe(1);
615
+ });
616
+
617
+ it.each(['templateData', 'integrationId', 'credentialId'] as const)(
618
+ 'rejects changing %s after preparation without allocating again',
619
+ async field => {
620
+ const input = {
621
+ recipient: HOLDER_PROFILE_ID,
622
+ template: inlineTemplate(),
623
+ templateData: { grade: 'A' },
624
+ integrationId: 'integration-a',
625
+ credentialId: 'urn:uuid:fixed-award',
626
+ idempotencyKey: 'bound-request',
627
+ };
628
+ await issuer.clients.fullAuth.boost.prepareRefreshableSend(input);
629
+ const changes = {
630
+ templateData: { grade: 'F' },
631
+ integrationId: 'integration-b',
632
+ credentialId: 'urn:uuid:another-award',
633
+ };
634
+ const baseline = await getMutationBaseline();
635
+ await expect(
636
+ issuer.clients.fullAuth.boost.prepareRefreshableSend({
637
+ ...input,
638
+ [field]: changes[field],
639
+ })
640
+ ).rejects.toMatchObject({ code: 'CONFLICT' });
641
+ await expectsNoMutation(baseline);
642
+ }
643
+ );
644
+
645
+ it.each(['templateData', 'integrationId'] as const)(
646
+ 'rejects changing %s after delivery instead of returning the old receipt',
647
+ async field => {
648
+ const input = {
649
+ type: 'boost' as const,
650
+ recipient: HOLDER_PROFILE_ID,
651
+ template: inlineTemplate(),
652
+ templateData: { grade: 'A' },
653
+ integrationId: 'integration-a',
654
+ refresh: true,
655
+ idempotencyKey: 'delivered-request',
656
+ };
657
+ await issuer.clients.fullAuth.boost.send(input);
658
+ const changes = { templateData: { grade: 'F' }, integrationId: 'integration-b' };
659
+ const baseline = await getMutationBaseline();
660
+ await expect(
661
+ issuer.clients.fullAuth.boost.send({ ...input, [field]: changes[field] })
662
+ ).rejects.toMatchObject({ code: 'CONFLICT' });
663
+ await expectsNoMutation(baseline);
664
+ }
665
+ );
666
+
667
+ it('accepts reordered delivery results but rejects changed receipt metadata', async () => {
668
+ const input = {
669
+ type: 'boost' as const,
670
+ recipient: HOLDER_PROFILE_ID,
671
+ template: inlineTemplate(),
672
+ refresh: true,
673
+ idempotencyKey: 'reordered-result',
674
+ };
675
+ const first = await issuer.clients.fullAuth.boost.send(input);
676
+ const intent = await getRefreshSendIntent(ISSUER_PROFILE_ID, input.idempotencyKey);
677
+ const receipt = first.refresh!;
678
+ const reordered = {
679
+ refresh: {
680
+ holderDid: receipt.holderDid,
681
+ issuerDid: receipt.issuerDid,
682
+ credentialId: receipt.credentialId,
683
+ credentialStatus: receipt.credentialStatus,
684
+ refreshService: {
685
+ authorization: receipt.refreshService.authorization,
686
+ type: receipt.refreshService.type,
687
+ id: receipt.refreshService.id,
688
+ },
689
+ refreshId: receipt.refreshId,
690
+ },
691
+ activityId: first.activityId,
692
+ credentialUri: first.credentialUri,
693
+ uri: first.uri,
694
+ type: first.type,
695
+ };
696
+ expect(reordered).toEqual(first);
697
+ expect(JSON.stringify(reordered)).not.toBe(JSON.stringify(first));
698
+ await expect(
699
+ markRefreshSendIntentDelivered(intent!, reordered)
700
+ ).resolves.toBeUndefined();
701
+ await expect(
702
+ markRefreshSendIntentDelivered(intent!, {
703
+ ...reordered,
704
+ refresh: { ...reordered.refresh, holderDid: 'did:key:another-holder' },
705
+ })
706
+ ).rejects.toMatchObject({ code: 'CONFLICT' });
707
+ expect(
708
+ (await getRefreshSendIntent(ISSUER_PROFILE_ID, input.idempotencyKey))?.result
709
+ ).toEqual(first);
710
+ });
711
+
712
+ it('fences every stale-owner write after another request takes over', async () => {
713
+ const request = {
714
+ issuerProfileId: ISSUER_PROFILE_ID,
715
+ idempotencyKey: 'stale-owner',
716
+ requestDigest: 'same-request',
717
+ };
718
+ const first = (await claimRefreshSendIntent(request)).intent;
719
+ await runQuery('MATCH (i:RefreshSendIntent {intentKey: $key}) SET i.updatedAt = $old', {
720
+ key: first.intentKey,
721
+ old: '2000-01-01T00:00:00.000Z',
722
+ });
723
+ const second = (await claimRefreshSendIntent(request)).intent;
724
+ expect(second.claimToken).not.toBe(first.claimToken);
725
+ await recordRefreshSendIntent(second, {
726
+ boostUri: 'boost:new-owner',
727
+ state: 'prepared',
728
+ });
729
+ const receipt = {
730
+ refreshId: 'r',
731
+ refreshService: {
732
+ id: 'https://example.com/refresh/r',
733
+ type: 'LearnCardCredentialRefresh2026' as const,
734
+ authorization: { type: 'LearnCardDIDAuth' as const },
735
+ },
736
+ credentialId: 'urn:uuid:award',
737
+ issuerDid: issuer.learnCard.id.did(),
738
+ holderDid: holder.learnCard.id.did(),
739
+ };
740
+ const result = {
741
+ type: 'boost' as const,
742
+ uri: 'boost:new-owner',
743
+ credentialUri: 'credential:new-owner',
744
+ activityId: 'activity',
745
+ };
746
+ await expect(
747
+ recordRefreshSendIntent(first, { boostUri: 'boost:old-owner', state: 'prepared' })
748
+ ).rejects.toMatchObject({ code: 'CONFLICT' });
749
+ await expect(recordRefreshSendIntentPending(first, receipt)).rejects.toMatchObject({
750
+ code: 'CONFLICT',
751
+ });
752
+ await expect(markRefreshSendIntentDelivered(first, result)).rejects.toMatchObject({
753
+ code: 'CONFLICT',
754
+ });
755
+ await recordRefreshSendIntentPending(second, receipt);
756
+ await markRefreshSendIntentDelivered(second, result);
757
+ await expect(
758
+ recordRefreshSendIntent(second, { state: 'prepared' })
759
+ ).rejects.toMatchObject({ code: 'CONFLICT' });
760
+ const final = await getRefreshSendIntent(ISSUER_PROFILE_ID, request.idempotencyKey);
761
+ expect(final).toMatchObject({
762
+ boostUri: 'boost:new-owner',
763
+ state: 'delivered',
764
+ result,
765
+ });
766
+ });
767
+
768
+ it('rejects idempotencyKey without refresh', async () => {
769
+ const boostUri = await issuer.clients.fullAuth.boost.createBoost({
770
+ credential: testUnsignedBoost,
771
+ });
772
+
773
+ await expect(
774
+ issuer.clients.fullAuth.boost.send({
775
+ type: 'boost',
776
+ recipient: HOLDER_PROFILE_ID,
777
+ templateUri: boostUri,
778
+ idempotencyKey: 'no-refresh',
779
+ })
780
+ ).rejects.toMatchObject({ code: 'BAD_REQUEST' });
781
+ });
782
+ });
783
+
784
+ describe('managed-profile holders', () => {
785
+ const createManagedHolder = async (): Promise<void> => {
786
+ // `holder` administers a profile manager that manages MANAGED_PROFILE_ID.
787
+ // Mirrors test/credentials.spec.ts ("should clear did:web cache for managed profiles").
788
+ const managerDid = await holder.clients.fullAuth.profileManager.createProfileManager(
789
+ {}
790
+ );
791
+ const managerClient = getClient({ did: managerDid, isChallengeValid: true });
792
+
793
+ await managerClient.profileManager.createManagedProfile({
794
+ profileId: MANAGED_PROFILE_ID,
795
+ });
796
+ };
797
+
798
+ it('encrypts managed refresh storage so the managing profile can decrypt it', async () => {
799
+ await createManagedHolder();
800
+
801
+ const boostUri = await issuer.clients.fullAuth.boost.createBoost({
802
+ credential: testUnsignedVcV2,
803
+ });
804
+
805
+ const result = (await issuer.clients.fullAuth.boost.send({
806
+ type: 'boost',
807
+ recipient: MANAGED_PROFILE_ID,
808
+ templateUri: boostUri,
809
+ refresh: true,
810
+ })) as SendResult;
811
+
812
+ const stored = JSON.parse(await getRootCredentialBody(result.refresh!.refreshId));
813
+
814
+ // The manager's own key must be a recipient: the managed profile's did:key
815
+ // seed was discarded at creation, so without it nobody could decrypt.
816
+ const decrypted = (await holder.learnCard.invoke.decryptDagJwe(stored)) as VC;
817
+ expect(decrypted.name).toBe('Refreshable VC');
818
+
819
+ // Still holder-side only: the brain cannot recover the plaintext.
820
+ // (DIDKit resolves an empty string instead of throwing for non-recipients,
821
+ // so assert on the outcome rather than the error.)
822
+ const brainAttempt = await brain.invoke.decryptDagJwe(stored).catch(() => null);
823
+ expect(brainAttempt).not.toEqual(decrypted);
824
+ });
825
+
826
+ it('encrypts managed publication storage so the managing profile can decrypt it', async () => {
827
+ await createManagedHolder();
828
+
829
+ const boostUri = await issuer.clients.fullAuth.boost.createBoost({
830
+ credential: testUnsignedVcV2,
831
+ });
832
+
833
+ const result = (await issuer.clients.fullAuth.boost.send({
834
+ type: 'boost',
835
+ recipient: MANAGED_PROFILE_ID,
836
+ templateUri: boostUri,
837
+ refresh: true,
838
+ })) as SendResult;
839
+
840
+ const receipt = result.refresh!;
841
+ expect(receipt.credentialStatus).toBeDefined();
842
+
843
+ // Rebuild the updated body from the receipt metadata plus the issuer's own
844
+ // claims, mirroring the existing receipt-driven publication test.
845
+ const updatedBody = {
846
+ '@context': ['https://www.w3.org/ns/credentials/v2'],
847
+ id: receipt.credentialId,
848
+ type: ['VerifiableCredential'],
849
+ issuer: receipt.issuerDid,
850
+ name: 'Updated Refreshable VC',
851
+ credentialSubject: { id: receipt.holderDid },
852
+ refreshService: receipt.refreshService,
853
+ credentialStatus: receipt.credentialStatus,
854
+ validFrom: new Date().toISOString(),
855
+ };
856
+
857
+ const updated = await issuer.learnCard.invoke.issueCredential(
858
+ updatedBody as UnsignedVC
859
+ );
860
+
861
+ const publishResult =
862
+ await issuer.clients.fullAuth.credentialRefresh.publishCredentialRefresh({
863
+ mode: 'issuer-signed',
864
+ refreshId: receipt.refreshId,
865
+ signedCredential: updated,
866
+ });
867
+
868
+ expect(publishResult.version).toBe(2);
869
+
870
+ const head = await runQuery(
871
+ `MATCH (:CredentialRefresh {refreshId: $refreshId})-[:HEAD]->(head:Credential)
872
+ RETURN head.credential AS credential`,
873
+ { refreshId: receipt.refreshId }
874
+ );
875
+ const stored = JSON.parse(head.records[0]!.get('credential'));
876
+
877
+ const decrypted = (await holder.learnCard.invoke.decryptDagJwe(stored)) as VC;
878
+ expect(decrypted.name).toBe('Updated Refreshable VC');
879
+
880
+ // Same as the initial send: the brain cannot recover the plaintext.
881
+ const brainAttempt = await brain.invoke.decryptDagJwe(stored).catch(() => null);
882
+ expect(brainAttempt).not.toEqual(decrypted);
883
+ });
884
+ });
885
+
886
+ describe('refresh sends produce a usable receipt', () => {
887
+ it('sends a refreshable boost from a templateUri to a local profile', async () => {
888
+ const boostUri = await issuer.clients.fullAuth.boost.createBoost({
889
+ credential: testUnsignedVcV2,
890
+ });
891
+
892
+ const result = (await issuer.clients.fullAuth.boost.send({
893
+ type: 'boost',
894
+ recipient: HOLDER_PROFILE_ID,
895
+ templateUri: boostUri,
896
+ refresh: true,
897
+ })) as SendResult;
898
+
899
+ expect(result.type).toBe('boost');
900
+ expect(result.uri).toBe(boostUri);
901
+ expect(result.credentialUri).toBeDefined();
902
+ expect(result.activityId).toBeDefined();
903
+
904
+ expect(result.refresh).toBeDefined();
905
+ expect(result.refresh!.refreshId).toBeDefined();
906
+ expect(result.refresh!.refreshService.type).toBe('LearnCardCredentialRefresh2026');
907
+ expect(result.refresh!.refreshService.id).toContain(result.refresh!.refreshId);
908
+ expect(result.refresh!.credentialId).toBeDefined();
909
+ expect(result.refresh!.issuerDid).toBe(issuer.learnCard.id.did());
910
+ expect(result.refresh!.holderDid).toBe(getDidWeb(DOMAIN, HOLDER_PROFILE_ID));
911
+ // Status descriptors are taken from the signed version 1
912
+ expect(result.refresh!.credentialStatus).toBeDefined();
913
+
914
+ // The stored root is a holder-only JWE, never plaintext
915
+ const stored = await getRootCredentialBody(result.refresh!.refreshId);
916
+ expect(stored).toBeDefined();
917
+ expect(stored).not.toContain('Refreshable VC');
918
+ expect(JWEValidator.safeParse(JSON.parse(stored)).success).toBe(true);
919
+
920
+ // Bound as version 1
921
+ const head = await getCredentialRefreshHead(result.refresh!.refreshId);
922
+ expect(head).toMatchObject({ version: 1 });
923
+ });
924
+
925
+ it('resolves profile DIDs as recipients', async () => {
926
+ const boostUri = await issuer.clients.fullAuth.boost.createBoost({
927
+ credential: testUnsignedBoost,
928
+ });
929
+
930
+ const holderProfile = await holder.clients.fullAuth.profile.getProfile();
931
+
932
+ const result = (await issuer.clients.fullAuth.boost.send({
933
+ type: 'boost',
934
+ recipient: holderProfile.did,
935
+ templateUri: boostUri,
936
+ refresh: true,
937
+ })) as SendResult;
938
+
939
+ expect(result.refresh).toBeDefined();
940
+ // The receipt holder identity is the holder's public did:web, matching the
941
+ // credential subject written by the template renderer.
942
+ expect(result.refresh!.holderDid).toBe(getDidWeb(DOMAIN, HOLDER_PROFILE_ID));
943
+
944
+ const didWeb = getDidWeb(DOMAIN, HOLDER_PROFILE_ID);
945
+
946
+ const result2 = (await issuer.clients.fullAuth.boost.send({
947
+ type: 'boost',
948
+ recipient: didWeb,
949
+ templateUri: boostUri,
950
+ refresh: true,
951
+ })) as SendResult;
952
+
953
+ expect(result2.refresh).toBeDefined();
954
+ expect(result2.refresh!.holderDid).toBe(didWeb);
955
+ });
956
+
957
+ it('creates the boost when sending a refreshable inline template', async () => {
958
+ const result = (await issuer.clients.fullAuth.boost.send({
959
+ type: 'boost',
960
+ recipient: HOLDER_PROFILE_ID,
961
+ template: { credential: testUnsignedBoost },
962
+ refresh: true,
963
+ })) as SendResult;
964
+
965
+ expect(result.uri).toBeDefined();
966
+ expect(result.refresh).toBeDefined();
967
+ expect(result.refresh!.refreshId).toBeDefined();
968
+
969
+ const boostId = await runQuery(
970
+ `MATCH (c:Credential {refreshVersionKey: $key})-[:INSTANCE_OF]->(b:Boost)
971
+ RETURN b.id AS id LIMIT 1`,
972
+ { key: `${result.refresh!.refreshId}:1` }
973
+ );
974
+
975
+ expect(boostId.records.length).toBe(1);
976
+ });
977
+
978
+ it('preserves activityId, integrationId, and boost linkage on the managed root', async () => {
979
+ const boostUri = await issuer.clients.fullAuth.boost.createBoost({
980
+ credential: testUnsignedBoost,
981
+ });
982
+
983
+ const result = (await issuer.clients.fullAuth.boost.send({
984
+ type: 'boost',
985
+ recipient: HOLDER_PROFILE_ID,
986
+ templateUri: boostUri,
987
+ integrationId: 'integration-123',
988
+ refresh: true,
989
+ })) as SendResult;
990
+
991
+ const rows = await runQuery(
992
+ `MATCH (:Profile {profileId: $issuerId})-[sent:CREDENTIAL_SENT]->(root:Credential {refreshVersionKey: $key})
993
+ OPTIONAL MATCH (root)-[:INSTANCE_OF]->(b:Boost)
994
+ RETURN sent.activityId AS activityId, sent.integrationId AS integrationId,
995
+ b.id AS boostNodeId, $boostUri AS boostUri`,
996
+ {
997
+ issuerId: ISSUER_PROFILE_ID,
998
+ key: `${result.refresh!.refreshId}:1`,
999
+ boostUri,
1000
+ }
1001
+ );
1002
+
1003
+ expect(rows.records.length).toBe(1);
1004
+ expect(rows.records[0].get('activityId')).toBe(result.activityId);
1005
+ expect(rows.records[0].get('integrationId')).toBe('integration-123');
1006
+ expect(String(rows.records[0].get('boostNodeId'))).toBe(
1007
+ boostUri.split('/').pop()!.split(':').pop()
1008
+ );
1009
+ });
1010
+
1011
+ it('correlates SEND and CLAIM activities through the same activityId', async () => {
1012
+ const boostUri = await issuer.clients.fullAuth.boost.createBoost({
1013
+ credential: testUnsignedBoost,
1014
+ });
1015
+
1016
+ const result = (await issuer.clients.fullAuth.boost.send({
1017
+ type: 'boost',
1018
+ recipient: HOLDER_PROFILE_ID,
1019
+ templateUri: boostUri,
1020
+ refresh: true,
1021
+ })) as SendResult;
1022
+
1023
+ await holder.clients.fullAuth.credential.acceptCredential({
1024
+ uri: result.credentialUri,
1025
+ });
1026
+
1027
+ const events = await runQuery(
1028
+ `MATCH (a:CredentialActivity {activityId: $activityId})
1029
+ RETURN a.eventType AS eventType ORDER BY a.eventType`,
1030
+ { activityId: result.activityId }
1031
+ );
1032
+
1033
+ const types = events.records.map(record => record.get('eventType')).sort();
1034
+
1035
+ expect(types).toEqual(['CLAIMED', 'DELIVERED']);
1036
+ });
1037
+
1038
+ it('preserves approved contract linkage on the managed root', async () => {
1039
+ const contractUri = await issuer.clients.fullAuth.contracts.createConsentFlowContract({
1040
+ contract: {
1041
+ read: { anonymize: false },
1042
+ write: { credentials: { categories: { TestBoosts: { required: false } } } },
1043
+ },
1044
+ name: 'Refresh Send Contract',
1045
+ writers: [ISSUER_PROFILE_ID],
1046
+ });
1047
+
1048
+ const boostUri = await issuer.clients.fullAuth.boost.createBoost({
1049
+ credential: testUnsignedBoost,
1050
+ category: 'TestBoosts',
1051
+ });
1052
+
1053
+ await holder.clients.fullAuth.contracts.consentToContract({
1054
+ contractUri,
1055
+ terms: {
1056
+ read: { anonymize: false },
1057
+ write: { credentials: { categories: { TestBoosts: true } } },
1058
+ },
1059
+ });
1060
+
1061
+ const result = (await issuer.clients.fullAuth.boost.send({
1062
+ type: 'boost',
1063
+ recipient: HOLDER_PROFILE_ID,
1064
+ templateUri: boostUri,
1065
+ contractUri,
1066
+ refresh: true,
1067
+ })) as SendResult;
1068
+
1069
+ expect(result.refresh).toBeDefined();
1070
+
1071
+ const rows = await runQuery(
1072
+ `MATCH (root:Credential {refreshVersionKey: $key})-[:ISSUED_VIA_TRANSACTION]->(:ConsentFlowTransaction)-[:IS_FOR]->(:ConsentFlowTerms)
1073
+ RETURN count(*) AS count`,
1074
+ { key: `${result.refresh!.refreshId}:1` }
1075
+ );
1076
+
1077
+ expect(toNum(rows.records[0].get('count'))).toBe(1);
1078
+ });
1079
+ });
1080
+
1081
+ describe('preallocated signed credential handoff', () => {
1082
+ const buildHandoff = async (withStatus = false) => {
1083
+ const boostUri = await issuer.clients.fullAuth.boost.createBoost({
1084
+ credential: testUnsignedBoost,
1085
+ });
1086
+
1087
+ const credentialId = `urn:uuid:refresh-send-handoff-${withStatus ? 'status' : 'plain'}`;
1088
+ const allocation =
1089
+ await issuer.clients.fullAuth.credentialRefresh.allocateCredentialRefresh({
1090
+ holder: { profileId: HOLDER_PROFILE_ID, did: holder.learnCard.id.did() },
1091
+ credentialId,
1092
+ });
1093
+
1094
+ const injected = injectManagedRefreshService(
1095
+ {
1096
+ '@context': ['https://www.w3.org/ns/credentials/v2'],
1097
+ id: credentialId,
1098
+ type: ['VerifiableCredential'],
1099
+ issuer: issuer.learnCard.id.did(),
1100
+ validFrom: '2026-01-01T00:00:00Z',
1101
+ name: 'Handoff Badge',
1102
+ credentialSubject: { id: holder.learnCard.id.did() },
1103
+ } as UnsignedVC,
1104
+ allocation.refreshService
1105
+ );
1106
+
1107
+ const toSign = withStatus
1108
+ ? ((await appendBitstringStatusListEntries(
1109
+ injected,
1110
+ ISSUER_PROFILE_ID,
1111
+ DOMAIN
1112
+ )) as UnsignedVC)
1113
+ : injected;
1114
+
1115
+ const signed = await issuer.learnCard.invoke.issueCredential(toSign);
1116
+
1117
+ return { boostUri, allocation, signed };
1118
+ };
1119
+
1120
+ it('rejects a directly signed keyed send before creating any send state', async () => {
1121
+ const { signed } = await buildHandoff(false);
1122
+ const baseline = await getMutationBaseline();
1123
+ await expect(
1124
+ issuer.clients.fullAuth.boost.send({
1125
+ type: 'boost',
1126
+ recipient: HOLDER_PROFILE_ID,
1127
+ signedCredential: signed,
1128
+ refresh: true,
1129
+ idempotencyKey: 'never-prepared',
1130
+ })
1131
+ ).rejects.toMatchObject({
1132
+ code: 'BAD_REQUEST',
1133
+ message: expect.stringContaining('omit idempotencyKey'),
1134
+ });
1135
+ await expectsNoMutation(baseline);
1136
+ });
1137
+
1138
+ it.each([
1139
+ '../..',
1140
+ '?query',
1141
+ '#fragment',
1142
+ 'a/b',
1143
+ 'a'.repeat(4096),
1144
+ 'a'.repeat(42),
1145
+ 'a'.repeat(44),
1146
+ ])('rejects a noncanonical managed refresh route identifier (%s)', async invalidId => {
1147
+ const { signed, allocation } = await buildHandoff(false);
1148
+ const prefix = allocation.refreshService.id.slice(0, -allocation.refreshId.length);
1149
+ expect(() =>
1150
+ extractManagedRefreshHandoff(
1151
+ {
1152
+ ...signed,
1153
+ refreshService: {
1154
+ ...allocation.refreshService,
1155
+ id: `${prefix}${invalidId}`,
1156
+ },
1157
+ },
1158
+ DOMAIN
1159
+ )
1160
+ ).toThrow();
1161
+ expect(extractManagedRefreshHandoff(signed, DOMAIN)?.refreshId).toBe(
1162
+ allocation.refreshId
1163
+ );
1164
+ });
1165
+
1166
+ it('binds an already-allocated signed credential and returns the receipt', async () => {
1167
+ const { boostUri, signed } = await buildHandoff(true);
1168
+
1169
+ const result = (await issuer.clients.fullAuth.boost.send({
1170
+ type: 'boost',
1171
+ recipient: HOLDER_PROFILE_ID,
1172
+ templateUri: boostUri,
1173
+ signedCredential: signed,
1174
+ refresh: true,
1175
+ })) as SendResult;
1176
+
1177
+ expect(result.refresh).toBeDefined();
1178
+ expect(result.refresh!.refreshId).toBeDefined();
1179
+ expect(result.refresh!.credentialId).toBe(signed.id);
1180
+ expect(result.refresh!.issuerDid).toBe(issuer.learnCard.id.did());
1181
+ expect(result.refresh!.holderDid).toBe(holder.learnCard.id.did());
1182
+ expect(result.refresh!.credentialStatus).toEqual(
1183
+ (signed as unknown as Record<string, unknown>).credentialStatus
1184
+ );
1185
+
1186
+ const head = await getCredentialRefreshHead(result.refresh!.refreshId);
1187
+ expect(head).toMatchObject({ version: 1 });
1188
+ });
1189
+
1190
+ it('rejects a signed credential without an allocated managed service, with no second allocation', async () => {
1191
+ const baseline = await getMutationBaseline();
1192
+
1193
+ const boostUri = await issuer.clients.fullAuth.boost.createBoost({
1194
+ credential: testUnsignedBoost,
1195
+ });
1196
+
1197
+ const signed = await issuer.learnCard.invoke.issueCredential({
1198
+ '@context': ['https://www.w3.org/ns/credentials/v2'],
1199
+ id: 'urn:uuid:no-managed-service',
1200
+ type: ['VerifiableCredential'],
1201
+ issuer: issuer.learnCard.id.did(),
1202
+ validFrom: '2026-01-01T00:00:00Z',
1203
+ credentialSubject: { id: holder.learnCard.id.did() },
1204
+ } as UnsignedVC);
1205
+
1206
+ await expect(
1207
+ issuer.clients.fullAuth.boost.send({
1208
+ type: 'boost',
1209
+ recipient: HOLDER_PROFILE_ID,
1210
+ templateUri: boostUri,
1211
+ signedCredential: signed,
1212
+ refresh: true,
1213
+ })
1214
+ ).rejects.toMatchObject({ code: 'BAD_REQUEST' });
1215
+
1216
+ // No allocation, no managed binding, no duplicate credential storage
1217
+ expect(await countNodes('CredentialRefresh')).toBe(baseline.refresh);
1218
+ expect(await countNodes('Credential')).toBe(baseline.credential);
1219
+ });
1220
+
1221
+ it('rejects a signed credential whose allocation belongs to another issuer', async () => {
1222
+ const boostUri = await issuer.clients.fullAuth.boost.createBoost({
1223
+ credential: testUnsignedBoost,
1224
+ });
1225
+
1226
+ const credentialId = 'urn:uuid:foreign-allocation';
1227
+ const allocation =
1228
+ await outsider.clients.fullAuth.credentialRefresh.allocateCredentialRefresh({
1229
+ holder: { profileId: HOLDER_PROFILE_ID, did: holder.learnCard.id.did() },
1230
+ credentialId,
1231
+ });
1232
+
1233
+ const signed = await outsider.learnCard.invoke.issueCredential(
1234
+ injectManagedRefreshService(
1235
+ {
1236
+ '@context': ['https://www.w3.org/ns/credentials/v2'],
1237
+ id: credentialId,
1238
+ type: ['VerifiableCredential'],
1239
+ issuer: outsider.learnCard.id.did(),
1240
+ validFrom: '2026-01-01T00:00:00Z',
1241
+ credentialSubject: { id: holder.learnCard.id.did() },
1242
+ } as UnsignedVC,
1243
+ allocation.refreshService
1244
+ )
1245
+ );
1246
+
1247
+ const baseline = await getMutationBaseline();
1248
+
1249
+ await expect(
1250
+ issuer.clients.fullAuth.boost.send({
1251
+ type: 'boost',
1252
+ recipient: HOLDER_PROFILE_ID,
1253
+ templateUri: boostUri,
1254
+ signedCredential: signed,
1255
+ refresh: true,
1256
+ })
1257
+ ).rejects.toMatchObject({ code: 'UNAUTHORIZED' });
1258
+
1259
+ expect(await countNodes('Credential')).toBe(baseline.credential);
1260
+ });
1261
+
1262
+ it('does not duplicate the root, activity, or notification when the same handoff is retried', async () => {
1263
+ const { boostUri, signed } = await buildHandoff();
1264
+
1265
+ const first = (await issuer.clients.fullAuth.boost.send({
1266
+ type: 'boost',
1267
+ recipient: HOLDER_PROFILE_ID,
1268
+ templateUri: boostUri,
1269
+ integrationId: 'retry-integration',
1270
+ signedCredential: signed,
1271
+ refresh: true,
1272
+ })) as SendResult;
1273
+
1274
+ const second = (await issuer.clients.fullAuth.boost.send({
1275
+ type: 'boost',
1276
+ recipient: HOLDER_PROFILE_ID,
1277
+ templateUri: boostUri,
1278
+ integrationId: 'retry-integration',
1279
+ signedCredential: signed,
1280
+ refresh: true,
1281
+ })) as SendResult;
1282
+
1283
+ expect(second.credentialUri).toBe(first.credentialUri);
1284
+ expect(second.refresh!.refreshId).toBe(first.refresh!.refreshId);
1285
+
1286
+ // Same activity reused — no duplicate issuance activity on resume
1287
+ expect(second.activityId).toBe(first.activityId);
1288
+ expect(await countNodes('CredentialActivity')).toBe(1);
1289
+
1290
+ // One stored root, one sent relationship, one initial notification
1291
+ expect(await countNodes('Credential')).toBe(1);
1292
+ expect(await countRelationships('CREDENTIAL_SENT')).toBe(1);
1293
+
1294
+ const notifications = addNotificationToQueueSpy.mock.calls.filter(
1295
+ ([event]) =>
1296
+ (event as { type?: string })?.type === 'CREDENTIAL_RECEIVED' ||
1297
+ (event as { notification?: { type?: string } })?.notification?.type ===
1298
+ 'CREDENTIAL_RECEIVED'
1299
+ );
1300
+ expect(notifications).toHaveLength(1);
1301
+
1302
+ const head = await getCredentialRefreshHead(first.refresh!.refreshId);
1303
+ expect(head).toMatchObject({ version: 1 });
1304
+ });
1305
+
1306
+ it('replays a signed handoff without templateUri without creating another boost', async () => {
1307
+ const { signed } = await buildHandoff();
1308
+
1309
+ const send = async () =>
1310
+ (await issuer.clients.fullAuth.boost.send({
1311
+ type: 'boost',
1312
+ recipient: HOLDER_PROFILE_ID,
1313
+ signedCredential: signed,
1314
+ refresh: true,
1315
+ })) as SendResult;
1316
+
1317
+ const first = await send();
1318
+ const boostsAfterFirst = await countNodes('Boost');
1319
+
1320
+ const second = await send();
1321
+
1322
+ expect(second.uri).toBe(first.uri);
1323
+ expect(second.credentialUri).toBe(first.credentialUri);
1324
+ expect(second.activityId).toBe(first.activityId);
1325
+ expect(second.refresh!.refreshId).toBe(first.refresh!.refreshId);
1326
+ expect(await countNodes('Boost')).toBe(boostsAfterFirst);
1327
+ expect(await countNodes('CredentialActivity')).toBe(1);
1328
+ });
1329
+
1330
+ it('does not store the refresh service in a boost auto-created from a signed handoff', async () => {
1331
+ const { signed } = await buildHandoff();
1332
+
1333
+ const result = (await issuer.clients.fullAuth.boost.send({
1334
+ type: 'boost',
1335
+ recipient: HOLDER_PROFILE_ID,
1336
+ signedCredential: signed,
1337
+ refresh: true,
1338
+ })) as SendResult;
1339
+
1340
+ const boostId = result.uri.split(':').pop();
1341
+ const boostRecord = await runQuery(
1342
+ 'MATCH (b:Boost {id: $boostId}) RETURN b.boost AS boost',
1343
+ { boostId }
1344
+ );
1345
+ const storedTemplate = JSON.parse(boostRecord.records[0]!.get('boost'));
1346
+
1347
+ expect(result.refresh).toBeDefined();
1348
+ expect(storedTemplate.refreshService).toBeUndefined();
1349
+ });
1350
+ });
1351
+
1352
+ describe('normal sends are unchanged', () => {
1353
+ it('returns no refresh receipt when refresh is omitted', async () => {
1354
+ const boostUri = await issuer.clients.fullAuth.boost.createBoost({
1355
+ credential: testUnsignedBoost,
1356
+ });
1357
+
1358
+ const result = (await issuer.clients.fullAuth.boost.send({
1359
+ type: 'boost',
1360
+ recipient: HOLDER_PROFILE_ID,
1361
+ templateUri: boostUri,
1362
+ })) as SendResult;
1363
+
1364
+ expect(result.refresh).toBeUndefined();
1365
+ expect(result.credentialUri).toBeDefined();
1366
+ expect(result.activityId).toBeDefined();
1367
+ expect(await countNodes('CredentialRefresh')).toBe(0);
1368
+ });
1369
+
1370
+ it('returns no refresh receipt when refresh is false', async () => {
1371
+ const boostUri = await issuer.clients.fullAuth.boost.createBoost({
1372
+ credential: testUnsignedBoost,
1373
+ });
1374
+
1375
+ const result = (await issuer.clients.fullAuth.boost.send({
1376
+ type: 'boost',
1377
+ recipient: HOLDER_PROFILE_ID,
1378
+ templateUri: boostUri,
1379
+ refresh: false,
1380
+ })) as SendResult;
1381
+
1382
+ expect(result.refresh).toBeUndefined();
1383
+ expect(await countNodes('CredentialRefresh')).toBe(0);
1384
+ });
1385
+
1386
+ it('keeps the normal BOOST_RECEIVED notification for non-refresh sends', async () => {
1387
+ const boostUri = await issuer.clients.fullAuth.boost.createBoost({
1388
+ credential: testUnsignedBoost,
1389
+ });
1390
+
1391
+ await issuer.clients.fullAuth.boost.send({
1392
+ type: 'boost',
1393
+ recipient: HOLDER_PROFILE_ID,
1394
+ templateUri: boostUri,
1395
+ });
1396
+
1397
+ const boostNotifications = addNotificationToQueueSpy.mock.calls.filter(
1398
+ ([event]) =>
1399
+ (event as { type?: string })?.type === 'BOOST_RECEIVED' ||
1400
+ (event as { notification?: { type?: string } })?.notification?.type ===
1401
+ 'BOOST_RECEIVED'
1402
+ );
1403
+
1404
+ expect(boostNotifications.length).toBeGreaterThan(0);
1405
+ });
1406
+ });
1407
+
1408
+ describe('early rejection before any mutation', () => {
1409
+ it.each(['holder@example.com', '+15551234567'])(
1410
+ 'rejects inbox refresh for %s before mutation when disabled',
1411
+ async recipient => {
1412
+ const baseline = await getMutationBaseline();
1413
+ process.env.CREDENTIAL_REFRESH_ENABLED = 'false';
1414
+ try {
1415
+ await expect(
1416
+ issuer.clients.fullAuth.boost.send({
1417
+ type: 'boost',
1418
+ recipient,
1419
+ template: { credential: testUnsignedBoost },
1420
+ refresh: true,
1421
+ })
1422
+ ).rejects.toMatchObject({ code: 'NOT_FOUND' });
1423
+ await expectsNoMutation(baseline);
1424
+ } finally {
1425
+ process.env.CREDENTIAL_REFRESH_ENABLED = 'true';
1426
+ }
1427
+ }
1428
+ );
1429
+
1430
+ it('rejects remote/unresolvable DIDs before federation or delivery', async () => {
1431
+ const baseline = await getMutationBaseline();
1432
+
1433
+ await expect(
1434
+ issuer.clients.fullAuth.boost.send({
1435
+ type: 'boost',
1436
+ recipient: 'did:web:example.com:users:remote-holder',
1437
+ template: { credential: testUnsignedBoost },
1438
+ refresh: true,
1439
+ })
1440
+ ).rejects.toMatchObject({ code: 'NOT_FOUND' });
1441
+
1442
+ await expectsNoMutation(baseline);
1443
+
1444
+ await expect(
1445
+ issuer.clients.fullAuth.boost.send({
1446
+ type: 'boost',
1447
+ recipient: 'did:key:z6MkremoteHolderThatIsNotRegistered0000000',
1448
+ template: { credential: testUnsignedBoost },
1449
+ refresh: true,
1450
+ })
1451
+ ).rejects.toMatchObject({ code: 'NOT_FOUND' });
1452
+
1453
+ await expectsNoMutation(baseline);
1454
+ });
1455
+
1456
+ it('rejects when the refresh feature is disabled and creates nothing', async () => {
1457
+ const baseline = await getMutationBaseline();
1458
+ const previous = process.env.CREDENTIAL_REFRESH_ENABLED;
1459
+ process.env.CREDENTIAL_REFRESH_ENABLED = 'false';
1460
+
1461
+ try {
1462
+ await expect(
1463
+ issuer.clients.fullAuth.boost.send({
1464
+ type: 'boost',
1465
+ recipient: HOLDER_PROFILE_ID,
1466
+ template: { credential: testUnsignedBoost },
1467
+ refresh: true,
1468
+ })
1469
+ ).rejects.toMatchObject({ code: 'NOT_FOUND' });
1470
+ } finally {
1471
+ process.env.CREDENTIAL_REFRESH_ENABLED = previous;
1472
+ }
1473
+
1474
+ await expectsNoMutation(baseline);
1475
+ });
1476
+
1477
+ it('rejects refresh sends without the credentials:write scope', async () => {
1478
+ const boostUri = await issuer.clients.fullAuth.boost.createBoost({
1479
+ credential: testUnsignedBoost,
1480
+ });
1481
+
1482
+ const scopedClient = getClient({
1483
+ did: issuer.learnCard.id.did(),
1484
+ isChallengeValid: true,
1485
+ scope: 'boosts:write boosts:read',
1486
+ });
1487
+
1488
+ const baseline = await getMutationBaseline();
1489
+
1490
+ await expect(
1491
+ scopedClient.boost.send({
1492
+ type: 'boost',
1493
+ recipient: HOLDER_PROFILE_ID,
1494
+ templateUri: boostUri,
1495
+ refresh: true,
1496
+ })
1497
+ ).rejects.toMatchObject({ code: 'UNAUTHORIZED' });
1498
+
1499
+ await expectsNoMutation(baseline);
1500
+ });
1501
+
1502
+ it('rejects blocked recipients before any mutation', async () => {
1503
+ const issuerProfile = await issuer.clients.fullAuth.profile.getProfile();
1504
+ const holderProfile = await holder.clients.fullAuth.profile.getProfile();
1505
+ await blockProfile(issuerProfile, holderProfile);
1506
+
1507
+ const baseline = await getMutationBaseline();
1508
+
1509
+ await expect(
1510
+ issuer.clients.fullAuth.boost.send({
1511
+ type: 'boost',
1512
+ recipient: HOLDER_PROFILE_ID,
1513
+ template: { credential: testUnsignedBoost },
1514
+ refresh: true,
1515
+ })
1516
+ ).rejects.toMatchObject({ code: 'NOT_FOUND' });
1517
+
1518
+ await expectsNoMutation(baseline);
1519
+ });
1520
+
1521
+ it('rejects unauthorized issuers and draft boosts', async () => {
1522
+ const boostUri = await issuer.clients.fullAuth.boost.createBoost({
1523
+ credential: testUnsignedBoost,
1524
+ });
1525
+
1526
+ await expect(
1527
+ outsider.clients.fullAuth.boost.send({
1528
+ type: 'boost',
1529
+ recipient: HOLDER_PROFILE_ID,
1530
+ templateUri: boostUri,
1531
+ refresh: true,
1532
+ })
1533
+ ).rejects.toMatchObject({ code: 'UNAUTHORIZED' });
1534
+
1535
+ const draftUri = await issuer.clients.fullAuth.boost.createBoost({
1536
+ credential: testUnsignedBoost,
1537
+ status: 'DRAFT',
1538
+ });
1539
+
1540
+ const baseline = await getMutationBaseline();
1541
+
1542
+ await expect(
1543
+ issuer.clients.fullAuth.boost.send({
1544
+ type: 'boost',
1545
+ recipient: HOLDER_PROFILE_ID,
1546
+ templateUri: draftUri,
1547
+ refresh: true,
1548
+ })
1549
+ ).rejects.toMatchObject({ code: 'FORBIDDEN' });
1550
+
1551
+ // The draft boost existed before the attempt; nothing new was created
1552
+ expect(await countNodes('CredentialRefresh')).toBe(baseline.refresh);
1553
+ expect(await countNodes('Credential')).toBe(baseline.credential);
1554
+ });
1555
+ });
1556
+
1557
+ describe('self-send and notification behavior', () => {
1558
+ it('suppresses the initial notification for self-sends', async () => {
1559
+ const boostUri = await issuer.clients.fullAuth.boost.createBoost({
1560
+ credential: testUnsignedBoost,
1561
+ });
1562
+
1563
+ const result = (await issuer.clients.fullAuth.boost.send({
1564
+ type: 'boost',
1565
+ recipient: ISSUER_PROFILE_ID,
1566
+ templateUri: boostUri,
1567
+ refresh: true,
1568
+ })) as SendResult;
1569
+
1570
+ expect(result.refresh).toBeDefined();
1571
+
1572
+ const refreshNotifications = addNotificationToQueueSpy.mock.calls.filter(
1573
+ ([event]) =>
1574
+ (event as { type?: string })?.type === 'CREDENTIAL_RECEIVED' ||
1575
+ (event as { notification?: { type?: string } })?.notification?.type ===
1576
+ 'CREDENTIAL_RECEIVED'
1577
+ );
1578
+ expect(refreshNotifications).toHaveLength(0);
1579
+ });
1580
+
1581
+ it('notifies the holder with a CREDENTIAL_RECEIVED notification for managed sends', async () => {
1582
+ const boostUri = await issuer.clients.fullAuth.boost.createBoost({
1583
+ credential: testUnsignedBoost,
1584
+ });
1585
+
1586
+ await issuer.clients.fullAuth.boost.send({
1587
+ type: 'boost',
1588
+ recipient: HOLDER_PROFILE_ID,
1589
+ templateUri: boostUri,
1590
+ refresh: true,
1591
+ });
1592
+
1593
+ const refreshNotifications = addNotificationToQueueSpy.mock.calls.filter(
1594
+ ([event]) =>
1595
+ (event as { type?: string })?.type === 'CREDENTIAL_RECEIVED' ||
1596
+ (event as { notification?: { type?: string } })?.notification?.type ===
1597
+ 'CREDENTIAL_RECEIVED'
1598
+ );
1599
+ expect(refreshNotifications).toHaveLength(1);
1600
+ });
1601
+ });
1602
+
1603
+ describe('receipt-driven publication', () => {
1604
+ it("allows a delegated issuer to send and publish refreshes on another profile's boost", async () => {
1605
+ const boostUri = await outsider.clients.fullAuth.boost.createBoost({
1606
+ credential: testUnsignedVcV2,
1607
+ });
1608
+ const baseline = await getMutationBaseline();
1609
+ await expect(
1610
+ issuer.clients.fullAuth.boost.send({
1611
+ type: 'boost',
1612
+ recipient: HOLDER_PROFILE_ID,
1613
+ templateUri: boostUri,
1614
+ refresh: true,
1615
+ })
1616
+ ).rejects.toMatchObject({ code: 'UNAUTHORIZED' });
1617
+ await expectsNoMutation(baseline);
1618
+ expect(signingAuthorityMocks.issueCredential).not.toHaveBeenCalled();
1619
+
1620
+ await outsider.clients.fullAuth.boost.addBoostAdmin({
1621
+ uri: boostUri,
1622
+ profileId: ISSUER_PROFILE_ID,
1623
+ });
1624
+ const result = await issuer.clients.fullAuth.boost.send({
1625
+ type: 'boost',
1626
+ recipient: HOLDER_PROFILE_ID,
1627
+ templateUri: boostUri,
1628
+ refresh: true,
1629
+ });
1630
+ const receipt = result.refresh!;
1631
+ const signedCredential = await issuer.learnCard.invoke.issueCredential({
1632
+ ...testUnsignedVcV2,
1633
+ id: receipt.credentialId,
1634
+ issuer: receipt.issuerDid,
1635
+ credentialSubject: { id: receipt.holderDid },
1636
+ name: 'Updated by delegated issuer',
1637
+ refreshService: receipt.refreshService,
1638
+ credentialStatus: receipt.credentialStatus,
1639
+ } as UnsignedVC);
1640
+ const published =
1641
+ await issuer.clients.fullAuth.credentialRefresh.publishCredentialRefresh({
1642
+ mode: 'issuer-signed',
1643
+ refreshId: receipt.refreshId,
1644
+ signedCredential,
1645
+ });
1646
+ expect(published.version).toBe(2);
1647
+ // Template ownership does not grant access to the delegate's aggregate.
1648
+ await expect(
1649
+ outsider.clients.fullAuth.credentialRefresh.publishCredentialRefresh({
1650
+ mode: 'issuer-signed',
1651
+ refreshId: receipt.refreshId,
1652
+ signedCredential,
1653
+ })
1654
+ ).rejects.toMatchObject({ code: 'UNAUTHORIZED' });
1655
+ });
1656
+
1657
+ it('publishes version 2 from the retained receipt without a new status allocation', async () => {
1658
+ const boostUri = await issuer.clients.fullAuth.boost.createBoost({
1659
+ credential: testUnsignedVcV2,
1660
+ });
1661
+
1662
+ const result = (await issuer.clients.fullAuth.boost.send({
1663
+ type: 'boost',
1664
+ recipient: HOLDER_PROFILE_ID,
1665
+ templateUri: boostUri,
1666
+ refresh: true,
1667
+ })) as SendResult;
1668
+
1669
+ const receipt = result.refresh!;
1670
+ expect(receipt.credentialStatus).toBeDefined();
1671
+
1672
+ // Rebuild the updated body from the receipt metadata plus the issuer's own
1673
+ // claims; the managed service and exact status descriptor come from the
1674
+ // receipt (the issuer cannot read the holder-encrypted stored version).
1675
+ const updatedBody = {
1676
+ '@context': ['https://www.w3.org/ns/credentials/v2'],
1677
+ id: receipt.credentialId,
1678
+ type: ['VerifiableCredential'],
1679
+ issuer: receipt.issuerDid,
1680
+ name: 'Updated Refreshable VC',
1681
+ credentialSubject: { id: receipt.holderDid },
1682
+ refreshService: receipt.refreshService,
1683
+ credentialStatus: receipt.credentialStatus,
1684
+ validFrom: new Date().toISOString(),
1685
+ };
1686
+
1687
+ const updated = await issuer.learnCard.invoke.issueCredential(
1688
+ updatedBody as UnsignedVC
1689
+ );
1690
+
1691
+ const publishResult =
1692
+ await issuer.clients.fullAuth.credentialRefresh.publishCredentialRefresh({
1693
+ mode: 'issuer-signed',
1694
+ refreshId: receipt.refreshId,
1695
+ signedCredential: updated,
1696
+ });
1697
+
1698
+ expect(publishResult.version).toBe(2);
1699
+
1700
+ const newHead = await getCredentialRefreshHead(receipt.refreshId);
1701
+ expect(newHead).toMatchObject({ version: 2 });
1702
+ });
1703
+
1704
+ it('applies managed context preparation to signing-authority publications', async () => {
1705
+ const boostUri = await issuer.clients.fullAuth.boost.createBoost({
1706
+ credential: testUnsignedBoost,
1707
+ });
1708
+
1709
+ const result = (await issuer.clients.fullAuth.boost.send({
1710
+ type: 'boost',
1711
+ recipient: HOLDER_PROFILE_ID,
1712
+ templateUri: boostUri,
1713
+ refresh: true,
1714
+ })) as SendResult;
1715
+
1716
+ const receipt = result.refresh!;
1717
+
1718
+ // Unsigned body WITHOUT the inline managed context fragment: the service
1719
+ // must add it before handing the body to the signing authority.
1720
+ const unsignedBody = {
1721
+ '@context': ['https://www.w3.org/ns/credentials/v2'],
1722
+ id: receipt.credentialId,
1723
+ type: ['VerifiableCredential'],
1724
+ issuer: receipt.issuerDid,
1725
+ name: 'SA Updated Badge',
1726
+ credentialSubject: { id: receipt.holderDid },
1727
+ refreshService: receipt.refreshService,
1728
+ validFrom: new Date().toISOString(),
1729
+ };
1730
+
1731
+ const capturedBodies: UnsignedVC[] = [];
1732
+ signingAuthorityMocks.issueCredential.mockImplementationOnce(
1733
+ async (_issuer: unknown, credential: UnsignedVC) => {
1734
+ capturedBodies.push(credential);
1735
+
1736
+ const vc = await issuer.learnCard.invoke.issueCredential({
1737
+ ...credential,
1738
+ issuer: issuer.learnCard.id.did(),
1739
+ });
1740
+
1741
+ return { kind: 'issued-credential', credential: vc, statusEntries: [] };
1742
+ }
1743
+ );
1744
+
1745
+ const publishResult =
1746
+ await issuer.clients.fullAuth.credentialRefresh.publishCredentialRefresh({
1747
+ mode: 'signing-authority',
1748
+ refreshId: receipt.refreshId,
1749
+ credential: unsignedBody as UnsignedVC,
1750
+ signingAuthority: {
1751
+ type: 'SigningAuthority',
1752
+ name: 'refresh-send-sa',
1753
+ endpoint: 'https://sa.example.com',
1754
+ },
1755
+ });
1756
+
1757
+ expect(publishResult.version).toBe(2);
1758
+
1759
+ expect(capturedBodies).toHaveLength(1);
1760
+ const contexts = capturedBodies[0]['@context'];
1761
+ const hasManagedTerm = (Array.isArray(contexts) ? contexts : [contexts]).some(
1762
+ entry =>
1763
+ typeof entry === 'object' &&
1764
+ entry !== null &&
1765
+ MANAGED_REFRESH_TYPE_TERM in (entry as Record<string, unknown>)
1766
+ );
1767
+ expect(hasManagedTerm).toBe(true);
1768
+
1769
+ // The publication must not have appended new status entries: the
1770
+ // credentialStatus descriptor supplied by the receipt is preserved.
1771
+ const head = await getCredentialRefreshHead(receipt.refreshId);
1772
+ expect(head).toMatchObject({ version: 2 });
1773
+ });
1774
+ });
1775
+
1776
+ describe('allocation state integrity', () => {
1777
+ it('binds the aggregate to the signed issuer identity and exposes it for publication', async () => {
1778
+ const boostUri = await issuer.clients.fullAuth.boost.createBoost({
1779
+ credential: testUnsignedBoost,
1780
+ });
1781
+
1782
+ const result = (await issuer.clients.fullAuth.boost.send({
1783
+ type: 'boost',
1784
+ recipient: HOLDER_PROFILE_ID,
1785
+ templateUri: boostUri,
1786
+ refresh: true,
1787
+ })) as SendResult;
1788
+
1789
+ const aggregate = await getCredentialRefresh(result.refresh!.refreshId);
1790
+ expect(aggregate).toMatchObject({
1791
+ issuerProfileId: ISSUER_PROFILE_ID,
1792
+ holderProfileId: HOLDER_PROFILE_ID,
1793
+ credentialId: result.refresh!.credentialId,
1794
+ state: 'awaiting_claim',
1795
+ currentVersion: 1,
1796
+ });
1797
+ });
1798
+ });
1799
+ });