@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,534 @@
1
+ import { vi, describe, it, expect, beforeAll, beforeEach } from 'vitest';
2
+ import type { UnsignedVC, VP } from '@learncard/types';
3
+ const mocks = vi.hoisted(() => ({ sign: vi.fn(), verify: vi.fn(), sendEmail: vi.fn() }));
4
+ vi.mock('@helpers/signingAuthority.helpers', async original => ({
5
+ ...(await original<Record<string, unknown>>()),
6
+ issueCredentialWithSigningAuthority: mocks.sign,
7
+ }));
8
+ // This suite tests DB races and lifecycle; real-signing E2E covers authority transport/proofs.
9
+ vi.mock('@helpers/credential-refresh-proof.helpers', () => ({
10
+ verifyManagedRefreshProof: mocks.verify,
11
+ }));
12
+ vi.mock('@services/delivery/delivery.factory', () => ({
13
+ getDeliveryService: () => ({ send: mocks.sendEmail }),
14
+ }));
15
+ vi.mock('@services/registry/registry.factory', () => ({
16
+ getRegistryService: () => ({ isTrusted: async () => true }),
17
+ }));
18
+ import { neogma } from '@instance';
19
+ import { getUser, getClient } from './helpers/getClient';
20
+ import { getContactMethodByValue } from '@accesslayer/contact-method/read';
21
+ import { createProfileContactMethodRelationship } from '@accesslayer/contact-method/relationships/create';
22
+ import { verifyContactMethod } from '@accesslayer/contact-method/update';
23
+ import { getProfileByProfileId } from '@accesslayer/profile/read';
24
+ import { getInboxCredentialById } from '@accesslayer/inbox-credential/read';
25
+ import {
26
+ updateInboxCredential,
27
+ expireInboxCredentials,
28
+ } from '@accesslayer/inbox-credential/update';
29
+ import { getCredentialRefresh, getCredentialRefreshHead } from '@accesslayer/credential-refresh';
30
+ import { finalizeInboxRefresh } from '@helpers/inbox-refresh.helpers';
31
+ import { decryptInboxCredential } from '@helpers/inbox-encryption.helpers';
32
+ import * as notifications from '@helpers/notifications.helpers';
33
+
34
+ const DOMAIN = 'localhost%3A3000';
35
+ const SA = { endpoint: 'https://sa.example.com', name: 'inbox-refresh' };
36
+ let issuer: Awaited<ReturnType<typeof getUser>>;
37
+ let holder: Awaited<ReturnType<typeof getUser>>;
38
+ let stranger: Awaited<ReturnType<typeof getUser>>;
39
+ const template = (): UnsignedVC => ({
40
+ '@context': ['https://www.w3.org/ns/credentials/v2'],
41
+ type: ['VerifiableCredential'],
42
+ name: 'Provisional Results',
43
+ issuer: 'did:web:localhost%3A3000:users:inbox-refresh-issuer',
44
+ credentialSubject: {},
45
+ });
46
+ const issue = (overrides = {}) =>
47
+ issuer.clients.fullAuth.inbox.issue({
48
+ recipient: { type: 'email', value: 'unknown@example.com' },
49
+ credential: template(),
50
+ refresh: true,
51
+ configuration: { signingAuthority: SA, delivery: { suppress: true } },
52
+ ...overrides,
53
+ });
54
+ const queue = async (
55
+ issued: Awaited<ReturnType<typeof issue>>,
56
+ name: string,
57
+ idempotencyKey?: string
58
+ ) => {
59
+ const inbox = (await getInboxCredentialById(issued.issuanceId))!;
60
+ const body = JSON.parse(await decryptInboxCredential(inbox.credential));
61
+ return issuer.clients.fullAuth.credentialRefresh.publishCredentialRefresh({
62
+ refreshId: issued.refresh!.refreshId,
63
+ mode: 'signing-authority',
64
+ credential: { ...body, name },
65
+ signingAuthority: { type: 'LearnCardSigningAuthority', ...SA },
66
+ idempotencyKey,
67
+ });
68
+ };
69
+ const claim = async (issued: Awaited<ReturnType<typeof issue>>) =>
70
+ finalizeInboxRefresh({
71
+ inboxId: issued.issuanceId,
72
+ holderDid: holder.learnCard.id.did(),
73
+ holderProfile: await getProfileByProfileId('inbox-refresh-holder'),
74
+ domain: DOMAIN,
75
+ });
76
+ const replay = (refreshId: string, idempotencyKey: string) =>
77
+ issuer.clients.fullAuth.credentialRefresh.publishCredentialRefresh({
78
+ mode: 'signing-authority',
79
+ refreshId,
80
+ credential: template(),
81
+ signingAuthority: { type: 'LearnCardSigningAuthority', ...SA },
82
+ idempotencyKey,
83
+ });
84
+
85
+ beforeAll(async () => {
86
+ issuer = await getUser('e'.repeat(64));
87
+ holder = await getUser('9'.repeat(64));
88
+ stranger = await getUser('1'.repeat(64));
89
+ vi.spyOn(notifications, 'addNotificationToQueue').mockResolvedValue();
90
+ });
91
+ beforeEach(async () => {
92
+ mocks.sendEmail.mockReset().mockResolvedValue(undefined);
93
+ // @instance is the isolated Testcontainers database, never the user's local demo stack.
94
+ await neogma.queryRunner.run('MATCH (n) DETACH DELETE n');
95
+ await issuer.clients.fullAuth.profile.createProfile({ profileId: 'inbox-refresh-issuer' });
96
+ await holder.clients.fullAuth.profile.createProfile({ profileId: 'inbox-refresh-holder' });
97
+ await stranger.clients.fullAuth.profile.createProfile({ profileId: 'inbox-refresh-stranger' });
98
+ await issuer.clients.fullAuth.profile.registerSigningAuthority({
99
+ ...SA,
100
+ did: issuer.learnCard.id.did(),
101
+ });
102
+ mocks.sign.mockReset();
103
+ mocks.verify.mockReset();
104
+ mocks.sign.mockImplementation(async (_owner, body) => ({
105
+ kind: 'issued-credential',
106
+ credential: {
107
+ ...body,
108
+ proof: {
109
+ type: 'DataIntegrityProof',
110
+ cryptosuite: 'eddsa-rdfc-2022',
111
+ proofValue: 'test',
112
+ created: new Date().toISOString(),
113
+ proofPurpose: 'assertionMethod',
114
+ verificationMethod: issuer.learnCard.id.did(),
115
+ },
116
+ },
117
+ statusEntries: [],
118
+ }));
119
+ });
120
+
121
+ describe('managed Universal Inbox refresh', () => {
122
+ it.each(['email', 'phone'] as const)(
123
+ 'allocates for unknown %s, claims latest, then publishes normally',
124
+ async type => {
125
+ const issued = await issue({
126
+ recipient: {
127
+ type,
128
+ value: type === 'email' ? 'unknown@example.com' : '+12025550123',
129
+ },
130
+ });
131
+ expect(issued.status).toBe('PENDING');
132
+ expect(issued.refresh?.holderDid).toBeUndefined();
133
+ expect(mocks.sign).not.toHaveBeenCalled();
134
+ const id = issued.refresh!.refreshId;
135
+ expect(await getCredentialRefresh(id)).toMatchObject({
136
+ state: 'pending_holder',
137
+ currentVersion: 1,
138
+ });
139
+ expect((await getInboxCredentialById(issued.issuanceId))?.credential).toMatch(
140
+ /^lc-inbox-jwe:v1:/
141
+ );
142
+ expect(await getCredentialRefreshHead(id)).toBeNull();
143
+ expect(await queue(issued, 'Review complete', 'update-1')).toMatchObject({
144
+ version: 2,
145
+ notification: 'not-applicable',
146
+ });
147
+ expect(await queue(issued, 'Final grade A', 'update-2')).toMatchObject({ version: 3 });
148
+ const delivered = await claim(issued);
149
+ expect(delivered.credential.name).toBe('Final grade A');
150
+ expect(delivered.credential.refreshService).toMatchObject({
151
+ id: issued.refresh!.refreshService.id,
152
+ });
153
+ expect(await getCredentialRefresh(id)).toMatchObject({
154
+ state: 'active',
155
+ currentVersion: 3,
156
+ holderDid: holder.learnCard.id.did(),
157
+ });
158
+ expect((await getInboxCredentialById(issued.issuanceId))?.credential).toBeUndefined();
159
+ const head = (await getCredentialRefreshHead(id))!;
160
+ expect(head.version).toBe(3);
161
+ expect(
162
+ await holder.learnCard.invoke.decryptDagJwe(JSON.parse(head.credential))
163
+ ).toMatchObject({ name: 'Final grade A' });
164
+ const issuerRead = await issuer.learnCard.invoke
165
+ .decryptDagJwe(JSON.parse(head.credential))
166
+ .catch(() => null);
167
+ expect(issuerRead).toBeFalsy();
168
+ const published =
169
+ await issuer.clients.fullAuth.credentialRefresh.publishCredentialRefresh({
170
+ mode: 'signing-authority',
171
+ refreshId: id,
172
+ credential: {
173
+ ...delivered.credential,
174
+ proof: undefined,
175
+ name: 'Final grade A with honors',
176
+ },
177
+ signingAuthority: { type: 'LearnCardSigningAuthority', ...SA },
178
+ idempotencyKey: 'after-claim',
179
+ });
180
+ expect(published.version).toBe(4);
181
+ expect(await replay(id, 'update-1')).toMatchObject({ version: 2 });
182
+ const history =
183
+ await issuer.clients.fullAuth.credentialRefresh.getCredentialRefreshHistory({
184
+ refreshId: id,
185
+ });
186
+ expect(history.records.map(v => v.version)).toEqual([4, 3, 2, 1]);
187
+ }
188
+ );
189
+ it('reuses one issuance/allocation and rejects a changed keyed request', async () => {
190
+ const first = await issue({ idempotencyKey: 'issue-key' });
191
+ expect(await issue({ idempotencyKey: 'issue-key' })).toEqual(first);
192
+ await expect(
193
+ issue({ idempotencyKey: 'issue-key', credential: { ...template(), name: 'Different' } })
194
+ ).rejects.toMatchObject({ code: 'CONFLICT' });
195
+ expect(
196
+ Number(
197
+ (
198
+ await neogma.queryRunner.run(
199
+ 'MATCH (ic:InboxCredential) RETURN count(ic) AS count'
200
+ )
201
+ ).records[0]?.get('count')
202
+ )
203
+ ).toBe(1);
204
+ });
205
+ it('re-signs latest content when publication wins during claim', async () => {
206
+ const issued = await issue();
207
+ mocks.sign.mockImplementationOnce(async (_owner, body) => {
208
+ await queue(issued, 'Published while signing', 'racing-update');
209
+ return {
210
+ kind: 'issued-credential',
211
+ credential: {
212
+ ...body,
213
+ proof: {
214
+ type: 'test',
215
+ created: new Date().toISOString(),
216
+ proofPurpose: 'assertionMethod',
217
+ verificationMethod: issuer.learnCard.id.did(),
218
+ },
219
+ },
220
+ statusEntries: [],
221
+ };
222
+ });
223
+ expect((await claim(issued)).credential.name).toBe('Published while signing');
224
+ expect(mocks.sign).toHaveBeenCalledTimes(2);
225
+ });
226
+ it('binds only one claimant and refuses guardian-gated or expired claims', async () => {
227
+ const issued = await issue();
228
+ await updateInboxCredential(issued.issuanceId, { guardianStatus: 'AWAITING_GUARDIAN' });
229
+ await expect(claim(issued)).rejects.toMatchObject({ code: 'CONFLICT' });
230
+ await updateInboxCredential(issued.issuanceId, { guardianStatus: 'GUARDIAN_APPROVED' });
231
+ const results = await Promise.allSettled([
232
+ claim(issued),
233
+ finalizeInboxRefresh({
234
+ inboxId: issued.issuanceId,
235
+ holderDid: stranger.learnCard.id.did(),
236
+ holderProfile: await getProfileByProfileId('inbox-refresh-stranger'),
237
+ domain: DOMAIN,
238
+ }),
239
+ ]);
240
+ expect(results.filter(r => r.status === 'fulfilled')).toHaveLength(1);
241
+ const expired = await issue({ recipient: { type: 'email', value: 'expired@example.com' } });
242
+ await updateInboxCredential(expired.issuanceId, { expiresAt: '2000-01-01T00:00:00.000Z' });
243
+ await expect(claim(expired)).rejects.toMatchObject({ code: 'CONFLICT' });
244
+ await expect(queue(expired, 'Too late')).rejects.toMatchObject({ code: 'CONFLICT' });
245
+ await expireInboxCredentials();
246
+ expect((await getInboxCredentialById(expired.issuanceId))?.credential).toBeUndefined();
247
+ });
248
+ it('supports the profile finalize route and exposes a bound receipt to the issuer', async () => {
249
+ const issued = await issue();
250
+ await queue(issued, 'Latest at profile claim');
251
+ const contact = (await getContactMethodByValue('email', 'unknown@example.com'))!;
252
+ await createProfileContactMethodRelationship('inbox-refresh-holder', contact.id);
253
+ await verifyContactMethod(contact.id);
254
+ await updateInboxCredential(issued.issuanceId, { isAccepted: true });
255
+ const result = await holder.clients.fullAuth.inbox.finalize({});
256
+ expect(result).toMatchObject({ claimed: 1, errors: 0 });
257
+ expect(result.verifiableCredentials[0]?.name).toBe('Latest at profile claim');
258
+ const metadata = await issuer.clients.fullAuth.inbox.getInboxCredential({
259
+ credentialId: issued.issuanceId,
260
+ });
261
+ expect(metadata.refresh?.holderDid).toBe(holder.learnCard.id.did());
262
+ });
263
+
264
+ it('supports the claim-link exchange with the newest pending version', async () => {
265
+ const issued = await issue();
266
+ await queue(issued, 'Latest at link claim');
267
+ const localExchangeId = new URL(issued.claimUrl!).pathname.split('/').pop()!;
268
+ const start = await holder.clients.fullAuth.workflows.participateInExchange({
269
+ localWorkflowId: 'inbox-claim',
270
+ localExchangeId,
271
+ });
272
+ const vp = (await holder.learnCard.invoke.getDidAuthVp({
273
+ challenge: start.verifiablePresentationRequest?.challenge,
274
+ domain: start.verifiablePresentationRequest?.domain,
275
+ })) as VP;
276
+ const result = await holder.clients.fullAuth.workflows.participateInExchange({
277
+ localWorkflowId: 'inbox-claim',
278
+ localExchangeId,
279
+ verifiablePresentation: vp,
280
+ });
281
+ expect(result.verifiablePresentation?.verifiableCredential?.[0]).toMatchObject({
282
+ name: 'Latest at link claim',
283
+ });
284
+ expect((await getCredentialRefresh(issued.refresh!.refreshId))?.state).toBe('active');
285
+ });
286
+
287
+ it('auto-delivers to a verified existing recipient with refresh enabled', async () => {
288
+ await holder.clients.fullAuth.contactMethods.addContactMethod({
289
+ type: 'email',
290
+ value: 'known@example.com',
291
+ });
292
+ await verifyContactMethod(
293
+ (await getContactMethodByValue('email', 'known@example.com'))!.id
294
+ );
295
+ mocks.sendEmail.mockClear(); // Ignore contact-method verification delivery above.
296
+ const issued = await issue({ recipient: { type: 'email', value: 'known@example.com' } });
297
+ expect(issued).toMatchObject({
298
+ status: 'ISSUED',
299
+ recipientDid: holder.learnCard.id.did(),
300
+ refresh: { holderDid: holder.learnCard.id.did() },
301
+ });
302
+ expect((await getInboxCredentialById(issued.issuanceId))?.credential).toBeUndefined();
303
+ expect((await getCredentialRefresh(issued.refresh!.refreshId))?.state).toBe(
304
+ 'awaiting_claim'
305
+ );
306
+ expect(mocks.sendEmail).not.toHaveBeenCalled();
307
+ });
308
+
309
+ it('emails existing recipients once with an app link and preserves delivery if email fails', async () => {
310
+ await holder.clients.fullAuth.contactMethods.addContactMethod({
311
+ type: 'email',
312
+ value: 'known@example.com',
313
+ });
314
+ await verifyContactMethod(
315
+ (await getContactMethodByValue('email', 'known@example.com'))!.id
316
+ );
317
+ mocks.sendEmail.mockClear(); // Ignore contact-method verification delivery above.
318
+ mocks.sendEmail.mockRejectedValueOnce(new Error('Email provider unavailable'));
319
+ const input = {
320
+ recipient: { type: 'email' as const, value: 'known@example.com' },
321
+ idempotencyKey: 'known-email',
322
+ configuration: { signingAuthority: SA, delivery: { suppress: false } },
323
+ };
324
+ const issued = await issue(input);
325
+ expect(issued.status).toBe('ISSUED');
326
+ expect(mocks.sendEmail).toHaveBeenCalledTimes(1);
327
+ expect(mocks.sendEmail).toHaveBeenCalledWith(
328
+ expect.objectContaining({
329
+ contactMethod: input.recipient,
330
+ templateId: 'universal-inbox-claim',
331
+ templateModel: expect.objectContaining({
332
+ claimUrl: expect.stringMatching(/\/notifications$/),
333
+ }),
334
+ })
335
+ );
336
+ expect((await issue(input)).issuanceId).toBe(issued.issuanceId);
337
+ expect(mocks.sendEmail).toHaveBeenCalledTimes(1);
338
+ });
339
+
340
+ it('routes unified send to the same inbox lifecycle and reuses its boost on retry', async () => {
341
+ await issuer.clients.fullAuth.profile.setPrimarySigningAuthority(SA);
342
+ const input = {
343
+ type: 'boost' as const,
344
+ recipient: 'unknown@example.com',
345
+ template: { credential: template() },
346
+ refresh: true,
347
+ idempotencyKey: 'unified-key',
348
+ options: { suppressDelivery: true },
349
+ };
350
+ const first = await issuer.clients.fullAuth.boost.send(input);
351
+ expect(first.inbox?.refresh?.refreshId).toBeTruthy();
352
+ expect(await issuer.clients.fullAuth.boost.send(input)).toEqual(first);
353
+ expect(
354
+ Number(
355
+ (
356
+ await neogma.queryRunner.run('MATCH (b:Boost) RETURN count(b) AS count')
357
+ ).records[0]?.get('count')
358
+ )
359
+ ).toBe(1);
360
+ await expect(
361
+ issuer.clients.fullAuth.boost.send({ ...input, recipient: 'different@example.com' })
362
+ ).rejects.toMatchObject({ code: 'CONFLICT' });
363
+ });
364
+
365
+ it('retains escrow when signing fails and can retry the same claim', async () => {
366
+ const issued = await issue();
367
+ mocks.sign.mockRejectedValueOnce(new Error('Authority unavailable'));
368
+ await expect(claim(issued)).rejects.toThrow('Authority unavailable');
369
+ expect((await getInboxCredentialById(issued.issuanceId))?.credential).toMatch(
370
+ /^lc-inbox-jwe:v1:/
371
+ );
372
+ expect((await getCredentialRefresh(issued.refresh!.refreshId))?.holderDid).toBeUndefined();
373
+ expect((await claim(issued)).credential.name).toBe('Provisional Results');
374
+ });
375
+
376
+ it('keeps concurrent issue retries on a single aggregate and inbox record', async () => {
377
+ const results = await Promise.all([
378
+ issue({ idempotencyKey: 'concurrent' }),
379
+ issue({ idempotencyKey: 'concurrent' }),
380
+ ]);
381
+ expect(results[0].issuanceId).toBe(results[1].issuanceId);
382
+ expect(results[0].refresh).toEqual(results[1].refresh);
383
+ expect(
384
+ Number(
385
+ (
386
+ await neogma.queryRunner.run(
387
+ 'MATCH (r:CredentialRefresh) RETURN count(r) AS count'
388
+ )
389
+ ).records[0]?.get('count')
390
+ )
391
+ ).toBe(1);
392
+ });
393
+
394
+ it('returns a valid allocation receipt for VC1 without status entries', async () => {
395
+ const issued = await issue({
396
+ credential: {
397
+ ...template(),
398
+ '@context': ['https://www.w3.org/2018/credentials/v1'],
399
+ issuanceDate: new Date().toISOString(),
400
+ },
401
+ });
402
+ expect(issued.refresh?.credentialStatus).toBeUndefined();
403
+ expect((await claim(issued)).credential.id).toBe(issued.refresh?.credentialId);
404
+ });
405
+
406
+ it('resumes notification delivery after an auto-delivery bind committed', async () => {
407
+ await holder.clients.fullAuth.contactMethods.addContactMethod({
408
+ type: 'email',
409
+ value: 'retry-known@example.com',
410
+ });
411
+ await verifyContactMethod(
412
+ (await getContactMethodByValue('email', 'retry-known@example.com'))!.id
413
+ );
414
+ vi.mocked(notifications.addNotificationToQueue).mockRejectedValueOnce(
415
+ new Error('Queue unavailable')
416
+ );
417
+ const options = {
418
+ recipient: { type: 'email', value: 'retry-known@example.com' },
419
+ idempotencyKey: 'known-retry',
420
+ };
421
+ await expect(issue(options)).rejects.toThrow();
422
+ const retried = await issue(options);
423
+ expect(retried).toMatchObject({
424
+ status: 'ISSUED',
425
+ recipientDid: holder.learnCard.id.did(),
426
+ });
427
+ expect(mocks.sign).toHaveBeenCalledTimes(1);
428
+ expect(
429
+ (await getCredentialRefresh(retried.refresh!.refreshId))?.initialNotificationSentAt
430
+ ).toBeTruthy();
431
+ });
432
+
433
+ it('concurrent pending publications with one key create one revision', async () => {
434
+ const issued = await issue();
435
+ const outcomes = await Promise.all([
436
+ queue(issued, 'Final', 'same-key'),
437
+ queue(issued, 'Final', 'same-key'),
438
+ ]);
439
+ expect(outcomes[0]).toEqual(outcomes[1]);
440
+ expect((await getCredentialRefresh(issued.refresh!.refreshId))?.currentVersion).toBe(2);
441
+ });
442
+
443
+ it('rejects changed pending identity and mechanisms without advancing the version', async () => {
444
+ const issued = await issue();
445
+ const inbox = (await getInboxCredentialById(issued.issuanceId))!;
446
+ const body = JSON.parse(await decryptInboxCredential(inbox.credential));
447
+ for (const patch of [
448
+ { id: 'urn:uuid:different' },
449
+ { issuer: stranger.learnCard.id.did() },
450
+ { credentialSubject: { id: stranger.learnCard.id.did() } },
451
+ { credentialStatus: undefined },
452
+ { refreshService: { ...body.refreshService, id: 'https://example.com/refresh' } },
453
+ { boostId: 'different-boost' },
454
+ ]) {
455
+ await expect(
456
+ issuer.clients.fullAuth.credentialRefresh.publishCredentialRefresh({
457
+ mode: 'signing-authority',
458
+ refreshId: issued.refresh!.refreshId,
459
+ credential: { ...body, ...patch },
460
+ signingAuthority: { type: 'LearnCardSigningAuthority', ...SA },
461
+ })
462
+ ).rejects.toMatchObject({ code: 'BAD_REQUEST' });
463
+ }
464
+ expect((await getCredentialRefresh(issued.refresh!.refreshId))?.currentVersion).toBe(1);
465
+ expect(mocks.sign).not.toHaveBeenCalled();
466
+ });
467
+
468
+ it('requires credentials:write in addition to inbox:write before allocation', async () => {
469
+ const limited = getClient({
470
+ did: issuer.learnCard.id.did(),
471
+ isChallengeValid: true,
472
+ scope: 'inbox:write',
473
+ });
474
+ await expect(
475
+ limited.inbox.issue({
476
+ recipient: { type: 'email', value: 'unknown@example.com' },
477
+ credential: template(),
478
+ refresh: true,
479
+ configuration: { signingAuthority: SA, delivery: { suppress: true } },
480
+ })
481
+ ).rejects.toMatchObject({ code: 'UNAUTHORIZED' });
482
+ const result = await neogma.queryRunner.run(
483
+ 'MATCH (r:CredentialRefresh) RETURN count(r) AS count'
484
+ );
485
+ expect(Number(result.records[0]?.get('count'))).toBe(0);
486
+ });
487
+
488
+ it('preserves the effective time across claim for later publication validation', async () => {
489
+ const issued = await issue({
490
+ credential: { ...template(), validFrom: '2026-09-17T00:00:00.000Z' },
491
+ });
492
+ const delivered = await claim(issued);
493
+ expect((await getCredentialRefreshHead(issued.refresh!.refreshId))?.effectiveAt).toBe(
494
+ '2026-09-17T00:00:00.000Z'
495
+ );
496
+ await expect(
497
+ issuer.clients.fullAuth.credentialRefresh.publishCredentialRefresh({
498
+ mode: 'signing-authority',
499
+ refreshId: issued.refresh!.refreshId,
500
+ credential: {
501
+ ...delivered.credential,
502
+ proof: undefined,
503
+ validFrom: '2026-09-16T00:00:00.000Z',
504
+ },
505
+ signingAuthority: { type: 'LearnCardSigningAuthority', ...SA },
506
+ })
507
+ ).rejects.toMatchObject({ code: 'BAD_REQUEST' });
508
+ });
509
+
510
+ it('rejects another issuer and pre-signed pending content', async () => {
511
+ const issued = await issue();
512
+ await expect(
513
+ stranger.clients.fullAuth.credentialRefresh.publishCredentialRefresh({
514
+ mode: 'signing-authority',
515
+ refreshId: issued.refresh!.refreshId,
516
+ credential: template(),
517
+ signingAuthority: { type: 'LearnCardSigningAuthority', ...SA },
518
+ })
519
+ ).rejects.toMatchObject({ code: 'UNAUTHORIZED' });
520
+ await expect(
521
+ issue({
522
+ credential: {
523
+ ...template(),
524
+ proof: {
525
+ type: 'test',
526
+ created: new Date().toISOString(),
527
+ proofPurpose: 'assertionMethod',
528
+ verificationMethod: issuer.learnCard.id.did(),
529
+ },
530
+ },
531
+ })
532
+ ).rejects.toMatchObject({ code: 'BAD_REQUEST' });
533
+ });
534
+ });
@@ -1,4 +1,7 @@
1
- import { PublishCredentialRefreshInputValidator } from '@learncard/types';
1
+ import {
2
+ PublishCredentialRefreshInputValidator,
3
+ SendBoostResponseValidator,
4
+ } from '@learncard/types';
2
5
 
