@learncard/vc-plugin 1.6.0 → 1.6.2

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.
@@ -3,6 +3,7 @@ vi.mock('multiformats/bases/base58', () => ({ base58btc: {} }));
3
3
  vi.mock('multiformats/bases/base64', () => ({ base64url: {} }));
4
4
 
5
5
  import { issueCredential } from './issueCredential';
6
+ import { MANAGED_REFRESH_SERVICE_CONTEXT } from '@learncard/helpers';
6
7
 
7
8
  type Keypair = {
8
9
  kty: string;
@@ -17,6 +18,7 @@ type Credential = {
17
18
  issuer: string;
18
19
  validFrom: string;
19
20
  credentialSubject: { id: string };
21
+ refreshService?: unknown;
20
22
  };
21
23
 
22
24
  type ProofOptionsForTest = {
@@ -135,4 +137,125 @@ describe('issueCredential', () => {
135
137
  expect(options.cryptosuite).toBeUndefined();
136
138
  expect(options.proofFormat).toBe('jwt');
137
139
  });
140
+
141
+ test('passes an untouched credential through unchanged when no managed refresh service is present', async () => {
142
+ const { initLearnCard, issueCredentialMock, learnCard } = getLearnCard();
143
+
144
+ await issueCredential(initLearnCard as never)(learnCard as never, credential as never);
145
+
146
+ const payload = issueCredentialMock.mock.calls[0][0] as Credential;
147
+
148
+ expect(payload).toEqual(credential);
149
+ expect(payload['@context']).toEqual(credential['@context']);
150
+ });
151
+
152
+ test('injects the managed inline context when a managed refresh service is present', async () => {
153
+ const { initLearnCard, issueCredentialMock, learnCard } = getLearnCard();
154
+
155
+ const refreshableCredential = {
156
+ ...credential,
157
+ refreshService: {
158
+ id: 'https://network.learncard.test/api/credential-refreshes/refresh-1',
159
+ type: 'LearnCardCredentialRefresh2026',
160
+ authorization: { type: 'LearnCardDIDAuth' },
161
+ },
162
+ };
163
+
164
+ await issueCredential(initLearnCard as never)(
165
+ learnCard as never,
166
+ refreshableCredential as never
167
+ );
168
+
169
+ const payload = issueCredentialMock.mock.calls[0][0] as Credential;
170
+
171
+ expect(payload).not.toBe(refreshableCredential);
172
+ expect(payload['@context']).toEqual([
173
+ ...credential['@context'],
174
+ MANAGED_REFRESH_SERVICE_CONTEXT,
175
+ ]);
176
+ expect(payload.refreshService).toEqual(refreshableCredential.refreshService);
177
+
178
+ // Signing options are unaffected by context preparation
179
+ const options = issueCredentialMock.mock.calls[0][1] as ProofOptionsForTest;
180
+
181
+ expect(options.type).toBe('DataIntegrityProof');
182
+ expect(options.cryptosuite).toBe('eddsa-rdfc-2022');
183
+ expect(options.verificationMethod).toBe('did:example:issuer#key-1');
184
+ });
185
+
186
+ test('does not duplicate the inline context when an equivalent mapping already exists', async () => {
187
+ const { initLearnCard, issueCredentialMock, learnCard } = getLearnCard();
188
+
189
+ const refreshableCredential = {
190
+ ...credential,
191
+ '@context': [...credential['@context'], MANAGED_REFRESH_SERVICE_CONTEXT],
192
+ refreshService: {
193
+ id: 'https://network.learncard.test/api/credential-refreshes/refresh-1',
194
+ type: 'LearnCardCredentialRefresh2026',
195
+ authorization: { type: 'LearnCardDIDAuth' },
196
+ },
197
+ };
198
+
199
+ await issueCredential(initLearnCard as never)(
200
+ learnCard as never,
201
+ refreshableCredential as never
202
+ );
203
+
204
+ const payload = issueCredentialMock.mock.calls[0][0] as Credential;
205
+
206
+ expect(payload['@context']).toEqual(refreshableCredential['@context']);
207
+ });
208
+
209
+ test('rejects a credential with a conflicting managed term mapping', async () => {
210
+ const { initLearnCard, learnCard } = getLearnCard();
211
+
212
+ const conflictingCredential = {
213
+ ...credential,
214
+ '@context': [
215
+ ...credential['@context'],
216
+ {
217
+ LearnCardCredentialRefresh2026:
218
+ 'https://evil.example/other#LearnCardCredentialRefresh2026',
219
+ },
220
+ ],
221
+ refreshService: {
222
+ id: 'https://network.learncard.test/api/credential-refreshes/refresh-1',
223
+ type: 'LearnCardCredentialRefresh2026',
224
+ },
225
+ };
226
+
227
+ await expect(
228
+ issueCredential(initLearnCard as never)(
229
+ learnCard as never,
230
+ conflictingCredential as never
231
+ )
232
+ ).rejects.toThrow(/LearnCardCredentialRefresh2026/);
233
+ });
234
+
235
+ test('preserves explicit signing options while preparing a refreshable credential', async () => {
236
+ const { initLearnCard, issueCredentialMock, learnCard } = getLearnCard();
237
+
238
+ const refreshableCredential = {
239
+ ...credential,
240
+ refreshService: {
241
+ id: 'https://network.learncard.test/api/credential-refreshes/refresh-1',
242
+ type: 'LearnCardCredentialRefresh2026',
243
+ },
244
+ };
245
+
246
+ await issueCredential(initLearnCard as never)(
247
+ learnCard as never,
248
+ refreshableCredential as never,
249
+ { type: 'Ed25519Signature2020' }
250
+ );
251
+
252
+ const options = issueCredentialMock.mock.calls[0][1] as ProofOptionsForTest;
253
+
254
+ expect(options.type).toBe('Ed25519Signature2020');
255
+ expect(options.cryptosuite).toBeUndefined();
256
+
257
+ const payload = issueCredentialMock.mock.calls[0][0] as Credential;
258
+
259
+ expect((payload['@context'] as unknown[]).at(-1)).toEqual(MANAGED_REFRESH_SERVICE_CONTEXT);
260
+ });
138
261
  });
