Skip to content

Commit d9b2e89

Browse files
committed
fix: CLR compliance
1 parent 18394ce commit d9b2e89

4 files changed

Lines changed: 260 additions & 12 deletions

File tree

apps/learner-ux/src/App.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -416,6 +416,7 @@ function LearnerPage() {
416416
}
417417
try {
418418
setExportingClr(true);
419+
setError("");
419420
const response = await createClrExport(learnerId, { consumerId: selectedConsumerId });
420421
const blob = new Blob([JSON.stringify(response.clr, null, 2)], { type: "application/json" });
421422
const url = URL.createObjectURL(blob);
@@ -530,6 +531,7 @@ function LearnerPage() {
530531
{consumers.find((consumer) => consumer.consumerId === selectedConsumerId)?.didKey ?? ""}
531532
</p>
532533
)}
534+
{error && <p className="mt-2 text-xs text-red-700">{error}</p>}
533535
</Card>
534536
)}
535537

apps/learner-ux/src/api.ts

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -136,15 +136,19 @@ export async function createClrExport(
136136
learnerId: string,
137137
payload: { consumerId: string; expirationDate?: string; issuer?: string }
138138
): Promise<CreateClrExportResponse> {
139-
const response = await fetch(buildUrl(`/learners/${encodeURIComponent(learnerId)}/export/clr2`), {
139+
const query = new URLSearchParams({ learnerId });
140+
const response = await fetch(buildUrl(`/learners/by-id/export/clr2?${query.toString()}`), {
140141
method: "POST",
141142
headers: {
142143
"content-type": "application/json",
143144
...(await authHeaders())
144145
},
145146
body: JSON.stringify(payload)
146147
});
147-
if (!response.ok) throw new Error("Failed to create CLR export");
148+
if (!response.ok) {
149+
const details = await readErrorDetails(response);
150+
throw new Error(`Failed to create CLR export: ${details}`);
151+
}
148152
return await response.json() as CreateClrExportResponse;
149153
}
150154

@@ -228,3 +232,21 @@ async function fetchWithRetry(
228232
}
229233
return response as Response;
230234
}
235+
236+
async function readErrorDetails(response: Response): Promise<string> {
237+
const fallback = `${response.status} ${response.statusText || "request_failed"}`;
238+
try {
239+
const payload = await response.json() as { reason?: string; details?: string[] };
240+
const detailText = Array.isArray(payload.details) && payload.details.length > 0
241+
? payload.details.join("; ")
242+
: (payload.reason ?? fallback);
243+
return detailText;
244+
} catch {
245+
try {
246+
const text = await response.text();
247+
return text || fallback;
248+
} catch {
249+
return fallback;
250+
}
251+
}
252+
}

services/learner-context-handler/src/index.ts

Lines changed: 199 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,10 @@ import {
2222
createToolbox,
2323
type Clr2Achievement,
2424
type PresentationMetadata,
25+
type SignedCredential,
2526
type SelectedClaim,
26-
type SelectedCredential
27+
type SelectedCredential,
28+
type UnsignedCredential
2729
} from "../../../toolbox/src/index.js";
2830

2931
const ddbDocClient = DynamoDBDocumentClient.from(new DynamoDBClient({}));
@@ -32,8 +34,10 @@ const EVENT_TABLE = process.env.EVENT_TABLE_NAME ?? "";
3234
const CREDENTIAL_TABLE = process.env.CREDENTIAL_TABLE_NAME ?? "";
3335
const GRAPH_PROJECTION_TABLE = process.env.GRAPH_PROJECTION_TABLE_NAME ?? "";
3436
const CONSUMER_TABLE = process.env.CONSUMER_TABLE_NAME ?? "";
37+
const TOOLBOX_ISSUER_DID = process.env.TOOLBOX_ISSUER_DID ?? "";
3538
const TRUSTED_ISSUERS = (process.env.TRUSTED_ISSUERS ?? "").split(",").map((issuer) => issuer.trim()).filter(Boolean);
3639
let toolboxPromise: Promise<Awaited<ReturnType<typeof createToolbox>>> | undefined;
40+
let exportIssuerDidPromise: Promise<string> | undefined;
3741

3842
interface HttpEvent {
3943
rawPath?: string;
@@ -571,18 +575,41 @@ async function handleExportClr2(event: HttpEvent, fromQuery: boolean) {
571575
});
572576
}
573577
const now = new Date().toISOString();
574-
const issuer = request.issuer && request.issuer.trim().length > 0
575-
? request.issuer.trim()
576-
: (TRUSTED_ISSUERS[0] ?? "did:example:issuer:trusted-learning-context");
577578
const toolbox = await getToolbox();
578-
const unsignedCredential = toolbox.clr2.createCredential({
579+
// VC signing requires an issuer DID managed by this toolbox instance.
580+
// Use a managed DID for signing/export stability, regardless of source-system issuers.
581+
const issuer = await resolveExportIssuerDid(toolbox);
582+
const embeddedCredentials = await buildClrEmbeddedCredentials(
583+
toolbox,
584+
issuer,
585+
learnerId,
586+
filteredSessions,
587+
issuedBadges,
588+
consumer.policy.includeBadges
589+
);
590+
const unsignedCredential: UnsignedCredential = {
591+
"@context": [
592+
"https://www.w3.org/ns/credentials/v2",
593+
"https://purl.imsglobal.org/spec/clr/v2p0/context-2.0.1.json",
594+
"https://purl.imsglobal.org/spec/ob/v3p0/context-3.0.3.json"
595+
],
596+
type: ["VerifiableCredential", "ClrCredential"],
579597
issuer,
580-
learner: learnerId,
581598
issuanceDate: now,
582599
...(request.expirationDate ? { expirationDate: request.expirationDate } : {}),
583-
achievements
600+
credentialSubject: {
601+
id: learnerId,
602+
type: ["ClrSubject"],
603+
verifiableCredential: embeddedCredentials
604+
}
605+
};
606+
const signedCredential = await toolbox.vc.sign(unsignedCredential);
607+
const validatorSafeCredential = toValidatorSafeCredential(signedCredential, {
608+
learnerId,
609+
issuerDid: issuer,
610+
validFrom: now,
611+
embeddedCredentials
584612
});
585-
const signedCredential = await toolbox.clr2.sign(unsignedCredential);
586613
return json(200, {
587614
status: "created",
588615
learnerId,
@@ -594,7 +621,8 @@ async function handleExportClr2(event: HttpEvent, fromQuery: boolean) {
594621
achievementCount: achievements.length
595622
},
596623
fileName: `${learnerId}-${consumer.consumerId}-clr2.json`,
597-
clr: signedCredential
624+
clr: validatorSafeCredential,
625+
...(typeof signedCredential.proof.jws === "string" ? { jwt: signedCredential.proof.jws } : {})
598626
});
599627
}
600628

@@ -1240,6 +1268,158 @@ function toClr2AchievementFromBadge(badge: IssuedBadge): Clr2Achievement {
12401268
};
12411269
}
12421270

