@learncard/types 5.21.0 → 5.23.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/__tests__/unsupportedSignInOperation.test.d.ts +2 -0
- package/dist/__tests__/unsupportedSignInOperation.test.d.ts.map +1 -0
- package/dist/auth.d.ts +233 -5
- package/dist/auth.d.ts.map +1 -1
- package/dist/clr.d.ts +2 -2
- package/dist/clr.d.ts.map +1 -1
- package/dist/clr.test.d.ts +2 -0
- package/dist/clr.test.d.ts.map +1 -0
- package/dist/inAppMessages.d.ts +10 -10
- package/dist/index.d.cts +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/lcn.d.ts +1638 -15
- package/dist/lcn.d.ts.map +1 -1
- package/dist/share-links.d.ts +1950 -0
- package/dist/share-links.d.ts.map +1 -0
- package/dist/types.cjs.development.cjs +873 -10
- package/dist/types.cjs.development.cjs.map +4 -4
- package/dist/types.cjs.production.min.cjs +33 -33
- package/dist/types.cjs.production.min.cjs.map +4 -4
- package/dist/types.esm.js +941 -76
- package/dist/types.esm.js.map +4 -4
- package/package.json +2 -1
- package/src/__tests__/unsupportedSignInOperation.test.ts +13 -0
- package/src/auth.ts +248 -4
- package/src/clr.test.ts +163 -0
- package/src/clr.ts +2 -1
- package/src/index.ts +1 -0
- package/src/lcn.ts +342 -5
- package/src/share-links.ts +931 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@learncard/types",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.23.0",
|
|
4
4
|
"description": "Shared types for learn card",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.cjs",
|
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
"scripts": {
|
|
26
26
|
"build": "node ./scripts/build.mjs && shx cp ./scripts/mixedEntypoint.js ./dist/index.cjs && tsc --p tsconfig.json && shx cp ./dist/index.d.ts ./dist/index.d.cts",
|
|
27
27
|
"typecheck": "tsc --noEmit -p tsconfig.json",
|
|
28
|
+
"test": "vitest run",
|
|
28
29
|
"start": "aqu watch"
|
|
29
30
|
},
|
|
30
31
|
"author": "Learning Economy Foundation (www.learningeconomy.io)",
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { UnsupportedSignInOperationError } from '../auth';
|
|
3
|
+
|
|
4
|
+
describe('UnsupportedSignInOperationError', () => {
|
|
5
|
+
it('preserves the operation and provider for callers', () => {
|
|
6
|
+
const error = new UnsupportedSignInOperationError('sendPhoneOtp', 'keycloak');
|
|
7
|
+
expect(error).toBeInstanceOf(Error);
|
|
8
|
+
expect(error.name).toBe('UnsupportedSignInOperationError');
|
|
9
|
+
expect(error.operation).toBe('sendPhoneOtp');
|
|
10
|
+
expect(error.providerType).toBe('keycloak');
|
|
11
|
+
expect(error.message).toContain('sendPhoneOtp');
|
|
12
|
+
});
|
|
13
|
+
});
|
package/src/auth.ts
CHANGED
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
* coupling consumers to any specific implementation.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
+
import { z } from 'zod';
|
|
10
|
+
|
|
9
11
|
// ---------------------------------------------------------------------------
|
|
10
12
|
// Auth Session Error
|
|
11
13
|
// ---------------------------------------------------------------------------
|
|
@@ -30,6 +32,17 @@ export class AuthSessionError extends Error {
|
|
|
30
32
|
// Auth Provider
|
|
31
33
|
// ---------------------------------------------------------------------------
|
|
32
34
|
|
|
35
|
+
/** An action that the selected sign-in provider does not support. */
|
|
36
|
+
export class UnsupportedSignInOperationError extends Error {
|
|
37
|
+
constructor(
|
|
38
|
+
public readonly operation: string,
|
|
39
|
+
public readonly providerType: string
|
|
40
|
+
) {
|
|
41
|
+
super(`${operation} is not supported by ${providerType}`);
|
|
42
|
+
this.name = 'UnsupportedSignInOperationError';
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
33
46
|
/**
|
|
34
47
|
* Auth provider identifier. Known values: 'firebase', 'supertokens', 'keycloak', 'oidc'.
|
|
35
48
|
* Use any string to support custom auth providers without modifying this type.
|
|
@@ -242,6 +255,8 @@ export interface RecoveryMethodInfo {
|
|
|
242
255
|
type: string;
|
|
243
256
|
createdAt: Date;
|
|
244
257
|
credentialId?: string;
|
|
258
|
+
shareVersion?: number;
|
|
259
|
+
confirmedAt?: Date;
|
|
245
260
|
}
|
|
246
261
|
|
|
247
262
|
/**
|
|
@@ -253,10 +268,54 @@ export interface RecoveryResult {
|
|
|
253
268
|
did: string;
|
|
254
269
|
}
|
|
255
270
|
|
|
271
|
+
export interface IdentityRecoverySession {
|
|
272
|
+
recoverySessionToken: string;
|
|
273
|
+
recoveryMethods: RecoveryMethodInfo[];
|
|
274
|
+
}
|
|
275
|
+
|
|
256
276
|
// ---------------------------------------------------------------------------
|
|
257
277
|
// Server Key Status
|
|
258
278
|
// ---------------------------------------------------------------------------
|
|
259
279
|
|
|
280
|
+
export type SssActivationState = 'provisional' | 'active';
|
|
281
|
+
|
|
282
|
+
/** Optional PIN enrollment requires rotating the existing escrow share. */
|
|
283
|
+
export type EscrowEnrollmentOptions = { pin?: string };
|
|
284
|
+
|
|
285
|
+
/** Stable error-message contract shared by PIN recovery clients and servers. */
|
|
286
|
+
export const ESCROW_PIN_LOCKED_MESSAGE =
|
|
287
|
+
'Too many incorrect PIN attempts. You can still recover by waiting.';
|
|
288
|
+
export const ESCROW_PIN_UNAVAILABLE_MESSAGE = 'PIN recovery is not available for this account.';
|
|
289
|
+
export const ESCROW_PIN_MISMATCH_PATTERN = /^Incorrect PIN\. (\d+) attempts left\.$/;
|
|
290
|
+
export const escrowPinMismatchMessage = (attemptsRemaining: number): string =>
|
|
291
|
+
`Incorrect PIN. ${attemptsRemaining} attempts left.`;
|
|
292
|
+
|
|
293
|
+
/** Public PIN availability and remaining lifetime attempts; never includes the verifier. */
|
|
294
|
+
export const EscrowPinStatusValidator = z.object({
|
|
295
|
+
state: z.enum(['none', 'enabled', 'locked', 'stale']),
|
|
296
|
+
enabled: z.boolean(),
|
|
297
|
+
attemptsRemaining: z.number().int().nonnegative(),
|
|
298
|
+
salt: z.string().optional(),
|
|
299
|
+
});
|
|
300
|
+
export type EscrowPinStatus = z.infer<typeof EscrowPinStatusValidator>;
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Reason a stored escrow blob no longer matches the server's active enclave:
|
|
304
|
+
* `mode-mismatch` — blob was sealed for `software`/`nitro` but the server now
|
|
305
|
+
* runs the other backend (e.g. the P6 software→Nitro migration, or a rollback);
|
|
306
|
+
* `key-rotated` — blob's `enclaveKeyId` no longer matches the active
|
|
307
|
+
* attestation's key (e.g. a PCR/measurement rotation).
|
|
308
|
+
*/
|
|
309
|
+
export type EscrowBlobStaleReason = 'mode-mismatch' | 'key-rotated';
|
|
310
|
+
|
|
311
|
+
/** Enrollment details for PIN-aware strategies. Legacy strategies may still return a string. */
|
|
312
|
+
export interface EscrowEnrollmentState {
|
|
313
|
+
state: 'enrolled' | 'not-enrolled' | 'opted-out' | 'disabled' | 'stale';
|
|
314
|
+
escrowPin?: EscrowPinStatus;
|
|
315
|
+
/** Present only when `state === 'stale'`. */
|
|
316
|
+
staleReason?: EscrowBlobStaleReason;
|
|
317
|
+
}
|
|
318
|
+
|
|
260
319
|
/**
|
|
261
320
|
* Server key status returned by the strategy's fetchServerKeyStatus.
|
|
262
321
|
* The strategy owns the server shape — different strategies may
|
|
@@ -270,8 +329,15 @@ export interface ServerKeyStatus {
|
|
|
270
329
|
authShare: string | null;
|
|
271
330
|
shareVersion: number | null;
|
|
272
331
|
maskedRecoveryEmail?: string | null;
|
|
332
|
+
escrowOptedOut?: boolean;
|
|
333
|
+
escrowPin?: EscrowPinStatus;
|
|
334
|
+
escrowStale?: EscrowBlobStaleReason;
|
|
335
|
+
sssActivationState?: SssActivationState | null;
|
|
273
336
|
}
|
|
274
337
|
|
|
338
|
+
/** Signs a DID-Auth VP. A supplied challenge must be embedded as the VP nonce. */
|
|
339
|
+
export type DidAuthVpSigner = (privateKey: string, challenge?: string) => Promise<string>;
|
|
340
|
+
|
|
275
341
|
// ---------------------------------------------------------------------------
|
|
276
342
|
// Key Derivation Capabilities
|
|
277
343
|
// ---------------------------------------------------------------------------
|
|
@@ -348,6 +414,7 @@ export interface KeyDerivationStrategy<
|
|
|
348
414
|
TRecoveryInput = unknown,
|
|
349
415
|
TRecoverySetupInput = unknown,
|
|
350
416
|
TRecoverySetupResult = unknown,
|
|
417
|
+
TRecoveryConfirmationInput = unknown,
|
|
351
418
|
> {
|
|
352
419
|
readonly name: string;
|
|
353
420
|
|
|
@@ -365,8 +432,8 @@ export interface KeyDerivationStrategy<
|
|
|
365
432
|
/** Store a local key component */
|
|
366
433
|
storeLocalKey(key: string): Promise<void>;
|
|
367
434
|
|
|
368
|
-
/** Clear
|
|
369
|
-
clearLocalKeys(): Promise<void>;
|
|
435
|
+
/** Clear local key data; automatic stale-key cleanup may retain unresolved writes. */
|
|
436
|
+
clearLocalKeys(options?: { preservePending?: boolean }): Promise<void>;
|
|
370
437
|
|
|
371
438
|
/** Split a private key into shares/components */
|
|
372
439
|
splitKey(privateKey: string): Promise<{ localKey: string; remoteKey: string }>;
|
|
@@ -382,6 +449,38 @@ export interface KeyDerivationStrategy<
|
|
|
382
449
|
didFromPrivateKey: (pk: string) => Promise<string>
|
|
383
450
|
): Promise<boolean>;
|
|
384
451
|
|
|
452
|
+
/**
|
|
453
|
+
* Atomically split and persist a private key's local and remote components.
|
|
454
|
+
* Strategies that implement this use it for initial setup and rotations so
|
|
455
|
+
* callers never have to coordinate device/server writes themselves.
|
|
456
|
+
*/
|
|
457
|
+
atomicUpdateShares?(params: {
|
|
458
|
+
token: string;
|
|
459
|
+
providerType: AuthProviderType;
|
|
460
|
+
privateKey: string;
|
|
461
|
+
did: string;
|
|
462
|
+
signDidAuthVp?: DidAuthVpSigner;
|
|
463
|
+
}): Promise<void>;
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* Repair local/server share-version skew after an ambiguous write. Returns
|
|
467
|
+
* the recovered key when reconciliation was needed, otherwise null.
|
|
468
|
+
*/
|
|
469
|
+
reconcileShares?(params: {
|
|
470
|
+
token: string;
|
|
471
|
+
providerType: AuthProviderType;
|
|
472
|
+
expectedDid: string;
|
|
473
|
+
didFromPrivateKey: (privateKey: string) => Promise<string>;
|
|
474
|
+
signDidAuthVp?: DidAuthVpSigner;
|
|
475
|
+
}): Promise<RecoveryResult | null>;
|
|
476
|
+
|
|
477
|
+
/** Obtain a short-lived, single-use challenged DID-Auth VP for a write. */
|
|
478
|
+
getFreshDidAuthVp?(
|
|
479
|
+
privateKey: string,
|
|
480
|
+
did: string,
|
|
481
|
+
signDidAuthVp: DidAuthVpSigner
|
|
482
|
+
): Promise<string>;
|
|
483
|
+
|
|
385
484
|
// --- Server communication ---
|
|
386
485
|
|
|
387
486
|
/** Fetch the server-side key status for the authenticated user */
|
|
@@ -399,8 +498,113 @@ export interface KeyDerivationStrategy<
|
|
|
399
498
|
/** Mark migration complete on the server (optional — only needed for migration-capable strategies) */
|
|
400
499
|
markMigrated?(token: string, providerType: AuthProviderType, didAuthVp?: string): Promise<void>;
|
|
401
500
|
|
|
501
|
+
/** Commit a provisioned key after the server verifies recovery enrollment. */
|
|
502
|
+
activate?(token: string, providerType: AuthProviderType, didAuthVp?: string): Promise<void>;
|
|
503
|
+
|
|
402
504
|
// --- Recovery ---
|
|
403
505
|
|
|
506
|
+
/** Read the current automatic recovery enrollment status. */
|
|
507
|
+
getEscrowEnrollmentState?(params: {
|
|
508
|
+
token: string;
|
|
509
|
+
providerType: AuthProviderType;
|
|
510
|
+
}): Promise<EscrowEnrollmentState['state'] | EscrowEnrollmentState>;
|
|
511
|
+
|
|
512
|
+
/** Opt out with an owner proof; requires another confirmed recovery method. */
|
|
513
|
+
disableEscrowRecovery?(params: {
|
|
514
|
+
token: string;
|
|
515
|
+
providerType: AuthProviderType;
|
|
516
|
+
privateKey: string;
|
|
517
|
+
signDidAuthVp: DidAuthVpSigner;
|
|
518
|
+
}): Promise<void>;
|
|
519
|
+
|
|
520
|
+
/** Opt back in and enroll automatic recovery material. */
|
|
521
|
+
enableEscrowRecovery?(params: {
|
|
522
|
+
token: string;
|
|
523
|
+
providerType: AuthProviderType;
|
|
524
|
+
privateKey: string;
|
|
525
|
+
signDidAuthVp: DidAuthVpSigner;
|
|
526
|
+
options?: EscrowEnrollmentOptions;
|
|
527
|
+
}): Promise<
|
|
528
|
+
| { enrolled: false; reason: 'disabled' | 'opted-out' }
|
|
529
|
+
| { enrolled: true; changed: false }
|
|
530
|
+
| { enrolled: true; changed: true; shareVersion: number }
|
|
531
|
+
>;
|
|
532
|
+
|
|
533
|
+
/** Repair escrow enrollment, rotating shares only when no current confirmed enrollment exists. */
|
|
534
|
+
ensureEscrowEnrollment?(params: {
|
|
535
|
+
token: string;
|
|
536
|
+
providerType: AuthProviderType;
|
|
537
|
+
privateKey: string;
|
|
538
|
+
signDidAuthVp: DidAuthVpSigner;
|
|
539
|
+
options?: EscrowEnrollmentOptions;
|
|
540
|
+
}): Promise<
|
|
541
|
+
| { enrolled: false; reason: 'disabled' | 'opted-out' }
|
|
542
|
+
| { enrolled: true; changed: false }
|
|
543
|
+
| { enrolled: true; changed: true; shareVersion: number }
|
|
544
|
+
>;
|
|
545
|
+
|
|
546
|
+
/** Set or change a PIN by rotating escrow material with an owner proof. */
|
|
547
|
+
setEscrowPin?(params: {
|
|
548
|
+
token: string;
|
|
549
|
+
providerType: AuthProviderType;
|
|
550
|
+
privateKey: string;
|
|
551
|
+
signDidAuthVp: DidAuthVpSigner;
|
|
552
|
+
pin: string;
|
|
553
|
+
}): Promise<void>;
|
|
554
|
+
|
|
555
|
+
/** Remove a PIN by rotating escrow material with an owner proof. */
|
|
556
|
+
clearEscrowPin?(params: {
|
|
557
|
+
token: string;
|
|
558
|
+
providerType: AuthProviderType;
|
|
559
|
+
privateKey: string;
|
|
560
|
+
signDidAuthVp: DidAuthVpSigner;
|
|
561
|
+
}): Promise<void>;
|
|
562
|
+
|
|
563
|
+
/** Start an escrow hold. Securely persist the returned secrets; null means an existing hold. */
|
|
564
|
+
startEscrowRecovery?(params: {
|
|
565
|
+
token?: string;
|
|
566
|
+
providerType?: AuthProviderType;
|
|
567
|
+
recoverySessionToken?: string;
|
|
568
|
+
tenantId?: string;
|
|
569
|
+
options?: { releasePolicy?: 'hold' | 'pin'; restart?: boolean };
|
|
570
|
+
}): Promise<{
|
|
571
|
+
holdId: string;
|
|
572
|
+
status: 'pending' | 'cancelled' | 'completed' | 'expired';
|
|
573
|
+
requestedAt: string;
|
|
574
|
+
releaseAfter: string;
|
|
575
|
+
cancelledAt?: string;
|
|
576
|
+
completedAt?: string;
|
|
577
|
+
resumeToken: string | null;
|
|
578
|
+
clientEphemeralPrivateKey: string;
|
|
579
|
+
pinSalt?: string;
|
|
580
|
+
/** Absent on legacy hold-only strategies. */
|
|
581
|
+
releasePolicy?: 'hold' | 'pin';
|
|
582
|
+
}>;
|
|
583
|
+
|
|
584
|
+
/** Read a hold using its resume proof or the active device's provider session. */
|
|
585
|
+
getEscrowRecoveryStatus?(
|
|
586
|
+
params:
|
|
587
|
+
| { holdId: string; resumeToken: string }
|
|
588
|
+
| { token: string; providerType: AuthProviderType }
|
|
589
|
+
): Promise<{
|
|
590
|
+
holdId: string;
|
|
591
|
+
status: 'pending' | 'cancelled' | 'completed' | 'expired';
|
|
592
|
+
requestedAt: string;
|
|
593
|
+
releaseAfter: string;
|
|
594
|
+
/** Absent on legacy hold-only strategies. */
|
|
595
|
+
releasePolicy?: 'hold' | 'pin';
|
|
596
|
+
cancelledAt?: string;
|
|
597
|
+
completedAt?: string;
|
|
598
|
+
} | null>;
|
|
599
|
+
|
|
600
|
+
/** Cancel a pending hold with a fresh, owner-signed DID challenge. */
|
|
601
|
+
cancelEscrowRecovery?(params: {
|
|
602
|
+
token: string;
|
|
603
|
+
providerType: AuthProviderType;
|
|
604
|
+
privateKey: string;
|
|
605
|
+
signDidAuthVp: DidAuthVpSigner;
|
|
606
|
+
}): Promise<{ cancelled: boolean }>;
|
|
607
|
+
|
|
404
608
|
/** Execute a recovery flow and return the recovered private key + DID */
|
|
405
609
|
executeRecovery(params: {
|
|
406
610
|
token: string;
|
|
@@ -408,6 +612,8 @@ export interface KeyDerivationStrategy<
|
|
|
408
612
|
input: TRecoveryInput;
|
|
409
613
|
/** Optional: validate the reconstructed key's DID before rotating shares */
|
|
410
614
|
didFromPrivateKey?: (privateKey: string) => Promise<string>;
|
|
615
|
+
/** Optional: sign the fresh challenge required to persist rotated shares */
|
|
616
|
+
signDidAuthVp?: DidAuthVpSigner;
|
|
411
617
|
}): Promise<RecoveryResult>;
|
|
412
618
|
|
|
413
619
|
/** Set up a new recovery method */
|
|
@@ -418,15 +624,52 @@ export interface KeyDerivationStrategy<
|
|
|
418
624
|
input: TRecoverySetupInput;
|
|
419
625
|
authUser?: AuthUser;
|
|
420
626
|
/** Optional: sign a DID-Auth VP JWT for server write operations */
|
|
421
|
-
signDidAuthVp?:
|
|
627
|
+
signDidAuthVp?: DidAuthVpSigner;
|
|
422
628
|
}): Promise<TRecoverySetupResult>;
|
|
423
629
|
|
|
630
|
+
/** Confirm a pending method after the strategy verifies proof of receipt locally. */
|
|
631
|
+
confirmRecoveryMethod?(params: {
|
|
632
|
+
token: string;
|
|
633
|
+
providerType: AuthProviderType;
|
|
634
|
+
privateKey: string;
|
|
635
|
+
input: TRecoveryConfirmationInput;
|
|
636
|
+
signDidAuthVp?: DidAuthVpSigner;
|
|
637
|
+
}): Promise<void>;
|
|
638
|
+
|
|
424
639
|
/** Get configured recovery methods for the authenticated user */
|
|
425
640
|
getAvailableRecoveryMethods?(
|
|
426
641
|
token: string,
|
|
427
642
|
providerType: AuthProviderType
|
|
428
643
|
): Promise<RecoveryMethodInfo[]>;
|
|
429
644
|
|
|
645
|
+
// --- Lost login identity recovery ---
|
|
646
|
+
|
|
647
|
+
/** Send an OTP to the verified recovery email without requiring provider auth. */
|
|
648
|
+
startIdentityRecovery?(email: string): Promise<void>;
|
|
649
|
+
|
|
650
|
+
/** Verify the OTP and receive a one-use, recovery-scoped session. */
|
|
651
|
+
verifyIdentityRecovery?(email: string, code: string): Promise<IdentityRecoverySession>;
|
|
652
|
+
|
|
653
|
+
/** Reconstruct and hard-validate the key before the replacement login is bound. */
|
|
654
|
+
prepareIdentityRecovery?(params: {
|
|
655
|
+
recoverySessionToken: string;
|
|
656
|
+
input: TRecoveryInput;
|
|
657
|
+
didFromPrivateKey: (privateKey: string) => Promise<string>;
|
|
658
|
+
}): Promise<RecoveryResult>;
|
|
659
|
+
|
|
660
|
+
/** Whether reconstructed identity recovery is waiting for a replacement login. */
|
|
661
|
+
hasPendingIdentityRecovery?(): boolean;
|
|
662
|
+
|
|
663
|
+
/** Discard any reconstructed identity recovery that has not been rebound. */
|
|
664
|
+
cancelIdentityRecovery?(): void;
|
|
665
|
+
|
|
666
|
+
/** Bind the current provider identity and commit a full share rotation. */
|
|
667
|
+
completeIdentityRecovery?(params: {
|
|
668
|
+
token: string;
|
|
669
|
+
providerType: AuthProviderType;
|
|
670
|
+
signDidAuthVp?: DidAuthVpSigner;
|
|
671
|
+
}): Promise<RecoveryResult>;
|
|
672
|
+
|
|
430
673
|
// --- Contact method management ---
|
|
431
674
|
|
|
432
675
|
/**
|
|
@@ -467,7 +710,8 @@ export interface KeyDerivationStrategy<
|
|
|
467
710
|
token: string,
|
|
468
711
|
providerType: AuthProviderType,
|
|
469
712
|
privateKey: string,
|
|
470
|
-
email: string
|
|
713
|
+
email: string,
|
|
714
|
+
didAuthVp?: string
|
|
471
715
|
): Promise<void>;
|
|
472
716
|
|
|
473
717
|
// --- Share versioning ---
|
package/src/clr.test.ts
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { describe, expect, expectTypeOf, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
AssociationValidator,
|
|
5
|
+
ClrCredentialValidator,
|
|
6
|
+
ClrSubjectValidator,
|
|
7
|
+
UnsignedClrCredentialValidator,
|
|
8
|
+
type Association,
|
|
9
|
+
} from './clr';
|
|
10
|
+
|
|
11
|
+
const association = {
|
|
12
|
+
type: 'Association' as const,
|
|
13
|
+
associationType: 'isPartOf',
|
|
14
|
+
sourceId: 'https://example.org/achievements/course',
|
|
15
|
+
targetId: 'https://example.org/achievements/program',
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const supportedTypeNames = [
|
|
19
|
+
'Association',
|
|
20
|
+
'https://purl.imsglobal.org/spec/vc/clr/vocab.html#Association',
|
|
21
|
+
'https://example.org/ExtendedAssociation',
|
|
22
|
+
'CustomAssociation',
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
const subject = {
|
|
26
|
+
id: 'did:example:learner',
|
|
27
|
+
type: ['ClrSubject'],
|
|
28
|
+
association: [association],
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const unsignedClr = {
|
|
32
|
+
'@context': [
|
|
33
|
+
'https://www.w3.org/ns/credentials/v2',
|
|
34
|
+
'https://purl.imsglobal.org/spec/clr/v2p0/context.json',
|
|
35
|
+
'https://purl.imsglobal.org/spec/ob/v3p0/context.json',
|
|
36
|
+
],
|
|
37
|
+
id: 'https://example.org/credentials/clr',
|
|
38
|
+
type: ['VerifiableCredential', 'ClrCredential'],
|
|
39
|
+
issuer: { id: 'did:example:issuer', type: ['Profile'], name: 'Example University' },
|
|
40
|
+
validFrom: '2026-01-01T00:00:00Z',
|
|
41
|
+
credentialSubject: subject,
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const proof = {
|
|
45
|
+
type: 'DataIntegrityProof',
|
|
46
|
+
cryptosuite: 'eddsa-rdfc-2022',
|
|
47
|
+
created: '2026-01-01T00:00:00Z',
|
|
48
|
+
proofPurpose: 'assertionMethod',
|
|
49
|
+
verificationMethod: 'did:example:issuer#key',
|
|
50
|
+
proofValue: 'zExampleSignature',
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
describe('CLR Association.type', () => {
|
|
54
|
+
it('accepts the canonical scalar without changing it or extension fields', () => {
|
|
55
|
+
// CLR 2.0 $defs.Association.properties.type requires the scalar "Association".
|
|
56
|
+
// https://purl.imsglobal.org/spec/clr/v2p0/schema/json/clr_v2p0_clrcredential_schema.json
|
|
57
|
+
const input = { ...association, 'ext:description': 'Course belongs to program' };
|
|
58
|
+
|
|
59
|
+
expect(AssociationValidator.parse(input)).toEqual(input);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it.each(supportedTypeNames)(
|
|
63
|
+
'preserves scalar and singleton-array type values through full CLR parsing: %s',
|
|
64
|
+
typeName => {
|
|
65
|
+
for (const type of [typeName, [typeName]]) {
|
|
66
|
+
const input = { ...association, type };
|
|
67
|
+
const credential = {
|
|
68
|
+
...unsignedClr,
|
|
69
|
+
credentialSubject: { ...subject, association: [input] },
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
// This SDK validator checks structure; the official CLR schema is stricter.
|
|
73
|
+
expect(AssociationValidator.parse(input)).toEqual(input);
|
|
74
|
+
expect(UnsignedClrCredentialValidator.parse(credential)).toEqual(credential);
|
|
75
|
+
expect(ClrCredentialValidator.parse({ ...credential, proof })).toEqual({
|
|
76
|
+
...credential,
|
|
77
|
+
proof,
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
it.each(
|
|
84
|
+
[
|
|
85
|
+
['Association'],
|
|
86
|
+
['Association', 'https://example.org/ExtendedAssociation'],
|
|
87
|
+
['https://example.org/LegacyAssociation'],
|
|
88
|
+
[''], // Existing arrays accept every string; preserve that contract.
|
|
89
|
+
].map(type => ({ type }))
|
|
90
|
+
)('preserves supported legacy arrays: $type', ({ type }) => {
|
|
91
|
+
const input = { ...association, type };
|
|
92
|
+
|
|
93
|
+
expect(AssociationValidator.parse(input)).toEqual(input);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it.each(
|
|
97
|
+
[undefined, null, false, 42, '', {}, [], ['Association', 42], [['Association']]].map(
|
|
98
|
+
type => ({ type })
|
|
99
|
+
)
|
|
100
|
+
)('rejects invalid values: $type', ({ type }) => {
|
|
101
|
+
expect(AssociationValidator.safeParse({ ...association, type }).success).toBe(false);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('exposes scalar and legacy array types to callers', () => {
|
|
105
|
+
expectTypeOf<Association['type']>().toEqualTypeOf<string | string[]>();
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
describe('CLR credentials with associations', () => {
|
|
110
|
+
it('parses a subject containing both canonical and legacy associations', () => {
|
|
111
|
+
const input = {
|
|
112
|
+
...subject,
|
|
113
|
+
association: [association, { ...association, type: ['Association'] }],
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
expect(ClrSubjectValidator.parse(input)).toEqual(input);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it.each([false, true])('parses unsigned and signed CLR subject arrays: %s', subjectArray => {
|
|
120
|
+
const input = {
|
|
121
|
+
...unsignedClr,
|
|
122
|
+
credentialSubject: subjectArray ? [subject] : subject,
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
expect(UnsignedClrCredentialValidator.parse(input)).toEqual(input);
|
|
126
|
+
expect(ClrCredentialValidator.parse({ ...input, proof })).toEqual({ ...input, proof });
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('preserves nested CLR content and validates its associations when parsed directly', () => {
|
|
130
|
+
const nested = {
|
|
131
|
+
...unsignedClr,
|
|
132
|
+
id: 'https://example.org/credentials/nested-clr',
|
|
133
|
+
credentialSubject: {
|
|
134
|
+
...subject,
|
|
135
|
+
association: [{ ...association, type: supportedTypeNames[1] }],
|
|
136
|
+
},
|
|
137
|
+
proof,
|
|
138
|
+
};
|
|
139
|
+
const input = {
|
|
140
|
+
...unsignedClr,
|
|
141
|
+
credentialSubject: { ...subject, verifiableCredential: [nested] },
|
|
142
|
+
proof,
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
expect(ClrCredentialValidator.parse(input)).toEqual(input);
|
|
146
|
+
// Embedded verifiableCredential values are intentionally opaque in ClrSubjectValidator.
|
|
147
|
+
expect(ClrCredentialValidator.parse(nested)).toEqual(nested);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it.each([[], '', ['Association', null]].map(type => ({ type })))(
|
|
151
|
+
'rejects invalid associations through full CLR parsing: $type',
|
|
152
|
+
({ type }) => {
|
|
153
|
+
const invalidSubject = { ...subject, association: [{ ...association, type }] };
|
|
154
|
+
|
|
155
|
+
for (const credentialSubject of [invalidSubject, [invalidSubject]]) {
|
|
156
|
+
const input = { ...unsignedClr, credentialSubject };
|
|
157
|
+
|
|
158
|
+
expect(UnsignedClrCredentialValidator.safeParse(input).success).toBe(false);
|
|
159
|
+
expect(ClrCredentialValidator.safeParse({ ...input, proof }).success).toBe(false);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
);
|
|
163
|
+
});
|
package/src/clr.ts
CHANGED
|
@@ -27,7 +27,8 @@ export type AssociationType = z.infer<typeof AssociationTypeValidator>;
|
|
|
27
27
|
|
|
28
28
|
export const AssociationValidator = z
|
|
29
29
|
.object({
|
|
30
|
-
|
|
30
|
+
// Accept compacted scalars while retaining the existing permissive array contract.
|
|
31
|
+
type: z.string().min(1).or(z.string().array().nonempty()),
|
|
31
32
|
associationType: AssociationTypeValidator,
|
|
32
33
|
sourceId: z.string().optional(),
|
|
33
34
|
targetId: z.string(),
|
package/src/index.ts
CHANGED