@@ -1,5 +1,6 @@
1
1
  import { UnsignedVC } from '@learncard/types';
2
2
 
3
+ import { prepareManagedRefreshContext } from '@learncard/helpers';
3
4
  import { ProofOptions } from '@learncard/didkit-plugin';
4
5
  import { VCDependentLearnCard, VCImplicitLearnCard } from './types';
5
6
  import { getDefaultVerificationMethod } from './helpers';
@@ -14,9 +15,14 @@ export const issueCredential = (initLearnCard: VCDependentLearnCard) => {
14
15
 
15
16
  if (!kp) throw new Error('Cannot issue credential: Could not get subject keypair');
16
17
 
17
- const contexts = Array.isArray(credential['@context'])
18
- ? credential['@context']
19
- : [credential['@context']];
18
+ // Credentials carrying a LearnCard-managed refresh service need the inline
19
+ // JSON-LD context fragment that defines its terms; sign the prepared payload.
20
+ // Untouched credentials (no managed service) pass through by reference.
21
+ const preparedCredential = prepareManagedRefreshContext(credential);
22
+
23
+ const contexts = Array.isArray(preparedCredential['@context'])
24
+ ? preparedCredential['@context']
25
+ : [preparedCredential['@context']];
20
26
 
21
27
  const hasV2Context = contexts.some(
22
28
  ctx =>
@@ -28,8 +34,8 @@ export const issueCredential = (initLearnCard: VCDependentLearnCard) => {
28
34
  const proofType =
29
35
  hasV2Context && signingOptions.type === 'Ed25519Signature2018'
30
36
  ? 'Ed25519Signature2020'
31
- : signingOptions.type ??
32
- (signingOptions.proofFormat === 'jwt' ? undefined : 'DataIntegrityProof');
37
+ : (signingOptions.type ??
38
+ (signingOptions.proofFormat === 'jwt' ? undefined : 'DataIntegrityProof'));
33
39
 
34
40
  const options: ProofOptions = {
35
41
  proofPurpose: 'assertionMethod',
@@ -43,17 +49,19 @@ export const issueCredential = (initLearnCard: VCDependentLearnCard) => {
43
49
 
44
50
  if (!('verificationMethod' in options)) {
45
51
  const issuerDid =
46
- typeof credential.issuer === 'string' ? credential.issuer : credential.issuer.id!;
52
+ typeof preparedCredential.issuer === 'string'
53
+ ? preparedCredential.issuer
54
+ : preparedCredential.issuer.id!;
47
55
 
48
56
  options.verificationMethod = await getDefaultVerificationMethod(learnCard, issuerDid);
49
57
  }
50
58
 
51
59
  learnCard.debug?.('Signing with these options', {
52
- credential,
60
+ credential: preparedCredential,
53
61
  options,
54
62
  kp,
55
63
  });
56
64
 
57
- return initLearnCard.invoke.issueCredential(credential, options, kp);
65
+ return initLearnCard.invoke.issueCredential(preparedCredential, options, kp);
58
66
  };
59
67
  };