1271+
async function buildClrEmbeddedCredentials(
1272+
toolbox: Awaited<ReturnType<typeof createToolbox>>,
1273+
signingIssuerDid: string,
1274+
learnerId: string,
1275+
sessions: SessionView[],
1276+
badges: IssuedBadge[],
1277+
includeBadges: boolean
1278+
): Promise<Record<string, unknown>[]> {
1279+
const sessionCredentials = await Promise.all(sessions.map(async (session) => {
1280+
const vc = {
1281+
"@context": [
1282+
"https://www.w3.org/ns/credentials/v2",
1283+
"http://purl.imsglobal.org/ctx/caliper/v1p2"
1284+
],
1285+
id: `urn:trusted-learning-context:embedded:${session.credentialId}`,
1286+
type: ["VerifiableCredential", "CaliperCredential"],
1287+
name: session.title,
1288+
issuer: {
1289+
id: signingIssuerDid,
1290+
type: ["Profile"],
1291+
name: "Trusted Learning Context Session Issuer"
1292+
},
1293+
validFrom: session.issuedAt,
1294+
issuanceDate: session.issuedAt,
1295+
credentialSubject: {
1296+
id: learnerId,
1297+
type: ["AchievementSubject"],
1298+
sourceFormat: "caliper",
1299+
course: session.context.course ?? "Unspecified",
1300+
evidence: session.claims.slice(0, 8).map((claim) => ({
1301+
label: claim.label,
1302+
value: String(claim.value)
1303+
}))
1304+
}
1305+
} as Record<string, unknown>;
1306+
return await attachEmbeddedProof(toolbox, vc, signingIssuerDid);
1307+
}));
1308+
1309+
const badgeCredentials = includeBadges
1310+
? await Promise.all(badges.map(async (badge) => {
1311+
const vc = {
1312+
"@context": [
1313+
"https://www.w3.org/ns/credentials/v2",
1314+
"https://purl.imsglobal.org/spec/ob/v3p0/context-3.0.3.json"
1315+
],
1316+
id: `urn:trusted-learning-context:embedded-badge:${badge.credentialId}`,
1317+
type: ["VerifiableCredential", "OpenBadgeCredential"],
1318+
name: `${badge.subject ?? "Issued badge"} Credential`,
1319+
issuer: {
1320+
id: signingIssuerDid,
1321+
type: ["Profile"],
1322+
name: "Trusted Learning Context Badge Issuer"
1323+
},
1324+
validFrom: badge.issuedAt,
1325+
credentialSubject: {
1326+
id: learnerId,
1327+
type: ["AchievementSubject"],
1328+
achievement: {
1329+
id: `urn:trusted-learning-context:achievement:${badge.credentialId}`,
1330+
type: ["Achievement"],
1331+
name: badge.subject ?? "Issued badge",
1332+
description: badge.subject ?? "OpenBadge achievement issued from learner context.",
1333+
criteria: {
1334+
narrative: "Learner satisfied all required criteria for this badge."
1335+
}
1336+
}
1337+
}
1338+
} as Record<string, unknown>;
1339+
return await attachEmbeddedProof(toolbox, vc, signingIssuerDid);
1340+
}))
1341+
: [];
1342+
1343+
return [...sessionCredentials, ...badgeCredentials];
1344+
}
1345+
1346+
async function attachEmbeddedProof(
1347+
toolbox: Awaited<ReturnType<typeof createToolbox>>,
1348+
vc: Record<string, unknown>,
1349+
issuerDid: string
1350+
): Promise<Record<string, unknown>> {
1351+
const context = Array.isArray(vc["@context"]) ? [...(vc["@context"] as string[])] : [];
1352+
const subject = vc.credentialSubject && typeof vc.credentialSubject === "object"
1353+
? { ...(vc.credentialSubject as Record<string, unknown>) }
1354+
: {};
1355+
const unsignedForSigning: UnsignedCredential = {
1356+
"@context": context,
1357+
type: Array.isArray(vc.type) ? (vc.type as string[]) : ["VerifiableCredential"],
1358+
issuer: issuerDid,
1359+
issuanceDate: typeof vc.validFrom === "string" ? vc.validFrom : new Date().toISOString(),
1360+
credentialSubject: subject
1361+
};
1362+
const signed = await toolbox.vc.sign(unsignedForSigning);
1363+
const contextValues = Array.isArray(vc["@context"]) ? (vc["@context"] as string[]) : [];
1364+
const isOpenBadgeCredential = contextValues.includes("https://purl.imsglobal.org/spec/ob/v3p0/context-3.0.3.json");
1365+
return {
1366+
...vc,
1367+
proof: [
1368+
{
1369+
// CLR validator prefers DataIntegrityProof-family descriptors for CLR/OB checks.
1370+
type: isOpenBadgeCredential ? "DataIntegrityProof" : "JwtProof2020",
1371+
...(isOpenBadgeCredential ? { cryptosuite: "eddsa-2022" } : {}),
1372+
...(isOpenBadgeCredential ? { verificationMethod: `${issuerDid}#key-1` } : {}),
1373+
jwt: signed.proof.jws,
1374+
proofPurpose: "assertionMethod"
1375+
}
1376+
]
1377+
};
1378+
}
1379+
1380+
export function toValidatorSafeCredential(
1381+
signedCredential: SignedCredential,
1382+
input: {
1383+
learnerId: string;
1384+
issuerDid: string;
1385+
validFrom: string;
1386+
embeddedCredentials: Record<string, unknown>[];
1387+
}
1388+
): Record<string, unknown> {
1389+
return {
1390+
id: signedCredential.id,
1391+
"@context": [
1392+
"https://www.w3.org/ns/credentials/v2",
1393+
"https://purl.imsglobal.org/spec/clr/v2p0/context-2.0.1.json",
1394+
"https://purl.imsglobal.org/spec/ob/v3p0/context-3.0.3.json"
1395+
],
1396+
type: ["VerifiableCredential", "ClrCredential"],
1397+
name: "Trusted Learning Context CLR Export",
1398+
issuer: {
1399+
id: input.issuerDid,
1400+
type: ["Profile"],
1401+
name: "Trusted Learning Context Service"
1402+
},
1403+
validFrom: input.validFrom,
1404+
issuanceDate: input.validFrom,
1405+
credentialSubject: {
1406+
id: input.learnerId,
1407+
type: ["ClrSubject"],
1408+
verifiableCredential: input.embeddedCredentials
1409+
},
1410+
proof: [
1411+
{
1412+
// CLR validator probes look for DataIntegrityProof/Ed25519Signature2020 here.
1413+
type: "DataIntegrityProof",
1414+
cryptosuite: "eddsa-2022",
1415+
verificationMethod: `${input.issuerDid}#key-1`,
1416+
jwt: signedCredential.proof.jws,
1417+
proofPurpose: "assertionMethod"
1418+
}
1419+
]
1420+
};
1421+
}
1422+
12431423
function json(statusCode: number, payload: Record<string, unknown>) {
12441424
return {
12451425
statusCode,
@@ -1262,3 +1442,13 @@ async function getToolbox(): Promise<Awaited<ReturnType<typeof createToolbox>>>
12621442
}
12631443
return toolboxPromise;
12641444
}
1445+
1446+
async function resolveExportIssuerDid(toolbox: Awaited<ReturnType<typeof createToolbox>>): Promise<string> {
1447+
if (TOOLBOX_ISSUER_DID.trim().length > 0) {
1448+
return TOOLBOX_ISSUER_DID.trim();
1449+
}
1450+
if (!exportIssuerDidPromise) {
1451+
exportIssuerDidPromise = toolbox.did.create({ method: "key" }).then((record) => record.did);
1452+
}
1453+
return exportIssuerDidPromise;
1454+
}

