@learncard/helpers 1.5.0 → 1.6.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.
- package/dist/credential-refresh.d.ts +58 -1
- package/dist/credential-refresh.d.ts.map +1 -1
- package/dist/helpers.cjs.development.cjs +4277 -3897
- package/dist/helpers.cjs.development.cjs.map +3 -3
- package/dist/helpers.cjs.production.min.cjs +30 -30
- package/dist/helpers.cjs.production.min.cjs.map +3 -3
- package/dist/helpers.esm.js +560 -209
- package/dist/helpers.esm.js.map +3 -3
- package/package.json +2 -2
- package/src/credential-refresh.ts +258 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@learncard/helpers",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.6.0",
|
|
4
4
|
"description": "Shared helpers for LearnCard packages",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.cjs",
|
|
@@ -54,7 +54,7 @@
|
|
|
54
54
|
"dependencies": {
|
|
55
55
|
"@noble/hashes": "^1.8.0",
|
|
56
56
|
"@sd-jwt/decode": "^0.19.0",
|
|
57
|
-
"@learncard/types": "5.
|
|
57
|
+
"@learncard/types": "5.21.0",
|
|
58
58
|
"@trpc/server": "11.8.0",
|
|
59
59
|
"immer": "^10.0.3",
|
|
60
60
|
"use-immer": "^0.9.0",
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import {
|
|
2
|
+
ManagedCredentialRefreshServiceValidator,
|
|
2
3
|
SupportedCredentialRefreshServiceValidator,
|
|
4
|
+
type ManagedCredentialRefreshService,
|
|
3
5
|
type SupportedCredentialRefreshService,
|
|
4
6
|
} from '@learncard/types';
|
|
5
7
|
|
|
@@ -105,6 +107,262 @@ export const canonicalizeCredentialContent = <T>(value: T): T => {
|
|
|
105
107
|
export const canonicalizeCredentialJson = (value: unknown): string =>
|
|
106
108
|
JSON.stringify(canonicalizeCredentialContent(value));
|
|
107
109
|
|
|
110
|
+
// --- Managed refresh JSON-LD context preparation (LC-2198) --------------------
|
|
111
|
+
|
|
112
|
+
/** Term used by the LearnCard-managed refresh service type. */
|
|
113
|
+
const MANAGED_REFRESH_TYPE_TERM = 'LearnCardCredentialRefresh2026';
|
|
114
|
+
|
|
115
|
+
/** Term for the LearnCard DID-auth extension inside a managed refresh service. */
|
|
116
|
+
const MANAGED_REFRESH_DID_AUTH_TERM = 'LearnCardDIDAuth';
|
|
117
|
+
|
|
118
|
+
/** IRI the managed refresh service type term must resolve to. */
|
|
119
|
+
export const MANAGED_REFRESH_TYPE_IRI =
|
|
120
|
+
'https://learncard.com/refresh#LearnCardCredentialRefresh2026';
|
|
121
|
+
|
|
122
|
+
/** OBv3-namespaced IRI for the authorization term of a managed refresh service. */
|
|
123
|
+
export const MANAGED_REFRESH_AUTHORIZATION_IRI =
|
|
124
|
+
'https://purl.imsglobal.org/spec/ob/v3p0#authorization';
|
|
125
|
+
|
|
126
|
+
/** IRI for the LearnCard DID-auth scheme of a managed refresh service. */
|
|
127
|
+
export const MANAGED_REFRESH_DID_AUTH_IRI =
|
|
128
|
+
'https://docs.learncard.com/definitions#LearnCardDIDAuth';
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Inline JSON-LD context fragment required to sign credentials carrying a
|
|
132
|
+
* LearnCard-managed `LearnCardCredentialRefresh2026` refresh service.
|
|
133
|
+
*
|
|
134
|
+
* Neither VCDM 1.1 (which defines only `ManualRefreshService2018`), VCDM 2.0, nor the
|
|
135
|
+
* live OBv3/CLR contexts define the term `LearnCardCredentialRefresh2026` (nor the
|
|
136
|
+
* LearnCard `authorization` / `LearnCardDIDAuth` extension terms), so DIDKit's
|
|
137
|
+
* data-loss detection refuses to sign unless issuers define these terms inline.
|
|
138
|
+
*/
|
|
139
|
+
export const MANAGED_REFRESH_SERVICE_CONTEXT = {
|
|
140
|
+
[MANAGED_REFRESH_TYPE_TERM]: MANAGED_REFRESH_TYPE_IRI,
|
|
141
|
+
authorization: {
|
|
142
|
+
'@id': MANAGED_REFRESH_AUTHORIZATION_IRI,
|
|
143
|
+
'@context': { [MANAGED_REFRESH_DID_AUTH_TERM]: MANAGED_REFRESH_DID_AUTH_IRI },
|
|
144
|
+
},
|
|
145
|
+
} as const;
|
|
146
|
+
|
|
147
|
+
type ContextObject = Record<string, unknown>;
|
|
148
|
+
|
|
149
|
+
type CredentialWithOptionalRefreshService = {
|
|
150
|
+
refreshService?: unknown;
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
const isPlainObject = (value: unknown): value is ContextObject =>
|
|
154
|
+
!!value && typeof value === 'object' && !Array.isArray(value);
|
|
155
|
+
|
|
156
|
+
/** `"iri"` and `{ "@id": "iri" }` are the same JSON-LD term definition. */
|
|
157
|
+
const toIdForm = (definition: unknown): unknown =>
|
|
158
|
+
typeof definition === 'string' ? { '@id': definition } : definition;
|
|
159
|
+
|
|
160
|
+
/** Extracts the `@id` of a term definition, accounting for the string shorthand. */
|
|
161
|
+
const definitionId = (definition: unknown): unknown => {
|
|
162
|
+
const idForm = toIdForm(definition);
|
|
163
|
+
|
|
164
|
+
return isPlainObject(idForm) ? idForm['@id'] : undefined;
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Effective (last-wins) definition of a term across the credential's inline context
|
|
169
|
+
* objects. Only top-level context objects participate: nested `@context` blocks scope
|
|
170
|
+
* to their term's value and cannot redefine unrelated terms.
|
|
171
|
+
*/
|
|
172
|
+
const findTopLevelDefinition = (contextObjects: ContextObject[], term: string): unknown => {
|
|
173
|
+
let found: unknown;
|
|
174
|
+
|
|
175
|
+
for (const contextObject of contextObjects) {
|
|
176
|
+
if (term in contextObject) found = contextObject[term];
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return found;
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
/** Ensures an existing term definition resolves to the expected IRI, or throws. */
|
|
183
|
+
const assertDefinitionMatchesIri = (term: string, definition: unknown, expectedIri: string) => {
|
|
184
|
+
const actualIri = definitionId(definition);
|
|
185
|
+
|
|
186
|
+
if (actualIri !== expectedIri) {
|
|
187
|
+
throw new Error(
|
|
188
|
+
`Conflicting JSON-LD definition for term "${term}": expected "${expectedIri}" but the credential already defines it as ${JSON.stringify(
|
|
189
|
+
definition
|
|
190
|
+
)}. Remove or correct the existing definition before signing.`
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Returns the LearnCard-managed refresh services carried by a credential.
|
|
197
|
+
*
|
|
198
|
+
* Accepts a single service object or an array; standard 1EdTech services and unknown
|
|
199
|
+
* entries are ignored.
|
|
200
|
+
*/
|
|
201
|
+
export const getManagedRefreshServices = (
|
|
202
|
+
vc: CredentialWithOptionalRefreshService
|
|
203
|
+
): ManagedCredentialRefreshService[] => {
|
|
204
|
+
const refreshService = vc?.refreshService;
|
|
205
|
+
|
|
206
|
+
if (!refreshService) return [];
|
|
207
|
+
|
|
208
|
+
const services = Array.isArray(refreshService) ? refreshService : [refreshService];
|
|
209
|
+
|
|
210
|
+
return services.flatMap(service => {
|
|
211
|
+
const parsed = ManagedCredentialRefreshServiceValidator.safeParse(service);
|
|
212
|
+
|
|
213
|
+
return parsed.success ? [parsed.data] : [];
|
|
214
|
+
});
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Ensures a credential carrying a LearnCard-managed refresh service also carries the
|
|
219
|
+
* inline JSON-LD context needed to sign it.
|
|
220
|
+
*
|
|
221
|
+
* - Credentials without a managed service are returned **by reference**, untouched.
|
|
222
|
+
* - The input is never mutated; a shallow-extended copy is returned when a fragment
|
|
223
|
+
* must be appended.
|
|
224
|
+
* - Equivalent existing definitions are idempotent (no duplicate fragment).
|
|
225
|
+
* - Incomplete mappings are completed with only the missing terms.
|
|
226
|
+
* - Conflicting definitions (same term, different IRI) throw a clear error instead of
|
|
227
|
+
* silently signing an unexpected expansion.
|
|
228
|
+
*/
|
|
229
|
+
export const prepareManagedRefreshContext = <T>(credential: T): T => {
|
|
230
|
+
const source = credential as unknown as CredentialWithOptionalRefreshService;
|
|
231
|
+
|
|
232
|
+
if (getManagedRefreshServices(source).length === 0) return credential;
|
|
233
|
+
|
|
234
|
+
const existingContext = (credential as unknown as ContextObject)['@context'];
|
|
235
|
+
const contextList: unknown[] =
|
|
236
|
+
existingContext === undefined || existingContext === null
|
|
237
|
+
? []
|
|
238
|
+
: Array.isArray(existingContext)
|
|
239
|
+
? [...existingContext]
|
|
240
|
+
: [existingContext];
|
|
241
|
+
const inlineObjects = contextList.filter(isPlainObject);
|
|
242
|
+
|
|
243
|
+
const fragment: Record<string, unknown> = {};
|
|
244
|
+
|
|
245
|
+
// Managed refresh service type term
|
|
246
|
+
const typeDefinition = findTopLevelDefinition(inlineObjects, MANAGED_REFRESH_TYPE_TERM);
|
|
247
|
+
|
|
248
|
+
if (typeDefinition === undefined) {
|
|
249
|
+
fragment[MANAGED_REFRESH_TYPE_TERM] = MANAGED_REFRESH_TYPE_IRI;
|
|
250
|
+
} else {
|
|
251
|
+
assertDefinitionMatchesIri(
|
|
252
|
+
MANAGED_REFRESH_TYPE_TERM,
|
|
253
|
+
typeDefinition,
|
|
254
|
+
MANAGED_REFRESH_TYPE_IRI
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Authorization term (scopes the DID-auth term inside managed refresh services)
|
|
259
|
+
const authorizationDefinition = findTopLevelDefinition(inlineObjects, 'authorization');
|
|
260
|
+
let didAuthCovered = false;
|
|
261
|
+
|
|
262
|
+
if (authorizationDefinition === undefined) {
|
|
263
|
+
// Full definition, including the nested LearnCardDIDAuth scope.
|
|
264
|
+
fragment['authorization'] = MANAGED_REFRESH_SERVICE_CONTEXT.authorization;
|
|
265
|
+
didAuthCovered = true;
|
|
266
|
+
} else {
|
|
267
|
+
assertDefinitionMatchesIri(
|
|
268
|
+
'authorization',
|
|
269
|
+
authorizationDefinition,
|
|
270
|
+
MANAGED_REFRESH_AUTHORIZATION_IRI
|
|
271
|
+
);
|
|
272
|
+
|
|
273
|
+
if (
|
|
274
|
+
isPlainObject(authorizationDefinition) &&
|
|
275
|
+
isPlainObject(authorizationDefinition['@context'])
|
|
276
|
+
) {
|
|
277
|
+
const scopedContext = authorizationDefinition['@context'];
|
|
278
|
+
|
|
279
|
+
if (MANAGED_REFRESH_DID_AUTH_TERM in scopedContext) {
|
|
280
|
+
assertDefinitionMatchesIri(
|
|
281
|
+
MANAGED_REFRESH_DID_AUTH_TERM,
|
|
282
|
+
scopedContext[MANAGED_REFRESH_DID_AUTH_TERM],
|
|
283
|
+
MANAGED_REFRESH_DID_AUTH_IRI
|
|
284
|
+
);
|
|
285
|
+
didAuthCovered = true;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// LearnCardDIDAuth term (top-level fallback when not covered by authorization's scope)
|
|
291
|
+
if (!didAuthCovered) {
|
|
292
|
+
const didAuthDefinition = findTopLevelDefinition(
|
|
293
|
+
inlineObjects,
|
|
294
|
+
MANAGED_REFRESH_DID_AUTH_TERM
|
|
295
|
+
);
|
|
296
|
+
|
|
297
|
+
if (didAuthDefinition === undefined) {
|
|
298
|
+
fragment[MANAGED_REFRESH_DID_AUTH_TERM] = MANAGED_REFRESH_DID_AUTH_IRI;
|
|
299
|
+
} else {
|
|
300
|
+
assertDefinitionMatchesIri(
|
|
301
|
+
MANAGED_REFRESH_DID_AUTH_TERM,
|
|
302
|
+
didAuthDefinition,
|
|
303
|
+
MANAGED_REFRESH_DID_AUTH_IRI
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
if (Object.keys(fragment).length === 0) return credential;
|
|
309
|
+
|
|
310
|
+
return {
|
|
311
|
+
...credential,
|
|
312
|
+
'@context': [...contextList, fragment],
|
|
313
|
+
} as T;
|
|
314
|
+
};
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Injects an allocated managed refresh service into an unsigned credential so the
|
|
318
|
+
* service becomes part of the signed payload.
|
|
319
|
+
*
|
|
320
|
+
* The managed service is made primary: any existing refresh services (for example a
|
|
321
|
+
* standard `1EdTechCredentialRefresh` service) are kept unchanged after it. Injecting
|
|
322
|
+
* a second, different managed service is rejected. The required inline context is
|
|
323
|
+
* prepared via {@link prepareManagedRefreshContext}.
|
|
324
|
+
*/
|
|
325
|
+
export const injectManagedRefreshService = <T>(
|
|
326
|
+
credential: T,
|
|
327
|
+
refreshService: ManagedCredentialRefreshService
|
|
328
|
+
): T => {
|
|
329
|
+
const service = ManagedCredentialRefreshServiceValidator.parse(refreshService);
|
|
330
|
+
const source = credential as unknown as ContextObject;
|
|
331
|
+
|
|
332
|
+
const existing = source['refreshService'];
|
|
333
|
+
const existingServices: unknown[] =
|
|
334
|
+
existing === undefined || existing === null
|
|
335
|
+
? []
|
|
336
|
+
: Array.isArray(existing)
|
|
337
|
+
? [...existing]
|
|
338
|
+
: [existing];
|
|
339
|
+
|
|
340
|
+
const existingManaged = getManagedRefreshServices({ refreshService: existingServices });
|
|
341
|
+
|
|
342
|
+
if (existingManaged.some(entry => entry.id !== service.id)) {
|
|
343
|
+
throw new Error(
|
|
344
|
+
'Credential already carries a different managed refresh service; a credential can only reference one managed refresh service.'
|
|
345
|
+
);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const nonManagedEntries = existingServices.filter(
|
|
349
|
+
entry => !ManagedCredentialRefreshServiceValidator.safeParse(entry).success
|
|
350
|
+
);
|
|
351
|
+
|
|
352
|
+
const mergedRefreshService: unknown =
|
|
353
|
+
existingServices.length === 0
|
|
354
|
+
? service
|
|
355
|
+
: !Array.isArray(existing) && existingManaged.length > 0
|
|
356
|
+
? // Re-injecting the same managed service: keep object form (idempotent)
|
|
357
|
+
service
|
|
358
|
+
: [service, ...nonManagedEntries];
|
|
359
|
+
|
|
360
|
+
return prepareManagedRefreshContext({
|
|
361
|
+
...credential,
|
|
362
|
+
refreshService: mergedRefreshService,
|
|
363
|
+
} as T);
|
|
364
|
+
};
|
|
365
|
+
|
|
108
366
|
/**
|
|
109
367
|
* Proof-insensitive content comparison for refresh changed-content detection.
|
|
110
368
|
*
|