@learncard/helpers 1.5.1 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@learncard/helpers",
3
- "version": "1.5.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.20.0",
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
  *