@learncard/helpers 1.7.2 → 1.8.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.
@@ -3,7 +3,15 @@ import {
3
3
  SupportedCredentialRefreshServiceValidator,
4
4
  type ManagedCredentialRefreshService,
5
5
  type SupportedCredentialRefreshService,
6
+ type CredentialRefreshReceipt,
7
+ type JWE,
6
8
  } from '@learncard/types';
9
+ import { getBitstringStatusListEntries } from './bitstring-status-list';
10
+
11
+ // VC v1 credentials need the Bitstring Status List context URI for verifiers to accept a
12
+ // status entry. DIDKit (and most JSON-LD loaders) cache this context, so no remote fetch.
13
+ const BITSTRING_STATUS_LIST_CONTEXT = 'https://www.w3.org/ns/credentials/status/v1';
14
+ const VC_V2_CONTEXT = 'https://www.w3.org/ns/credentials/v2';
7
15
 
8
16
  /**
9
17
  * Canonical helpers for credential refresh (LC-2117, LC-2135, LC-2136).
@@ -107,6 +115,78 @@ export const canonicalizeCredentialContent = <T>(value: T): T => {
107
115
  export const canonicalizeCredentialJson = (value: unknown): string =>
108
116
  JSON.stringify(canonicalizeCredentialContent(value));
109
117
 
118
+ /** Structural recipient check only; decryption and credential verification remain mandatory. */
119
+ export const assertExactJweRecipients = (jwe: JWE, recipientKeyIds: string[]): void => {
120
+ const expected = new Set(recipientKeyIds);
121
+ const recipients = jwe.recipients ?? [];
122
+ const actual = recipients.map(recipient => recipient.header.kid);
123
+ if (
124
+ expected.size === 0 ||
125
+ expected.size !== recipientKeyIds.length ||
126
+ actual.length !== expected.size ||
127
+ new Set(actual).size !== actual.length ||
128
+ actual.some(kid => !kid || !expected.has(kid))
129
+ ) {
130
+ throw new Error('ENCRYPTION_RECIPIENTS_MISMATCH');
131
+ }
132
+ };
133
+
134
+ /** Compares exact public bindings while ignoring JSON object-key order, never array order. */
135
+ export const credentialRefreshBindingsEqual = (
136
+ credential: { credentialStatus?: unknown; refreshService?: unknown },
137
+ baseline: { credentialStatus?: unknown; refreshService?: unknown }
138
+ ): boolean =>
139
+ canonicalizeCredentialJson(credential.credentialStatus) ===
140
+ canonicalizeCredentialJson(baseline.credentialStatus) &&
141
+ canonicalizeCredentialJson(credential.refreshService) ===
142
+ canonicalizeCredentialJson(baseline.refreshService);
143
+
144
+ /** Checks signed identity and public bindings. Legacy managed-entry receipts bind only the
145
+ * unique managed service; exact receipts also bind additional services and array shape.
146
+ * This does not replace proof/status verification.
147
+ */
148
+ export const assertManagedCredentialBinding = (
149
+ credential: RefreshableCredential,
150
+ receipt: CredentialRefreshReceipt
151
+ ): void => {
152
+ const subjects = Array.isArray(credential.credentialSubject)
153
+ ? credential.credentialSubject
154
+ : [credential.credentialSubject];
155
+ const holderMatches =
156
+ subjects.length > 0 &&
157
+ subjects.every(
158
+ (subject: unknown) =>
159
+ typeof subject === 'object' &&
160
+ subject !== null &&
161
+ 'id' in subject &&
162
+ subject.id === receipt.holderDid
163
+ );
164
+ const managedServices = getManagedRefreshServices(credential);
165
+ const services = Array.isArray(credential.refreshService)
166
+ ? credential.refreshService
167
+ : [credential.refreshService];
168
+ const managedEntryCount = services.filter(
169
+ service => service?.type === 'LearnCardCredentialRefresh2026'
170
+ ).length;
171
+ const bindingsMatch =
172
+ receipt.refreshServiceBinding === 'managed-entry'
173
+ ? managedServices.length === 1 &&
174
+ managedEntryCount === 1 &&
175
+ credentialRefreshBindingsEqual(
176
+ { ...credential, refreshService: managedServices[0] },
177
+ receipt
178
+ )
179
+ : credentialRefreshBindingsEqual(credential, receipt);
180
+ if (
181
+ credential.id !== receipt.credentialId ||
182
+ getCredentialIssuerId(credential) !== receipt.issuerDid ||
183
+ !holderMatches ||
184
+ !bindingsMatch
185
+ ) {
186
+ throw new Error('MANAGED_CREDENTIAL_BINDING_MISMATCH');
187
+ }
188
+ };
189
+
110
190
  // --- Managed refresh JSON-LD context preparation (LC-2198) --------------------
