@learncard/cli 3.6.7 → 4.0.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.
Files changed (72) hide show
  1. package/CHANGELOG.md +79 -0
  2. package/README.md +36 -12
  3. package/dist/index.js +6673 -2038
  4. package/package.json +27 -15
  5. package/project.json +0 -42
  6. package/rollup.config.js +0 -34
  7. package/scripts/embed-snippets.ts +0 -47
  8. package/src/auth-grant.test.ts +0 -54
  9. package/src/auth-grant.ts +0 -34
  10. package/src/clr/validate.test.ts +0 -65
  11. package/src/clr/validate.ts +0 -242
  12. package/src/clr.ts +0 -119
  13. package/src/consent-contract.ts +0 -116
  14. package/src/demo-inbox-refresh.test.ts +0 -737
  15. package/src/demo-inbox-refresh.ts +0 -804
  16. package/src/demo-refresh-command.test.ts +0 -57
  17. package/src/demo-refresh-command.ts +0 -22
  18. package/src/demo-refresh-ui.test.ts +0 -66
  19. package/src/demo-refresh-ui.ts +0 -65
  20. package/src/demo-refresh.test.ts +0 -140
  21. package/src/demo-refresh.ts +0 -309
  22. package/src/doctor/checks.test.ts +0 -448
  23. package/src/doctor/checks.ts +0 -497
  24. package/src/doctor.test.ts +0 -67
  25. package/src/doctor.ts +0 -118
  26. package/src/embed.ts +0 -102
  27. package/src/inbox.test.ts +0 -257
  28. package/src/inbox.ts +0 -221
  29. package/src/index.tsx +0 -876
  30. package/src/init.ts +0 -26
  31. package/src/open.test.ts +0 -40
  32. package/src/open.ts +0 -186
  33. package/src/org/apply.test.ts +0 -1108
  34. package/src/org/apply.ts +0 -924
  35. package/src/org/branding.test.ts +0 -60
  36. package/src/org/diff.ts +0 -14
  37. package/src/org/load.ts +0 -50
  38. package/src/org/schema.test.ts +0 -256
  39. package/src/org/schema.ts +0 -216
  40. package/src/org.ts +0 -124
  41. package/src/out.test.ts +0 -40
  42. package/src/out.ts +0 -16
  43. package/src/phase-two.test.ts +0 -154
  44. package/src/project.test.ts +0 -232
  45. package/src/project.ts +0 -489
  46. package/src/promote.test.ts +0 -142
  47. package/src/promote.ts +0 -202
  48. package/src/random.ts +0 -3
  49. package/src/refresh.test.ts +0 -86
  50. package/src/refresh.ts +0 -93
  51. package/src/replHelpers.test.ts +0 -80
  52. package/src/replHelpers.ts +0 -45
  53. package/src/revoke.test.ts +0 -29
  54. package/src/revoke.ts +0 -65
  55. package/src/send-template.test.ts +0 -20
  56. package/src/send.test.ts +0 -321
  57. package/src/send.ts +0 -373
  58. package/src/setup-signing.test.ts +0 -62
  59. package/src/setup-signing.ts +0 -185
  60. package/src/snippet-files.ts +0 -16
  61. package/src/status.test.ts +0 -33
  62. package/src/status.ts +0 -94
  63. package/src/token.test.ts +0 -28
  64. package/src/token.ts +0 -139
  65. package/src/types.d.ts +0 -17
  66. package/src/verify.test.ts +0 -35
  67. package/src/verify.ts +0 -66
  68. package/src/webhook.ts +0 -247
  69. package/src/whoami.test.ts +0 -67
  70. package/src/whoami.ts +0 -129
  71. package/tsconfig.json +0 -39
  72. package/vitest.config.ts +0 -8
