@learncard/cli 3.5.1 → 3.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/CHANGELOG.md +44 -0
- package/README.md +230 -2
- package/dist/index.js +4337 -986
- package/examples/branded.network.yaml +20 -0
- package/examples/delegated-service-account.network.yaml +23 -0
- package/examples/minimal.network.yaml +7 -0
- package/examples/self-hosted-signing.network.yaml +10 -0
- package/examples/service-account.network.yaml +13 -0
- package/examples/state-districts.network.yaml +36 -0
- package/package.json +21 -17
- package/src/auth-grant.test.ts +54 -0
- package/src/auth-grant.ts +34 -0
- package/src/clr/validate.test.ts +65 -0
- package/src/clr/validate.ts +242 -0
- package/src/clr.ts +119 -0
- package/src/demo-inbox-refresh.test.ts +737 -0
- package/src/demo-inbox-refresh.ts +804 -0
- package/src/demo-refresh-command.test.ts +57 -0
- package/src/demo-refresh-command.ts +22 -0
- package/src/demo-refresh-ui.test.ts +66 -0
- package/src/demo-refresh-ui.ts +65 -0
- package/src/demo-refresh.test.ts +140 -0
- package/src/demo-refresh.ts +309 -0
- package/src/doctor/checks.test.ts +448 -0
- package/src/doctor/checks.ts +497 -0
- package/src/doctor.test.ts +67 -0
- package/src/doctor.ts +118 -0
- package/src/inbox.test.ts +257 -0
- package/src/inbox.ts +221 -0
- package/src/index.tsx +70 -8
- package/src/init.ts +1 -1
- package/src/open.ts +1 -1
- package/src/org/apply.test.ts +1108 -0
- package/src/org/apply.ts +924 -0
- package/src/org/branding.test.ts +60 -0
- package/src/org/diff.ts +14 -0
- package/src/org/load.ts +50 -0
- package/src/org/schema.test.ts +256 -0
- package/src/org/schema.ts +216 -0
- package/src/org.ts +124 -0
- package/src/project.test.ts +26 -1
- package/src/project.ts +105 -10
- package/src/promote.test.ts +142 -0
- package/src/promote.ts +202 -0
- package/src/refresh.test.ts +86 -0
- package/src/refresh.ts +93 -0
- package/src/send.test.ts +278 -2
- package/src/send.ts +152 -24
- package/src/setup-signing.ts +1 -1
- package/src/status.ts +2 -4
- package/src/whoami.test.ts +67 -0
- package/src/whoami.ts +129 -0
- package/tsconfig.json +1 -1
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# Profile branding as seen in the LearnCard wallet. Images must be https URLs you host.
|
|
2
|
+
# Only fields listed here are compared; `display` colours are merged, never wiped.
|
|
3
|
+
issuer:
|
|
4
|
+
profileId: example-issuer
|
|
5
|
+
displayName: Example Issuer
|
|
6
|
+
branding:
|
|
7
|
+
image: https://cdn.example.org/example-issuer/logo.png
|
|
8
|
+
heroImage: https://cdn.example.org/example-issuer/hero.jpg
|
|
9
|
+
shortBio: Issuing verified learning records since 2026.
|
|
10
|
+
bio: Longer description shown on the full profile page.
|
|
11
|
+
websiteLink: https://example.org
|
|
12
|
+
type: organization
|
|
13
|
+
display:
|
|
14
|
+
backgroundColor: '#18224E'
|
|
15
|
+
fontColor: '#FFFFFF'
|
|
16
|
+
accentColor: '#2E7D32'
|
|
17
|
+
accentFontColor: '#FFFFFF'
|
|
18
|
+
signingAuthority:
|
|
19
|
+
type: learncard-hosted
|
|
20
|
+
name: example-signer
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# A service account limited to specific managed profiles instead of '*' (see state-districts).
|
|
2
|
+
issuer:
|
|
3
|
+
profileId: example-issuer
|
|
4
|
+
displayName: Example Issuer
|
|
5
|
+
signingAuthority:
|
|
6
|
+
type: learncard-hosted
|
|
7
|
+
name: example-signer
|
|
8
|
+
|
|
9
|
+
profileManager:
|
|
10
|
+
displayName: Example Districts
|
|
11
|
+
managed:
|
|
12
|
+
- profileId: sc-greenville
|
|
13
|
+
displayName: Greenville County Schools
|
|
14
|
+
- profileId: sc-north
|
|
15
|
+
displayName: North County Schools
|
|
16
|
+
|
|
17
|
+
serviceAccounts:
|
|
18
|
+
- name: districts-issuer
|
|
19
|
+
# boosts:write lets the token send through a district's signer via `send({ template })`.
|
|
20
|
+
scopes: [boosts:write, inbox:write, inbox:read, credentials:write, credentials:read]
|
|
21
|
+
expiresAt: 2027-06-30
|
|
22
|
+
# Only these two profiles can be delegated to, not every profile under profileManager.
|
|
23
|
+
actAs: [sc-greenville, sc-north]
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# The issuer keeps its own keys: LearnCard calls your VC-API endpoint to sign.
|
|
2
|
+
# `did` is the DID your service signs with; it becomes the credential's issuer.
|
|
3
|
+
issuer:
|
|
4
|
+
profileId: example-issuer
|
|
5
|
+
displayName: Example Issuer
|
|
6
|
+
signingAuthority:
|
|
7
|
+
type: self-hosted
|
|
8
|
+
name: example-signer
|
|
9
|
+
endpoint: https://signing.example.org/api
|
|
10
|
+
did: did:web:example.org
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# An issuer plus one scoped, expiring API token for a backend that must not hold the seed.
|
|
2
|
+
# The token is written once to --secrets-out; the seed stays with whoever runs `org apply`.
|
|
3
|
+
issuer:
|
|
4
|
+
profileId: example-issuer
|
|
5
|
+
displayName: Example Issuer
|
|
6
|
+
signingAuthority:
|
|
7
|
+
type: learncard-hosted
|
|
8
|
+
name: example-signer
|
|
9
|
+
|
|
10
|
+
serviceAccounts:
|
|
11
|
+
- name: nightly-issuer
|
|
12
|
+
scopes: [inbox:write, inbox:read, credentials:write, credentials:read]
|
|
13
|
+
expiresAt: 2027-06-30
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# A state agency with districts underneath it. The agency issues; each district is a
|
|
2
|
+
# managed profile (no seed of its own) that the agency can act as with `send --as`.
|
|
3
|
+
issuer:
|
|
4
|
+
profileId: exde
|
|
5
|
+
displayName: Example Department of Education
|
|
6
|
+
branding:
|
|
7
|
+
image: https://cdn.example.org/exde-logo.png
|
|
8
|
+
shortBio: State education agency for Example.
|
|
9
|
+
websiteLink: https://ed.example.gov
|
|
10
|
+
type: organization
|
|
11
|
+
display:
|
|
12
|
+
backgroundColor: '#18224E'
|
|
13
|
+
accentColor: '#2E7D32'
|
|
14
|
+
signingAuthority:
|
|
15
|
+
type: learncard-hosted
|
|
16
|
+
name: exde-clr
|
|
17
|
+
|
|
18
|
+
profileManager:
|
|
19
|
+
displayName: Example Districts
|
|
20
|
+
managed:
|
|
21
|
+
- profileId: cs-exampleville
|
|
22
|
+
displayName: Exampleville County Schools
|
|
23
|
+
branding:
|
|
24
|
+
websiteLink: https://exampleville.k12.example
|
|
25
|
+
display: { accentColor: '#B45309' }
|
|
26
|
+
|
|
27
|
+
serviceAccounts:
|
|
28
|
+
- name: ex-clr-issuer
|
|
29
|
+
# boosts:write lets the token send through a district's signer via `send({ template })`.
|
|
30
|
+
scopes: [boosts:write, inbox:write, inbox:read, credentials:write, credentials:read]
|
|
31
|
+
expiresAt: 2027-06-30
|
|
32
|
+
# '*' lets this token act as any profile under profileManager.managed (e.g. cs-exampleville).
|
|
33
|
+
actAs: '*'
|
|
34
|
+
|
|
35
|
+
webhooks:
|
|
36
|
+
- url: https://clr.example.org/learncard/webhook
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@learncard/cli",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.6.0",
|
|
4
4
|
"description": "Command Line Interface for LearnCard",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"bin": "dist/index.js",
|
|
@@ -16,33 +16,37 @@
|
|
|
16
16
|
"author": "Learning Economy Foundation (www.learningeconomy.io)",
|
|
17
17
|
"license": "MIT",
|
|
18
18
|
"dependencies": {
|
|
19
|
-
"@learncard/core": "9.4.
|
|
20
|
-
"@learncard/didkit-plugin": "^1.10.
|
|
21
|
-
"@learncard/
|
|
22
|
-
"@learncard/
|
|
23
|
-
"@learncard/
|
|
24
|
-
"@learncard/
|
|
25
|
-
"@learncard/
|
|
26
|
-
"@learncard/
|
|
27
|
-
"@learncard/
|
|
28
|
-
"@learncard/
|
|
29
|
-
"@learncard/
|
|
19
|
+
"@learncard/core": "9.4.36",
|
|
20
|
+
"@learncard/didkit-plugin": "^1.10.1",
|
|
21
|
+
"@learncard/holder-continuity": "0.2.19",
|
|
22
|
+
"@learncard/init": "^2.5.0",
|
|
23
|
+
"@learncard/lca-api-plugin": "2.0.6",
|
|
24
|
+
"@learncard/learn-cloud-plugin": "2.3.42",
|
|
25
|
+
"@learncard/ler-rs-plugin": "0.1.27",
|
|
26
|
+
"@learncard/linked-claims-plugin": "0.2.36",
|
|
27
|
+
"@learncard/network-brain-client": "2.5.57",
|
|
28
|
+
"@learncard/open-badge-v2-plugin": "1.1.37",
|
|
29
|
+
"@learncard/render-method-plugin": "8.0.0",
|
|
30
|
+
"@learncard/types": "5.21.0",
|
|
30
31
|
"clipboardy": "^4.0.0",
|
|
31
32
|
"commander": "^9.3.0",
|
|
32
33
|
"figlet": "^1.5.2",
|
|
33
34
|
"gradient-string": "^2.0.1",
|
|
34
|
-
"pretty-repl": "^3.1.1"
|
|
35
|
+
"pretty-repl": "^3.1.1",
|
|
36
|
+
"yaml": "^2.9.1",
|
|
37
|
+
"zod": "^4.1.13"
|
|
35
38
|
},
|
|
36
39
|
"devDependencies": {
|
|
40
|
+
"@learncard/credential-library": "2.0.4",
|
|
41
|
+
"@rollup/plugin-json": "^4.1.0",
|
|
37
42
|
"@types/cors": "^2.8.12",
|
|
38
43
|
"@types/figlet": "^1.5.4",
|
|
39
44
|
"@types/node": "^18.0.0",
|
|
40
45
|
"nodemon": "^2.0.16",
|
|
41
|
-
"ts-node": "^10.8.1",
|
|
42
|
-
"vitest": "4.1.11",
|
|
43
|
-
"@rollup/plugin-json": "^4.1.0",
|
|
44
46
|
"rollup": "^2.80.0",
|
|
45
|
-
"rollup-plugin-esbuild": "^4.9.1"
|
|
47
|
+
"rollup-plugin-esbuild": "^4.9.1",
|
|
48
|
+
"ts-node": "^10.8.1",
|
|
49
|
+
"vitest": "4.1.11"
|
|
46
50
|
},
|
|
47
51
|
"repository": {
|
|
48
52
|
"type": "git",
|
|
@@ -0,0 +1,54 @@
|
|
|
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
|
+
});
|
|
@@ -0,0 +1,34 @@
|
|
|
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
|
+
};
|
|
@@ -0,0 +1,65 @@
|
|
|
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
|
+
});
|
|
@@ -0,0 +1,242 @@
|
|
|
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
|
+
};
|