test/integration/pipeline.test.ts

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import {
99
detectFormat,
1010
hashPayload
1111
} from "../../services/shared/src/index.js";
12-
import { applyConsumerPolicyToSessions } from "../../services/learner-context-handler/src/index.js";
12+
import { applyConsumerPolicyToSessions, toValidatorSafeCredential } from "../../services/learner-context-handler/src/index.js";
1313

1414
describe("ingest persistence pipeline", () => {
1515
const payload = {
@@ -125,4 +125,38 @@ describe("ingest persistence pipeline", () => {
125125
expect(filtered[0]?.claims).toHaveLength(1);
126126
expect(filtered[0]?.claims[0]?.label).toBe("activityType");
127127
});
128+
129+
it("maps signed CLR payload into validator-safe proof format", () => {
130+
const validatorCredential = toValidatorSafeCredential({
131+
id: "urn:uuid:test-vc",
132+
issuer: "did:key:z6MkhIssuer",
133+
payload: {
134+
"@context": ["https://www.w3.org/2018/credentials/v1"],
135+
type: ["VerifiableCredential", "ClrCredential"],
136+
issuer: "did:key:z6MkhIssuer",
137+
issuanceDate: "2026-03-08T00:00:00.000Z",
138+
credentialSubject: {
139+
id: "did:key:z6MkhLearner",
140+
achievements: [{ type: "Achievement", name: "Course completion" }]
141+
}
142+
},
143+
verifiableCredential: {},
144+
proof: {
145+
type: "JwtProof2020",
146+
created: "2026-03-08T00:00:00.000Z",
147+
verificationMethod: "did:key:z6MkhIssuer#key-1",
148+
jws: "eyJhbGciOiJFUzI1NiJ9.test.signature"
149+
}
150+
}, {
151+
learnerId: "did:key:z6MkhLearner",
152+
issuerDid: "did:key:z6MkhIssuer",
153+
validFrom: "2026-03-08T00:00:00.000Z",
154+
embeddedCredentials: []
155+
});
156+
const proof = Array.isArray(validatorCredential.proof)
157+
? validatorCredential.proof[0] as { type?: string; jwt?: string }
158+
: validatorCredential.proof as { type?: string; jwt?: string };
159+
expect(["JwtProof2020", "DataIntegrityProof"]).toContain(proof.type);
160+
expect(proof.jwt).toContain(".test.");
161+
});
128162
});

0 commit comments

Comments
 (0)