package/package.json CHANGED
@@ -1,9 +1,21 @@
1
1
  {
2
2
  "name": "@learncard/cli",
3
- "version": "3.6.7",
3
+ "version": "4.0.0",
4
4
  "description": "Command Line Interface for LearnCard",
5
5
  "main": "dist/index.js",
6
- "bin": "dist/index.js",
6
+ "bin": {
7
+ "learncard": "dist/index.js",
8
+ "cli": "dist/index.js"
9
+ },
10
+ "files": [
11
+ "dist",
12
+ "examples",
13
+ "README.md",
14
+ "CHANGELOG.md"
15
+ ],
16
+ "engines": {
17
+ "node": ">=20"
18
+ },
7
19
  "scripts": {
8
20
  "start": "bun scripts/embed-snippets.ts && bun --conditions=development src/index.tsx",
9
21
  "start:built": "bun run build && node --enable-source-maps dist/index.js",
@@ -16,18 +28,18 @@
16
28
  "author": "Learning Economy Foundation (www.learningeconomy.io)",
17
29
  "license": "MIT",
18
30
  "dependencies": {
19
- "@learncard/core": "9.4.41",
20
- "@learncard/didkit-plugin": "^1.10.6",
21
- "@learncard/holder-continuity": "0.2.25",
22
- "@learncard/init": "^2.5.6",
23
- "@learncard/lca-api-plugin": "2.0.12",
24
- "@learncard/learn-cloud-plugin": "2.3.48",
25
- "@learncard/ler-rs-plugin": "0.1.32",
26
- "@learncard/linked-claims-plugin": "0.2.41",
27
- "@learncard/network-brain-client": "2.6.0",
28
- "@learncard/open-badge-v2-plugin": "1.1.42",
29
- "@learncard/render-method-plugin": "12.0.0",
30
- "@learncard/types": "5.24.0",
31
+ "@learncard/core": "9.4.42",
32
+ "@learncard/didkit-plugin": "^1.10.8",
33
+ "@learncard/holder-continuity": "0.2.27",
34
+ "@learncard/init": "^2.5.8",
35
+ "@learncard/lca-api-plugin": "2.0.14",
36
+ "@learncard/learn-cloud-plugin": "2.3.50",
37
+ "@learncard/ler-rs-plugin": "0.1.33",
38
+ "@learncard/linked-claims-plugin": "0.2.42",
39
+ "@learncard/network-brain-client": "2.6.2",
40
+ "@learncard/open-badge-v2-plugin": "1.1.43",
41
+ "@learncard/render-method-plugin": "12.0.1",
42
+ "@learncard/types": "5.24.1",
31
43
  "clipboardy": "^4.0.0",
32
44
  "commander": "^9.3.0",
33
45
  "figlet": "^1.5.2",
@@ -37,7 +49,7 @@
37
49
  "zod": "^4.1.13"
38
50
  },
39
51
  "devDependencies": {
40
- "@learncard/credential-library": "2.1.4",
52
+ "@learncard/credential-library": "2.1.5",
41
53
  "@rollup/plugin-json": "^4.1.0",
42
54
  "@types/cors": "^2.8.12",
43
55
  "@types/figlet": "^1.5.4",
package/project.json DELETED
@@ -1,42 +0,0 @@
1
- {
2
- "$schema": "../../node_modules/nx/schemas/project-schema.json",
3
- "name": "cli",
4
- "sourceRoot": "packages/learn-card-cli/src",
5
- "projectType": "library",
6
- "root": "packages/learn-card-cli",
7
- "tags": [],
8
- "implicitDependencies": [
9
- "nx-run-command",
10
- "types",
11
- "init",
12
- "open-badge-v2-plugin",
13
- "holder-continuity"
14
- ],
15
- "namedInputs": {
16
- "source": ["{projectRoot}/src/**/*"],
17
- "dist": ["{projectRoot}/dist/**/*"]
18
- },
19
- "targets": {
20
- "build": {
21
- "executor": "nx:run-script",
22
- "inputs": ["source"],
23
- "options": {
24
- "script": "build"
25
- }
26
- },
27
- "test": {
28
- "executor": "nx:run-script",
29
- "inputs": ["source"],
30
- "options": {
31
- "script": "test"
32
- }
33
- },
34
- "start": {
35
- "executor": "nx:run-script",
36
- "dependsOn": ["^build"],
37
- "options": {
38
- "script": "start"
39
- }
40
- }
41
- }
42
- }
package/rollup.config.js DELETED
@@ -1,34 +0,0 @@
1
- import json from '@rollup/plugin-json';
2
- import esbuild from 'rollup-plugin-esbuild';
3
-
4
- import { readFileSync } from 'node:fs';
5
-
6
- const packageJson = JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf8'));
7
-
8
- export default [
9
- {
10
- input: ['src/index.tsx'],
11
- output: [
12
- {
13
- file: packageJson.bin,
14
- format: 'cjs',
15
- banner: '#!/usr/bin/env node',
16
- inlineDynamicImports: true,
17
- },
18
- ],
19
- plugins: [
20
- json(),
21
- esbuild(),
22
- {
23
- name: 'native-webhook-import',
24
- renderDynamicImport({ moduleId }) {
25
- // Rollup 2 otherwise emits require(data:...), which Node cannot load.
26
- if (moduleId.endsWith('/src/webhook.ts')) {
27
- return { left: 'import(', right: ')' };
28
- }
29
- return null;
30
- },
31
- },
32
- ],
33
- },
34
- ];
@@ -1,47 +0,0 @@
1
- import { randomUUID } from 'node:crypto';
2
- import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
3
- import { dirname, resolve } from 'node:path';
4
- import { fileURLToPath } from 'node:url';
5
-
6
- const root = resolve(dirname(fileURLToPath(import.meta.url)), '../../..');
7
- const snippets = {
8
- SEND_MJS: 'quickstart/send.mjs',
9
- SEND_SH: 'quickstart/send.sh',
10
- SEND_FROM_TEMPLATE_MJS: 'quickstart/send-from-template.mjs',
11
- WEBHOOK_MJS: 'cli/webhook.mjs',
12
- CLAIM_BUTTON_HTML: 'cli/claim-button.html',
13
- CREATE_CONTRACT_MJS: 'consentflow/create-contract.mjs',
14
- CONSENT_CALLBACK_MJS: 'consentflow/consent-callback.mjs',
15
- READ_USER_DATA_MJS: 'consentflow/read-user-data.mjs',
16
- ISSUE_THROUGH_CONTRACT_MJS: 'consentflow/issue-through-contract.mjs',
17
- };
18
- /** Deterministic and atomic: independent checkout users never read a partial module. */
19
- export const embedSnippets = async (signal?: AbortSignal): Promise<void> => {
20
- signal?.throwIfAborted();
21
- const lines = await Promise.all(
22
- Object.entries(snippets).map(
23
- async ([name, file]) =>
24
- `export const ${name} = ${JSON.stringify(await readFile(resolve(root, 'docs/snippets', file), { encoding: 'utf8', signal }))};\n`
25
- )
26
- );
27
- const output = resolve(root, 'packages/learn-card-cli/src/generated/snippets.ts');
28
- const content = '// Generated from docs/snippets. Do not edit.\n' + lines.join('');
29
- try {
30
- if ((await readFile(output, 'utf8')) === content) return;
31
- } catch (error) {
32
- if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
33
- }
34
- await mkdir(dirname(output), { recursive: true });
35
- const temporary = `${output}.${randomUUID()}.tmp`;
36
- try {
37
- await writeFile(temporary, content, { flag: 'wx', signal });
38
- signal?.throwIfAborted();
39
- await rename(temporary, output);
40
- } finally {
41
- await rm(temporary, { force: true });
42
- }
43
- };
44
-
45
- if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
46
- await embedSnippets();
47
- }
@@ -1,54 +0,0 @@
1
- import { describe, expect, it } from 'vitest';
2
- import {
3
- describeActAs,
4
- getGrantActAs,
5
- normalizeActAs,
6
- type AuthGrantWithActAs,
7
- } from './auth-grant';
8
-
9
- describe('getGrantActAs', () => {
10
- it('reads actAs off a grant', () => {
11
- const grant: AuthGrantWithActAs = { id: 'g1', actAs: 'sc-greenville,sc-north' };
12
- expect(getGrantActAs(grant)).toBe('sc-greenville,sc-north');
13
- });
14
-
15
- it('is undefined when the grant has no actAs', () => {
16
- expect(getGrantActAs({ id: 'g1' })).toBeUndefined();
17
- });
18
-
19
- it.each([null, ''])(
20
- 'coerces %j from the wire to undefined so it matches an absent spec value',
21
- value => {
22
- expect(getGrantActAs({ id: 'g1', actAs: value as unknown as string })).toBeUndefined();
23
- }
24
- );
25
- });
26
-
27
- describe('describeActAs', () => {
28
- it('renders a comma-separated list with spaces', () => {
29
- expect(describeActAs('sc-greenville,sc-north')).toBe('sc-greenville, sc-north');
30
- });
31
-
32
- it('renders "*" as any managed profile', () => {
33
- expect(describeActAs('*')).toBe('any managed profile');
34
- });
35
-
36
- it('renders undefined as no delegation', () => {
37
- expect(describeActAs(undefined)).toBe('no delegation');
38
- });
39
- });
40
-
41
- describe('normalizeActAs', () => {
42
- it('sorts, trims, and de-duplicates profile IDs', () => {
43
- expect(normalizeActAs(' sc-north , sc-greenville,sc-north ')).toBe(
44
- 'sc-greenville,sc-north'
45
- );
46
- });
47
-
48
- it('keeps "*" and treats empty values as no delegation', () => {
49
- expect(normalizeActAs(' * ')).toBe('*');
50
- expect(normalizeActAs('')).toBeUndefined();
51
- expect(normalizeActAs(' , ')).toBeUndefined();
52
- expect(normalizeActAs(undefined)).toBeUndefined();
53
- });
54
- });
package/src/auth-grant.ts DELETED
@@ -1,34 +0,0 @@
1
- import type { AuthGrantType } from '@learncard/types';
2
-
3
- /**
4
- * `actAs` on an auth grant: `'*'` = the token may act as any managed profile,
5
- * a comma-separated list of profileIds = only those profiles, absent = the
6
- * token may not act as anyone (deny-by-default).
7
- */
8
- export type AuthGrantWithActAs = Partial<AuthGrantType>;
9
-
10
- /** Coerces `null` / `''` from the wire to `undefined` so drift checks compare like with like. */
11
- export const getGrantActAs = (grant: Partial<AuthGrantType>): string | undefined =>
12
- grant.actAs || undefined;
13
-
14
- /** `'*'` -> "any managed profile"; `'a,b'` -> "a, b"; absent -> "no delegation". */
15
- export const describeActAs = (actAs: string | undefined): string => {
16
- if (!actAs) return 'no delegation';
17
- if (actAs === '*') return 'any managed profile';
18
- return actAs.split(',').filter(Boolean).join(', ');
19
- };
20
-
21
- /** Order- and whitespace-insensitive form for drift checks: `'b, a'` and `'a,b'` are the same policy. */
22
- export const normalizeActAs = (actAs: string | undefined): string | undefined => {
23
- if (!actAs) return undefined;
24
- if (actAs.trim() === '*') return '*';
25
- const ids = [
26
- ...new Set(
27
- actAs
28
- .split(',')
29
- .map(id => id.trim())
30
- .filter(Boolean)
31
- ),
32
- ].sort();
33
- return ids.length ? ids.join(',') : undefined;
34
- };
@@ -1,65 +0,0 @@
1
- import { describe, expect, it } from 'vitest';
2
- import { clrProvisionalTranscript, clrWestbridgeFull } from '@learncard/credential-library';
3
-
4
- import { validateClr } from './validate';
5
-
6
- const clone = <T>(value: T): T => structuredClone(value);
7
-
8
- const provisional = (): Record<string, unknown> =>
9
- clone(clrProvisionalTranscript.credential) as Record<string, unknown>;
10
-
11
- describe('validateClr', () => {
12
- it('passes the library provisional transcript with zero errors', () => {
13
- const result = validateClr(provisional(), { profile: 'provisional' });
14
- expect(result.errors).toEqual([]);
15
- expect(result.warnings).toEqual([]);
16
- });
17
-
18
- it('errors on partial when the same transcript is checked as official', () => {
19
- const result = validateClr(provisional(), { profile: 'official' });
20
- expect(result.errors.some(e => /partial/i.test(e))).toBe(true);
21
- });
22
-
23
- it('errors when @context uses the 2.0.1 URL that requires remote loading', () => {
24
- const credential = clone(clrWestbridgeFull.credential) as Record<string, unknown>;
25
- credential['@context'] = (credential['@context'] as unknown[]).map(entry =>
26
- entry === 'https://purl.imsglobal.org/spec/clr/v2p0/context.json'
27
- ? 'https://purl.imsglobal.org/spec/clr/v2p0/context-2.0.1.json'
28
- : entry
29
- );
30
- const result = validateClr(credential);
31
- expect(
32
- result.errors.some(e => e.includes('2.0.1 URL requires remote context loading'))
33
- ).toBe(true);
34
- });
35
-
36
- it('errors when id is missing', () => {
37
- const credential = clone(clrWestbridgeFull.credential) as Record<string, unknown>;
38
- delete credential.id;
39
- const result = validateClr(credential);
40
- expect(result.errors.some(e => /^id:/.test(e))).toBe(true);
41
- });
42
-
43
- it('warns on a dateOfBirth field added anywhere in the document', () => {
44
- const credential = clone(clrWestbridgeFull.credential) as Record<string, unknown>;
45
- (credential.credentialSubject as Record<string, unknown>).dateOfBirth = '1999-03-14';
46
- const result = validateClr(credential, { profile: 'official' });
47
- expect(result.warnings.some(e => /dateOfBirth/.test(e))).toBe(true);
48
- });
49
-
50
- it('warns when a provisional transcript omits validUntil', () => {
51
- const credential = provisional();
52
- delete credential.validUntil;
53
- const result = validateClr(credential, { profile: 'provisional' });
54
- expect(result.warnings.some(e => /validUntil/.test(e))).toBe(true);
55
- });
56
-
57
- it('errors when an InProgress result has no Status-typed ResultDescription', () => {
58
- const credential = provisional();
59
- const subject = credential.credentialSubject as Record<string, unknown>;
60
- const achievement = (subject.achievement as Record<string, unknown>[])[0]!;
61
- (achievement.resultDescription as Record<string, unknown>[])[0]!.resultType = 'RawScore';
62
- const result = validateClr(credential, { profile: 'provisional' });
63
- expect(result.errors.some(e => /resultType "Status"/.test(e))).toBe(true);
64
- });
65
- });
@@ -1,242 +0,0 @@
1
- import { ClrCredentialValidator, UnsignedClrCredentialValidator } from '@learncard/types';
2
-
3
- /**
4
- * Issuer-facing lint profile. `provisional` transcripts are expected to change again
5
- * before finalization; `official` transcripts are the final, certified record.
6
- */
7
- export type ClrValidationProfile = 'provisional' | 'official';
8
-
9
- export interface ClrValidationOptions {
10
- profile?: ClrValidationProfile;
11
- }
12
-
13
- export interface ClrValidationSummary {
14
- name?: string;
15
- id?: string;
16
- partial?: boolean;
17
- achievementCount: number;
18
- resultStatuses: Record<string, number>;
19
- }
20
-
21
- export interface ClrValidationResult {
22
- errors: string[];
23
- warnings: string[];
24
- summary: ClrValidationSummary;
25
- }
26
-
27
- const CLR_V2_CONTEXT_URL = 'https://purl.imsglobal.org/spec/clr/v2p0/context.json';
28
- const CLR_V2_CONTEXT_PATTERN = /purl\.imsglobal\.org\/spec\/clr\/v2p0\/context/;
29
- const CLR_V2_LEGACY_CONTEXT_PATTERN = /context-2\.0\.1\.json/;
30
- const PII_KEY_PATTERN = /ssn|socialsecurity|dateofbirth|dob|birthdate/i;
31
- const IN_PROGRESS_STATUSES = new Set(['InProgress', 'Provisional']);
32
-
33
- const isRecord = (value: unknown): value is Record<string, unknown> =>
34
- typeof value === 'object' && value !== null && !Array.isArray(value);
35
-
36
- const toArray = (value: unknown): unknown[] => {
37
- if (value === undefined || value === null) return [];
38
- return Array.isArray(value) ? value : [value];
39
- };
40
-
41
- /** `@context` must include the CLR v2 context; the 2.0.1 URL fails to sign (remote-load only). */
42
- const lintContext = (doc: Record<string, unknown>, errors: string[]): void => {
43
- const contexts = toArray(doc['@context']).filter(
44
- (entry): entry is string => typeof entry === 'string'
45
- );
46
- const clrEntry = contexts.find(entry => CLR_V2_CONTEXT_PATTERN.test(entry));
47
- if (!clrEntry) {
48
- errors.push(`@context: must include the CLR v2 context (${CLR_V2_CONTEXT_URL}).`);
49
- return;
50
- }
51
- if (CLR_V2_LEGACY_CONTEXT_PATTERN.test(clrEntry)) {
52
- errors.push(
53
- `Use ${CLR_V2_CONTEXT_URL} (the 2.0.1 URL requires remote context loading and will fail to sign)`
54
- );
55
- }
56
- };
57
-
58
- /** A missing id is an error; a non-`urn:uuid:` id is a warning (ids must be stable across versions). */
59
- const lintId = (doc: Record<string, unknown>, errors: string[], warnings: string[]): void => {
60
- const id = doc.id;
61
- if (typeof id !== 'string' || !id) {
62
- errors.push('id: missing — every CLR transcript needs a stable id.');
63
- return;
64
- }
65
- if (!id.startsWith('urn:uuid:')) {
66
- warnings.push('id: use a stable urn:uuid per transcript, never per version');
67
- }
68
- };
69
-
70
- /** Recursively flag keys that look like they hold PII, wherever they appear in the document. */
71
- const collectPii = (value: unknown, path: string, warnings: string[]): void => {
72
- if (Array.isArray(value)) {
73
- value.forEach((item, index) => collectPii(item, `${path}[${index}]`, warnings));
74
- return;
75
- }
76
- if (!isRecord(value)) return;
77
- for (const [key, nested] of Object.entries(value)) {
78
- const nextPath = path ? `${path}.${key}` : key;
79
- if (PII_KEY_PATTERN.test(key)) {
80
- warnings.push(
81
- `${nextPath}: possible PII field ("${key}") — avoid embedding sensitive personal data in a transcript.`
82
- );
83
- }
84
- collectPii(nested, nextPath, warnings);
85
- }
86
- };
87
-
88
- interface ClrResultNode {
89
- path: string;
90
- status?: string;
91
- resultDescription?: string;
92
- }
93
-
94
- interface ClrWalkData {
95
- achievementCount: number;
96
- resultDescriptionTypes: Map<string, string>;
97
- results: ClrResultNode[];
98
- }
99
-
100
- const addAchievement = (achievement: unknown, data: ClrWalkData): void => {
101
- if (!isRecord(achievement)) return;
102
- data.achievementCount += 1;
103
- for (const resultDescription of toArray(achievement.resultDescription)) {
104
- if (
105
- isRecord(resultDescription) &&
106
- typeof resultDescription.id === 'string' &&
107
- typeof resultDescription.resultType === 'string'
108
- ) {
109
- data.resultDescriptionTypes.set(resultDescription.id, resultDescription.resultType);
110
- }
111
- }
112
- };
113
-
114
- /**
115
- * Walks the CLR document collecting every achievement (top-level and nested, so
116
- * `resultDescription`s from both can satisfy a result's reference), and every result
117
- * carried by a nested `AchievementCredential` (CLR subjects have no `result` field of
118
- * their own — only nested verifiableCredentials do).
119
- */
120
- const walkClr = (doc: Record<string, unknown>): ClrWalkData => {
121
- const data: ClrWalkData = {
122
- achievementCount: 0,
123
- resultDescriptionTypes: new Map(),
124
- results: [],
125
- };
126
-
127
- toArray(doc.credentialSubject).forEach((subject, subjectIndex) => {
128
- if (!isRecord(subject)) return;
129
-
130
- for (const achievement of toArray(subject.achievement)) addAchievement(achievement, data);
131
-
132
- toArray(subject.verifiableCredential).forEach((nested, nestedIndex) => {
133
- if (!isRecord(nested)) return;
134
-
135
- toArray(nested.credentialSubject).forEach((nestedSubject, nestedSubjectIndex) => {
136
- if (!isRecord(nestedSubject)) return;
137
-
138
- for (const achievement of toArray(nestedSubject.achievement)) {
139
- addAchievement(achievement, data);
140
- }
141
-
142
- for (const result of toArray(nestedSubject.result)) {
143
- if (!isRecord(result)) continue;
144
- data.results.push({
145
- path: `credentialSubject[${subjectIndex}].verifiableCredential[${nestedIndex}].credentialSubject[${nestedSubjectIndex}].result`,
146
- status: typeof result.status === 'string' ? result.status : undefined,
147
- resultDescription:
148
- typeof result.resultDescription === 'string'
149
- ? result.resultDescription
150
- : undefined,
151
- });
152
- }
153
- });
154
- });
155
- });
156
-
157
- return data;
158
- };
159
-
160
- /**
161
- * Lints a CLR 2.0 transcript (signed or unsigned) against schema and issuer-profile rules.
162
- * Pure: performs no I/O and never throws on malformed input — problems are reported in
163
- * `errors`/`warnings` instead.
164
- */
165
- export const validateClr = (
166
- json: unknown,
167
- opts: ClrValidationOptions = {}
168
- ): ClrValidationResult => {
169
- const errors: string[] = [];
170
- const warnings: string[] = [];
171
-
172
- const hasProof = isRecord(json) && json.proof !== undefined;
173
- const parsed = hasProof
174
- ? ClrCredentialValidator.safeParse(json)
175
- : UnsignedClrCredentialValidator.safeParse(json);
176
-
177
- if (!parsed.success) {
178
- for (const issue of parsed.error.issues) {
179
- const path = issue.path.length ? issue.path.join('.') : '(root)';
180
- errors.push(`${path}: ${issue.message}`);
181
- }
182
- }
183
-
184
- const doc = isRecord(json) ? json : {};
185
-
186
- lintContext(doc, errors);
187
- lintId(doc, errors, warnings);
188
- collectPii(doc, '', warnings);
189
-
190
- const walked = walkClr(doc);
191
-
192
- const resultStatuses: Record<string, number> = {};
193
- for (const result of walked.results) {
194
- if (!result.status) continue;
195
- resultStatuses[result.status] = (resultStatuses[result.status] ?? 0) + 1;
196
- }
197
-
198
- const partial = typeof doc.partial === 'boolean' ? doc.partial : undefined;
199
-
200
- if (opts.profile === 'provisional') {
201
- if (partial !== true) {
202
- errors.push('partial: must be true for a provisional transcript (set partial: true).');
203
- }
204
- for (const result of walked.results) {
205
- if (!result.status || !IN_PROGRESS_STATUSES.has(result.status)) continue;
206
- const resultType = result.resultDescription
207
- ? walked.resultDescriptionTypes.get(result.resultDescription)
208
- : undefined;
209
- if (resultType !== 'Status') {
210
- errors.push(
211
- `${result.path}: status "${result.status}" must reference a ResultDescription with resultType "Status" (found ${resultType ? `"${resultType}"` : 'none'}).`
212
- );
213
- }
214
- }
215
- if (typeof doc.validUntil !== 'string' || !doc.validUntil) {
216
- warnings.push(
217
- 'validUntil: missing — provisional transcripts should set an expected finalization date.'
218
- );
219
- }
220
- } else if (opts.profile === 'official') {
221
- if (partial === true) {
222
- errors.push('partial: must not be true for an official transcript.');
223
- }
224
- for (const result of walked.results) {
225
- if (result.status && IN_PROGRESS_STATUSES.has(result.status)) {
226
- errors.push(
227
- `${result.path}: status "${result.status}" is not allowed in an official transcript.`
228
- );
229
- }
230
- }
231
- }
232
-
233
- const summary: ClrValidationSummary = {
234
- name: typeof doc.name === 'string' ? doc.name : undefined,
235
- id: typeof doc.id === 'string' ? doc.id : undefined,
236
- partial,
237
- achievementCount: walked.achievementCount,
238
- resultStatuses,
239
- };
240
-
241
- return { errors, warnings, summary };
242
- };