111
191
 
112
192
  /** Term used by the LearnCard-managed refresh service type. */
@@ -240,6 +320,22 @@ export const prepareManagedRefreshContext = <T>(credential: T): T => {
240
320
  : [existingContext];
241
321
  const inlineObjects = contextList.filter(isPlainObject);
242
322
 
323
+ let addedStatusContext = false;
324
+ if (
325
+ getBitstringStatusListEntries(credential).length > 0 &&
326
+ !contextList.some(
327
+ context => context === VC_V2_CONTEXT || context === BITSTRING_STATUS_LIST_CONTEXT
328
+ )
329
+ ) {
330
+ const firstInline = contextList.findIndex(isPlainObject);
331
+ contextList.splice(
332
+ firstInline === -1 ? contextList.length : firstInline,
333
+ 0,
334
+ BITSTRING_STATUS_LIST_CONTEXT
335
+ );
336
+ addedStatusContext = true;
337
+ }
338
+
243
339
  const fragment: Record<string, unknown> = {};
244
340
 
245
341
  // Managed refresh service type term
@@ -305,11 +401,12 @@ export const prepareManagedRefreshContext = <T>(credential: T): T => {
305
401
  }
306
402
  }
307
403
 
308
- if (Object.keys(fragment).length === 0) return credential;
404
+ const hasFragment = Object.keys(fragment).length > 0;
405
+ if (!hasFragment && !addedStatusContext) return credential;
309
406
 
310
407
  return {
311
408
  ...credential,
312
- '@context': [...contextList, fragment],
409
+ '@context': hasFragment ? [...contextList, fragment] : contextList,
313
410
  } as T;
314
411
  };
315
412
 
package/src/index.ts CHANGED
@@ -61,6 +61,22 @@ export const isVC2Format = (credential: unknown): boolean => {
61
61
  : context === 'https://www.w3.org/ns/credentials/v2';
62
62
  };
63
63
 
64
+ /**
65
+ * The marker field on the placeholder credential JSON of a "display-only anchor"
66
+ * Boost (one created by a managed/signed-only send that had no explicit credential).
67
+ * `'display' in boost` is NOT a safe way to detect this: ordinary Boosts commonly have
68
+ * their own top-level `display` object too (e.g. the LearnCard app's appearance fields —
69
+ * backgroundImage, backgroundColor, displayType), so that check produces false
70
+ * positives on real credentials.
71
+ */
72
+ export const DISPLAY_ONLY_ANCHOR_MARKER = 'displayOnlyAnchor' as const;
73
+
74
+ /** Detects a display-only anchor via its dedicated marker (see `DISPLAY_ONLY_ANCHOR_MARKER`). */
75
+ export const isDisplayOnlyAnchorTemplate = (credential: unknown): boolean =>
76
+ !!credential &&
77
+ typeof credential === 'object' &&
78
+ (credential as Record<string, unknown>)[DISPLAY_ONLY_ANCHOR_MARKER] === true;
79
+
64
80
  /** Unwraps a boost credential from a CertifiedBoostCredential, if it is one */
65
81
  export const unwrapBoostCredential = (vc?: VC | UnsignedVC) => {
66
82
  if (vc?.type?.includes('CertifiedBoostCredential') && vc?.boostCredential) {
@@ -108,5 +124,7 @@ export const isAppDidWeb = (did?: string): boolean => {
108
124
  };
109
125
 
110
126
  export * from './share-content-canonical';
127
+ export * from './consent-flow-contract-updates';
111
128
 
112
129
  export * from './credential-materialization';
130
+ export * from './age-policy';