3
6
  import { openApiDocument } from '../src/openapi';
4
7
 
@@ -66,6 +69,43 @@ describe('OpenAPI generation', () => {
66
69
  ]);
67
70
  });
68
71
 
72
+ it('documents the unified send route with the refresh receipt in its response schema', () => {
73
+ const paths = openApiDocument.paths ?? {};
74
+
75
+ expect(paths['/send']).toBeDefined();
76
+
77
+ // The managed receipt must survive response schema generation: the schema is
78
+ // derived from SendBoostResponseValidator, which carries the optional receipt.
79
+ const response = SendBoostResponseValidator.safeParse({
80
+ type: 'boost',
81
+ uri: 'https://localhost%3A3000/boost/abc',
82
+ credentialUri: 'https://localhost%3A3000/credentials/def',
83
+ activityId: 'activity-1',
84
+ refresh: {
85
+ refreshId: 'refresh-1',
86
+ refreshService: {
87
+ id: 'https://localhost%3A3000/refresh/refresh-1',
88
+ type: 'LearnCardCredentialRefresh2026',
89
+ authorization: { type: 'LearnCardDIDAuth' },
90
+ },
91
+ credentialId: 'urn:uuid:credential-1',
92
+ issuerDid: 'did:key:issuer',
93
+ holderDid: 'did:key:holder',
94
+ // Unknown keys ride along in the input but must be stripped by the
95
+ // receipt validator, never silently persisted or returned.
96
+ credentialSubject: { id: 'did:key:holder' },
97
+ },
98
+ });
99
+
100
+ expect(response.success).toBe(true);
101
+
102
+ if (response.success) {
103
+ expect(response.data.refresh).toBeDefined();
104
+ expect(response.data.refresh).not.toHaveProperty('credentialSubject');
105
+ expect(response.data.refresh!.refreshId).toBe('refresh-1');
106
+ }
107
+ });
108
+
69
109
  it.each([
70
110
  [
71
111
  'issuer-signed with signing-authority fields',