From ca7a544f6393cb7ee6c5155551ed4cf0f6dac887 Mon Sep 17 00:00:00 2001 From: Sunny Lee <1424627+threeqube@users.noreply.github.com> Date: Wed, 21 Oct 2026 10:08:13 -0700 Subject: [PATCH 01/17] Move to verifier-core 2.0.0-beta.1 Nate's 2.0.0-beta.1 is on npm, built from verifier-core main e86bb79, so depend on the published package rather than a commit on GitHub. What changed upstream, and what follows here: - Validity dates have their own check, cryptographic.proof.validity-period, which runs once the signature has verified. Expiry is read from it, the seal is just the signature passing, and the Dates row now has that id. Expiry and a withdrawal no longer stop the checks after them, so an expired credential now reports its withdrawal list and its issuer, and a "withdrawn" mark on a list someone else signed is caught. - The registry check carries what it found as data. Names are read from it, not parsed out of the message, so a name with a comma survives. - verifier-core#65 is fixed: an issuer whose did:web document won't load reads DID_WEB_UNRESOLVED. The namingDidWebOutages stopgap goes; its end-to-end tests now run the library unaided, and pass. - openBadgesSchemaSuite moved to /openbadges/schema and validates against bundled schemas. `verbose` and CheckResult.check are gone. No wording changes. One new outcome code, dates_unchecked, for a signature that passed with no dates check reported: it says COULD_NOT_CHECK, so "Verified" never sits above a Dates row reading "not checked". The consistency suite gains the dates as a dimension (38,880 combinations). A browser test that counted rows one tick after a re-check now counts them as the check starts: with nothing to fetch, the check finishes in a tick. Co-Authored-By: Claude Opus 5.5 --- package-lock.json | 8 +- package.json | 2 +- src/developer.ts | 8 +- src/outcomes.ts | 244 ++++++++++++++--------------------- src/types.ts | 64 +++++---- src/verify.ts | 104 ++------------- test/browser/states.spec.ts | 13 +- test/consistency.test.ts | 160 ++++++++++++++--------- test/developer.test.ts | 18 +-- test/did-web-outages.test.ts | 66 +--------- test/fixtures.test.ts | 1 - test/outcomes.test.ts | 160 ++++++++++++++--------- test/problem-types.test.ts | 36 ++++-- 13 files changed, 396 insertions(+), 488 deletions(-) diff --git a/package-lock.json b/package-lock.json index d67442b..d189b51 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,7 +14,7 @@ "@digitalcredentials/security-document-loader": "^6.0.0", "@digitalcredentials/vc": "^9.0.1", "@digitalcredentials/vc-bitstring-status-list": "^1.0.0", - "@digitalcredentials/verifier-core": "github:digitalcredentials/verifier-core#d217b042371096e84b8cd0046fbe79a667683d8a", + "@digitalcredentials/verifier-core": "2.0.0-beta.1", "@playwright/test": "^1.63.0", "@types/react": "^19.3.0", "oxlint": "^1.75.0", @@ -1098,9 +1098,9 @@ } }, "node_modules/@digitalcredentials/verifier-core": { - "version": "2.0.0", - "resolved": "git+ssh://git@github.com/digitalcredentials/verifier-core.git#d217b042371096e84b8cd0046fbe79a667683d8a", - "integrity": "sha512-afPV7+9CuyZ/BUfRA+AIp0SSJN9xNaCZ0bAZozjWVFtcWA6jvIkPo8XqzZcZC3eF/T6rAE6MX4UX/+TPy5F8hw==", + "version": "2.0.0-beta.1", + "resolved": "https://registry.npmjs.org/@digitalcredentials/verifier-core/-/verifier-core-2.0.0-beta.1.tgz", + "integrity": "sha512-5s9ABhZnz4gPRVPLsGoWQdqj8PlgvLRCYtP96+IUSP9YU0LiBppNQQZ8UgoxLHuF+VY6Z6TWD0tVxuROR/rSiw==", "dev": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 7d5022c..9c3f418 100644 --- a/package.json +++ b/package.json @@ -38,7 +38,7 @@ "@digitalcredentials/security-document-loader": "^6.0.0", "@digitalcredentials/vc": "^9.0.1", "@digitalcredentials/vc-bitstring-status-list": "^1.0.0", - "@digitalcredentials/verifier-core": "github:digitalcredentials/verifier-core#d217b042371096e84b8cd0046fbe79a667683d8a", + "@digitalcredentials/verifier-core": "2.0.0-beta.1", "@playwright/test": "^1.63.0", "@types/react": "^19.3.0", "oxlint": "^1.75.0", diff --git a/src/developer.ts b/src/developer.ts index 580f599..06efe54 100644 --- a/src/developer.ts +++ b/src/developer.ts @@ -39,7 +39,7 @@ export const groupChecks = (r: VerificationResponse): CheckGroup[] => { let home: CheckGroup | undefined; for (const group of groups) { const id = group.suite!.id; - if (check.id && belongsTo(check.id, id) && (!home || id.length > home.suite!.id.length)) { + if (belongsTo(check.id, id) && (!home || id.length > home.suite!.id.length)) { home = group; } } @@ -52,11 +52,9 @@ export const groupChecks = (r: VerificationResponse): CheckGroup[] => { /** * What to call a check inside its group: the part of its id after the suite's, * so `cryptographic.core.proof-exists` reads `proof-exists` under - * `cryptographic.core`. Outside a group it keeps its whole id — or, for a - * hand-built result with no id, the deprecated `suite` and `check` it carries. + * `cryptographic.core`. Outside a group it keeps its whole id. */ -export const checkName = (check: CheckResult, suiteId?: string): string => { - const id = check.id ?? [check.suite, check.check].filter(Boolean).join('.'); +export const checkName = ({ id }: CheckResult, suiteId?: string): string => { if (!suiteId || id === suiteId || !belongsTo(id, suiteId)) return id; return id.slice(suiteId.length + 1); }; diff --git a/src/outcomes.ts b/src/outcomes.ts index 30dd670..73ebad0 100644 --- a/src/outcomes.ts +++ b/src/outcomes.ts @@ -11,15 +11,14 @@ * severities that never grow; a message list that does. */ -import { - CHECK, - PROBLEM, - STATUS_LIST_PROBLEM_PREFIX, - REGISTRY_FOUND, - REGISTRY_UNCHECKED, - STRUCTURAL_CONTEXT_DETAILS, +import { CHECK, PROBLEM, STATUS_LIST_PROBLEM_PREFIX, STRUCTURAL_CONTEXT_DETAILS } from './types.js'; +import type { + Severity, + VerificationResponse, + CheckResult, + ProblemDetail, + RegistryCheckPayload, } from './types.js'; -import type { Severity, VerificationResponse, CheckResult, ProblemDetail } from './types.js'; /** One thing we tell the person. `code` is ours, and stable. */ export interface Outcome { @@ -82,9 +81,9 @@ export interface IssuerIdentity { registries: string[]; /** * Named registries we could not reach. May be empty while - * `registriesUnreachable` is true: 2.x reports the names only in prose, so - * they can be lost when the fact is not. Always empty when the host said - * its list was unavailable, since nothing was looked up. + * `registriesUnreachable` is true: a lookup that threw reports no names. + * Always empty when the host said its list was unavailable, since nothing + * was looked up. */ unreachable: string[]; /** @@ -116,13 +115,7 @@ export interface IssuerIdentity { const checksById = (r: VerificationResponse): Map => { const map = new Map(); - for (const check of r.results ?? []) { - // The library always computes `id` after running the suites, so the - // fallback only keeps a hand-built literal from vanishing. It cannot match - // a CHECK constant: those carry the phase, which `suite` and `check` do - // not, so there is nothing to reconstruct it from. - map.set(check.id ?? check.check, check); - } + for (const check of r.results ?? []) map.set(check.id, check); return map; }; @@ -191,79 +184,45 @@ const failed = (check: CheckResult | undefined): boolean => check?.outcome.status === 'failure'; /** - * Skipped because an earlier check failed fatally. verifier-core stops there - * since its "halt after a fatal failure" change, and the signature check is - * fatal for expiry and a start date still to come, as well as for tampering — - * so after any of those, the withdrawal list, the issuer lookup and the - * schema are never consulted. Each then reads "not checked", which is what - * happened, rather than the account of a failure that didn't. + * Skipped because an earlier check failed fatally. verifier-core stops after + * most fatal failures — the core checks and the signature — so after any of + * those, the dates, the withdrawal list, the issuer lookup and the schema are + * never consulted. Each then reads "not checked", which is what happened, + * rather than the account of a failure that didn't. Expiry and a withdrawal + * are fatal too, but since 2.0.0-beta.1 they don't halt: everything after + * them still reports. * - * Read off the skip reason, "Not run: proof.signature failed": the library - * marks a halted check no other way. Getting it wrong costs only which of two - * "not checked" wordings a row uses. + * Read off the skip reason, "Not run: failed": the library marks a + * halted check no other way. Getting it wrong costs only which of two "not + * checked" wordings a row uses. */ const halted = (check: CheckResult | undefined): boolean => check?.outcome.status === 'skipped' && check.outcome.reason.startsWith('Not run:'); -/** - * Pull the "A, B" list out of one of the registry sentences. - * - * The library joins names with ", ", so a registry whose own name contains a - * comma cannot be recovered faithfully — another reason this wants to be data - * rather than prose. Splitting is deliberately on ", " rather than "," so at - * least "Acme Registry, Inc." degrades to two plausible names instead of a - * name and a fragment, and nothing but a sentence depends on the result. - */ -const namesIn = (text: string, marker: RegExp): string[] => { - const captured = marker.exec(text)?.[1]; - return (captured ?? '') - .split(', ') - .map((n) => n.trim()) - .filter((n) => n.length > 0); -}; +/** What the lookup found, as data, whenever it ran. */ +const registryPayload = (check: CheckResult | undefined): RegistryCheckPayload | undefined => + check && check.outcome.status !== 'skipped' + ? (check.outcome.payload as RegistryCheckPayload | undefined) + : undefined; /** - * Which registries recognised the issuer. - * - * 2.x carries no payload on a successful registry check, but the names are in - * the message — `Issuer found in registry: A`, or `Issuer found in 2 - * registries: A, B`. Parsing prose is not how this should arrive and it is - * raised on verifier-core#32, but the information is real and §5 is built on - * it, so we read it rather than pretend it is gone. - * - * Failing to parse costs a name in a sentence: the callers fall back to "a - * registry we check", which is still true. Nothing about the verdict rests on - * it — that rests on the check having passed. + * Which registries recognised the issuer. Only from a check that passed: a + * name in a sentence, and nothing about the verdict rests on it — that rests + * on the check having passed. */ const registryNames = (check: CheckResult | undefined): string[] => check?.outcome.status === 'success' - ? namesIn(check.outcome.message, REGISTRY_FOUND) + ? (registryPayload(check)?.matches ?? []).map((m) => m.registry.name) : []; /** - * Which registries we could not reach. - * - * The fact is reported as a problem type, which is reliable. The names are - * only in that problem's prose — "2 registries could not be checked: A, B" — - * so they are taken from after the first colon, best effort. Failing to - * extract them costs a name in a sentence; the distinction that matters - * (unreachable is not the same as "not listed") rests on the type, not the - * prose, and so survives a wording change. + * Which registries we could not reach. A name in a sentence too: the + * distinction that matters (unreachable is not the same as "not listed") + * rests on `anyUnreachable`, not on these. */ -const unreachableNames = (check: CheckResult | undefined): string[] => { - if (check?.outcome.status === 'success') { - // Appended to the success message after the matched registries. - return namesIn(check.outcome.message, REGISTRY_UNCHECKED); - } - const problem = problemsOf(check).find((p) => p.type === PROBLEM.registryUnchecked); - return problem ? namesIn(problem.detail ?? '', REGISTRY_UNCHECKED) : []; -}; +const unreachableNames = (check: CheckResult | undefined): string[] => + (registryPayload(check)?.uncheckedRegistries ?? []).map((u) => u.name); -/** - * Whether any registry went unchecked, independently of whether we could read - * its name. This is the load-bearing half: it decides whether we say "we don't - * know" or "they aren't listed", and it rests on the problem type alone. - */ /** * The registry check reached a verdict on this issuer. * @@ -275,10 +234,15 @@ const unreachableNames = (check: CheckResult | undefined): string[] => { const registryAnswered = (check: CheckResult | undefined): boolean => check?.outcome.status === 'success' || hasProblem(check, PROBLEM.issuerNotRegistered); +/** + * Whether any registry went unchecked. This is the load-bearing half: it + * decides whether we say "we don't know" or "they aren't listed". On a + * failure it rests on the problem type; on a pass, which has no problems, + * on the payload's list of registries it couldn't check. + */ const anyUnreachable = (check: CheckResult | undefined): boolean => hasProblem(check, PROBLEM.registryUnchecked) || - (check?.outcome.status === 'success' && - REGISTRY_UNCHECKED.test(check.outcome.message)); + (check?.outcome.status === 'success' && unreachableNames(check).length > 0); /** * Whether the withdrawal list is someone else's. @@ -287,14 +251,10 @@ const anyUnreachable = (check: CheckResult | undefined): boolean => * been read, and leaves the withdrawal check's own result as it was. See * PROBLEM.statusListIssuerMismatch for why we repeat neither answer. * - * Only half of it reaches us today. A clean pass from a stranger's list is - * reported, and reads "we couldn't check". A *mark* on one is not: the - * withdrawal check is fatal, so a mark ends the status suite before the signer - * is compared, and the list-issuer check arrives "Not run". A stranger's - * "withdrawn" therefore still reads as the issuer's, and nothing in the result - * can tell the two apart (verifier-core, raised 7 October 2026). The rules - * below already treat a reported mismatch on a mark correctly, so the fix - * lands here unchanged when upstream reports it. + * Both halves reach us since 2.0.0-beta.1. A clean pass from a stranger's + * list reads "we couldn't check", and so now does a *mark* on one: until + * then the withdrawal check halted on a mark, the signer was never compared, + * and a stranger's "withdrawn" read as the issuer's (raised 7 October 2026). */ const listNotIssuers = (checks: Map): boolean => hasProblem(checks.get(CHECK.statusListIssuer), PROBLEM.statusListIssuerMismatch); @@ -357,7 +317,9 @@ export type SchemaFinding = /** * The schema check is a real check in 2.x, but it is neither fatal nor in the - * default suites — verify.ts adds it back. So a credential can still be + * default suites — verify.ts adds it back. It applies only to Open Badges + * credentials and endorsements; for anything else the suite reports itself + * not applicable and this check leaves no row, which reads as `no_schema`. So a credential can still be * reported as `verified` while failing its own schema, exactly as in 1.x * where the result was filed outside the log entirely. Either way, reading * only what bears on authenticity throws this away silently. @@ -374,10 +336,10 @@ export const schemaFinding = (r: VerificationResponse): SchemaFinding => { if (halted(check)) return { state: 'not_run' }; if (check.outcome.status === 'skipped') { - // The two reasons the check actually emits. "Does not appear to be an OBv3 - // credential" means nothing declared a standard we know how to check - // against — nothing failed and there was nothing to try. Anything else - // means the check could not be carried out, which establishes nothing. + // "Does not appear to be an OBv3 credential" means there is no standard + // we know how to check it against — nothing failed and there was nothing + // to try. Anything else means the check could not be carried out, which + // establishes nothing. return /does not appear to be an OBv3/i.test(check.outcome.reason) ? { state: 'no_schema' } : { state: 'unavailable' }; @@ -387,8 +349,9 @@ export const schemaFinding = (r: VerificationResponse): SchemaFinding => { const problems = problemsOf(check); // A failure that is not a validation failure means we could not carry the - // check out — an unreachable schema, say — which establishes nothing and - // must not be reported as "built wrong". + // check out — the validator itself failed — which establishes nothing and + // must not be reported as "built wrong". The schemas are bundled with the + // library since 2.0.0-beta.1, so one that won't load is no longer the cause. if (!problems.some((p) => p.type === PROBLEM.schemaValidationFailed)) { return { state: 'unavailable' }; } @@ -417,22 +380,12 @@ export const schemaFinding = (r: VerificationResponse): SchemaFinding => { * Whether the issuer's seal held: nothing in the credential changed since they * signed it. The one answer every row, reassurance and marker uses. * - * More than `passed(signature)`, because the validity dates are judged inside - * the signature check: an expired credential, or one not valid yet, *fails* - * it. But @digitalcredentials/vc checks dates only after the proof has - * verified, so a failure whose only problems are about dates means the seal - * itself was fine. verifier-core has said so by type since #58; before, it was - * the same INVALID_SIGNATURE as tampering and we had to read its prose. Any - * other problem alongside a date — tampering, a key that isn't the issuer's — - * and the seal did not hold. + * Just `passed(signature)` since 2.0.0-beta.1, which judges the dates in a + * check of their own. Before, an expired credential *failed* the signature + * check, and a failure whose only problems were about dates had to be read as + * a seal that held. */ -const DATE_PROBLEMS: readonly string[] = [PROBLEM.credentialExpired, PROBLEM.credentialNotYetValid]; -const sealHeld = (checks: Map): boolean => { - const signature = checks.get(CHECK.signature); - if (passed(signature)) return true; - const problems = problemsOf(signature); - return problems.length > 0 && problems.every((p) => DATE_PROBLEMS.includes(p.type)); -}; +const sealHeld = (checks: Map): boolean => passed(checks.get(CHECK.signature)); /** The seal does not match the content: altered after issue. */ const isTampered = (checks: Map): boolean => @@ -480,8 +433,9 @@ export const issuerIdentity = (r: VerificationResponse, options: OutcomeOptions const rawName: unknown = typeof rawIssuer === 'string' ? undefined : rawIssuer?.name; const claimedName = (typeof rawName === 'string' ? rawName.trim() : '') || undefined; - // 2.x carries no registry payload, so there is no registry-sourced name to - // prefer over the credential's own. See registryNames(). + // The registry's own record of the issuer arrives on the payload since + // 2.0.0-beta.1, but which name to show is a wording decision not yet taken, + // so the credential's own name stays. See RegistryCheckPayload. const registryName: string | undefined = undefined; // If a registry recognised the issuer, say so even when it gave us no name @@ -581,10 +535,10 @@ export const issuerMarker = (source: IssuerNameSource, sealHeld: boolean): strin * is exactly what is in question, so the finding is the point. A deliberate * exception, and only for this case. * - * This briefly also covered a failed signature, which swept in expiry — - * 2.x has no expiration check, so an expired credential fails the signature - * check and was having its title pushed below the verdict. An expired - * credential is not in question; it has simply run out. + * This briefly also covered a failed signature, which swept in expiry — an + * expired credential then failed the signature check, and was having its + * title pushed below the verdict. An expired credential is not in question; + * it has simply run out. */ export const verdictLeads = (r: VerificationResponse): boolean => stoppedEarly(r); @@ -736,15 +690,11 @@ const FATAL: Record> = { // The two below mean we could not find out — not that anything is wrong. // Conflating them with invalid_signature is the worst mistake available. // - // An issuer's did.json that won't load arrives as DID_WEB_UNRESOLVED and - // reads as did_web_unresolved — but only because verify.ts's - // namingDidWebOutages makes it so. Left alone, verifier-core almost never - // uses either type: its classifier recognises only an error named - // `HTTPError`, and its own fetcher and did:web resolver throw plain errors, - // so a did.json that answers 404, or a host that doesn't answer at all, - // arrives as INVALID_SIGNATURE and reads as tampering (verified against the - // library, 7 October 2026; verifier-core#65). Reading the sentence here - // instead would bring back the prose-matching this file shed. + // An issuer's DID document that won't load — a 404, or a site that doesn't + // answer — arrives as DID_WEB_UNRESOLVED, and a key on another site as + // HTTP_ERROR. Until verifier-core#65 was fixed in 2.0.0-beta.1 both arrived + // as INVALID_SIGNATURE and read as tampering, and verify.ts carried a + // stopgap for it. http_error_with_signature_check: { severity: 'unchecked', ...COULD_NOT_CHECK }, did_web_unresolved: { severity: 'unchecked', @@ -811,9 +761,9 @@ const reassurance = ( * clock second-guessing it could only produce a headline it disagrees with. */ const isExpired = (checks: Map): boolean => - hasProblem(checks.get(CHECK.signature), PROBLEM.credentialExpired); + hasProblem(checks.get(CHECK.validityPeriod), PROBLEM.credentialExpired); const isNotYetValid = (checks: Map): boolean => - hasProblem(checks.get(CHECK.signature), PROBLEM.credentialNotYetValid); + hasProblem(checks.get(CHECK.validityPeriod), PROBLEM.credentialNotYetValid); /** The end date, written out, or nothing if there isn't a readable one. */ const expiryDate = (r: VerificationResponse): string | undefined => @@ -921,10 +871,8 @@ export const summarise = (r: VerificationResponse, options: OutcomeOptions = {}) // Expiry sits below withdrawal deliberately. A credential that was withdrawn // *and* has since run out is withdrawn: the issuer has already decided, and // "ask whether it can be renewed" would bury that behind a lesser finding. - // verifier-core's main can't currently tell us both: expiry is fatal, so the - // withdrawal list is never read and an expired credential reads "Expired" - // whether or not it was withdrawn. Its reassurance then makes no withdrawal - // claim. The order still stands for when both are reported. + // Both are reported since 2.0.0-beta.1: expiry no longer stops the + // withdrawal list being read. if (isExpired(checks)) { // Name the date. How stale it is changes what someone does about it, and // "eight months ago" is vaguer than a date when a qualification is at @@ -934,8 +882,8 @@ export const summarise = (r: VerificationResponse, options: OutcomeOptions = {}) severity: 'warning', code: 'expired', headline: on ? `Expired on ${on}` : 'This has passed its end date', - // Only what reported: the seal held (the library checks dates after it), - // so this normally says it hasn't been tampered with. It is assembled + // Only what reported: the seal held (the library checks dates only after + // it), so this normally says it hasn't been tampered with. It is assembled // rather than written out so that it appears when the checks behind it // did pass, and stays absent when they did not. Nothing fills the gap // otherwise: "Its dates have run out" only restated the headline. @@ -1125,10 +1073,12 @@ export const summarise = (r: VerificationResponse, options: OutcomeOptions = {}) return { code: 'signature_unchecked', ...UNCHECKED_SIGNATURE }; } - // There is no separate `expiry_unchecked` any more. 2.x folds the validity - // period into the signature check, so dates cannot be unknown while the - // signature is known — and if the signature did not report, the branch above - // has already said so. + // The dates have their own check since 2.0.0-beta.1, which runs whenever + // the signature passes. Absent anyway, "Verified" would sit above a Dates + // row reading "not checked". + if (!passed(checks.get(CHECK.validityPeriod))) { + return { code: 'dates_unchecked', severity: 'unchecked', ...COULD_NOT_CHECK }; + } return { severity: 'success', @@ -1154,7 +1104,6 @@ export const listChecks = (r: VerificationResponse, options: OutcomeOptions = {} // moment its value stops being a plain yes or no — and it also forces the // reader to flip between "yes is good" and "no is good" partway down the // list. - const signature = checks.get(CHECK.signature); const tampered = isTampered(checks); const keyMismatch = isKeyMismatch(checks); const confirmed = contentConfirmed(r); @@ -1166,12 +1115,9 @@ export const listChecks = (r: VerificationResponse, options: OutcomeOptions = {} // someone who earned the credential. The label stays a subject rather // than a claim, as every other row does. label: 'Tampering', - // Only a signature failure the library calls tampering says so. An - // expired or not-yet-valid credential also fails this check — but only - // after its seal verified (see sealHeld), so it reads "none detected", not - // "not checked": we did check, and nothing had changed. A key that isn't - // the issuer's reads "not checked" here: the seal could not be tested - // against the issuer at all, and the Issuer row says why. + // Only a signature failure the library calls tampering says so. A key + // that isn't the issuer's reads "not checked" here: the seal could not be + // tested against the issuer at all, and the Issuer row says why. severity: sealHeld(checks) ? 'success' : tampered ? 'error' : 'unchecked', value: sealHeld(checks) ? 'none detected' @@ -1264,18 +1210,18 @@ export const listChecks = (r: VerificationResponse, options: OutcomeOptions = {} }); } - // The validity period is judged inside the signature check, so a passing - // signature means the dates held, and a date outside them shows up as that - // check failing with its own problem type. Reading the row off the same - // check as the verdict is also what keeps the two from disagreeing. + // Read off the same check as the verdict, which is what keeps the two from + // disagreeing. It runs only after the signature passed, so after a seal + // that failed it reads "not checked". + const dates = checks.get(CHECK.validityPeriod); const expiredNow = isExpired(checks); const notYet = !expiredNow && isNotYetValid(checks); const end = expiryDate(r); const start = startDate(r); rows.push({ - id: CHECK.signature + '#dates', + id: CHECK.validityPeriod, label: 'Dates', - severity: expiredNow || notYet ? 'warning' : passed(signature) ? 'success' : 'unchecked', + severity: expiredNow || notYet ? 'warning' : passed(dates) ? 'success' : 'unchecked', value: expiredNow ? end ? `expired on ${end}` @@ -1284,7 +1230,7 @@ export const listChecks = (r: VerificationResponse, options: OutcomeOptions = {} ? start ? `not valid until ${start}` : 'not valid yet' - : passed(signature) + : passed(dates) ? 'in date' : 'not checked', }); diff --git a/src/types.ts b/src/types.ts index 5adb45a..2ba616a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -44,40 +44,37 @@ export interface ProblemDetail { */ export type CheckOutcome = | { status: 'success'; message: string; payload?: unknown } - | { status: 'failure'; problems: ProblemDetail[] } + | { status: 'failure'; problems: ProblemDetail[]; payload?: unknown } | { status: 'skipped'; reason: string }; export interface CheckResult { - /** - * `..`. Optional in the library's declarations for - * backwards compatibility with hand-built literals, so the fallback below is - * not defensive padding — `id` really can be absent. - */ - id?: string; - /** @deprecated by the library in favour of `id`; still the fallback. */ - check: string; - /** @deprecated by the library in favour of `id`; still the fallback. */ + /** `..`, on every row. The one thing to match on. */ + id: string; suite: string; outcome: CheckOutcome; /** Set when the check was marked fatal in its suite definition. */ fatal?: boolean; + /** + * `false` on a fatal check whose failure leaves the checks after it + * running: validity dates and status. Absent, a fatal failure halts. + */ + halts?: boolean; } /** - * Per-suite rollup. Always present, whatever `verbose` is set to. + * Per-suite rollup. * * We do not read it for the verdict, and the reason is worth recording: its * `counts` are per suite, not per check, so in any suite with more than one * check it cannot tell you *which* check passed. That is precisely the * distinction this component exists to get right, so the verdict is built from - * `results[]` with `verbose: true` instead. See verify.ts. + * `results[]`, which lists every check that ran, was skipped, or was not run. */ export interface SuiteSummary { id: string; phase: string; suite: string; status: 'success' | 'failure' | 'skipped' | 'mixed'; - verified: boolean; message: string; counts: { passed: number; failed: number; skipped: number }; fatalFailureAt?: string; @@ -116,6 +113,13 @@ export const CHECK = { credentialId: 'cryptographic.core.credential-id', proofExists: 'cryptographic.core.proof-exists', signature: 'cryptographic.proof.signature', + /** + * The credential's own dates against the library's clock. Runs only once + * the signature has verified, so a failure here is a genuine credential + * that has run out or hasn't started. Fatal, but it doesn't halt: the + * withdrawal list and the issuer lookup are still read. + */ + validityPeriod: 'cryptographic.proof.validity-period', status: 'cryptographic.status.bitstring', /** * Whether the withdrawal list was signed by the credential's own issuer. @@ -136,9 +140,9 @@ export const PROBLEM = { */ invalidSignature: 'https://www.w3.org/TR/vc-data-model#INVALID_SIGNATURE', /** - * The seal held, and the end date has passed. @digitalcredentials/vc reads - * dates only after the proof verifies, so this is evidence the credential - * is genuine, not a doubt about it. + * The seal held, and the end date has passed. Reported by the validity + * period check, which runs only after the signature verified, so this is + * evidence the credential is genuine, not a doubt about it. */ credentialExpired: 'https://www.w3.org/TR/vc-data-model#CREDENTIAL_EXPIRED', /** As above, for a start date still in the future. */ @@ -189,20 +193,24 @@ export const STATUS_LIST_PROBLEM_PREFIX = 'https://www.w3.org/TR/vc-data-model#STATUS_LIST_'; /** - * How the registry check reports what it found — in prose, on both paths. - * - * Success: `Issuer found in registry: A`, or `Issuer found in 2 registries: - * A, B`, optionally followed by `. 1 registries could not be checked: C`. - * Failure: an ISSUER_NOT_REGISTERED problem, plus a REGISTRY_UNCHECKED one - * carrying the same "could not be checked" sentence. + * What the registry check found, as data: the payload on its outcome, on + * success and on failure alike, whenever the lookup ran. Until the 2.0.0 + * betas this arrived only in prose and was read out of the message, which + * lost any registry whose name had a comma in it (verifier-core#32). * - * None of it arrives as data — the success outcome has no payload at all — - * so rendering §5 means reading these sentences. Raised on verifier-core#32; - * `RegistryLookupResult` on the payload would retire all of this. + * `entity` is the registry's own record of the issuer. Not read yet: which + * name to show, the registry's or the credential's, is a wording decision. */ -export const REGISTRY_FOUND = - /Issuer found in (?:\d+ )?registr(?:y|ies): (.+?)(?=\. \d+ registries could not be checked:|$)/; -export const REGISTRY_UNCHECKED = /\d+ registries could not be checked: (.+)$/; +export interface RegistryReference { + name: string; + type: string; + url?: string; +} + +export interface RegistryCheckPayload { + matches: { registry: RegistryReference; entity?: { name?: string; url?: string; logo?: string } }[]; + uncheckedRegistries: RegistryReference[]; +} /** * The three structural failures `context-check` reports, all under the title diff --git a/src/verify.ts b/src/verify.ts index 4fe364c..3d4f49c 100644 --- a/src/verify.ts +++ b/src/verify.ts @@ -4,19 +4,15 @@ * Kept deliberately thin. Everything that decides what a person reads lives in * outcomes.ts, so it can be tested without a browser or a network. * - * Thin does not mean empty, though. Three of 2.x's defaults are wrong for this - * component, and correcting them is this file's real job — see `verbose`, - * `recognizers` and `additionalSuites` below. Left alone, 2.x reports a - * credential that violates its own standard as verified, and reports a check - * that passed identically to one that never ran. A fourth correction is a - * stopgap for a library bug: see `namingDidWebOutages`. + * Thin does not mean empty, though. Two of 2.x's defaults are wrong for this + * component, and correcting them is this file's real job — see `recognizers` + * and `additionalSuites` below. Left alone, 2.x reports a credential that + * violates its own standard as verified, with nothing to show for it. */ import { BuiltinHttpGetService, verifyCredential } from '@digitalcredentials/verifier-core'; -import { - obv3p0Recognizer, - openBadgesSchemaSuite, -} from '@digitalcredentials/verifier-core/openbadges'; +import { obv3p0Recognizer } from '@digitalcredentials/verifier-core/openbadges'; +import { openBadgesSchemaSuite } from '@digitalcredentials/verifier-core/openbadges/schema'; import type { VerificationResponse } from './types.js'; export interface Registry { @@ -85,79 +81,12 @@ const devHttpGetService = { }, }; -/** - * verifier-core's HttpGetService, restated. Named from the library, it would - * put an import of verifier-core in the published types, and hosts never - * install verifier-core: it is bundled. - */ -interface HttpGetService { - get: (url: string) => Promise<{ body: unknown; headers: Headers; status: number }>; -} - -/** - * The error verifier-core's signature check recognises as "a fetch failed": - * it goes by the name, and reads the address from `requestUrl`. - */ -class HTTPError extends Error { - override name = 'HTTPError'; - constructor( - message: string, - readonly requestUrl: string, - options?: { cause?: unknown }, - ) { - super(message, options); - } -} - -/** - * Stopgap for verifier-core#65: an issuer website that is down reads as down, - * not as tampering. - * - * A did:web issuer publishes its signing key in a did.json on its own site. - * When that file won't load — the site doesn't answer, or answers 404 — - * verifier-core's did:web resolver throws a plain Error, and its signature - * check, which only knows a fetch failed if the error is named `HTTPError`, - * reports INVALID_SIGNATURE: what a tampered credential gets. outcomes.ts - * then tells an earner their honest credential was tampered with. - * - * This wraps the fetcher so a did.json that won't load fails with an - * `HTTPError` naming its address. The signature check then reports - * DID_WEB_UNRESOLVED, which outcomes.ts already words as "we couldn't reach" - * the issuer's website (verified against the library and vc-test-fixtures' - * badDidWeb.json, 8 October 2026). Delete this when verifier-core#65 is fixed. - * - * - "Won't load" includes the built-in fetcher refusing it: an issuer on a - * private address, too many redirects, a body over 5 MB. We didn't reach - * their key, which is true; telling refusals apart would mean reading the - * fetcher's sentences, which outcomes.ts doesn't do. It used to say tampered. - * - The signature check only says DID_WEB_UNRESOLVED when the address - * contains the issuer's did:web, so a key on another host, or an issuer - * whose did:web has a port, reads as HTTP_ERROR instead: "something we - * needed didn't load". Not tampered either. - * - Only addresses ending in /did.json, which is where did:web keeps a key; - * status lists and registries don't, and get their statuses back as - * before, to branch on themselves. A did.json that loads but is wrong is - * left alone: that is not an outage. - */ -export const namingDidWebOutages = (inner: HttpGetService): HttpGetService => ({ - get: async (url) => { - if (!url.endsWith('/did.json')) return inner.get(url); - const result = await inner.get(url).catch((error: unknown) => { - throw new HTTPError(`Could not fetch ${url}`, url, { cause: error }); - }); - if (result.status < 200 || result.status >= 300) { - throw new HTTPError(`Could not fetch ${url}: HTTP ${result.status}`, url); - } - return result; - }, -}); - /** * One for the life of the page: verifier-core keeps a document loader, and * its cache, per fetcher. The built-in fetcher is what verifier-core uses when * given none, so builds lose nothing by being given this. */ -const httpGetService = namingDidWebOutages(import.meta.env.DEV ? devHttpGetService : BuiltinHttpGetService()); +const httpGetService = import.meta.env.DEV ? devHttpGetService : BuiltinHttpGetService(); /** * The status-list gap is closed, and it closed upstream rather than here. @@ -199,8 +128,7 @@ export const verify = async ( ...(options.registriesUnavailable ? {} : { registries: registries as never }), // The built-in fetcher in builds, the dev one under the dev server and in - // the browser tests (see devHttpGetService), each wrapped by - // namingDidWebOutages. Note what the dev one costs: the cross-site tests + // the browser tests (see devHttpGetService). Note what the dev one costs: the cross-site tests // (pages-like-server.js, which refuses CORS preflights as GitHub Pages // does) exercise it, not the built-in one. Both send a bare GET with no // custom headers, so neither triggers a preflight (read off the library, @@ -208,21 +136,12 @@ export const verify = async ( // built-in one for real. httpGetService, - // 2.x defaults this to false, which drops every check that passed and - // keeps only failures and skips. A passed check and a check that never ran - // would then both be simply absent, and telling those two apart is the - // single thing this component must not get wrong — six of the ten defects - // found in review were a verdict claiming something the breakdown said was - // never checked. The per-suite rollup cannot stand in for it either: its - // counts are per suite, not per check. - verbose: true, - // Without a recognizer, 2.x skips recognition entirely and hands back no // normalized form. Supplying it is also what makes `recognizedProfile` // meaningful for the developer view later. recognizers: [obv3p0Recognizer], - // The Open Badges schema check is not in the default suites, so a + // The Open Badges schema checks are not in the default suites, so a // credential that breaks its own standard comes back verified with nothing // to show. In 1.x the same check ran unasked and was filed under // `additionalInformation`. Adding it back keeps the "How it was built" row @@ -231,7 +150,10 @@ export const verify = async ( // Only the schema suite. The semantic suite's extra checks (result // references, achievement levels) are real but we display none of them, // and running checks we throw away would slow every verification for - // nothing. + // nothing. Since 2.0.0-beta.1 it validates against the Open Badges + // schemas bundled with the library, chosen by the credential's type, and + // fetches nothing. Its second check, on a declared `credentialSchema` + // that doesn't fit, shows in the developer view only. additionalSuites: [openBadgesSchemaSuite], })) as VerificationResponse; }; diff --git a/test/browser/states.spec.ts b/test/browser/states.spec.ts index 7370b64..e57c173 100644 --- a/test/browser/states.spec.ts +++ b/test/browser/states.spec.ts @@ -637,12 +637,19 @@ test.describe('lifecycle, from review', () => { await viewButton(page, 'Developer view').click(); // Mid-check, the old run's rows and checks must be gone rather than - // sitting under a verdict that no longer describes them. + // sitting under a verdict that no longer describes them. Counted as the + // check starts: with nothing to fetch, it can now finish within a tick. const during = await page.evaluate(async () => { const el = document.getElementById('vc') as HTMLElement & { registries?: unknown }; + const started = new Promise((resolve) => + el.addEventListener( + 'verification-started', + () => resolve(el.shadowRoot!.querySelectorAll('.check, .dev-check').length), + { once: true }, + ), + ); el.registries = []; - await new Promise((r) => setTimeout(r, 0)); - return el.shadowRoot!.querySelectorAll('.check, .dev-check').length; + return started; }); expect(during).toBe(0); diff --git a/test/consistency.test.ts b/test/consistency.test.ts index 479d11e..9d0bb1e 100644 --- a/test/consistency.test.ts +++ b/test/consistency.test.ts @@ -141,21 +141,22 @@ describe('the claim patterns themselves', () => { */ /** - * The validity dates are judged inside the signature check, so the signature - * dimension covers every way that one check can fail. Since verifier-core #58 - * and #59 each has its own problem type: tampering, expiry, a start date not - * yet reached, and a key that isn't the issuer's. The end date stays a - * dimension of its own, because the headline names it and must not invent an - * expiry the library didn't report. + * Every way the signature check can end. Since verifier-core #59 a key that + * isn't the issuer's has its own problem type, apart from tampering. */ -type Sig = 'passed' | 'tampered' | 'expired' | 'not_yet_valid' | 'key_mismatch' | 'unattributable' | 'missing'; +type Sig = 'passed' | 'tampered' | 'key_mismatch' | 'unattributable' | 'missing'; +/** + * The validity period, a check of its own since 2.0.0-beta.1: before, the + * dates were judged inside the signature check. The end date stays a + * dimension of its own too, because the headline names it and must not + * invent an expiry the library didn't report. + */ +type Val = 'in_date' | 'expired' | 'not_yet_valid' | 'halted' | 'missing'; /** * `unreadable`: a status type the library does not recognise, so it skips. * `+other`: the list loaded and was read, but the issuer didn't sign it — the - * status check's own result is untouched, and a second check reports it. - * verifier-core's main reports that only for a list that passed; after a mark - * the second check is "Not run". `revoked+other` and `suspended+other` are - * here for when it does, so the rules are ready rather than merely untested. + * status check's own result is untouched, and a second check reports it, + * after a mark as well as after a pass since 2.0.0-beta.1. */ type Rev = | 'passed' @@ -188,12 +189,13 @@ type Iss = type Sch = 'valid' | 'invalid' | 'no_schema' | 'unavailable' | 'missing' | 'halted'; /** * `halted`: skipped because an earlier check failed fatally. verifier-core - * stops after a fatal failure, and the signature check is fatal for expiry - * too, so after it none of these run. + * stops after a failed signature, so after it none of these run. Expiry and + * a withdrawal are fatal too, but don't stop anything. */ -const NOT_RUN = 'Not run: proof.signature failed'; +const NOT_RUN = 'Not run: cryptographic.proof.signature failed'; -const SIGNATURES: Sig[] = ['passed', 'tampered', 'expired', 'not_yet_valid', 'key_mismatch', 'unattributable', 'missing']; +const SIGNATURES: Sig[] = ['passed', 'tampered', 'key_mismatch', 'unattributable', 'missing']; +const VALIDITIES: Val[] = ['in_date', 'expired', 'not_yet_valid', 'halted', 'missing']; const REVOCATIONS: Rev[] = [ 'passed', 'revoked', @@ -221,8 +223,6 @@ const SCHEMAS: Sch[] = ['valid', 'invalid', 'no_schema', 'unavailable', 'missing const check = (id: string, outcome: CheckResult['outcome'], fatal = false): CheckResult => ({ id, - // The deprecated pair, carried so the shape matches what the library emits. - check: id.split('.').slice(-1)[0]!, suite: id.split('.').slice(1, 2)[0]!, outcome, fatal, @@ -248,61 +248,76 @@ const signatureCheck = (sig: Sig): CheckResult | undefined => { }, true, ); - case 'expired': + case 'key_mismatch': return check( CHECK.signature, { status: 'failure', problems: [ { - type: PROBLEM.credentialExpired, - title: 'Credential Expired', - detail: `The current date time (2026-09-23T00:00:00Z) is after "validUntil" (${PAST}).`, + type: PROBLEM.verificationMethod, + title: 'Verification Method Error', + detail: 'The credential issuer did:key:z6Mkn does not control the verification method.', }, ], }, true, ); - case 'not_yet_valid': + case 'unattributable': return check( CHECK.signature, { status: 'failure', problems: [ { - type: PROBLEM.credentialNotYetValid, - title: 'Credential Not Yet Valid', - detail: `The current date time (2026-09-23T00:00:00Z) is before "validFrom" (${FUTURE}).`, + type: PROBLEM.proofVerification, + title: 'No Applicable Crypto Service', + detail: 'No registered crypto service can verify this subject.', }, ], }, true, ); - case 'key_mismatch': + } +}; + +const validityCheck = (val: Val): CheckResult | undefined => { + switch (val) { + case 'missing': + return undefined; + case 'halted': + return check(CHECK.validityPeriod, { status: 'skipped', reason: NOT_RUN }, true); + case 'in_date': return check( - CHECK.signature, + CHECK.validityPeriod, + { status: 'success', message: 'The credential is within its validity period.' }, + true, + ); + case 'expired': + return check( + CHECK.validityPeriod, { status: 'failure', problems: [ { - type: PROBLEM.verificationMethod, - title: 'Verification Method Error', - detail: 'The credential issuer did:key:z6Mkn does not control the verification method.', + type: PROBLEM.credentialExpired, + title: 'Credential Expired', + detail: `The current date time (2026-09-23T00:00:00Z) is after "validUntil" (${PAST}).`, }, ], }, true, ); - case 'unattributable': + case 'not_yet_valid': return check( - CHECK.signature, + CHECK.validityPeriod, { status: 'failure', problems: [ { - type: PROBLEM.proofVerification, - title: 'No Applicable Crypto Service', - detail: 'No registered crypto service can verify this subject.', + type: PROBLEM.credentialNotYetValid, + title: 'Credential Not Yet Valid', + detail: `The current date time (2026-09-23T00:00:00Z) is before "validFrom" (${FUTURE}).`, }, ], }, @@ -370,6 +385,11 @@ const revocationCheck = (rev: Rev): CheckResult | undefined => { const registryCheck = (iss: Iss): CheckResult | undefined => { const unchecked = '1 registries could not be checked: Second Registry'; + // What the lookup found, as the library also gives it: as data. + const payload = (matched: boolean, unreached: boolean) => ({ + matches: matched ? [{ registry: { name: 'DCC Registry', type: 'dcc-legacy' } }] : [], + uncheckedRegistries: unreached ? [{ name: 'Second Registry', type: 'dcc-legacy' }] : [], + }); switch (iss) { case 'halted': return check(CHECK.registeredIssuer, { status: 'skipped', reason: NOT_RUN }); @@ -382,11 +402,13 @@ const registryCheck = (iss: Iss): CheckResult | undefined => { return check(CHECK.registeredIssuer, { status: 'success', message: 'Issuer found in registry: DCC Registry', + payload: payload(true, false), }); case 'matched+unreachable': return check(CHECK.registeredIssuer, { status: 'success', message: `Issuer found in registry: DCC Registry. ${unchecked}`, + payload: payload(true, true), }); case 'unlisted': return check(CHECK.registeredIssuer, { @@ -398,6 +420,7 @@ const registryCheck = (iss: Iss): CheckResult | undefined => { detail: 'Issuer did:key:z6Mkn was not found in any known DID registry.', }, ], + payload: payload(false, false), }); case 'unreachable': return check(CHECK.registeredIssuer, { @@ -410,6 +433,7 @@ const registryCheck = (iss: Iss): CheckResult | undefined => { }, { type: PROBLEM.registryUnchecked, title: 'Registry Unchecked', detail: unchecked }, ], + payload: payload(false, true), }); case 'none_reached': return check(CHECK.registeredIssuer, { @@ -421,6 +445,7 @@ const registryCheck = (iss: Iss): CheckResult | undefined => { detail: `Issuer registration could not be determined: ${unchecked}`, }, ], + payload: payload(false, true), }); case 'errored': return check(CHECK.registeredIssuer, { @@ -470,7 +495,7 @@ const schemaCheck = (sch: Sch): CheckResult | undefined => { } }; -const build = (sig: Sig, rev: Rev, end: End, iss: Iss, sch: Sch): VerificationResponse => { +const build = (sig: Sig, val: Val, rev: Rev, end: End, iss: Iss, sch: Sch): VerificationResponse => { const results = [ // The four core checks pass throughout: a failure among them is // "it stopped", which has its own describe block below. @@ -479,6 +504,7 @@ const build = (sig: Sig, rev: Rev, end: End, iss: Iss, sch: Sch): VerificationRe check(CHECK.credentialId, { status: 'success', message: 'ok' }, true), check(CHECK.proofExists, { status: 'success', message: 'ok' }, true), signatureCheck(sig), + validityCheck(val), revocationCheck(rev), rev.endsWith('+other') ? check(CHECK.statusListIssuer, { @@ -501,7 +527,7 @@ const build = (sig: Sig, rev: Rev, end: End, iss: Iss, sch: Sch): VerificationRe verifiableCredential: { issuer: { id: 'did:key:z6Mkn', name: 'Springfield College' }, validUntil: end === 'past' ? PAST : FUTURE, - validFrom: sig === 'not_yet_valid' ? FUTURE : '2025-01-09T10:00:00Z', + validFrom: val === 'not_yet_valid' ? FUTURE : '2025-01-09T10:00:00Z', // "none" means the issuer never set up a way to withdraw it at all. ...(rev === 'none' ? {} : { credentialStatus: { type: 'BitstringStatusListEntry' } }), }, @@ -518,43 +544,49 @@ const HOST_LIST: ('loaded' | 'unavailable')[] = ['loaded', 'unavailable']; /** * A check is only ever skipped as "not run" because one before it failed - * fatally: the signature for everything after it, the withdrawal check for - * the issuer lookup and the schema. A `halted` check with nothing failed - * ahead of it is a contradiction in the input, not a combination the rules - * owe an answer to, so it is left out — everything else, possible or not, - * stays in. + * fatally and halted: since 2.0.0-beta.1 that is the signature, for + * everything after it. Expiry and the withdrawal check are fatal but halt + * nothing. A `halted` check with no failed signature ahead of it is a + * contradiction in the input, not a combination the rules owe an answer to, + * so it is left out — everything else, possible or not, stays in. */ -const SIG_FATAL: Sig[] = ['tampered', 'expired', 'not_yet_valid', 'key_mismatch', 'unattributable']; -const REV_FATAL: Rev[] = ['revoked', 'suspended', 'revoked+other', 'suspended+other', 'list_error']; -const coherent = (sig: Sig, rev: Rev, iss: Iss, sch: Sch): boolean => { - const before = SIG_FATAL.includes(sig); - if (rev === 'halted' && !before) return false; - const beforeLater = before || REV_FATAL.includes(rev); - return (iss !== 'halted' && sch !== 'halted') || beforeLater; -}; +const SIG_FATAL: Sig[] = ['tampered', 'key_mismatch', 'unattributable']; +const coherent = (sig: Sig, val: Val, rev: Rev, iss: Iss, sch: Sch): boolean => + SIG_FATAL.includes(sig) || ![val, rev, iss, sch].includes('halted'); const cases = SIGNATURES.flatMap((sig) => - REVOCATIONS.flatMap((rev) => - ENDS.flatMap((end) => - ISSUERS.flatMap((iss) => - SCHEMAS.filter((sch) => coherent(sig, rev, iss, sch)).flatMap((sch) => - HOST_LIST.map((list) => ({ sig, rev, end, iss, sch, list, name: `${sig}/${rev}/${end}/${iss}/${sch}/${list}` })), + VALIDITIES.flatMap((val) => + REVOCATIONS.flatMap((rev) => + ENDS.flatMap((end) => + ISSUERS.flatMap((iss) => + SCHEMAS.filter((sch) => coherent(sig, val, rev, iss, sch)).flatMap((sch) => + HOST_LIST.map((list) => ({ + sig, + val, + rev, + end, + iss, + sch, + list, + name: `${sig}/${val}/${rev}/${end}/${iss}/${sch}/${list}`, + })), + ), ), ), ), ), ); -const DATES_ROW = `${CHECK.signature}#dates`; +const DATES_ROW = CHECK.validityPeriod; describe(`the headline and the breakdown agree (${cases.length} combinations)`, () => { - it.each(cases)('$name', ({ sig, rev, end, iss, sch, list }) => { - const r = build(sig, rev, end, iss, sch); + it.each(cases)('$name', ({ sig, val, rev, end, iss, sch, list, name }) => { + const r = build(sig, val, rev, end, iss, sch); const options = { registriesUnavailable: list === 'unavailable' }; const out = summarise(r, options); const rows = listChecks(r, options); const row = (id: string) => rows.find((c) => c.id === id); - const where = `${sig}/${rev}/${end}/${iss}/${sch}/${list} → ${out.code}`; + const where = `${name} → ${out.code}`; expect(rows.length, `${where}: verification ran, so there must be rows`).toBeGreaterThan(0); @@ -680,6 +712,7 @@ describe(`the headline and the breakdown agree (${cases.length} combinations)`, // when the seal failed, which talks about the seal instead (James, 1 // October 2026): that is the reason, and it outranks the lookup. const haltedRows = [ + val === 'halted' && DATES_ROW, rev === 'halted' && CHECK.status, iss === 'halted' && CHECK.registeredIssuer, sch === 'halted' && CHECK.schema, @@ -702,6 +735,7 @@ describe(`the headline and the breakdown agree (${cases.length} combinations)`, withdrawal_unknown: [CHECK.status, 'unchecked'], expired: [DATES_ROW, 'warning'], not_yet_valid: [DATES_ROW, 'warning'], + dates_unchecked: [DATES_ROW, 'unchecked'], key_mismatch: [CHECK.registeredIssuer, 'error'], malformed: [CHECK.schema, 'warning'], }; @@ -741,13 +775,13 @@ const NAMELESS: [string, unknown][] = [ describe(`an issuer with no name is never addressed by its identifier (${cases.length} combinations each)`, () => { it.each(NAMELESS)('%s', (_, issuer) => { - for (const { sig, rev, end, iss, sch, list } of cases) { - const r = build(sig, rev, end, iss, sch); + for (const { sig, val, rev, end, iss, sch, list, name } of cases) { + const r = build(sig, val, rev, end, iss, sch); r.verifiableCredential = { ...r.verifiableCredential, issuer }; const out = summarise(r, { registriesUnavailable: list === 'unavailable' }); // Two spaces in a row: an empty name dropped into a sentence. for (const sentence of [out.headline, out.detail, out.action ?? '']) { - expect(sentence, `${sig}/${rev}/${end}/${iss}/${sch}/${list} → ${out.code}`).not.toMatch(/did:|Unknown issuer|\s\s/); + expect(sentence, `${name} → ${out.code}`).not.toMatch(/did:|Unknown issuer|\s\s/); } } }); @@ -758,7 +792,7 @@ describe('a credential that is both withdrawn and expired', () => { // catch: expiry moved ahead of withdrawal, so the headline was a warning // about renewal while the breakdown reported an error. it('reports the withdrawal, because the issuer has already decided', () => { - const r = build('expired', 'revoked', 'past', 'matched', 'valid'); + const r = build('passed', 'expired', 'revoked', 'past', 'matched', 'valid'); const out = summarise(r); expect(out.code).toBe('withdrawn'); expect(out.severity).toBe('error'); diff --git a/test/developer.test.ts b/test/developer.test.ts index a6d6ced..042c01e 100644 --- a/test/developer.test.ts +++ b/test/developer.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest'; import { readFileSync } from 'node:fs'; import { verifyCredential } from '@digitalcredentials/verifier-core'; import { obv3p0Recognizer } from '@digitalcredentials/verifier-core/openbadges'; +import { openBadgesSchemaSuite } from '@digitalcredentials/verifier-core/openbadges/schema'; import { groupChecks, checkName, belongsTo } from '../src/developer.js'; import { CHECK } from '../src/types.js'; import type { CheckResult, SuiteSummary, VerificationResponse } from '../src/types.js'; @@ -12,9 +13,10 @@ import type { CheckResult, SuiteSummary, VerificationResponse } from '../src/typ * a change in how it names or groups its checks fails here rather than * quietly dropping a row. * - * No network: `registries: []` skips the lookup, and did:key resolves locally. - * The Open Badges schema suite is left out because it fetches the schema from - * purl.imsglobal.org; its doubled-"schema" id is covered by hand below. + * No network: `registries: []` skips the lookup, did:key resolves locally, and + * the Open Badges schema suite validates against schemas bundled with the + * library (2.0.0-beta.1; before, it fetched them from purl.imsglobal.org and + * was left out here). */ const fixture = (name: string): Record => @@ -24,8 +26,8 @@ const verify = async (name: string): Promise => (await verifyCredential({ credential: fixture(name), registries: [], - verbose: true, recognizers: [obv3p0Recognizer], + additionalSuites: [openBadgesSchemaSuite], })) as VerificationResponse; const ids = (r: VerificationResponse) => (r.results ?? []).map((c) => c.id); @@ -76,14 +78,12 @@ const suite = (id: string, message = 'm'): SuiteSummary => ({ phase: id.split('.')[0]!, suite: id.split('.').slice(1).join('.'), status: 'success', - verified: true, message, counts: { passed: 1, failed: 0, skipped: 0 }, }); -const check = (id: string | undefined, extra: Partial = {}): CheckResult => ({ +const check = (id: string, extra: Partial = {}): CheckResult => ({ id, - check: 'c', suite: 's', outcome: { status: 'success', message: 'ok' }, ...extra, @@ -153,8 +153,4 @@ describe('checkName', () => { expect(checkName(check('a.b'))).toBe('a.b'); expect(checkName(check('a.b'), 'c')).toBe('a.b'); }); - - it('falls back to suite and check for a result with no id', () => { - expect(checkName(check(undefined, { suite: 'core', check: 'proof' }))).toBe('core.proof'); - }); }); diff --git a/test/did-web-outages.test.ts b/test/did-web-outages.test.ts index a2952df..c931a49 100644 --- a/test/did-web-outages.test.ts +++ b/test/did-web-outages.test.ts @@ -1,16 +1,16 @@ import { describe, it, expect } from 'vitest'; import { readFileSync } from 'node:fs'; -import { BuiltinHttpGetService, verifyCredential } from '@digitalcredentials/verifier-core'; -import { namingDidWebOutages } from '../src/verify.js'; +import { verifyCredential } from '@digitalcredentials/verifier-core'; import { summarise } from '../src/outcomes.js'; import { CHECK, PROBLEM } from '../src/types.js'; import type { VerificationResponse } from '../src/types.js'; /** - * The stopgap for verifier-core#65: an issuer's website that won't serve its - * did.json reads as unreachable, not as tampering. + * An issuer's website that won't serve its did.json reads as unreachable, not + * as tampering. verify.ts carried a stopgap for this until verifier-core + * fixed it (#65, in 2.0.0-beta.1); these now run the library unaided. * - * The end-to-end half runs the real library over two credentials from + * They run the real library over two credentials from * vc-test-fixtures (main b3a0c3e, verifiableCredentials/v2/ed25519/didWeb): * did-web.json is oidf-noStatus-notExpired.json, and * did-web-unreachable.json is badDidWeb.json, whose issuer's did.json is @@ -23,57 +23,6 @@ const fixture = (name: string): Record => const answer = (status: number, body: unknown = {}) => ({ body, headers: new Headers(), status }); -const DID_JSON = 'https://issuer.example/dids/a/did.json'; - -describe('the wrapper', () => { - it('passes every other address through, whatever its status', async () => { - const wrapped = namingDidWebOutages({ get: async () => answer(404) }); - await expect(wrapped.get('https://issuer.example/status/1')).resolves.toMatchObject({ status: 404 }); - }); - - it("leaves another address's failure as it was", async () => { - const failure = new Error('down'); - const wrapped = namingDidWebOutages({ - get: async () => { - throw failure; - }, - }); - await expect(wrapped.get('https://issuer.example/status/1')).rejects.toBe(failure); - }); - - it('passes a did.json that loads through unchanged', async () => { - const loaded = answer(200, { id: 'did:web:issuer.example:dids:a' }); - const wrapped = namingDidWebOutages({ get: async () => loaded }); - await expect(wrapped.get(DID_JSON)).resolves.toBe(loaded); - }); - - it('turns a did.json that answers an error into an HTTPError naming it', async () => { - const wrapped = namingDidWebOutages({ get: async () => answer(404) }); - await expect(wrapped.get(DID_JSON)).rejects.toMatchObject({ name: 'HTTPError', requestUrl: DID_JSON }); - }); - - it("turns a did.json that doesn't answer into an HTTPError, keeping the original", async () => { - const failure = new Error(`Request to ${DID_JSON} failed`); - const wrapped = namingDidWebOutages({ - get: async () => { - throw failure; - }, - }); - await expect(wrapped.get(DID_JSON)).rejects.toMatchObject({ - name: 'HTTPError', - requestUrl: DID_JSON, - cause: failure, - }); - }); - // Decided, not overlooked: we didn't reach the key, and the fetcher's reasons - // are only sentences. See namingDidWebOutages. - it('turns a did.json the built-in fetcher refuses into an HTTPError too', async () => { - const local = 'https://localhost/.well-known/did.json'; - const wrapped = namingDidWebOutages(BuiltinHttpGetService()); - await expect(wrapped.get(local)).rejects.toMatchObject({ name: 'HTTPError', requestUrl: local }); - }); -}); - describe('through the real library', () => { /** Answers the issuer's did.json as `did` says; any other request fails the test. */ const verify = async ( @@ -83,13 +32,12 @@ describe('through the real library', () => { (await verifyCredential({ credential, registries: [], - verbose: true, - httpGetService: namingDidWebOutages({ + httpGetService: { get: async (url) => { if (url.endsWith('/did.json')) return did(url); throw new Error(`unexpected fetch in a test: ${url}`); }, - }), + }, })) as VerificationResponse; const signature = (response: VerificationResponse) => diff --git a/test/fixtures.test.ts b/test/fixtures.test.ts index 317ae2e..ecb1ee0 100644 --- a/test/fixtures.test.ts +++ b/test/fixtures.test.ts @@ -22,7 +22,6 @@ const verify = async (name: string): Promise => (await verifyCredential({ credential: fixture(name), registries: [], - verbose: true, recognizers: [obv3p0Recognizer], })) as VerificationResponse; diff --git a/test/outcomes.test.ts b/test/outcomes.test.ts index 9b8320d..f6adbcf 100644 --- a/test/outcomes.test.ts +++ b/test/outcomes.test.ts @@ -22,7 +22,6 @@ const withoutStatusList = { const pass = (id: string, message = 'ok', fatal = false): CheckResult => ({ id, - check: id.split('.').slice(-1)[0]!, suite: id.split('.').slice(1, 2)[0]!, outcome: { status: 'success', message }, fatal, @@ -30,7 +29,6 @@ const pass = (id: string, message = 'ok', fatal = false): CheckResult => ({ const fail = (id: string, problems: ProblemDetail[], fatal = false): CheckResult => ({ id, - check: id.split('.').slice(-1)[0]!, suite: id.split('.').slice(1, 2)[0]!, outcome: { status: 'failure', problems }, fatal, @@ -38,11 +36,33 @@ const fail = (id: string, problems: ProblemDetail[], fatal = false): CheckResult const skip = (id: string, reason: string): CheckResult => ({ id, - check: id.split('.').slice(-1)[0]!, suite: id.split('.').slice(1, 2)[0]!, outcome: { status: 'skipped', reason }, }); +/** + * The registry check as the library passes it: the names in its message, and + * again as data on its payload, which is the copy we read. + */ +const registered = (names: string[], unchecked: string[] = []): CheckResult => { + const message = `Issuer found in registry: ${names.join(', ')}`; + return { + id: CHECK.registeredIssuer, + suite: 'registry', + outcome: { + status: 'success', + message: unchecked.length + ? `${message}. ${unchecked.length} registries could not be checked: ${unchecked.join(', ')}` + : message, + payload: { + matches: names.map((name) => ({ registry: { name, type: 'dcc-legacy' } })), + uncheckedRegistries: unchecked.map((name) => ({ name, type: 'dcc-legacy' })), + }, + }, + fatal: false, + }; +}; + /** Replace one check in place, leaving the rest of the result alone. */ const set = (r: VerificationResponse, replacement: CheckResult): void => { r.results = (r.results ?? []).map((c) => (c.id === replacement.id ? replacement : c)); @@ -78,10 +98,9 @@ const tamperedSignature = (): CheckResult => ); /** - * An expired credential still fails the *signature* check — the dates are - * judged there — but since verifier-core #58 with its own problem type, - * carrying the library's date sentence as its detail. The end date is set too, - * because the headline names it. + * An expired credential fails the validity period check, which runs after the + * signature passed, with the library's date sentence as its detail. The end + * date is set too, because the headline names it. */ const PAST = '2026-01-09T10:00:00Z'; const expire = (r: VerificationResponse): void => { @@ -89,7 +108,7 @@ const expire = (r: VerificationResponse): void => { set( r, fail( - CHECK.signature, + CHECK.validityPeriod, [ { type: PROBLEM.credentialExpired, @@ -112,8 +131,9 @@ const ok = (): VerificationResponse => ({ pass(CHECK.credentialId, 'ok', true), pass(CHECK.proofExists, 'ok', true), pass(CHECK.signature, 'Signature verified.', true), + pass(CHECK.validityPeriod, 'The credential is within its validity period.', true), pass(CHECK.status, 'Credential status is valid (not revoked or suspended).'), - pass(CHECK.registeredIssuer, 'Issuer found in registry: DCC Registry'), + registered(['DCC Registry']), pass(CHECK.schema, 'Schema validation passed.'), ], }); @@ -475,7 +495,7 @@ describe('findings from review', () => { // The registry recognised the issuer but supplied no organisation name. // The top line must not say "Verified" while the details deny the match. const r = ok(); - set(r, pass(CHECK.registeredIssuer, 'Issuer found in registry: DCC Registry')); + set(r, registered(['DCC Registry'])); expect(issuerIdentity(r).source).toBe('registry'); expect(summarise(r).severity).toBe('success'); const issuerRow = listChecks(r).find((c) => c.id === CHECK.registeredIssuer); @@ -543,21 +563,7 @@ describe('what an expired credential says', () => { expire(r); // The installed library can't produce this — it checks dates only after // the seal — but the rule must not depend on that staying true. - set( - r, - fail( - CHECK.signature, - [ - { type: PROBLEM.invalidSignature, title: 'Invalid Signature', detail: 'Invalid signature.' }, - { - type: PROBLEM.credentialExpired, - title: 'Credential Expired', - detail: `The current date time is after "validUntil" (${PAST}).`, - }, - ], - true, - ), - ); + set(r, tamperedSignature()); expect(summarise(r).code).toBe('invalid_signature'); expect(listChecks(r).find((c) => c.id === CHECK.signature)).toMatchObject({ severity: 'error', @@ -1021,14 +1027,8 @@ describe('an unreachable registry when another one answered', () => { // issued this" above a row reading "found in DCC Registry". const matchedAndUnreachable = (): VerificationResponse => { const r = ok(); - // Both facts arrive in one sentence on the success message. - set( - r, - pass( - CHECK.registeredIssuer, - 'Issuer found in registry: DCC Registry. 1 registries could not be checked: Second Registry', - ), - ); + // Both facts arrive on the success outcome's payload. + set(r, registered(['DCC Registry'], ['Second Registry'])); return r; }; @@ -1272,8 +1272,9 @@ describe('findings from the review of the 2.x migration', () => { true, ), ); + set(r, skip(CHECK.validityPeriod, 'Not run: proof.signature failed')); expect(summarise(r).code).not.toBe('expired'); - expect(listChecks(r).find((c) => c.id === `${CHECK.signature}#dates`)?.severity).toBe( + expect(listChecks(r).find((c) => c.id === CHECK.validityPeriod)?.severity).toBe( 'unchecked', ); }); @@ -1340,12 +1341,14 @@ describe('findings from the review of the 2.x migration', () => { } }); - it('keeps a registry name that contains a period intact', () => { + it('keeps a registry name that contains a comma intact', () => { // No earner text shows the name any more, but the identity still carries - // it, and reading it out of the library's prose must not cut it short. + // it. Read out of the library's prose, which joins names with ", ", this + // came back as two registries. const r = ok(); - set(r, pass(CHECK.registeredIssuer, 'Issuer found in registry: registry.example.edu')); - expect(issuerIdentity(r).registries).toEqual(['registry.example.edu']); + set(r, registered(['Acme Registry, Inc.'], ['Beta, Ltd.'])); + expect(issuerIdentity(r).registries).toEqual(['Acme Registry, Inc.']); + expect(issuerIdentity(r).unreachable).toEqual(['Beta, Ltd.']); }); it('names no registry anywhere the earner reads', () => { @@ -1491,7 +1494,7 @@ describe('when the host could not get its list of registries', () => { }); // --------------------------------------------------------------------------- -// verifier-core main (d217b04): problems it now names by type +// Problems verifier-core names by type (since its main of 7 October 2026) // --------------------------------------------------------------------------- const revokedBy = (type: string, title: string): CheckResult => @@ -1548,10 +1551,9 @@ describe('a suspended credential', () => { }); describe('a withdrawal list signed by someone other than the issuer', () => { - // The first two and the last are shapes verifier-core's main can't produce - // yet: a mark ends its status suite before the signer is compared, so the - // mismatch arrives only for a list that passed. They pin the rules for when - // it does. The third is the case it reports today. + // A mismatch on a list that marked the credential arrives since + // 2.0.0-beta.1: before, a mark ended the status suite before the signer was + // compared, and only a list that passed reported it. it('is not repeated when it says "withdrawn"', () => { const r = ok(); set(r, revokedBy(PROBLEM.revoked, 'Credential Revoked')); @@ -1617,7 +1619,7 @@ describe('a credential not valid yet', () => { set( r, fail( - CHECK.signature, + CHECK.validityPeriod, [ { type: PROBLEM.credentialNotYetValid, @@ -1641,7 +1643,7 @@ describe('a credential not valid yet', () => { detail: "It hasn't been tampered with, and the issuer hasn't deactivated it.", action: 'Check it again on or after that date.', }); - expect(row(r, `${CHECK.signature}#dates`)).toMatchObject({ severity: 'warning', value: 'not valid until 1 January 2099' }); + expect(row(r, CHECK.validityPeriod)).toMatchObject({ severity: 'warning', value: 'not valid until 1 January 2099' }); expect(row(r, CHECK.signature)).toMatchObject({ severity: 'success', value: 'none detected' }); expect(issuerIdentity(r).sealHeld).toBe(true); }); @@ -1659,7 +1661,7 @@ describe('a credential not valid yet', () => { const out = summarise(r); expect(out.headline).toBe("This isn't valid yet"); expect(out.action).toBe('Check it again later.'); - expect(row(r, `${CHECK.signature}#dates`)?.value).toBe('not valid yet'); + expect(row(r, CHECK.validityPeriod)?.value).toBe('not valid yet'); }); }); @@ -1724,30 +1726,24 @@ describe('a seal made with a key that is not the issuer’s', () => { describe('checks verifier-core stopped before, after a fatal signature failure', () => { const NOT_RUN = 'Not run: proof.signature failed'; - /** What the library now returns for an expired credential: nothing after the seal. */ - const expiredAndHalted = (): VerificationResponse => { + /** What the library returns after a seal it couldn't check: nothing after it. */ + const unsealedAndHalted = (): VerificationResponse => { const r = ok(); - expire(r); - set(r, skip(CHECK.status, NOT_RUN)); - set(r, skip(CHECK.registeredIssuer, NOT_RUN)); - set(r, skip(CHECK.schema, NOT_RUN)); + set(r, fail(CHECK.signature, [{ type: PROBLEM.proofVerification, title: 'No Applicable Crypto Service', detail: 'x' }], true)); + for (const id of [CHECK.validityPeriod, CHECK.status, CHECK.registeredIssuer, CHECK.schema]) { + set(r, skip(id, NOT_RUN)); + } return r; }; it('reads "not checked" rather than inventing a failure', () => { - const rows = listChecks(expiredAndHalted()); + const rows = listChecks(unsealedAndHalted()); const value = (id: string) => rows.find((c) => c.id === id)?.value; - expect(value(CHECK.registeredIssuer)).toBe('Springfield College — not checked'); + expect(value(CHECK.validityPeriod)).toBe('not checked'); expect(value(CHECK.status)).toBe('not checked'); // Not "couldn't load the standard": nothing was tried. expect(value(CHECK.schema)).toBe('not checked'); - expect(schemaFinding(expiredAndHalted())).toEqual({ state: 'not_run' }); - }); - - it('still reports the expiry, and claims nothing about the withdrawal it never checked', () => { - const out = summarise(expiredAndHalted()); - expect(out.code).toBe('expired'); - expect(out.detail).toBe("It hasn't been tampered with."); + expect(schemaFinding(unsealedAndHalted())).toEqual({ state: 'not_run' }); }); it('marks a tampered credential’s name unconfirmed, though its issuer was never looked up', () => { @@ -1760,6 +1756,44 @@ describe('checks verifier-core stopped before, after a fatal signature failure', }); }); +describe('an expired credential, now that expiry stops nothing after it', () => { + // Until 2.0.0-beta.1 expiry halted every later check, so an expired + // credential said nothing of its withdrawal or its issuer. + it('still says it was deactivated, ahead of the expiry', () => { + const r = ok(); + expire(r); + set(r, revokedBy(PROBLEM.revoked, 'Credential Revoked')); + expect(summarise(r).code).toBe('withdrawn'); + expect(row(r, CHECK.status)).toMatchObject({ severity: 'error', value: 'deactivated by the issuer' }); + expect(row(r, CHECK.validityPeriod)).toMatchObject({ severity: 'warning', value: 'expired on 9 January 2026' }); + }); + + it('still says it was put on hold, ahead of the expiry', () => { + const r = ok(); + expire(r); + set(r, revokedBy(PROBLEM.suspended, 'Credential Suspended')); + expect(summarise(r).code).toBe('suspended'); + }); + + it('says the issuer has not deactivated it, now the list was read', () => { + const r = ok(); + expire(r); + expect(summarise(r)).toMatchObject({ + code: 'expired', + detail: "It hasn't been tampered with, and the issuer hasn't deactivated it.", + }); + }); + + it('names its issuer as known, now the lookup ran', () => { + const r = ok(); + expire(r); + expect(row(r, CHECK.registeredIssuer)).toMatchObject({ + severity: 'success', + value: 'Springfield College — a known issuer', + }); + }); +}); + describe('findings from the review of the verifier-core main adaptation', () => { const NOT_RUN = (id: string) => `Not run: ${id} failed`; @@ -1809,7 +1843,9 @@ describe('findings from the review of the verifier-core main adaptation', () => expect(summarise(r).action).toBe("Try again in a little while. If it still doesn't work, let the issuer know."); }); - it('says the issuer was not checked after the withdrawal check stopped everything', () => { + it('says the issuer was not checked if a check before the lookup stopped it', () => { + // The library's own fatal checks don't stop the lookup once the seal has + // held, but a halted lookup still reads as one, not as a finding. const r = ok(); set(r, revokedBy(PROBLEM.revoked, 'Credential Revoked')); set(r, skip(CHECK.registeredIssuer, NOT_RUN('status.bitstring'))); diff --git a/test/problem-types.test.ts b/test/problem-types.test.ts index 6fd87e0..7fa9972 100644 --- a/test/problem-types.test.ts +++ b/test/problem-types.test.ts @@ -11,7 +11,8 @@ import type { VerificationResponse } from '../src/types.js'; * Everything that decides what a person is told about the seal and the dates * now rests on the library's problem *type*: tampering, expiry, a start date * not yet reached, and a key that isn't the issuer's each have their own since - * verifier-core #58 and #59. Before that, expiry and tampering arrived as the + * verifier-core #58 and #59, and since 2.0.0-beta.1 the dates come from a + * check of their own, which runs once the seal has held. Before that, expiry and tampering arrived as the * same INVALID_SIGNATURE, and this file pinned the sentences we told them * apart by. Those are gone; this pins the types instead, against the real * library and the real fixtures, so a change upstream fails here rather than @@ -26,25 +27,36 @@ const fixture = (name: string): Record => JSON.parse(readFileSync(new URL(`../dev/fixtures/${name}.json`, import.meta.url), 'utf8')); const verify = async (credential: Record): Promise => - (await verifyCredential({ credential, registries: [], verbose: true })) as VerificationResponse; + (await verifyCredential({ credential, registries: [] })) as VerificationResponse; -const signatureTypes = (r: VerificationResponse): string[] => { - const outcome = (r.results ?? []).find((c) => c.id === CHECK.signature)?.outcome; +const problemTypes = (r: VerificationResponse, id: string): string[] => { + const outcome = (r.results ?? []).find((c) => c.id === id)?.outcome; return outcome?.status === 'failure' ? outcome.problems.map((p) => p.type) : []; }; +const status = (r: VerificationResponse, id: string) => (r.results ?? []).find((c) => c.id === id)?.outcome.status; + describe('what verifier-core reports, by type', () => { it.each([ - ['expired', PROBLEM.credentialExpired], - ['not-yet-valid', PROBLEM.credentialNotYetValid], ['tampered', PROBLEM.invalidSignature], ['not-their-seal', PROBLEM.verificationMethod], ])('fails the signature check on %s with its own type', async (name, type) => { - expect(signatureTypes(await verify(fixture(name)))).toEqual([type]); + expect(problemTypes(await verify(fixture(name)), CHECK.signature)).toEqual([type]); + }); + + it.each([ + ['expired', PROBLEM.credentialExpired], + ['not-yet-valid', PROBLEM.credentialNotYetValid], + ])('passes the signature on %s, and fails the dates with their own type', async (name, type) => { + const r = await verify(fixture(name)); + expect(status(r, CHECK.signature)).toBe('success'); + expect(problemTypes(r, CHECK.validityPeriod)).toEqual([type]); }); - it('passes the signature check on a credential with nothing wrong', async () => { - expect(signatureTypes(await verify(fixture('verified')))).toEqual([]); + it('passes the signature and the dates on a credential with nothing wrong', async () => { + const r = await verify(fixture('verified')); + expect(status(r, CHECK.signature)).toBe('success'); + expect(status(r, CHECK.validityPeriod)).toBe('success'); }); }); @@ -55,7 +67,7 @@ describe('what a person is told, end to end', () => { expect(out).toMatchObject({ code: 'expired', severity: 'warning', headline: 'Expired on 9 January 2026' }); expect(out.action).toContain('renewed'); const rows = listChecks(r); - expect(rows.find((c) => c.id === `${CHECK.signature}#dates`)).toMatchObject({ severity: 'warning', value: 'expired on 9 January 2026' }); + expect(rows.find((c) => c.id === CHECK.validityPeriod)).toMatchObject({ severity: 'warning', value: 'expired on 9 January 2026' }); expect(rows.find((c) => c.id === CHECK.signature)).toMatchObject({ severity: 'success', value: 'none detected' }); }); @@ -88,7 +100,9 @@ describe('what a person is told, end to end', () => { const altered = fixture('expired'); (altered['credentialSubject'] as Record)['name'] = 'Someone Else'; const r = await verify(altered); - expect(signatureTypes(r)).toEqual([PROBLEM.invalidSignature]); + expect(problemTypes(r, CHECK.signature)).toEqual([PROBLEM.invalidSignature]); + // The dates are never reached: a failed seal stops everything after it. + expect(status(r, CHECK.validityPeriod)).toBe('skipped'); expect(summarise(r).code).toBe('invalid_signature'); expect(listChecks(r).find((c) => c.id === CHECK.signature)?.value).toBe('detected'); }); From b3bee674bb2e2f9cb7172b9a4ab33f3fed521d67 Mon Sep 17 00:00:00 2001 From: Sunny Lee <1424627+threeqube@users.noreply.github.com> Date: Wed, 21 Oct 2026 10:09:54 -0700 Subject: [PATCH 02/17] Drop the Open Badges schema copy the tests served verifier-core fetched the Open Badges schema from purl.imsglobal.org on every verification until 2.0.0-beta.1, so the browser tests served a copy, and the cross-site tests' credentials named one on the second server. It now validates against schemas bundled with it and fetches nothing. Removed, as support.ts said to once that happened: the copy and its route, the second server's copy, FIXTURES_SCHEMA_URL in make-fixtures.js and its empty override in build-site.js. The watch on purl.imsglobal.org stays, now with no exception: any request there fails the test. Co-Authored-By: Claude Opus 5.5 --- scripts/build-site.js | 3 - scripts/make-fixtures.js | 16 +- .../ob_v3p0_achievementcredential_schema.json | 2877 ----------------- test/browser/pages-like-server.js | 12 +- test/browser/states.spec.ts | 25 +- test/browser/support.ts | 37 +- 6 files changed, 21 insertions(+), 2949 deletions(-) delete mode 100644 test/browser/fixtures/ob_v3p0_achievementcredential_schema.json diff --git a/scripts/build-site.js b/scripts/build-site.js index b0c8d54..d554555 100644 --- a/scripts/build-site.js +++ b/scripts/build-site.js @@ -38,9 +38,6 @@ run('npx', ['vite', 'build', '--mode', 'site'], { SITE_BASE: url.pathname }); run('node', ['scripts/make-fixtures.js'], { FIXTURES_OUT: join(SITE, 'fixtures'), FIXTURES_STATUS_LIST_URL: new URL('fixtures/status-list.json', url).href, - // Empty, whatever the shell has: the published credentials name no schema, - // like the demo's own. - FIXTURES_SCHEMA_URL: '', }); const commit = process.env.GITHUB_SHA ?? execFileSync('git', ['rev-parse', 'HEAD'], { cwd: ROOT }).toString().trim(); diff --git a/scripts/make-fixtures.js b/scripts/make-fixtures.js index 2015b5f..7083183 100644 --- a/scripts/make-fixtures.js +++ b/scripts/make-fixtures.js @@ -14,8 +14,6 @@ * * FIXTURES_STATUS_LIST_URL where the withdrawal list will be served * FIXTURES_OUT the folder to write into - * FIXTURES_SCHEMA_URL a schema for the credentials to name; unset or - * empty, they name none (see below) */ import { writeFile, mkdir } from 'node:fs/promises'; @@ -43,12 +41,6 @@ const OUT = */ const STATUS_LIST_URL = process.env.FIXTURES_STATUS_LIST_URL ?? 'http://localhost:5180/fixtures/status-list.json'; -/** - * Unset, the credentials name no schema, and verifier-core falls back to the - * Open Badges schema on purl.imsglobal.org. The cross-site browser tests set - * it to a copy on their own server: see test/browser/pages-like-server.js. - */ -const SCHEMA_URL = process.env.FIXTURES_SCHEMA_URL; const WITHDRAWN_INDEX = 42; const LIST_LENGTH = 131072; @@ -96,10 +88,6 @@ const baseCredential = (issuerDid, overrides = {}) => ({ criteria: { narrative: 'Completed all coursework and the final assessment.' }, }, }, - // `JsonSchema`, the VC 2.0 context's own type. The Open Badges one, - // 1EdTechJsonSchemaValidator2019, signs but then fails the signature check - // ("Safe mode validation error") under the VC 2.0 context. - ...(SCHEMA_URL && { credentialSchema: [{ id: SCHEMA_URL, type: 'JsonSchema' }] }), ...overrides.credential, }); @@ -206,8 +194,8 @@ const main = async () => { // required — the same omission in Nate Otto's 22 September screenshot. // Nothing here is fraudulent: the signature is real, the dates are fine, // and verifier-core still reports the credential as verified, because the - // schema result is filed under `additionalInformation` and never reaches - // `verified`. It is the issuer's mistake and only the issuer's to fix. + // schema check is not fatal and never reaches `verified`. It is the + // issuer's mistake and only the issuer's to fix. await write( 'malformed', await sign( diff --git a/test/browser/fixtures/ob_v3p0_achievementcredential_schema.json b/test/browser/fixtures/ob_v3p0_achievementcredential_schema.json deleted file mode 100644 index edb3370..0000000 --- a/test/browser/fixtures/ob_v3p0_achievementcredential_schema.json +++ /dev/null @@ -1,2877 +0,0 @@ -{ - "$schema" : "https://json-schema.org/draft/2019-09/schema#", - "$id" : "https://purl.imsglobal.org/spec/ob/v3p0/schema/json/ob_v3p0_achievementcredential_schema.json", - "title" : "JSON-LD Schema adaptation for the AchievementCredential class, enabling JSON-LD value compaction features to the formal JSON Schema (https://www.w3.org/TR/json-ld11/#representing-singular-values-as-arrays). \nNOTE: Only use this schema when working with JSON-LD payloads.\nPlease refer to https://purl.imsglobal.org/spec/ob/v3p0/schema/json/ob_v3p0_achievementcredential_schema.json for the formal JSON Schema definition.", - "description" : "AchievementCredentials are representations of an awarded achievement, used to share information about a achievement belonging to one earner. Maps to a Verifiable Credential as defined in the [[VC-DATA-MODEL-2.0]]. As described in [[[#data-integrity]]], at least one proof mechanism, and the details necessary to evaluate that proof, MUST be expressed for a credential to be a verifiable credential. In the case of an embedded proof, the credential MUST append the proof in the `proof` property.", - "type" : "object", - "properties" : { - "@context" : { - "oneOf" : [ { - "$ref" : "#/$defs/Context" - }, { - "type" : "array", - "minItems" : 2, - "items" : [ { - "enum" : [ "https://www.w3.org/ns/credentials/v2" ] - }, { - "type" : "string", - "pattern" : "^https:\\/\\/purl\\.imsglobal\\.org\\/spec\\/ob\\/v3p0\\/context(-3\\.\\d\\.\\d)*\\.json$" - } ], - "additionalItems" : { - "$ref" : "#/$defs/Context" - } - } ] - }, - "id" : { - "description" : "Unambiguous reference to the credential.", - "$comment" : "Origin: URI (DerivedType); A `NormalizedString` that respresents a Uniform Resource Identifier (URI).", - "oneOf" : [ { - "type" : "string" - }, { - "type" : "object", - "additionalProperties" : { - "type" : "string" - }, - "propertyNames" : { - "pattern" : "^[a-z]{2}(-[A-Z]{2})?$" - } - } ] - }, - "type" : { - "oneOf" : [ { - "description" : "The value of the type property MUST be an unordered set. One of the items MUST be the URI 'VerifiableCredential', and one of the items MUST be the URI 'AchievementCredential' or the URI 'OpenBadgeCredential'.", - "$comment" : "Origin: IRI (DerivedType); A `NormalizedString` that represents an Internationalized Resource Identifier (IRI), which extends the ASCII characters subset of the Uniform Resource Identifier (URI).", - "oneOf" : [ { - "type" : "string" - }, { - "type" : "object", - "additionalProperties" : { - "type" : "string" - }, - "propertyNames" : { - "pattern" : "^[a-z]{2}(-[A-Z]{2})?$" - } - } ] - }, { - "allOf" : [ { - "type" : "array", - "minItems" : 1, - "contains" : { - "enum" : [ "VerifiableCredential" ] - }, - "items" : { - "description" : "The value of the type property MUST be an unordered set. One of the items MUST be the URI 'VerifiableCredential', and one of the items MUST be the URI 'AchievementCredential' or the URI 'OpenBadgeCredential'.", - "$comment" : "Origin: IRI (DerivedType); A `NormalizedString` that represents an Internationalized Resource Identifier (IRI), which extends the ASCII characters subset of the Uniform Resource Identifier (URI).", - "oneOf" : [ { - "type" : "string" - }, { - "type" : "object", - "additionalProperties" : { - "type" : "string" - }, - "propertyNames" : { - "pattern" : "^[a-z]{2}(-[A-Z]{2})?$" - } - } ] - } - }, { - "type" : "array", - "minItems" : 1, - "contains" : { - "enum" : [ "AchievementCredential", "OpenBadgeCredential" ] - }, - "items" : { - "description" : "The value of the type property MUST be an unordered set. One of the items MUST be the URI 'VerifiableCredential', and one of the items MUST be the URI 'AchievementCredential' or the URI 'OpenBadgeCredential'.", - "$comment" : "Origin: IRI (DerivedType); A `NormalizedString` that represents an Internationalized Resource Identifier (IRI), which extends the ASCII characters subset of the Uniform Resource Identifier (URI).", - "oneOf" : [ { - "type" : "string" - }, { - "type" : "object", - "additionalProperties" : { - "type" : "string" - }, - "propertyNames" : { - "pattern" : "^[a-z]{2}(-[A-Z]{2})?$" - } - } ] - } - } ] - } ] - }, - "name" : { - "description" : "The name of the credential for display purposes in wallets. For example, in a list of credentials and in detail views.", - "$comment" : "Origin: String (PrimitiveType); Character strings.", - "oneOf" : [ { - "type" : "string" - }, { - "type" : "object", - "additionalProperties" : { - "type" : "string" - }, - "propertyNames" : { - "pattern" : "^[a-z]{2}(-[A-Z]{2})?$" - } - } ] - }, - "description" : { - "description" : "The short description of the credential for display purposes in wallets.", - "$comment" : "Origin: String (PrimitiveType); Character strings.", - "oneOf" : [ { - "type" : "string" - }, { - "type" : "object", - "additionalProperties" : { - "type" : "string" - }, - "propertyNames" : { - "pattern" : "^[a-z]{2}(-[A-Z]{2})?$" - } - } ] - }, - "image" : { - "$ref" : "#/$defs/Image" - }, - "awardedDate" : { - "description" : "Timestamp of when the credential was awarded. `validFrom` is used to determine the most recent version of a Credential in conjunction with `issuer` and `id`. Consequently, the only way to update a Credental is to update the `validFrom`, losing the date when the Credential was originally awarded. `awardedDate` is meant to keep this original date.", - "$comment" : "Origin: DateTimeZ (DerivedType); A `DateTime` with the trailing timezone specifier included, e.g. `2021-09-07T02:09:59+02:00`", - "type" : "string", - "format" : "date-time" - }, - "credentialSubject" : { - "$ref" : "#/$defs/AchievementSubject" - }, - "endorsement" : { - "oneOf" : [ { - "$ref" : "#/$defs/EndorsementCredential" - }, { - "type" : "array", - "items" : { - "$ref" : "#/$defs/EndorsementCredential" - } - } ] - }, - "endorsementJwt" : { - "oneOf" : [ { - "description" : "Allows endorsers to make specific claims about the credential, and the achievement and profiles in the credential. These endorsements are signed with the VC-JWT proof format.", - "$comment" : "Origin: CompactJws (DerivedType); A `String` in Compact JWS format [[RFC7515]].", - "oneOf" : [ { - "type" : "string", - "pattern" : "^[a-zA-Z0-9_-]+\\.[a-zA-Z0-9_-]*\\.[a-zA-Z0-9_-]+$" - }, { - "type" : "object", - "additionalProperties" : { - "type" : "string", - "pattern" : "^[a-zA-Z0-9_-]+\\.[a-zA-Z0-9_-]*\\.[a-zA-Z0-9_-]+$" - }, - "propertyNames" : { - "pattern" : "^[a-z]{2}(-[A-Z]{2})?$" - } - } ] - }, { - "type" : "array", - "items" : { - "description" : "Allows endorsers to make specific claims about the credential, and the achievement and profiles in the credential. These endorsements are signed with the VC-JWT proof format.", - "$comment" : "Origin: CompactJws (DerivedType); A `String` in Compact JWS format [[RFC7515]].", - "oneOf" : [ { - "type" : "string", - "pattern" : "^[a-zA-Z0-9_-]+\\.[a-zA-Z0-9_-]*\\.[a-zA-Z0-9_-]+$" - }, { - "type" : "object", - "additionalProperties" : { - "type" : "string", - "pattern" : "^[a-zA-Z0-9_-]+\\.[a-zA-Z0-9_-]*\\.[a-zA-Z0-9_-]+$" - }, - "propertyNames" : { - "pattern" : "^[a-z]{2}(-[A-Z]{2})?$" - } - } ] - } - } ] - }, - "issuer" : { - "$ref" : "#/$defs/ProfileRef" - }, - "validFrom" : { - "description" : "Timestamp of when the credential becomes valid.", - "$comment" : "Origin: DateTimeZ (DerivedType); A `DateTime` with the trailing timezone specifier included, e.g. `2021-09-07T02:09:59+02:00`", - "type" : "string", - "format" : "date-time" - }, - "validUntil" : { - "description" : "If the credential has some notion of validity period, this indicates a timestamp when a credential should no longer be considered valid. After this time, the credential should be considered invalid.", - "$comment" : "Origin: DateTimeZ (DerivedType); A `DateTime` with the trailing timezone specifier included, e.g. `2021-09-07T02:09:59+02:00`", - "type" : "string", - "format" : "date-time" - }, - "proof" : { - "oneOf" : [ { - "$ref" : "#/$defs/Proof" - }, { - "type" : "array", - "items" : { - "$ref" : "#/$defs/Proof" - } - } ] - }, - "credentialSchema" : { - "oneOf" : [ { - "$ref" : "#/$defs/CredentialSchema" - }, { - "type" : "array", - "items" : { - "$ref" : "#/$defs/CredentialSchema" - } - } ] - }, - "credentialStatus" : { - "$ref" : "#/$defs/CredentialStatus" - }, - "refreshService" : { - "$ref" : "#/$defs/RefreshService" - }, - "termsOfUse" : { - "oneOf" : [ { - "$ref" : "#/$defs/TermsOfUse" - }, { - "type" : "array", - "items" : { - "$ref" : "#/$defs/TermsOfUse" - } - } ] - }, - "evidence" : { - "oneOf" : [ { - "$ref" : "#/$defs/Evidence" - }, { - "type" : "array", - "items" : { - "$ref" : "#/$defs/Evidence" - } - } ] - } - }, - "required" : [ "@context", "id", "type", "credentialSubject", "issuer", "validFrom" ], - "additionalProperties" : true, - "$defs" : { - "Context" : { - "description" : "JSON-LD Context. Either a URI with the context definition or a Map with a local context definition MUST be supplied.", - "anyOf" : [ { - "description" : "A `NormalizedString` that respresents a Uniform Resource Identifier (URI).", - "$comment" : "Origin: URI (DerivedType); A `NormalizedString` that respresents a Uniform Resource Identifier (URI).", - "oneOf" : [ { - "type" : "string" - }, { - "type" : "object", - "additionalProperties" : { - "type" : "string" - }, - "propertyNames" : { - "pattern" : "^[a-z]{2}(-[A-Z]{2})?$" - } - } ] - }, { - "description" : "A map representing an object with unknown, arbitrary properties", - "$comment" : "Origin: Map (PrimitiveType); A map representing an object with unknown, arbitrary properties", - "type" : "object" - } ] - }, - "TermsOfUse" : { - "description" : "Terms of use can be utilized by an issuer or a holder to communicate the terms under which a verifiable credential or verifiable presentation was issued", - "type" : "object", - "properties" : { - "id" : { - "description" : "The value MUST be a URI identifying the term of use.", - "$comment" : "Origin: URI (DerivedType); A `NormalizedString` that respresents a Uniform Resource Identifier (URI).", - "oneOf" : [ { - "type" : "string" - }, { - "type" : "object", - "additionalProperties" : { - "type" : "string" - }, - "propertyNames" : { - "pattern" : "^[a-z]{2}(-[A-Z]{2})?$" - } - } ] - }, - "type" : { - "description" : "The value MUST identify the type of the terms of use.", - "$comment" : "Origin: IRI (DerivedType); A `NormalizedString` that represents an Internationalized Resource Identifier (IRI), which extends the ASCII characters subset of the Uniform Resource Identifier (URI).", - "oneOf" : [ { - "type" : "string" - }, { - "type" : "object", - "additionalProperties" : { - "type" : "string" - }, - "propertyNames" : { - "pattern" : "^[a-z]{2}(-[A-Z]{2})?$" - } - } ] - } - }, - "required" : [ "type" ], - "additionalProperties" : true - }, - "CredentialStatus" : { - "description" : "The information in CredentialStatus is used to discover information about the current status of a verifiable credential, such as whether it is suspended or revoked.", - "type" : "object", - "properties" : { - "id" : { - "description" : "The value MUST be the URL of the issuer's credential status method.", - "$comment" : "Origin: URI (DerivedType); A `NormalizedString` that respresents a Uniform Resource Identifier (URI).", - "oneOf" : [ { - "type" : "string" - }, { - "type" : "object", - "additionalProperties" : { - "type" : "string" - }, - "propertyNames" : { - "pattern" : "^[a-z]{2}(-[A-Z]{2})?$" - } - } ] - }, - "type" : { - "description" : "The name of the credential status method.", - "$comment" : "Origin: IRI (DerivedType); A `NormalizedString` that represents an Internationalized Resource Identifier (IRI), which extends the ASCII characters subset of the Uniform Resource Identifier (URI).", - "oneOf" : [ { - "type" : "string" - }, { - "type" : "object", - "additionalProperties" : { - "type" : "string" - }, - "propertyNames" : { - "pattern" : "^[a-z]{2}(-[A-Z]{2})?$" - } - } ] - } - }, - "required" : [ "type" ], - "additionalProperties" : true - }, - "AchievementSubject" : { - "description" : "A collection of information about the recipient of an achievement. Maps to Credential Subject in [[VC-DATA-MODEL-2.0]].", - "type" : "object", - "properties" : { - "id" : { - "description" : "An identifier for the Credential Subject. Either `id` or at least one `identifier` MUST be supplied.", - "$comment" : "Origin: URI (DerivedType); A `NormalizedString` that respresents a Uniform Resource Identifier (URI).", - "oneOf" : [ { - "type" : "string" - }, { - "type" : "object", - "additionalProperties" : { - "type" : "string" - }, - "propertyNames" : { - "pattern" : "^[a-z]{2}(-[A-Z]{2})?$" - } - } ] - }, - "type" : { - "oneOf" : [ { - "description" : "The value of the type property MUST be an unordered set. One of the items MUST be the IRI 'AchievementSubject'.", - "$comment" : "Origin: IRI (DerivedType); A `NormalizedString` that represents an Internationalized Resource Identifier (IRI), which extends the ASCII characters subset of the Uniform Resource Identifier (URI).", - "oneOf" : [ { - "type" : "string" - }, { - "type" : "object", - "additionalProperties" : { - "type" : "string" - }, - "propertyNames" : { - "pattern" : "^[a-z]{2}(-[A-Z]{2})?$" - } - } ] - }, { - "type" : "array", - "minItems" : 1, - "contains" : { - "enum" : [ "AchievementSubject" ] - }, - "items" : { - "description" : "The value of the type property MUST be an unordered set. One of the items MUST be the IRI 'AchievementSubject'.", - "$comment" : "Origin: IRI (DerivedType); A `NormalizedString` that represents an Internationalized Resource Identifier (IRI), which extends the ASCII characters subset of the Uniform Resource Identifier (URI).", - "oneOf" : [ { - "type" : "string" - }, { - "type" : "object", - "additionalProperties" : { - "type" : "string" - }, - "propertyNames" : { - "pattern" : "^[a-z]{2}(-[A-Z]{2})?$" - } - } ] - } - } ] - }, - "activityEndDate" : { - "description" : "The datetime the activity ended.", - "$comment" : "Origin: DateTime (PrimitiveType); An [[ISO8601]] time using the syntax YYYY-MM-DDThh:mm:ss.", - "type" : "string", - "format" : "date-time" - }, - "activityStartDate" : { - "description" : "The datetime the activity started.", - "$comment" : "Origin: DateTime (PrimitiveType); An [[ISO8601]] time using the syntax YYYY-MM-DDThh:mm:ss.", - "type" : "string", - "format" : "date-time" - }, - "creditsEarned" : { - "description" : "The number of credits earned, generally in semester or quarter credit hours. This field correlates with the Achievement `creditsAvailable` field.", - "$comment" : "Origin: Float (PrimitiveType)", - "type" : "number" - }, - "achievement" : { - "$ref" : "#/$defs/Achievement" - }, - "identifier" : { - "oneOf" : [ { - "$ref" : "#/$defs/IdentityObject" - }, { - "type" : "array", - "items" : { - "$ref" : "#/$defs/IdentityObject" - } - } ] - }, - "image" : { - "$ref" : "#/$defs/Image" - }, - "licenseNumber" : { - "description" : "The license number that was issued with this credential.", - "$comment" : "Origin: String (PrimitiveType); Character strings.", - "oneOf" : [ { - "type" : "string" - }, { - "type" : "object", - "additionalProperties" : { - "type" : "string" - }, - "propertyNames" : { - "pattern" : "^[a-z]{2}(-[A-Z]{2})?$" - } - } ] - }, - "narrative" : { - "description" : "A narrative that connects multiple pieces of evidence. Likely only present at this location if evidence is a multi-value array.", - "$comment" : "Origin: Markdown (DerivedType); A `String` that may contain Markdown.", - "oneOf" : [ { - "type" : "string" - }, { - "type" : "object", - "additionalProperties" : { - "type" : "string" - }, - "propertyNames" : { - "pattern" : "^[a-z]{2}(-[A-Z]{2})?$" - } - } ] - }, - "result" : { - "oneOf" : [ { - "$ref" : "#/$defs/Result" - }, { - "type" : "array", - "items" : { - "$ref" : "#/$defs/Result" - } - } ] - }, - "role" : { - "description" : "Role, position, or title of the learner when demonstrating or performing the achievement or evidence of learning being asserted. Examples include 'Student President', 'Intern', 'Captain', etc.", - "$comment" : "Origin: String (PrimitiveType); Character strings.", - "oneOf" : [ { - "type" : "string" - }, { - "type" : "object", - "additionalProperties" : { - "type" : "string" - }, - "propertyNames" : { - "pattern" : "^[a-z]{2}(-[A-Z]{2})?$" - } - } ] - }, - "source" : { - "$ref" : "#/$defs/Profile" - }, - "term" : { - "description" : "The academic term in which this assertion was achieved.", - "$comment" : "Origin: String (PrimitiveType); Character strings.", - "oneOf" : [ { - "type" : "string" - }, { - "type" : "object", - "additionalProperties" : { - "type" : "string" - }, - "propertyNames" : { - "pattern" : "^[a-z]{2}(-[A-Z]{2})?$" - } - } ] - } - }, - "required" : [ "type", "achievement" ], - "additionalProperties" : true - }, - "IdentityObject" : { - "description" : "A collection of information about the recipient of an achievement.", - "type" : "object", - "properties" : { - "type" : { - "description" : "MUST be the IRI 'IdentityObject'.", - "$comment" : "Origin: IRI (DerivedType); A `NormalizedString` that represents an Internationalized Resource Identifier (IRI), which extends the ASCII characters subset of the Uniform Resource Identifier (URI).", - "oneOf" : [ { - "type" : "string", - "enum" : [ "IdentityObject" ] - }, { - "type" : "object", - "additionalProperties" : { - "type" : "string", - "enum" : [ "IdentityObject" ] - }, - "propertyNames" : { - "pattern" : "^[a-z]{2}(-[A-Z]{2})?$" - } - } ] - }, - "hashed" : { - "description" : "Whether or not the `identityHash` value is hashed.", - "$comment" : "Origin: Boolean (PrimitiveType); A boolean, expressed as `true` or `false`", - "type" : "boolean" - }, - "identityHash" : { - "description" : "Either the IdentityHash of the identity or the plaintext value. If it's possible that the plaintext transmission and storage of the identity value would leak personally identifiable information where there is an expectation of privacy, it is strongly recommended that an IdentityHash be used.", - "$comment" : "Origin: IdentityHash (DerivedType); A `String` consisting of an algorithm identifier, a `$` separator, and a hash across an identifier and an optionally appended salt string. The only supported algorithms are MD5 [[RFC1321]] and SHA-256 [[FIPS-180-4]], identified by the strings 'md5' and 'sha256' respectively. Identifiers and salts MUST be encoded in UTF-8 prior to hashing, and the resulting hash MUST be expressed in hexadecimal using uppercase (A-F, 0-9) or lowercase character (a-f, 0-9) sets. For example: 'sha256$b5809d8a92f8858436d7e6b87c12ebc0ae1eac4baecc2c0b913aee2c922ef399' represents the result of calculating a SHA-256 hash on the string 'a@example.comKosher'. in which the email identifier 'a@example.com' is salted with 'Kosher'", - "oneOf" : [ { - "type" : "string" - }, { - "type" : "object", - "additionalProperties" : { - "type" : "string" - }, - "propertyNames" : { - "pattern" : "^[a-z]{2}(-[A-Z]{2})?$" - } - } ] - }, - "identityType" : { - "description" : "The identity type.", - "$comment" : "Origin: IdentifierTypeEnum (EnumExt)", - "oneOf" : [ { - "type" : "string", - "enum" : [ "name", "sourcedId", "systemId", "productId", "userName", "accountId", "emailAddress", "nationalIdentityNumber", "isbn", "issn", "lisSourcedId", "oneRosterSourcedId", "sisSourcedId", "ltiContextId", "ltiDeploymentId", "ltiToolId", "ltiPlatformId", "ltiUserId", "identifier" ] - }, { - "type" : "string", - "pattern" : "(ext:)[a-z|A-Z|0-9|.|-|_]+" - } ] - }, - "salt" : { - "description" : "If the `identityHash` is hashed, this should contain the string used to salt the hash. If this value is not provided, it should be assumed that the hash was not salted.", - "$comment" : "Origin: String (PrimitiveType); Character strings.", - "oneOf" : [ { - "type" : "string" - }, { - "type" : "object", - "additionalProperties" : { - "type" : "string" - }, - "propertyNames" : { - "pattern" : "^[a-z]{2}(-[A-Z]{2})?$" - } - } ] - } - }, - "required" : [ "type", "hashed", "identityHash", "identityType" ], - "additionalProperties" : false - }, - "Evidence" : { - "description" : "Descriptive metadata about evidence related to the achievement assertion. Each instance of the evidence class present in an assertion corresponds to one entity, though a single entry can describe a set of items collectively. There may be multiple evidence entries referenced from an assertion. The narrative property is also in scope of the assertion class to provide an overall description of the achievement related to the assertion in rich text. It is used here to provide a narrative of achievement of the specific entity described. If both the description and narrative properties are present, displayers can assume the narrative value goes into more detail and is not simply a recapitulation of description.", - "type" : "object", - "properties" : { - "id" : { - "description" : "The URL of a webpage presenting evidence of achievement or the evidence encoded as a Data URI. The schema of the webpage is undefined.", - "$comment" : "Origin: URI (DerivedType); A `NormalizedString` that respresents a Uniform Resource Identifier (URI).", - "oneOf" : [ { - "type" : "string" - }, { - "type" : "object", - "additionalProperties" : { - "type" : "string" - }, - "propertyNames" : { - "pattern" : "^[a-z]{2}(-[A-Z]{2})?$" - } - } ] - }, - "type" : { - "oneOf" : [ { - "description" : "The value of the type property MUST be an unordered set. One of the items MUST be the IRI 'Evidence'.", - "$comment" : "Origin: IRI (DerivedType); A `NormalizedString` that represents an Internationalized Resource Identifier (IRI), which extends the ASCII characters subset of the Uniform Resource Identifier (URI).", - "oneOf" : [ { - "type" : "string" - }, { - "type" : "object", - "additionalProperties" : { - "type" : "string" - }, - "propertyNames" : { - "pattern" : "^[a-z]{2}(-[A-Z]{2})?$" - } - } ] - }, { - "type" : "array", - "minItems" : 1, - "contains" : { - "enum" : [ "Evidence" ] - }, - "items" : { - "description" : "The value of the type property MUST be an unordered set. One of the items MUST be the IRI 'Evidence'.", - "$comment" : "Origin: IRI (DerivedType); A `NormalizedString` that represents an Internationalized Resource Identifier (IRI), which extends the ASCII characters subset of the Uniform Resource Identifier (URI).", - "oneOf" : [ { - "type" : "string" - }, { - "type" : "object", - "additionalProperties" : { - "type" : "string" - }, - "propertyNames" : { - "pattern" : "^[a-z]{2}(-[A-Z]{2})?$" - } - } ] - } - } ] - }, - "narrative" : { - "description" : "A narrative that describes the evidence and process of achievement that led to an assertion.", - "$comment" : "Origin: Markdown (DerivedType); A `String` that may contain Markdown.", - "oneOf" : [ { - "type" : "string" - }, { - "type" : "object", - "additionalProperties" : { - "type" : "string" - }, - "propertyNames" : { - "pattern" : "^[a-z]{2}(-[A-Z]{2})?$" - } - } ] - }, - "name" : { - "description" : "A descriptive title of the evidence.", - "$comment" : "Origin: String (PrimitiveType); Character strings.", - "oneOf" : [ { - "type" : "string" - }, { - "type" : "object", - "additionalProperties" : { - "type" : "string" - }, - "propertyNames" : { - "pattern" : "^[a-z]{2}(-[A-Z]{2})?$" - } - } ] - }, - "description" : { - "description" : "A longer description of the evidence.", - "$comment" : "Origin: String (PrimitiveType); Character strings.", - "oneOf" : [ { - "type" : "string" - }, { - "type" : "object", - "additionalProperties" : { - "type" : "string" - }, - "propertyNames" : { - "pattern" : "^[a-z]{2}(-[A-Z]{2})?$" - } - } ] - }, - "genre" : { - "description" : "A string that describes the type of evidence. For example, Poetry, Prose, Film.", - "$comment" : "Origin: String (PrimitiveType); Character strings.", - "oneOf" : [ { - "type" : "string" - }, { - "type" : "object", - "additionalProperties" : { - "type" : "string" - }, - "propertyNames" : { - "pattern" : "^[a-z]{2}(-[A-Z]{2})?$" - } - } ] - }, - "audience" : { - "description" : "A description of the intended audience for a piece of evidence.", - "$comment" : "Origin: String (PrimitiveType); Character strings.", - "oneOf" : [ { - "type" : "string" - }, { - "type" : "object", - "additionalProperties" : { - "type" : "string" - }, - "propertyNames" : { - "pattern" : "^[a-z]{2}(-[A-Z]{2})?$" - } - } ] - } - }, - "required" : [ "type" ], - "additionalProperties" : true - }, - "EndorsementCredential" : { - "description" : "A verifiable credential that asserts a claim about an entity. As described in [[[#data-integrity]]], at least one proof mechanism, and the details necessary to evaluate that proof, MUST be expressed for a credential to be a verifiable credential. In the case of an embedded proof, the credential MUST append the proof in the `proof` property.", - "type" : "object", - "properties" : { - "@context" : { - "oneOf" : [ { - "$ref" : "#/$defs/Context" - }, { - "type" : "array", - "minItems" : 2, - "items" : [ { - "enum" : [ "https://www.w3.org/ns/credentials/v2" ] - }, { - "type" : "string", - "pattern" : "^https:\\/\\/purl\\.imsglobal\\.org\\/spec\\/ob\\/v3p0\\/context(-3\\.\\d\\.\\d)*\\.json$" - } ], - "additionalItems" : { - "$ref" : "#/$defs/Context" - } - } ] - }, - "type" : { - "oneOf" : [ { - "description" : "The value of the type property MUST be an unordered set. One of the items MUST be the URI 'VerifiableCredential', and one of the items MUST be the URI 'EndorsementCredential'.", - "$comment" : "Origin: IRI (DerivedType); A `NormalizedString` that represents an Internationalized Resource Identifier (IRI), which extends the ASCII characters subset of the Uniform Resource Identifier (URI).", - "oneOf" : [ { - "type" : "string" - }, { - "type" : "object", - "additionalProperties" : { - "type" : "string" - }, - "propertyNames" : { - "pattern" : "^[a-z]{2}(-[A-Z]{2})?$" - } - } ] - }, { - "allOf" : [ { - "type" : "array", - "minItems" : 1, - "contains" : { - "enum" : [ "VerifiableCredential" ] - }, - "items" : { - "description" : "The value of the type property MUST be an unordered set. One of the items MUST be the URI 'VerifiableCredential', and one of the items MUST be the URI 'EndorsementCredential'.", - "$comment" : "Origin: IRI (DerivedType); A `NormalizedString` that represents an Internationalized Resource Identifier (IRI), which extends the ASCII characters subset of the Uniform Resource Identifier (URI).", - "oneOf" : [ { - "type" : "string" - }, { - "type" : "object", - "additionalProperties" : { - "type" : "string" - }, - "propertyNames" : { - "pattern" : "^[a-z]{2}(-[A-Z]{2})?$" - } - } ] - } - }, { - "type" : "array", - "minItems" : 1, - "contains" : { - "enum" : [ "EndorsementCredential" ] - }, - "items" : { - "description" : "The value of the type property MUST be an unordered set. One of the items MUST be the URI 'VerifiableCredential', and one of the items MUST be the URI 'EndorsementCredential'.", - "$comment" : "Origin: IRI (DerivedType); A `NormalizedString` that represents an Internationalized Resource Identifier (IRI), which extends the ASCII characters subset of the Uniform Resource Identifier (URI).", - "oneOf" : [ { - "type" : "string" - }, { - "type" : "object", - "additionalProperties" : { - "type" : "string" - }, - "propertyNames" : { - "pattern" : "^[a-z]{2}(-[A-Z]{2})?$" - } - } ] - } - } ] - } ] - }, - "id" : { - "description" : "Unambiguous reference to the credential.", - "$comment" : "Origin: URI (DerivedType); A `NormalizedString` that respresents a Uniform Resource Identifier (URI).", - "oneOf" : [ { - "type" : "string" - }, { - "type" : "object", - "additionalProperties" : { - "type" : "string" - }, - "propertyNames" : { - "pattern" : "^[a-z]{2}(-[A-Z]{2})?$" - } - } ] - }, - "name" : { - "description" : "The name of the credential for display purposes in wallets. For example, in a list of credentials and in detail views.", - "$comment" : "Origin: String (PrimitiveType); Character strings.", - "oneOf" : [ { - "type" : "string" - }, { - "type" : "object", - "additionalProperties" : { - "type" : "string" - }, - "propertyNames" : { - "pattern" : "^[a-z]{2}(-[A-Z]{2})?$" - } - } ] - }, - "description" : { - "description" : "The short description of the credential for display purposes in wallets.", - "$comment" : "Origin: String (PrimitiveType); Character strings.", - "oneOf" : [ { - "type" : "string" - }, { - "type" : "object", - "additionalProperties" : { - "type" : "string" - }, - "propertyNames" : { - "pattern" : "^[a-z]{2}(-[A-Z]{2})?$" - } - } ] - }, - "credentialSubject" : { - "$ref" : "#/$defs/EndorsementSubject" - }, - "awardedDate" : { - "description" : "Timestamp of when the credential was awarded. `validFrom` is used to determine the most recent version of a Credential in conjunction with `issuer` and `id`. Consequently, the only way to update a Credental is to update the `validFrom`, losing the date when the Credential was originally awarded. `awardedDate` is meant to keep this original date.", - "$comment" : "Origin: DateTimeZ (DerivedType); A `DateTime` with the trailing timezone specifier included, e.g. `2021-09-07T02:09:59+02:00`", - "type" : "string", - "format" : "date-time" - }, - "issuer" : { - "$ref" : "#/$defs/ProfileRef" - }, - "validFrom" : { - "description" : "Timestamp of when the credential becomes valid.", - "$comment" : "Origin: DateTimeZ (DerivedType); A `DateTime` with the trailing timezone specifier included, e.g. `2021-09-07T02:09:59+02:00`", - "type" : "string", - "format" : "date-time" - }, - "validUntil" : { - "description" : "If the credential has some notion of validity period, this indicates a timestamp when a credential should no longer be considered valid. After this time, the credential should be considered invalid.", - "$comment" : "Origin: DateTimeZ (DerivedType); A `DateTime` with the trailing timezone specifier included, e.g. `2021-09-07T02:09:59+02:00`", - "type" : "string", - "format" : "date-time" - }, - "proof" : { - "oneOf" : [ { - "$ref" : "#/$defs/Proof" - }, { - "type" : "array", - "items" : { - "$ref" : "#/$defs/Proof" - } - } ] - }, - "credentialSchema" : { - "oneOf" : [ { - "$ref" : "#/$defs/CredentialSchema" - }, { - "type" : "array", - "items" : { - "$ref" : "#/$defs/CredentialSchema" - } - } ] - }, - "credentialStatus" : { - "$ref" : "#/$defs/CredentialStatus" - }, - "refreshService" : { - "$ref" : "#/$defs/RefreshService" - }, - "termsOfUse" : { - "oneOf" : [ { - "$ref" : "#/$defs/TermsOfUse" - }, { - "type" : "array", - "items" : { - "$ref" : "#/$defs/TermsOfUse" - } - } ] - }, - "evidence" : { - "oneOf" : [ { - "$ref" : "#/$defs/Evidence" - }, { - "type" : "array", - "items" : { - "$ref" : "#/$defs/Evidence" - } - } ] - } - }, - "required" : [ "@context", "type", "id", "name", "credentialSubject", "issuer", "validFrom" ], - "additionalProperties" : true - }, - "Achievement" : { - "description" : "A collection of information about the accomplishment recognized by the Assertion. Many assertions may be created corresponding to one Achievement.", - "type" : "object", - "properties" : { - "id" : { - "description" : "Unique URI for the Achievement.", - "$comment" : "Origin: URI (DerivedType); A `NormalizedString` that respresents a Uniform Resource Identifier (URI).", - "oneOf" : [ { - "type" : "string" - }, { - "type" : "object", - "additionalProperties" : { - "type" : "string" - }, - "propertyNames" : { - "pattern" : "^[a-z]{2}(-[A-Z]{2})?$" - } - } ] - }, - "type" : { - "oneOf" : [ { - "description" : "The type MUST include the IRI 'Achievement'.", - "$comment" : "Origin: IRI (DerivedType); A `NormalizedString` that represents an Internationalized Resource Identifier (IRI), which extends the ASCII characters subset of the Uniform Resource Identifier (URI).", - "oneOf" : [ { - "type" : "string" - }, { - "type" : "object", - "additionalProperties" : { - "type" : "string" - }, - "propertyNames" : { - "pattern" : "^[a-z]{2}(-[A-Z]{2})?$" - } - } ] - }, { - "type" : "array", - "minItems" : 1, - "contains" : { - "enum" : [ "Achievement" ] - }, - "items" : { - "description" : "The type MUST include the IRI 'Achievement'.", - "$comment" : "Origin: IRI (DerivedType); A `NormalizedString` that represents an Internationalized Resource Identifier (IRI), which extends the ASCII characters subset of the Uniform Resource Identifier (URI).", - "oneOf" : [ { - "type" : "string" - }, { - "type" : "object", - "additionalProperties" : { - "type" : "string" - }, - "propertyNames" : { - "pattern" : "^[a-z]{2}(-[A-Z]{2})?$" - } - } ] - } - } ] - }, - "alignment" : { - "oneOf" : [ { - "$ref" : "#/$defs/Alignment" - }, { - "type" : "array", - "items" : { - "$ref" : "#/$defs/Alignment" - } - } ] - }, - "achievementType" : { - "description" : "The type of achievement. This is an extensible vocabulary.", - "$comment" : "Origin: AchievementType (EnumExt); The type of achievement, for example 'Award' or 'Certification'. This is an extensible enumerated vocabulary. Extending the vocabulary makes use of a naming convention.", - "oneOf" : [ { - "type" : "string", - "enum" : [ "Achievement", "ApprenticeshipCertificate", "Assessment", "Assignment", "AssociateDegree", "Award", "Badge", "BachelorDegree", "Certificate", "CertificateOfCompletion", "Certification", "CommunityService", "Competency", "Course", "CoCurricular", "Degree", "Diploma", "DoctoralDegree", "Fieldwork", "GeneralEducationDevelopment", "JourneymanCertificate", "LearningProgram", "License", "Membership", "ProfessionalDoctorate", "QualityAssuranceCredential", "MasterCertificate", "MasterDegree", "MicroCredential", "ResearchDoctorate", "SecondarySchoolDiploma" ] - }, { - "type" : "string", - "pattern" : "(ext:)[a-z|A-Z|0-9|.|-|_]+" - } ] - }, - "creator" : { - "$ref" : "#/$defs/Profile" - }, - "creditsAvailable" : { - "description" : "Credit hours associated with this entity, or credit hours possible. For example 3.0.", - "$comment" : "Origin: Float (PrimitiveType)", - "type" : "number" - }, - "criteria" : { - "$ref" : "#/$defs/Criteria" - }, - "description" : { - "description" : "A short description of the achievement.", - "$comment" : "Origin: String (PrimitiveType); Character strings.", - "oneOf" : [ { - "type" : "string" - }, { - "type" : "object", - "additionalProperties" : { - "type" : "string" - }, - "propertyNames" : { - "pattern" : "^[a-z]{2}(-[A-Z]{2})?$" - } - } ] - }, - "endorsement" : { - "oneOf" : [ { - "$ref" : "#/$defs/EndorsementCredential" - }, { - "type" : "array", - "items" : { - "$ref" : "#/$defs/EndorsementCredential" - } - } ] - }, - "endorsementJwt" : { - "oneOf" : [ { - "description" : "Allows endorsers to make specific claims about the Achievement. These endorsements are signed with the VC-JWT proof format.", - "$comment" : "Origin: CompactJws (DerivedType); A `String` in Compact JWS format [[RFC7515]].", - "oneOf" : [ { - "type" : "string", - "pattern" : "^[a-zA-Z0-9_-]+\\.[a-zA-Z0-9_-]*\\.[a-zA-Z0-9_-]+$" - }, { - "type" : "object", - "additionalProperties" : { - "type" : "string", - "pattern" : "^[a-zA-Z0-9_-]+\\.[a-zA-Z0-9_-]*\\.[a-zA-Z0-9_-]+$" - }, - "propertyNames" : { - "pattern" : "^[a-z]{2}(-[A-Z]{2})?$" - } - } ] - }, { - "type" : "array", - "items" : { - "description" : "Allows endorsers to make specific claims about the Achievement. These endorsements are signed with the VC-JWT proof format.", - "$comment" : "Origin: CompactJws (DerivedType); A `String` in Compact JWS format [[RFC7515]].", - "oneOf" : [ { - "type" : "string", - "pattern" : "^[a-zA-Z0-9_-]+\\.[a-zA-Z0-9_-]*\\.[a-zA-Z0-9_-]+$" - }, { - "type" : "object", - "additionalProperties" : { - "type" : "string", - "pattern" : "^[a-zA-Z0-9_-]+\\.[a-zA-Z0-9_-]*\\.[a-zA-Z0-9_-]+$" - }, - "propertyNames" : { - "pattern" : "^[a-z]{2}(-[A-Z]{2})?$" - } - } ] - } - } ] - }, - "fieldOfStudy" : { - "description" : "Category, subject, area of study, discipline, or general branch of knowledge. Examples include Business, Education, Psychology, and Technology.", - "$comment" : "Origin: String (PrimitiveType); Character strings.", - "oneOf" : [ { - "type" : "string" - }, { - "type" : "object", - "additionalProperties" : { - "type" : "string" - }, - "propertyNames" : { - "pattern" : "^[a-z]{2}(-[A-Z]{2})?$" - } - } ] - }, - "humanCode" : { - "description" : "The code, generally human readable, associated with an achievement.", - "$comment" : "Origin: String (PrimitiveType); Character strings.", - "oneOf" : [ { - "type" : "string" - }, { - "type" : "object", - "additionalProperties" : { - "type" : "string" - }, - "propertyNames" : { - "pattern" : "^[a-z]{2}(-[A-Z]{2})?$" - } - } ] - }, - "image" : { - "$ref" : "#/$defs/Image" - }, - "inLanguage" : { - "description" : "The language of the achievement.", - "$comment" : "Origin: LanguageCode (DerivedType); A language code [[BCP47]].", - "oneOf" : [ { - "type" : "string", - "pattern" : "^[a-z]{2,4}(-[A-Z][a-z]{3})?(-([A-Z]{2}|[0-9]{3}))?$" - }, { - "type" : "object", - "additionalProperties" : { - "type" : "string", - "pattern" : "^[a-z]{2,4}(-[A-Z][a-z]{3})?(-([A-Z]{2}|[0-9]{3}))?$" - }, - "propertyNames" : { - "pattern" : "^[a-z]{2}(-[A-Z]{2})?$" - } - } ] - }, - "name" : { - "description" : "The name of the achievement.", - "$comment" : "Origin: String (PrimitiveType); Character strings.", - "oneOf" : [ { - "type" : "string" - }, { - "type" : "object", - "additionalProperties" : { - "type" : "string" - }, - "propertyNames" : { - "pattern" : "^[a-z]{2}(-[A-Z]{2})?$" - } - } ] - }, - "otherIdentifier" : { - "oneOf" : [ { - "$ref" : "#/$defs/IdentifierEntry" - }, { - "type" : "array", - "items" : { - "$ref" : "#/$defs/IdentifierEntry" - } - } ] - }, - "related" : { - "oneOf" : [ { - "$ref" : "#/$defs/Related" - }, { - "type" : "array", - "items" : { - "$ref" : "#/$defs/Related" - } - } ] - }, - "resultDescription" : { - "oneOf" : [ { - "$ref" : "#/$defs/ResultDescription" - }, { - "type" : "array", - "items" : { - "$ref" : "#/$defs/ResultDescription" - } - } ] - }, - "specialization" : { - "description" : "Name given to the focus, concentration, or specific area of study defined in the achievement. Examples include 'Entrepreneurship', 'Technical Communication', and 'Finance'.", - "$comment" : "Origin: String (PrimitiveType); Character strings.", - "oneOf" : [ { - "type" : "string" - }, { - "type" : "object", - "additionalProperties" : { - "type" : "string" - }, - "propertyNames" : { - "pattern" : "^[a-z]{2}(-[A-Z]{2})?$" - } - } ] - }, - "tag" : { - "oneOf" : [ { - "description" : "One or more short, human-friendly, searchable, keywords that describe the type of achievement.", - "$comment" : "Origin: String (PrimitiveType); Character strings.", - "oneOf" : [ { - "type" : "string" - }, { - "type" : "object", - "additionalProperties" : { - "type" : "string" - }, - "propertyNames" : { - "pattern" : "^[a-z]{2}(-[A-Z]{2})?$" - } - } ] - }, { - "type" : "array", - "items" : { - "description" : "One or more short, human-friendly, searchable, keywords that describe the type of achievement.", - "$comment" : "Origin: String (PrimitiveType); Character strings.", - "oneOf" : [ { - "type" : "string" - }, { - "type" : "object", - "additionalProperties" : { - "type" : "string" - }, - "propertyNames" : { - "pattern" : "^[a-z]{2}(-[A-Z]{2})?$" - } - } ] - } - } ] - }, - "version" : { - "description" : "The version property allows issuers to set a version string for an Achievement. This is particularly useful when replacing a previous version with an update.", - "$comment" : "Origin: String (PrimitiveType); Character strings.", - "oneOf" : [ { - "type" : "string" - }, { - "type" : "object", - "additionalProperties" : { - "type" : "string" - }, - "propertyNames" : { - "pattern" : "^[a-z]{2}(-[A-Z]{2})?$" - } - } ] - } - }, - "required" : [ "id", "type", "criteria", "description", "name" ], - "additionalProperties" : true - }, - "Alignment" : { - "description" : "Describes an alignment between an achievement and a node in an educational framework.", - "type" : "object", - "properties" : { - "type" : { - "oneOf" : [ { - "description" : "The value of the type property MUST be an unordered set. One of the items MUST be the IRI 'Alignment'.", - "$comment" : "Origin: IRI (DerivedType); A `NormalizedString` that represents an Internationalized Resource Identifier (IRI), which extends the ASCII characters subset of the Uniform Resource Identifier (URI).", - "oneOf" : [ { - "type" : "string" - }, { - "type" : "object", - "additionalProperties" : { - "type" : "string" - }, - "propertyNames" : { - "pattern" : "^[a-z]{2}(-[A-Z]{2})?$" - } - } ] - }, { - "type" : "array", - "minItems" : 1, - "contains" : { - "enum" : [ "Alignment" ] - }, - "items" : { - "description" : "The value of the type property MUST be an unordered set. One of the items MUST be the IRI 'Alignment'.", - "$comment" : "Origin: IRI (DerivedType); A `NormalizedString` that represents an Internationalized Resource Identifier (IRI), which extends the ASCII characters subset of the Uniform Resource Identifier (URI).", - "oneOf" : [ { - "type" : "string" - }, { - "type" : "object", - "additionalProperties" : { - "type" : "string" - }, - "propertyNames" : { - "pattern" : "^[a-z]{2}(-[A-Z]{2})?$" - } - } ] - } - } ] - }, - "targetCode" : { - "description" : "If applicable, a locally unique string identifier that identifies the alignment target within its framework and/or targetUrl.", - "$comment" : "Origin: String (PrimitiveType); Character strings.", - "oneOf" : [ { - "type" : "string" - }, { - "type" : "object", - "additionalProperties" : { - "type" : "string" - }, - "propertyNames" : { - "pattern" : "^[a-z]{2}(-[A-Z]{2})?$" - } - } ] - }, - "targetDescription" : { - "description" : "Short description of the alignment target.", - "$comment" : "Origin: String (PrimitiveType); Character strings.", - "oneOf" : [ { - "type" : "string" - }, { - "type" : "object", - "additionalProperties" : { - "type" : "string" - }, - "propertyNames" : { - "pattern" : "^[a-z]{2}(-[A-Z]{2})?$" - } - } ] - }, - "targetName" : { - "description" : "Name of the alignment.", - "$comment" : "Origin: String (PrimitiveType); Character strings.", - "oneOf" : [ { - "type" : "string" - }, { - "type" : "object", - "additionalProperties" : { - "type" : "string" - }, - "propertyNames" : { - "pattern" : "^[a-z]{2}(-[A-Z]{2})?$" - } - } ] - }, - "targetFramework" : { - "description" : "Name of the framework the alignment target.", - "$comment" : "Origin: String (PrimitiveType); Character strings.", - "oneOf" : [ { - "type" : "string" - }, { - "type" : "object", - "additionalProperties" : { - "type" : "string" - }, - "propertyNames" : { - "pattern" : "^[a-z]{2}(-[A-Z]{2})?$" - } - } ] - }, - "targetType" : { - "description" : "The type of the alignment target node.", - "$comment" : "Origin: AlignmentTargetType (EnumExt); The type of the alignment target node in the target framework.", - "oneOf" : [ { - "type" : "string", - "enum" : [ "ceasn:Competency", "ceterms:Credential", "CFItem", "CFRubric", "CFRubricCriterion", "CFRubricCriterionLevel", "CTDL" ] - }, { - "type" : "string", - "pattern" : "(ext:)[a-z|A-Z|0-9|.|-|_]+" - } ] - }, - "targetUrl" : { - "description" : "URL linking to the official description of the alignment target, for example an individual standard within an educational framework.", - "$comment" : "Origin: URL (DerivedType); A `URI` that represents a Uniform Resource Locator (URL).", - "oneOf" : [ { - "type" : "string" - }, { - "type" : "object", - "additionalProperties" : { - "type" : "string" - }, - "propertyNames" : { - "pattern" : "^[a-z]{2}(-[A-Z]{2})?$" - } - } ] - } - }, - "required" : [ "type", "targetName", "targetUrl" ], - "additionalProperties" : true - }, - "Criteria" : { - "description" : "Descriptive metadata about the achievements necessary to be recognized with an assertion of a particular achievement. This data is added to the Achievement class so that it may be rendered when the achievement assertion is displayed, instead of simply a link to human-readable criteria external to the achievement. Embedding criteria allows either enhancement of an external criteria page or increased portability and ease of use by allowing issuers to skip hosting the formerly-required external criteria page altogether. Criteria is used to allow would-be recipients to learn what is required of them to be recognized with an assertion of a particular achievement. It is also used after the assertion is awarded to a recipient to let those inspecting earned achievements know the general requirements that the recipients met in order to earn it.", - "type" : "object", - "properties" : { - "id" : { - "description" : "The URI of a webpage that describes in a human-readable format the criteria for the achievement.", - "$comment" : "Origin: URI (DerivedType); A `NormalizedString` that respresents a Uniform Resource Identifier (URI).", - "oneOf" : [ { - "type" : "string" - }, { - "type" : "object", - "additionalProperties" : { - "type" : "string" - }, - "propertyNames" : { - "pattern" : "^[a-z]{2}(-[A-Z]{2})?$" - } - } ] - }, - "narrative" : { - "description" : "A narrative of what is needed to earn the achievement. Markdown is allowed.", - "$comment" : "Origin: Markdown (DerivedType); A `String` that may contain Markdown.", - "oneOf" : [ { - "type" : "string" - }, { - "type" : "object", - "additionalProperties" : { - "type" : "string" - }, - "propertyNames" : { - "pattern" : "^[a-z]{2}(-[A-Z]{2})?$" - } - } ] - } - }, - "required" : [ ], - "additionalProperties" : true - }, - "IdentifierEntry" : { - "description" : "No description supplied.", - "type" : "object", - "properties" : { - "type" : { - "description" : "MUST be the IRI 'IdentifierEntry'.", - "$comment" : "Origin: IRI (DerivedType); A `NormalizedString` that represents an Internationalized Resource Identifier (IRI), which extends the ASCII characters subset of the Uniform Resource Identifier (URI).", - "oneOf" : [ { - "type" : "string", - "enum" : [ "IdentifierEntry" ] - }, { - "type" : "object", - "additionalProperties" : { - "type" : "string", - "enum" : [ "IdentifierEntry" ] - }, - "propertyNames" : { - "pattern" : "^[a-z]{2}(-[A-Z]{2})?$" - } - } ] - }, - "identifier" : { - "description" : "An identifier.", - "$comment" : "Origin: Identifier (DerivedType); A `NormalizedString` that functions as an identifier.", - "oneOf" : [ { - "type" : "string" - }, { - "type" : "object", - "additionalProperties" : { - "type" : "string" - }, - "propertyNames" : { - "pattern" : "^[a-z]{2}(-[A-Z]{2})?$" - } - } ] - }, - "identifierType" : { - "description" : "The identifier type.", - "$comment" : "Origin: IdentifierTypeEnum (EnumExt)", - "oneOf" : [ { - "type" : "string", - "enum" : [ "name", "sourcedId", "systemId", "productId", "userName", "accountId", "emailAddress", "nationalIdentityNumber", "isbn", "issn", "lisSourcedId", "oneRosterSourcedId", "sisSourcedId", "ltiContextId", "ltiDeploymentId", "ltiToolId", "ltiPlatformId", "ltiUserId", "identifier" ] - }, { - "type" : "string", - "pattern" : "(ext:)[a-z|A-Z|0-9|.|-|_]+" - } ] - } - }, - "required" : [ "type", "identifier", "identifierType" ], - "additionalProperties" : false - }, - "Proof" : { - "description" : "A JSON-LD Linked Data proof.", - "type" : "object", - "properties" : { - "type" : { - "description" : "Signature suite used to produce proof.", - "$comment" : "Origin: IRI (DerivedType); A `NormalizedString` that represents an Internationalized Resource Identifier (IRI), which extends the ASCII characters subset of the Uniform Resource Identifier (URI).", - "oneOf" : [ { - "type" : "string" - }, { - "type" : "object", - "additionalProperties" : { - "type" : "string" - }, - "propertyNames" : { - "pattern" : "^[a-z]{2}(-[A-Z]{2})?$" - } - } ] - }, - "created" : { - "description" : "Date the proof was created.", - "$comment" : "Origin: DateTime (PrimitiveType); An [[ISO8601]] time using the syntax YYYY-MM-DDThh:mm:ss.", - "type" : "string", - "format" : "date-time" - }, - "cryptosuite" : { - "description" : "The suite used to create the proof.", - "$comment" : "Origin: String (PrimitiveType); Character strings.", - "oneOf" : [ { - "type" : "string" - }, { - "type" : "object", - "additionalProperties" : { - "type" : "string" - }, - "propertyNames" : { - "pattern" : "^[a-z]{2}(-[A-Z]{2})?$" - } - } ] - }, - "challenge" : { - "description" : "A value chosen by the verifier to mitigate authentication proof replay attacks.", - "$comment" : "Origin: String (PrimitiveType); Character strings.", - "oneOf" : [ { - "type" : "string" - }, { - "type" : "object", - "additionalProperties" : { - "type" : "string" - }, - "propertyNames" : { - "pattern" : "^[a-z]{2}(-[A-Z]{2})?$" - } - } ] - }, - "domain" : { - "description" : "The domain of the proof to restrict its use to a particular target.", - "$comment" : "Origin: String (PrimitiveType); Character strings.", - "oneOf" : [ { - "type" : "string" - }, { - "type" : "object", - "additionalProperties" : { - "type" : "string" - }, - "propertyNames" : { - "pattern" : "^[a-z]{2}(-[A-Z]{2})?$" - } - } ] - }, - "nonce" : { - "description" : "A value chosen by the creator of proof to randomize proof values for privacy purposes.", - "$comment" : "Origin: String (PrimitiveType); Character strings.", - "oneOf" : [ { - "type" : "string" - }, { - "type" : "object", - "additionalProperties" : { - "type" : "string" - }, - "propertyNames" : { - "pattern" : "^[a-z]{2}(-[A-Z]{2})?$" - } - } ] - }, - "proofPurpose" : { - "description" : "The purpose of the proof to be used with `verificationMethod`. MUST be 'assertionMethod'.", - "$comment" : "Origin: String (PrimitiveType); Character strings.", - "oneOf" : [ { - "type" : "string" - }, { - "type" : "object", - "additionalProperties" : { - "type" : "string" - }, - "propertyNames" : { - "pattern" : "^[a-z]{2}(-[A-Z]{2})?$" - } - } ] - }, - "proofValue" : { - "description" : "Value of the proof.", - "$comment" : "Origin: String (PrimitiveType); Character strings.", - "oneOf" : [ { - "type" : "string" - }, { - "type" : "object", - "additionalProperties" : { - "type" : "string" - }, - "propertyNames" : { - "pattern" : "^[a-z]{2}(-[A-Z]{2})?$" - } - } ] - }, - "verificationMethod" : { - "description" : "The URL of the public key that can verify the signature.", - "$comment" : "Origin: URI (DerivedType); A `NormalizedString` that respresents a Uniform Resource Identifier (URI).", - "oneOf" : [ { - "type" : "string" - }, { - "type" : "object", - "additionalProperties" : { - "type" : "string" - }, - "propertyNames" : { - "pattern" : "^[a-z]{2}(-[A-Z]{2})?$" - } - } ] - } - }, - "required" : [ "type" ], - "additionalProperties" : true - }, - "RefreshService" : { - "description" : "The information in RefreshService is used to refresh the verifiable credential.", - "type" : "object", - "properties" : { - "id" : { - "description" : "The value MUST be the URL of the issuer's refresh service.", - "$comment" : "Origin: URI (DerivedType); A `NormalizedString` that respresents a Uniform Resource Identifier (URI).", - "oneOf" : [ { - "type" : "string" - }, { - "type" : "object", - "additionalProperties" : { - "type" : "string" - }, - "propertyNames" : { - "pattern" : "^[a-z]{2}(-[A-Z]{2})?$" - } - } ] - }, - "type" : { - "description" : "The name of the refresh service method.", - "$comment" : "Origin: IRI (DerivedType); A `NormalizedString` that represents an Internationalized Resource Identifier (IRI), which extends the ASCII characters subset of the Uniform Resource Identifier (URI).", - "oneOf" : [ { - "type" : "string" - }, { - "type" : "object", - "additionalProperties" : { - "type" : "string" - }, - "propertyNames" : { - "pattern" : "^[a-z]{2}(-[A-Z]{2})?$" - } - } ] - } - }, - "required" : [ "id", "type" ], - "additionalProperties" : true - }, - "Image" : { - "description" : "Metadata about images that represent assertions, achieve or profiles. These properties can typically be represented as just the id string of the image, but using a fleshed-out document allows for including captions and other applicable metadata.", - "type" : "object", - "properties" : { - "id" : { - "description" : "The URI or Data URI of the image.", - "$comment" : "Origin: URI (DerivedType); A `NormalizedString` that respresents a Uniform Resource Identifier (URI).", - "oneOf" : [ { - "type" : "string" - }, { - "type" : "object", - "additionalProperties" : { - "type" : "string" - }, - "propertyNames" : { - "pattern" : "^[a-z]{2}(-[A-Z]{2})?$" - } - } ] - }, - "type" : { - "description" : "MUST be the IRI 'Image'.", - "$comment" : "Origin: IRI (DerivedType); A `NormalizedString` that represents an Internationalized Resource Identifier (IRI), which extends the ASCII characters subset of the Uniform Resource Identifier (URI).", - "oneOf" : [ { - "type" : "string", - "enum" : [ "Image" ] - }, { - "type" : "object", - "additionalProperties" : { - "type" : "string", - "enum" : [ "Image" ] - }, - "propertyNames" : { - "pattern" : "^[a-z]{2}(-[A-Z]{2})?$" - } - } ] - }, - "caption" : { - "description" : "The caption for the image.", - "$comment" : "Origin: String (PrimitiveType); Character strings.", - "oneOf" : [ { - "type" : "string" - }, { - "type" : "object", - "additionalProperties" : { - "type" : "string" - }, - "propertyNames" : { - "pattern" : "^[a-z]{2}(-[A-Z]{2})?$" - } - } ] - } - }, - "required" : [ "id", "type" ], - "additionalProperties" : false - }, - "EndorsementSubject" : { - "description" : "A collection of information about the subject of the endorsement.", - "type" : "object", - "properties" : { - "id" : { - "description" : "The identifier of the individual, entity, organization, assertion, or achievement that is endorsed.", - "$comment" : "Origin: URI (DerivedType); A `NormalizedString` that respresents a Uniform Resource Identifier (URI).", - "oneOf" : [ { - "type" : "string" - }, { - "type" : "object", - "additionalProperties" : { - "type" : "string" - }, - "propertyNames" : { - "pattern" : "^[a-z]{2}(-[A-Z]{2})?$" - } - } ] - }, - "type" : { - "oneOf" : [ { - "description" : "The value of the type property MUST be an unordered set. One of the items MUST be the URI 'EndorsementSubject'.", - "$comment" : "Origin: IRI (DerivedType); A `NormalizedString` that represents an Internationalized Resource Identifier (IRI), which extends the ASCII characters subset of the Uniform Resource Identifier (URI).", - "oneOf" : [ { - "type" : "string" - }, { - "type" : "object", - "additionalProperties" : { - "type" : "string" - }, - "propertyNames" : { - "pattern" : "^[a-z]{2}(-[A-Z]{2})?$" - } - } ] - }, { - "type" : "array", - "minItems" : 1, - "contains" : { - "enum" : [ "EndorsementSubject" ] - }, - "items" : { - "description" : "The value of the type property MUST be an unordered set. One of the items MUST be the URI 'EndorsementSubject'.", - "$comment" : "Origin: IRI (DerivedType); A `NormalizedString` that represents an Internationalized Resource Identifier (IRI), which extends the ASCII characters subset of the Uniform Resource Identifier (URI).", - "oneOf" : [ { - "type" : "string" - }, { - "type" : "object", - "additionalProperties" : { - "type" : "string" - }, - "propertyNames" : { - "pattern" : "^[a-z]{2}(-[A-Z]{2})?$" - } - } ] - } - } ] - }, - "endorsementComment" : { - "description" : "Allows endorsers to make a simple claim in writing about the entity.", - "$comment" : "Origin: Markdown (DerivedType); A `String` that may contain Markdown.", - "oneOf" : [ { - "type" : "string" - }, { - "type" : "object", - "additionalProperties" : { - "type" : "string" - }, - "propertyNames" : { - "pattern" : "^[a-z]{2}(-[A-Z]{2})?$" - } - } ] - } - }, - "required" : [ "id", "type" ], - "additionalProperties" : true - }, - "Result" : { - "description" : "Describes a result that was achieved.", - "type" : "object", - "properties" : { - "type" : { - "oneOf" : [ { - "description" : "The value of the type property MUST be an unordered set. One of the items MUST be the IRI 'Result'.", - "$comment" : "Origin: IRI (DerivedType); A `NormalizedString` that represents an Internationalized Resource Identifier (IRI), which extends the ASCII characters subset of the Uniform Resource Identifier (URI).", - "oneOf" : [ { - "type" : "string" - }, { - "type" : "object", - "additionalProperties" : { - "type" : "string" - }, - "propertyNames" : { - "pattern" : "^[a-z]{2}(-[A-Z]{2})?$" - } - } ] - }, { - "type" : "array", - "minItems" : 1, - "contains" : { - "enum" : [ "Result" ] - }, - "items" : { - "description" : "The value of the type property MUST be an unordered set. One of the items MUST be the IRI 'Result'.", - "$comment" : "Origin: IRI (DerivedType); A `NormalizedString` that represents an Internationalized Resource Identifier (IRI), which extends the ASCII characters subset of the Uniform Resource Identifier (URI).", - "oneOf" : [ { - "type" : "string" - }, { - "type" : "object", - "additionalProperties" : { - "type" : "string" - }, - "propertyNames" : { - "pattern" : "^[a-z]{2}(-[A-Z]{2})?$" - } - } ] - } - } ] - }, - "achievedLevel" : { - "description" : "If the result represents an achieved rubric criterion level (e.g. Mastered), the value is the `id` of the RubricCriterionLevel in linked ResultDescription.", - "$comment" : "Origin: URI (DerivedType); A `NormalizedString` that respresents a Uniform Resource Identifier (URI).", - "oneOf" : [ { - "type" : "string" - }, { - "type" : "object", - "additionalProperties" : { - "type" : "string" - }, - "propertyNames" : { - "pattern" : "^[a-z]{2}(-[A-Z]{2})?$" - } - } ] - }, - "alignment" : { - "oneOf" : [ { - "$ref" : "#/$defs/Alignment" - }, { - "type" : "array", - "items" : { - "$ref" : "#/$defs/Alignment" - } - } ] - }, - "resultDescription" : { - "description" : "An achievement can have many result descriptions describing possible results. The value of `resultDescription` is the `id` of the result description linked to this result. The linked result description must be in the achievement that is being asserted.", - "$comment" : "Origin: URI (DerivedType); A `NormalizedString` that respresents a Uniform Resource Identifier (URI).", - "oneOf" : [ { - "type" : "string" - }, { - "type" : "object", - "additionalProperties" : { - "type" : "string" - }, - "propertyNames" : { - "pattern" : "^[a-z]{2}(-[A-Z]{2})?$" - } - } ] - }, - "status" : { - "description" : "The status of the achievement. Required if `resultType` of the linked ResultDescription is Status.", - "$comment" : "Origin: ResultStatusType (Enumeration); Defined vocabulary to convey the status of an achievement.", - "type" : "string", - "enum" : [ "Completed", "Enrolled", "Failed", "InProgress", "OnHold", "Provisional", "Withdrew" ] - }, - "value" : { - "description" : "A string representing the result of the performance, or demonstration, of the achievement. For example, 'A' if the recipient received an A grade in class.", - "$comment" : "Origin: String (PrimitiveType); Character strings.", - "oneOf" : [ { - "type" : "string" - }, { - "type" : "object", - "additionalProperties" : { - "type" : "string" - }, - "propertyNames" : { - "pattern" : "^[a-z]{2}(-[A-Z]{2})?$" - } - } ] - } - }, - "required" : [ "type" ], - "additionalProperties" : true - }, - "ProfileRef" : { - "description" : "A description of the individual, entity, or organization that issued the credential. Either a URI with the Unique URI for the Issuer/Profile file, or a Profile object MUST be supplied.", - "anyOf" : [ { - "$ref" : "#/$defs/Profile" - }, { - "description" : "A `NormalizedString` that respresents a Uniform Resource Identifier (URI).", - "$comment" : "Origin: URI (DerivedType); A `NormalizedString` that respresents a Uniform Resource Identifier (URI).", - "oneOf" : [ { - "type" : "string" - }, { - "type" : "object", - "additionalProperties" : { - "type" : "string" - }, - "propertyNames" : { - "pattern" : "^[a-z]{2}(-[A-Z]{2})?$" - } - } ] - } ] - }, - "ResultDescription" : { - "description" : "Describes a possible achievement result.", - "type" : "object", - "properties" : { - "id" : { - "description" : "The unique URI for this result description. Required so a result can link to this result description.", - "$comment" : "Origin: URI (DerivedType); A `NormalizedString` that respresents a Uniform Resource Identifier (URI).", - "oneOf" : [ { - "type" : "string" - }, { - "type" : "object", - "additionalProperties" : { - "type" : "string" - }, - "propertyNames" : { - "pattern" : "^[a-z]{2}(-[A-Z]{2})?$" - } - } ] - }, - "type" : { - "oneOf" : [ { - "description" : "The value of the type property MUST be an unordered set. One of the items MUST be the IRI 'ResultDescription'.", - "$comment" : "Origin: IRI (DerivedType); A `NormalizedString` that represents an Internationalized Resource Identifier (IRI), which extends the ASCII characters subset of the Uniform Resource Identifier (URI).", - "oneOf" : [ { - "type" : "string" - }, { - "type" : "object", - "additionalProperties" : { - "type" : "string" - }, - "propertyNames" : { - "pattern" : "^[a-z]{2}(-[A-Z]{2})?$" - } - } ] - }, { - "type" : "array", - "minItems" : 1, - "contains" : { - "enum" : [ "ResultDescription" ] - }, - "items" : { - "description" : "The value of the type property MUST be an unordered set. One of the items MUST be the IRI 'ResultDescription'.", - "$comment" : "Origin: IRI (DerivedType); A `NormalizedString` that represents an Internationalized Resource Identifier (IRI), which extends the ASCII characters subset of the Uniform Resource Identifier (URI).", - "oneOf" : [ { - "type" : "string" - }, { - "type" : "object", - "additionalProperties" : { - "type" : "string" - }, - "propertyNames" : { - "pattern" : "^[a-z]{2}(-[A-Z]{2})?$" - } - } ] - } - } ] - }, - "alignment" : { - "oneOf" : [ { - "$ref" : "#/$defs/Alignment" - }, { - "type" : "array", - "items" : { - "$ref" : "#/$defs/Alignment" - } - } ] - }, - "allowedValue" : { - "oneOf" : [ { - "description" : "An ordered list of allowed values. The values should be ordered from low to high as determined by the achievement creator.", - "$comment" : "Origin: String (PrimitiveType); Character strings.", - "oneOf" : [ { - "type" : "string" - }, { - "type" : "object", - "additionalProperties" : { - "type" : "string" - }, - "propertyNames" : { - "pattern" : "^[a-z]{2}(-[A-Z]{2})?$" - } - } ] - }, { - "type" : "array", - "items" : { - "description" : "An ordered list of allowed values. The values should be ordered from low to high as determined by the achievement creator.", - "$comment" : "Origin: String (PrimitiveType); Character strings.", - "oneOf" : [ { - "type" : "string" - }, { - "type" : "object", - "additionalProperties" : { - "type" : "string" - }, - "propertyNames" : { - "pattern" : "^[a-z]{2}(-[A-Z]{2})?$" - } - } ] - } - } ] - }, - "name" : { - "description" : "The name of the result.", - "$comment" : "Origin: String (PrimitiveType); Character strings.", - "oneOf" : [ { - "type" : "string" - }, { - "type" : "object", - "additionalProperties" : { - "type" : "string" - }, - "propertyNames" : { - "pattern" : "^[a-z]{2}(-[A-Z]{2})?$" - } - } ] - }, - "requiredLevel" : { - "description" : "The `id` of the rubric criterion level required to pass as determined by the achievement creator.", - "$comment" : "Origin: URI (DerivedType); A `NormalizedString` that respresents a Uniform Resource Identifier (URI).", - "oneOf" : [ { - "type" : "string" - }, { - "type" : "object", - "additionalProperties" : { - "type" : "string" - }, - "propertyNames" : { - "pattern" : "^[a-z]{2}(-[A-Z]{2})?$" - } - } ] - }, - "requiredValue" : { - "description" : "A value from `allowedValue` or within the range of `valueMin` to `valueMax` required to pass as determined by the achievement creator.", - "$comment" : "Origin: String (PrimitiveType); Character strings.", - "oneOf" : [ { - "type" : "string" - }, { - "type" : "object", - "additionalProperties" : { - "type" : "string" - }, - "propertyNames" : { - "pattern" : "^[a-z]{2}(-[A-Z]{2})?$" - } - } ] - }, - "resultType" : { - "description" : "The type of result this description represents. This is an extensible enumerated vocabulary.", - "$comment" : "Origin: ResultType (EnumExt); The type of result. This is an extensible enumerated vocabulary. Extending the vocabulary makes use of a naming convention.", - "oneOf" : [ { - "type" : "string", - "enum" : [ "GradePointAverage", "LetterGrade", "Percent", "PerformanceLevel", "PredictedScore", "RawScore", "Result", "RubricCriterion", "RubricCriterionLevel", "RubricScore", "ScaledScore", "Status" ] - }, { - "type" : "string", - "pattern" : "(ext:)[a-z|A-Z|0-9|.|-|_]+" - } ] - }, - "rubricCriterionLevel" : { - "oneOf" : [ { - "$ref" : "#/$defs/RubricCriterionLevel" - }, { - "type" : "array", - "items" : { - "$ref" : "#/$defs/RubricCriterionLevel" - } - } ] - }, - "valueMax" : { - "description" : "The maximum possible `value` that may be asserted in a linked result.", - "$comment" : "Origin: String (PrimitiveType); Character strings.", - "oneOf" : [ { - "type" : "string" - }, { - "type" : "object", - "additionalProperties" : { - "type" : "string" - }, - "propertyNames" : { - "pattern" : "^[a-z]{2}(-[A-Z]{2})?$" - } - } ] - }, - "valueMin" : { - "description" : "The minimum possible `value` that may be asserted in a linked result.", - "$comment" : "Origin: String (PrimitiveType); Character strings.", - "oneOf" : [ { - "type" : "string" - }, { - "type" : "object", - "additionalProperties" : { - "type" : "string" - }, - "propertyNames" : { - "pattern" : "^[a-z]{2}(-[A-Z]{2})?$" - } - } ] - } - }, - "required" : [ "id", "type", "name", "resultType" ], - "additionalProperties" : true - }, - "RubricCriterionLevel" : { - "description" : "Describes a rubric criterion level.", - "type" : "object", - "properties" : { - "id" : { - "description" : "The unique URI for this rubric criterion level. Required so a result can link to this rubric criterion level.", - "$comment" : "Origin: URI (DerivedType); A `NormalizedString` that respresents a Uniform Resource Identifier (URI).", - "oneOf" : [ { - "type" : "string" - }, { - "type" : "object", - "additionalProperties" : { - "type" : "string" - }, - "propertyNames" : { - "pattern" : "^[a-z]{2}(-[A-Z]{2})?$" - } - } ] - }, - "type" : { - "oneOf" : [ { - "description" : "The value of the type property MUST be an unordered set. One of the items MUST be the IRI 'RubricCriterionLevel'.", - "$comment" : "Origin: IRI (DerivedType); A `NormalizedString` that represents an Internationalized Resource Identifier (IRI), which extends the ASCII characters subset of the Uniform Resource Identifier (URI).", - "oneOf" : [ { - "type" : "string" - }, { - "type" : "object", - "additionalProperties" : { - "type" : "string" - }, - "propertyNames" : { - "pattern" : "^[a-z]{2}(-[A-Z]{2})?$" - } - } ] - }, { - "type" : "array", - "minItems" : 1, - "contains" : { - "enum" : [ "RubricCriterionLevel" ] - }, - "items" : { - "description" : "The value of the type property MUST be an unordered set. One of the items MUST be the IRI 'RubricCriterionLevel'.", - "$comment" : "Origin: IRI (DerivedType); A `NormalizedString` that represents an Internationalized Resource Identifier (IRI), which extends the ASCII characters subset of the Uniform Resource Identifier (URI).", - "oneOf" : [ { - "type" : "string" - }, { - "type" : "object", - "additionalProperties" : { - "type" : "string" - }, - "propertyNames" : { - "pattern" : "^[a-z]{2}(-[A-Z]{2})?$" - } - } ] - } - } ] - }, - "alignment" : { - "oneOf" : [ { - "$ref" : "#/$defs/Alignment" - }, { - "type" : "array", - "items" : { - "$ref" : "#/$defs/Alignment" - } - } ] - }, - "description" : { - "description" : "Description of the rubric criterion level.", - "$comment" : "Origin: String (PrimitiveType); Character strings.", - "oneOf" : [ { - "type" : "string" - }, { - "type" : "object", - "additionalProperties" : { - "type" : "string" - }, - "propertyNames" : { - "pattern" : "^[a-z]{2}(-[A-Z]{2})?$" - } - } ] - }, - "level" : { - "description" : "The rubric performance level in terms of success.", - "$comment" : "Origin: String (PrimitiveType); Character strings.", - "oneOf" : [ { - "type" : "string" - }, { - "type" : "object", - "additionalProperties" : { - "type" : "string" - }, - "propertyNames" : { - "pattern" : "^[a-z]{2}(-[A-Z]{2})?$" - } - } ] - }, - "name" : { - "description" : "The name of the rubric criterion level.", - "$comment" : "Origin: String (PrimitiveType); Character strings.", - "oneOf" : [ { - "type" : "string" - }, { - "type" : "object", - "additionalProperties" : { - "type" : "string" - }, - "propertyNames" : { - "pattern" : "^[a-z]{2}(-[A-Z]{2})?$" - } - } ] - }, - "points" : { - "description" : "The points associated with this rubric criterion level.", - "$comment" : "Origin: String (PrimitiveType); Character strings.", - "oneOf" : [ { - "type" : "string" - }, { - "type" : "object", - "additionalProperties" : { - "type" : "string" - }, - "propertyNames" : { - "pattern" : "^[a-z]{2}(-[A-Z]{2})?$" - } - } ] - } - }, - "required" : [ "id", "type", "name" ], - "additionalProperties" : true - }, - "Related" : { - "description" : "Identifies a related achievement.", - "type" : "object", - "properties" : { - "id" : { - "description" : "The related achievement.", - "$comment" : "Origin: URI (DerivedType); A `NormalizedString` that respresents a Uniform Resource Identifier (URI).", - "oneOf" : [ { - "type" : "string" - }, { - "type" : "object", - "additionalProperties" : { - "type" : "string" - }, - "propertyNames" : { - "pattern" : "^[a-z]{2}(-[A-Z]{2})?$" - } - } ] - }, - "type" : { - "oneOf" : [ { - "description" : "The value of the type property MUST be an unordered set. One of the items MUST be the IRI 'Related'.", - "$comment" : "Origin: IRI (DerivedType); A `NormalizedString` that represents an Internationalized Resource Identifier (IRI), which extends the ASCII characters subset of the Uniform Resource Identifier (URI).", - "oneOf" : [ { - "type" : "string" - }, { - "type" : "object", - "additionalProperties" : { - "type" : "string" - }, - "propertyNames" : { - "pattern" : "^[a-z]{2}(-[A-Z]{2})?$" - } - } ] - }, { - "type" : "array", - "minItems" : 1, - "contains" : { - "enum" : [ "Related" ] - }, - "items" : { - "description" : "The value of the type property MUST be an unordered set. One of the items MUST be the IRI 'Related'.", - "$comment" : "Origin: IRI (DerivedType); A `NormalizedString` that represents an Internationalized Resource Identifier (IRI), which extends the ASCII characters subset of the Uniform Resource Identifier (URI).", - "oneOf" : [ { - "type" : "string" - }, { - "type" : "object", - "additionalProperties" : { - "type" : "string" - }, - "propertyNames" : { - "pattern" : "^[a-z]{2}(-[A-Z]{2})?$" - } - } ] - } - } ] - }, - "inLanguage" : { - "description" : "The language of the related achievement.", - "$comment" : "Origin: LanguageCode (DerivedType); A language code [[BCP47]].", - "oneOf" : [ { - "type" : "string", - "pattern" : "^[a-z]{2,4}(-[A-Z][a-z]{3})?(-([A-Z]{2}|[0-9]{3}))?$" - }, { - "type" : "object", - "additionalProperties" : { - "type" : "string", - "pattern" : "^[a-z]{2,4}(-[A-Z][a-z]{3})?(-([A-Z]{2}|[0-9]{3}))?$" - }, - "propertyNames" : { - "pattern" : "^[a-z]{2}(-[A-Z]{2})?$" - } - } ] - }, - "version" : { - "description" : "The version of the related achievement.", - "$comment" : "Origin: String (PrimitiveType); Character strings.", - "oneOf" : [ { - "type" : "string" - }, { - "type" : "object", - "additionalProperties" : { - "type" : "string" - }, - "propertyNames" : { - "pattern" : "^[a-z]{2}(-[A-Z]{2})?$" - } - } ] - } - }, - "required" : [ "id", "type" ], - "additionalProperties" : true - }, - "Profile" : { - "description" : "A Profile is a collection of information that describes the entity or organization using Open Badges. Issuers must be represented as Profiles, and endorsers, or other entities may also be represented using this vocabulary. Each Profile that represents an Issuer may be referenced in many BadgeClasses that it has defined. Anyone can create and host an Issuer file to start issuing Open Badges. Issuers may also serve as recipients of Open Badges, often identified within an Assertion by specific properties, like their url or contact email address.", - "type" : "object", - "properties" : { - "id" : { - "description" : "Unique URI for the Issuer/Profile file.", - "$comment" : "Origin: URI (DerivedType); A `NormalizedString` that respresents a Uniform Resource Identifier (URI).", - "oneOf" : [ { - "type" : "string" - }, { - "type" : "object", - "additionalProperties" : { - "type" : "string" - }, - "propertyNames" : { - "pattern" : "^[a-z]{2}(-[A-Z]{2})?$" - } - } ] - }, - "type" : { - "oneOf" : [ { - "description" : "The value of the type property MUST be an unordered set. One of the items MUST be the IRI 'Profile'.", - "$comment" : "Origin: IRI (DerivedType); A `NormalizedString` that represents an Internationalized Resource Identifier (IRI), which extends the ASCII characters subset of the Uniform Resource Identifier (URI).", - "oneOf" : [ { - "type" : "string" - }, { - "type" : "object", - "additionalProperties" : { - "type" : "string" - }, - "propertyNames" : { - "pattern" : "^[a-z]{2}(-[A-Z]{2})?$" - } - } ] - }, { - "type" : "array", - "minItems" : 1, - "contains" : { - "enum" : [ "Profile" ] - }, - "items" : { - "description" : "The value of the type property MUST be an unordered set. One of the items MUST be the IRI 'Profile'.", - "$comment" : "Origin: IRI (DerivedType); A `NormalizedString` that represents an Internationalized Resource Identifier (IRI), which extends the ASCII characters subset of the Uniform Resource Identifier (URI).", - "oneOf" : [ { - "type" : "string" - }, { - "type" : "object", - "additionalProperties" : { - "type" : "string" - }, - "propertyNames" : { - "pattern" : "^[a-z]{2}(-[A-Z]{2})?$" - } - } ] - } - } ] - }, - "name" : { - "description" : "The name of the entity or organization.", - "$comment" : "Origin: String (PrimitiveType); Character strings.", - "oneOf" : [ { - "type" : "string" - }, { - "type" : "object", - "additionalProperties" : { - "type" : "string" - }, - "propertyNames" : { - "pattern" : "^[a-z]{2}(-[A-Z]{2})?$" - } - } ] - }, - "url" : { - "description" : "The homepage or social media profile of the entity, whether individual or institutional. Should be a URL/URI Accessible via HTTP.", - "$comment" : "Origin: URI (DerivedType); A `NormalizedString` that respresents a Uniform Resource Identifier (URI).", - "oneOf" : [ { - "type" : "string" - }, { - "type" : "object", - "additionalProperties" : { - "type" : "string" - }, - "propertyNames" : { - "pattern" : "^[a-z]{2}(-[A-Z]{2})?$" - } - } ] - }, - "phone" : { - "description" : "A phone number.", - "$comment" : "Origin: PhoneNumber (DerivedType); A `NormalizedString` representing a phone number.", - "oneOf" : [ { - "type" : "string" - }, { - "type" : "object", - "additionalProperties" : { - "type" : "string" - }, - "propertyNames" : { - "pattern" : "^[a-z]{2}(-[A-Z]{2})?$" - } - } ] - }, - "description" : { - "description" : "A short description of the issuer entity or organization.", - "$comment" : "Origin: String (PrimitiveType); Character strings.", - "oneOf" : [ { - "type" : "string" - }, { - "type" : "object", - "additionalProperties" : { - "type" : "string" - }, - "propertyNames" : { - "pattern" : "^[a-z]{2}(-[A-Z]{2})?$" - } - } ] - }, - "endorsement" : { - "oneOf" : [ { - "$ref" : "#/$defs/EndorsementCredential" - }, { - "type" : "array", - "items" : { - "$ref" : "#/$defs/EndorsementCredential" - } - } ] - }, - "endorsementJwt" : { - "oneOf" : [ { - "description" : "Allows endorsers to make specific claims about the individual or organization represented by this profile. These endorsements are signed with the VC-JWT proof format.", - "$comment" : "Origin: CompactJws (DerivedType); A `String` in Compact JWS format [[RFC7515]].", - "oneOf" : [ { - "type" : "string", - "pattern" : "^[a-zA-Z0-9_-]+\\.[a-zA-Z0-9_-]*\\.[a-zA-Z0-9_-]+$" - }, { - "type" : "object", - "additionalProperties" : { - "type" : "string", - "pattern" : "^[a-zA-Z0-9_-]+\\.[a-zA-Z0-9_-]*\\.[a-zA-Z0-9_-]+$" - }, - "propertyNames" : { - "pattern" : "^[a-z]{2}(-[A-Z]{2})?$" - } - } ] - }, { - "type" : "array", - "items" : { - "description" : "Allows endorsers to make specific claims about the individual or organization represented by this profile. These endorsements are signed with the VC-JWT proof format.", - "$comment" : "Origin: CompactJws (DerivedType); A `String` in Compact JWS format [[RFC7515]].", - "oneOf" : [ { - "type" : "string", - "pattern" : "^[a-zA-Z0-9_-]+\\.[a-zA-Z0-9_-]*\\.[a-zA-Z0-9_-]+$" - }, { - "type" : "object", - "additionalProperties" : { - "type" : "string", - "pattern" : "^[a-zA-Z0-9_-]+\\.[a-zA-Z0-9_-]*\\.[a-zA-Z0-9_-]+$" - }, - "propertyNames" : { - "pattern" : "^[a-z]{2}(-[A-Z]{2})?$" - } - } ] - } - } ] - }, - "image" : { - "$ref" : "#/$defs/Image" - }, - "email" : { - "description" : "An email address.", - "$comment" : "Origin: EmailAddress (DerivedType); A `NormalizedString` representing an email address.", - "oneOf" : [ { - "type" : "string" - }, { - "type" : "object", - "additionalProperties" : { - "type" : "string" - }, - "propertyNames" : { - "pattern" : "^[a-z]{2}(-[A-Z]{2})?$" - } - } ] - }, - "address" : { - "$ref" : "#/$defs/Address" - }, - "otherIdentifier" : { - "oneOf" : [ { - "$ref" : "#/$defs/IdentifierEntry" - }, { - "type" : "array", - "items" : { - "$ref" : "#/$defs/IdentifierEntry" - } - } ] - }, - "official" : { - "description" : "If the entity is an organization, `official` is the name of an authorized official of the organization.", - "$comment" : "Origin: String (PrimitiveType); Character strings.", - "oneOf" : [ { - "type" : "string" - }, { - "type" : "object", - "additionalProperties" : { - "type" : "string" - }, - "propertyNames" : { - "pattern" : "^[a-z]{2}(-[A-Z]{2})?$" - } - } ] - }, - "parentOrg" : { - "$ref" : "#/$defs/Profile" - }, - "familyName" : { - "description" : "Family name. In the western world, often referred to as the 'last name' of a person.", - "$comment" : "Origin: String (PrimitiveType); Character strings.", - "oneOf" : [ { - "type" : "string" - }, { - "type" : "object", - "additionalProperties" : { - "type" : "string" - }, - "propertyNames" : { - "pattern" : "^[a-z]{2}(-[A-Z]{2})?$" - } - } ] - }, - "givenName" : { - "description" : "Given name. In the western world, often referred to as the 'first name' of a person.", - "$comment" : "Origin: String (PrimitiveType); Character strings.", - "oneOf" : [ { - "type" : "string" - }, { - "type" : "object", - "additionalProperties" : { - "type" : "string" - }, - "propertyNames" : { - "pattern" : "^[a-z]{2}(-[A-Z]{2})?$" - } - } ] - }, - "additionalName" : { - "description" : "Additional name. Includes what is often referred to as 'middle name' in the western world.", - "$comment" : "Origin: String (PrimitiveType); Character strings.", - "oneOf" : [ { - "type" : "string" - }, { - "type" : "object", - "additionalProperties" : { - "type" : "string" - }, - "propertyNames" : { - "pattern" : "^[a-z]{2}(-[A-Z]{2})?$" - } - } ] - }, - "patronymicName" : { - "description" : "Patronymic name.", - "$comment" : "Origin: String (PrimitiveType); Character strings.", - "oneOf" : [ { - "type" : "string" - }, { - "type" : "object", - "additionalProperties" : { - "type" : "string" - }, - "propertyNames" : { - "pattern" : "^[a-z]{2}(-[A-Z]{2})?$" - } - } ] - }, - "honorificPrefix" : { - "description" : "Honorific prefix(es) preceding a person's name (e.g. 'Dr', 'Mrs' or 'Mr').", - "$comment" : "Origin: String (PrimitiveType); Character strings.", - "oneOf" : [ { - "type" : "string" - }, { - "type" : "object", - "additionalProperties" : { - "type" : "string" - }, - "propertyNames" : { - "pattern" : "^[a-z]{2}(-[A-Z]{2})?$" - } - } ] - }, - "honorificSuffix" : { - "description" : "Honorific suffix(es) following a person's name (e.g. 'M.D, PhD').", - "$comment" : "Origin: String (PrimitiveType); Character strings.", - "oneOf" : [ { - "type" : "string" - }, { - "type" : "object", - "additionalProperties" : { - "type" : "string" - }, - "propertyNames" : { - "pattern" : "^[a-z]{2}(-[A-Z]{2})?$" - } - } ] - }, - "familyNamePrefix" : { - "description" : "Family name prefix. As used in some locales, this is the leading part of a family name (e.g. 'de' in the name 'de Boer').", - "$comment" : "Origin: String (PrimitiveType); Character strings.", - "oneOf" : [ { - "type" : "string" - }, { - "type" : "object", - "additionalProperties" : { - "type" : "string" - }, - "propertyNames" : { - "pattern" : "^[a-z]{2}(-[A-Z]{2})?$" - } - } ] - }, - "dateOfBirth" : { - "description" : "Birthdate of the person.", - "$comment" : "Origin: Date (PrimitiveType); An [[ISO8601]] calendar date using the syntax YYYY-MM-DD.", - "type" : "string", - "format" : "date" - } - }, - "required" : [ "id", "type" ], - "additionalProperties" : true - }, - "Address" : { - "description" : "An address for the described entity.", - "type" : "object", - "properties" : { - "type" : { - "oneOf" : [ { - "description" : "The value of the type property MUST be an unordered set. One of the items MUST be the IRI 'Address'.", - "$comment" : "Origin: IRI (DerivedType); A `NormalizedString` that represents an Internationalized Resource Identifier (IRI), which extends the ASCII characters subset of the Uniform Resource Identifier (URI).", - "oneOf" : [ { - "type" : "string" - }, { - "type" : "object", - "additionalProperties" : { - "type" : "string" - }, - "propertyNames" : { - "pattern" : "^[a-z]{2}(-[A-Z]{2})?$" - } - } ] - }, { - "type" : "array", - "minItems" : 1, - "contains" : { - "enum" : [ "Address" ] - }, - "items" : { - "description" : "The value of the type property MUST be an unordered set. One of the items MUST be the IRI 'Address'.", - "$comment" : "Origin: IRI (DerivedType); A `NormalizedString` that represents an Internationalized Resource Identifier (IRI), which extends the ASCII characters subset of the Uniform Resource Identifier (URI).", - "oneOf" : [ { - "type" : "string" - }, { - "type" : "object", - "additionalProperties" : { - "type" : "string" - }, - "propertyNames" : { - "pattern" : "^[a-z]{2}(-[A-Z]{2})?$" - } - } ] - } - } ] - }, - "addressCountry" : { - "description" : "A country.", - "$comment" : "Origin: String (PrimitiveType); Character strings.", - "oneOf" : [ { - "type" : "string" - }, { - "type" : "object", - "additionalProperties" : { - "type" : "string" - }, - "propertyNames" : { - "pattern" : "^[a-z]{2}(-[A-Z]{2})?$" - } - } ] - }, - "addressCountryCode" : { - "description" : "A country code. The value must be a ISO 3166-1 alpha-2 country code [[ISO3166-1]].", - "$comment" : "Origin: CountryCode (DerivedType); A two-digit ISO 3166-1 alpha-2 country code [[ISO3166-1]].", - "oneOf" : [ { - "type" : "string" - }, { - "type" : "object", - "additionalProperties" : { - "type" : "string" - }, - "propertyNames" : { - "pattern" : "^[a-z]{2}(-[A-Z]{2})?$" - } - } ] - }, - "addressRegion" : { - "description" : "A region within the country.", - "$comment" : "Origin: String (PrimitiveType); Character strings.", - "oneOf" : [ { - "type" : "string" - }, { - "type" : "object", - "additionalProperties" : { - "type" : "string" - }, - "propertyNames" : { - "pattern" : "^[a-z]{2}(-[A-Z]{2})?$" - } - } ] - }, - "addressLocality" : { - "description" : "A locality within the region.", - "$comment" : "Origin: String (PrimitiveType); Character strings.", - "oneOf" : [ { - "type" : "string" - }, { - "type" : "object", - "additionalProperties" : { - "type" : "string" - }, - "propertyNames" : { - "pattern" : "^[a-z]{2}(-[A-Z]{2})?$" - } - } ] - }, - "streetAddress" : { - "description" : "A street address within the locality.", - "$comment" : "Origin: String (PrimitiveType); Character strings.", - "oneOf" : [ { - "type" : "string" - }, { - "type" : "object", - "additionalProperties" : { - "type" : "string" - }, - "propertyNames" : { - "pattern" : "^[a-z]{2}(-[A-Z]{2})?$" - } - } ] - }, - "postOfficeBoxNumber" : { - "description" : "A post office box number for PO box addresses.", - "$comment" : "Origin: String (PrimitiveType); Character strings.", - "oneOf" : [ { - "type" : "string" - }, { - "type" : "object", - "additionalProperties" : { - "type" : "string" - }, - "propertyNames" : { - "pattern" : "^[a-z]{2}(-[A-Z]{2})?$" - } - } ] - }, - "postalCode" : { - "description" : "A postal code.", - "$comment" : "Origin: String (PrimitiveType); Character strings.", - "oneOf" : [ { - "type" : "string" - }, { - "type" : "object", - "additionalProperties" : { - "type" : "string" - }, - "propertyNames" : { - "pattern" : "^[a-z]{2}(-[A-Z]{2})?$" - } - } ] - }, - "geo" : { - "$ref" : "#/$defs/GeoCoordinates" - } - }, - "required" : [ "type" ], - "additionalProperties" : true - }, - "GeoCoordinates" : { - "description" : "The geographic coordinates of a location.", - "type" : "object", - "properties" : { - "type" : { - "description" : "MUST be the IRI 'GeoCoordinates'.", - "$comment" : "Origin: IRI (DerivedType); A `NormalizedString` that represents an Internationalized Resource Identifier (IRI), which extends the ASCII characters subset of the Uniform Resource Identifier (URI).", - "oneOf" : [ { - "type" : "string", - "enum" : [ "GeoCoordinates" ] - }, { - "type" : "object", - "additionalProperties" : { - "type" : "string", - "enum" : [ "GeoCoordinates" ] - }, - "propertyNames" : { - "pattern" : "^[a-z]{2}(-[A-Z]{2})?$" - } - } ] - }, - "latitude" : { - "description" : "The latitude of the location [[WGS84]].", - "$comment" : "Origin: Float (PrimitiveType)", - "type" : "number" - }, - "longitude" : { - "description" : "The longitude of the location [[WGS84]].", - "$comment" : "Origin: Float (PrimitiveType)", - "type" : "number" - } - }, - "required" : [ "type", "latitude", "longitude" ], - "additionalProperties" : true - }, - "CredentialSchema" : { - "description" : "Identify the type and location of a data schema.", - "type" : "object", - "properties" : { - "id" : { - "description" : "The value MUST be a URI identifying the schema file. One instance of `CredentialSchema` MUST have an `id` that is the URL of the JSON Schema for this credential defined by this specification.", - "$comment" : "Origin: URI (DerivedType); A `NormalizedString` that respresents a Uniform Resource Identifier (URI).", - "oneOf" : [ { - "type" : "string" - }, { - "type" : "object", - "additionalProperties" : { - "type" : "string" - }, - "propertyNames" : { - "pattern" : "^[a-z]{2}(-[A-Z]{2})?$" - } - } ] - }, - "type" : { - "description" : "The value MUST identify the type of data schema validation. One instance of `CredentialSchema` MUST have a `type` of '1EdTechJsonSchemaValidator2019'.", - "$comment" : "Origin: IRI (DerivedType); A `NormalizedString` that represents an Internationalized Resource Identifier (IRI), which extends the ASCII characters subset of the Uniform Resource Identifier (URI).", - "oneOf" : [ { - "type" : "string" - }, { - "type" : "object", - "additionalProperties" : { - "type" : "string" - }, - "propertyNames" : { - "pattern" : "^[a-z]{2}(-[A-Z]{2})?$" - } - } ] - } - }, - "required" : [ "id", "type" ], - "additionalProperties" : true - } - } -} \ No newline at end of file diff --git a/test/browser/pages-like-server.js b/test/browser/pages-like-server.js index f27caa2..b35a8af 100644 --- a/test/browser/pages-like-server.js +++ b/test/browser/pages-like-server.js @@ -11,7 +11,7 @@ * wallet does with every credential. */ import { execFileSync } from 'node:child_process'; -import { copyFileSync, mkdtempSync, readdirSync, readFileSync, rmSync } from 'node:fs'; +import { mkdtempSync, readdirSync, readFileSync, rmSync } from 'node:fs'; import { createServer } from 'node:http'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -47,21 +47,11 @@ for (const [signal, code] of [['SIGINT', 130], ['SIGTERM', 143]]) { const files = (() => { const out = mkdtempSync(join(tmpdir(), 'pages-like-')); try { - // The credentials name this server's copy of the Open Badges schema, so - // the tests using them need no request interception. They can't use any: - // once a Playwright route is active, Chromium stops refusing preflighted - // requests across sites (checked 1 October 2026), so a route here would - // hide exactly the failure these tests exist to catch. - copyFileSync( - fileURLToPath(new URL('./fixtures/ob_v3p0_achievementcredential_schema.json', import.meta.url)), - join(out, 'schema.json'), - ); execFileSync(process.execPath, [fileURLToPath(new URL('../../scripts/make-fixtures.js', import.meta.url))], { env: { ...process.env, FIXTURES_OUT: out, FIXTURES_STATUS_LIST_URL: `${ORIGIN}/status-list.json`, - FIXTURES_SCHEMA_URL: `${ORIGIN}/schema.json`, }, // Quiet unless it fails: Playwright shows only that the server never came up. stdio: ['ignore', 'ignore', 'inherit'], diff --git a/test/browser/states.spec.ts b/test/browser/states.spec.ts index e57c173..c3d2ec5 100644 --- a/test/browser/states.spec.ts +++ b/test/browser/states.spec.ts @@ -1,7 +1,7 @@ import { readFileSync } from 'node:fs'; import { test, expect, type Page } from '@playwright/test'; import { CHECK, PROBLEM } from '../../src/types.js'; -import { card, preparePages, purl, settle } from './support.js'; +import { card, preparePages, settle } from './support.js'; /** * Drives the real component against really-signed credentials, in a real @@ -59,8 +59,8 @@ test('an altered credential is an error and asks for a fresh copy', async ({ pag /** * The one state verifier-core itself calls a pass. Its schema result never * reaches `verified`, so this whole case is invisible to anything reading the - * log alone — and it only shows up here because the schema was really - * fetched and really validated against. + * log alone — and it only shows up here because the credential was really + * validated against the Open Badges schema bundled with the library. */ test('a credential built wrong is a warning, and asks nothing of the holder', async ({ page }) => { const c = await pick(page, 'Built wrong'); @@ -741,20 +741,9 @@ test.describe('a withdrawal list on another site', () => { const OTHER_SITE = 'http://127.0.0.1:5182'; - // Any active Playwright route stops Chromium refusing preflighted requests - // across sites, so the schema route (in support.ts) has to go. These - // credentials name the other site's copy of the schema instead, and - // listening (unlike routing) leaves the browser's rules alone. - // Unrouted, nothing stops a schema fetch from reaching purl, so here even - // that one counts: these credentials name the other site's copy. - test.beforeEach(async ({ page }) => { - await page.unroute('https://purl.imsglobal.org/**'); - purl.length = 0; - }); - - test.afterEach(() => { - expect(purl, 'requests to purl.imsglobal.org').toEqual([]); - }); + // No routes here: any active Playwright route stops Chromium refusing + // preflighted requests across sites (checked 1 October 2026), which would + // hide exactly the failure these tests exist to catch. /** Gives the card a credential from the other site; returns the library's seal and status checks. */ const fromOtherSite = async (page: Page, file: string) => { @@ -790,7 +779,7 @@ test.describe('a withdrawal list on another site', () => { }); const credential = await (await fetch(url)).json(); // Both in one go: set apart, the registries alone would re-check the - // demo's own credential, which fetches the schema from purl. + // demo's own credential first. el.registries = [ { name: 'Local Dev Registry', type: 'dcc-legacy', url: new URL('./fixtures/registry.json', location.href).href }, ]; diff --git a/test/browser/support.ts b/test/browser/support.ts index 661041a..446605a 100644 --- a/test/browser/support.ts +++ b/test/browser/support.ts @@ -1,4 +1,3 @@ -import { fileURLToPath } from 'node:url'; import { test, expect, type Page } from '@playwright/test'; /** Reads the card once the verification that follows an action has landed. */ @@ -23,26 +22,20 @@ export const card = (page: Page) => }); /** - * verifier-core fetches the Open Badges schema from purl.imsglobal.org on - * every verification, uncached. On 1 October 2026 one fetch took 52s and - * timed out 11 tests, so the tests serve a copy instead, downloaded from - * SCHEMA on that date (Last-Modified 23 October 2025). To refresh it, - * download SCHEMA over the copy. Any other request to that host is refused - * and fails the test, so a new dependency on it shows up straight away - * rather than as a slow run. Nate plans to drop this schema check from - * verifier-core; once it is gone, delete the copy and this route, and the - * schema plumbing for the cross-site tests in states.spec.ts: its copy in - * pages-like-server.js, FIXTURES_SCHEMA_URL in scripts/make-fixtures.js, and - * its empty override in scripts/build-site.js. + * Every request to purl.imsglobal.org, where the Open Badges schemas live, + * seen by listening, which (unlike a route) changes nothing. + * + * Until 2.0.0-beta.1 verifier-core fetched a schema from there on every + * verification, uncached; on 1 October 2026 one fetch took 52s and timed out + * 11 tests, and the tests served a copy. It now validates against schemas + * bundled with it, so any request there fails the test: a new dependency on + * that host shows up straight away rather than as a slow run. */ -export const SCHEMA = 'https://purl.imsglobal.org/spec/ob/v3p0/schema/json/ob_v3p0_achievementcredential_schema.json'; -const SCHEMA_COPY = fileURLToPath(new URL('./fixtures/ob_v3p0_achievementcredential_schema.json', import.meta.url)); -/** Every request to purl.imsglobal.org, seen by listening, which (unlike a route) changes nothing. */ export const purl: string[] = []; /** - * Sets up every test in the calling file: the schema copy above, and a count - * of finished verifications (`window.__done`) for settle() to wait on. + * Sets up every test in the calling file: the watch on purl above, and a + * count of finished verifications (`window.__done`) for settle() to wait on. * Before the page is opened, which each file does its own way. */ export const preparePages = () => { @@ -51,11 +44,6 @@ export const preparePages = () => { page.on('request', (r) => { if (new URL(r.url()).hostname === 'purl.imsglobal.org') purl.push(r.url()); }); - await page.route('https://purl.imsglobal.org/**', (route) => - route.request().url() === SCHEMA - ? route.fulfill({ path: SCHEMA_COPY, headers: { 'access-control-allow-origin': '*' } }) - : route.abort('blockedbyclient'), - ); await page.addInitScript(() => { window.__done = 0; document.addEventListener('verification-complete', (e) => { @@ -67,9 +55,6 @@ export const preparePages = () => { }); test.afterEach(() => { - expect( - purl.filter((url) => url !== SCHEMA), - 'requests to purl.imsglobal.org other than the schema', - ).toEqual([]); + expect(purl, 'requests to purl.imsglobal.org').toEqual([]); }); }; From abf51157481f5b3cc9e1d5acc0d6a2cdea6ab9a0 Mon Sep 17 00:00:00 2001 From: Sunny Lee <1424627+threeqube@users.noreply.github.com> Date: Wed, 21 Oct 2026 10:10:17 -0700 Subject: [PATCH 03/17] Docs: verifier-core 2.0.0-beta.1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The README's library note, its account of the schema copy and two rows of its file table; the inventory's note on what is on npm; and an update to requirements §5 on why an expired credential's seal still holds. Co-Authored-By: Claude Opus 5.5 --- README.md | 16 +++++++++------- docs/planning/inventory.md | 4 ++-- docs/planning/requirements.md | 6 ++++++ 3 files changed, 17 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index f9bddf6..ea40a6d 100644 --- a/README.md +++ b/README.md @@ -137,9 +137,10 @@ From the standup on 21 September 2026: separated, and `skipped`-with-a-reason and a per-check `fatal` flag as first-class, both of which had been hand-built here. - 2.x is not on npm yet, so `package.json` pins a commit on - digitalcredentials/verifier-core `main`, d217b04 (#22). It is bundled into - `dist/`, so a host app never installs it. + Since 21 October 2026 `package.json` pins the published + `2.0.0-beta.1`, built from digitalcredentials/verifier-core `main` + e86bb79; before, 2.x was not on npm and it pinned a commit (d217b04, + #22). It is bundled into `dist/`, so a host app never installs it. - **veri-good is a reference, not a foundation.** It was an experiment, it targets a different setting (an issuer's own web page), and nobody uses it. Worth learning from, not worth inheriting. @@ -163,9 +164,10 @@ tested.** It was a 1.x problem. 2.x fetches with a bare `fetch(url)`, so nothing is preflighted, and a page on localhost reads the withdrawal lists on GitHub Pages correctly. The browser tests cover it against a second local server that refuses preflights the way GitHub Pages does -(`test/browser/pages-like-server.js`). Separately, the tests now serve a copy +(`test/browser/pages-like-server.js`). Separately, the tests served a copy of the Open Badges schema instead of fetching it, after one request took 52 -seconds. The rest of this section is the 21 September record of 1.x, kept as +seconds; since 2.0.0-beta.1 the library bundles the schemas and fetches +none, and the copy is gone. The rest of this section is the 21 September record of 1.x, kept as it was: it no longer describes how things stand. **One real problem: fetching a status list fails in the browser.** The request @@ -248,8 +250,8 @@ A first slice: a real credential in, real verification, one card out. | `src/jsx.ts` | Tells a React host's JSX about ``; types only | | `test/outcomes.test.ts` | The mapping, and the ways a good credential can be made to look bad | | `test/fixtures.test.ts` | The demo's credentials are well-formed Open Badges, apart from the one built wrong on purpose | -| `test/did-web-outages.test.ts` | An issuer's website that won't serve its key reads as unreachable, not tampered (stopgap for verifier-core#65) | -| `test/consistency.test.ts` | 1,500 combinations asserting the headline and the breakdown can never disagree | +| `test/did-web-outages.test.ts` | An issuer's website that won't serve its key reads as unreachable, not tampered (verifier-core#65, fixed in 2.0.0-beta.1) | +| `test/consistency.test.ts` | 38,880 combinations asserting the headline and the breakdown can never disagree | | `test/browser/states.spec.ts` | Drives the component in a real browser, locally or against a published copy | | `test/browser/link.spec.ts` | The demo page opened from a link to a credential | | `test/browser/support.ts` | Page setup both browser specs share | diff --git a/docs/planning/inventory.md b/docs/planning/inventory.md index ae24b02..794dee9 100644 --- a/docs/planning/inventory.md +++ b/docs/planning/inventory.md @@ -13,8 +13,8 @@ non-blocking severity; it is **not published to npm**, so anything depending on it depends on a git reference until it lands upstream. *Since then (October 2026):* the fork's work has landed upstream, as 2.x on -`digitalcredentials/verifier-core`'s main. 2.x is still not on npm (the latest -there is 1.0.0-beta.11), so verifier-plugin depends on it by git commit. This +`digitalcredentials/verifier-core`'s main, and was published to npm as +2.0.0-beta.1 on 21 October, which verifier-plugin now depends on. This document still describes 1.0.0-beta.11. A note on wording: the library's own code calls each individual check a diff --git a/docs/planning/requirements.md b/docs/planning/requirements.md index f5b9339..ed6194a 100644 --- a/docs/planning/requirements.md +++ b/docs/planning/requirements.md @@ -536,6 +536,12 @@ stays green, and the reassurance can be said. Only the Dates row reports a problem. (Established from the library's code, 30 September 2026, and pinned by a test that alters an expired credential and checks it reads as tampered.) +*Update, 21 October 2026:* verifier-core 2.0.0-beta.1 checks the dates in a +check of their own, which runs only once the seal has verified, so an expired +credential no longer fails the signature at all. The rows read as above. And +expiry no longer stops the checks after it, so an expired credential now also +reports whether it was deactivated and who issued it. + **The name at the top follows the same rule.** When the seal didn't hold — an altered credential, or one with no seal at all — the issuer's name in the card's header carries "(unconfirmed)", as it already does for an issuer no From 1255fc8ac529b81afa6a4c5f3866b339771a5547 Mon Sep 17 00:00:00 2001 From: Sunny Lee <1424627+threeqube@users.noreply.github.com> Date: Wed, 21 Oct 2026 10:16:31 -0700 Subject: [PATCH 04/17] Review fixes for the verifier-core 2.0.0-beta.1 move - Dates that went unchecked while the seal held now lead over the issuer verdicts. A dates check that throws reports CHECK_ERROR, not a date, and "Genuine, but we can't confirm who issued it" sat above a Dates row reading "not checked". The consistency suite gains the rule that would have caught it, and outcomes.test.ts pins the outcome. - The halted reason names the check's id within its suite ("Not run: proof.signature failed"), as the library writes it; the consistency fixture and halted()'s note had the full id. - The browser tests refuse requests to purl.imsglobal.org again, rather than leaving one to the network before failing; the cross-site tests drop the refusal, as before, and keep the watch. - Three comments that described the stopgap, the schema fetch, or a fatal failure ending only its own suite. Co-Authored-By: Claude Opus 5.5 --- src/outcomes.ts | 22 ++++++++++-------- src/verifier-credential.ts | 6 ++--- src/verify.ts | 4 ++-- test/browser/states.spec.ts | 12 ++++++---- test/browser/support.ts | 6 +++-- test/consistency.test.ts | 11 ++++++++- test/outcomes.test.ts | 45 +++++++++++++++++++++++++++++++++++++ 7 files changed, 85 insertions(+), 21 deletions(-) diff --git a/src/outcomes.ts b/src/outcomes.ts index 73ebad0..353d5bd 100644 --- a/src/outcomes.ts +++ b/src/outcomes.ts @@ -192,8 +192,9 @@ const failed = (check: CheckResult | undefined): boolean => * are fatal too, but since 2.0.0-beta.1 they don't halt: everything after * them still reports. * - * Read off the skip reason, "Not run: failed": the library marks a - * halted check no other way. Getting it wrong costs only which of two "not + * Read off the skip reason, "Not run: proof.signature failed" — the halting + * check's id within its suite, not the full one: the library marks a halted + * check no other way. Getting it wrong costs only which of two "not * checked" wordings a row uses. */ const halted = (check: CheckResult | undefined): boolean => @@ -967,6 +968,16 @@ export const summarise = (r: VerificationResponse, options: OutcomeOptions = {}) return code ? { code, ...FATAL[code]! } : { code: 'signature_unchecked', ...UNCHECKED_SIGNATURE }; } + // The seal held, so the dates check ran — unless something stopped it, or it + // failed without naming a date (a check that throws reports CHECK_ERROR). + // Then we don't know whether this is in force, which is as much of an + // unknown as the withdrawal list, and outranks who issued it: "Genuine, but + // we can't confirm who issued it" would say nothing of the dates the Dates + // row reports unchecked. + if (sealHeld(checks) && !passed(checks.get(CHECK.validityPeriod))) { + return { code: 'dates_unchecked', severity: 'unchecked', ...COULD_NOT_CHECK }; + } + const revocationError = withdrawalUnavailable(revocation); if (hasStatusList(r) && !notWithdrawn(checks)) { if (listNotIssuers(checks)) { @@ -1073,13 +1084,6 @@ export const summarise = (r: VerificationResponse, options: OutcomeOptions = {}) return { code: 'signature_unchecked', ...UNCHECKED_SIGNATURE }; } - // The dates have their own check since 2.0.0-beta.1, which runs whenever - // the signature passes. Absent anyway, "Verified" would sit above a Dates - // row reading "not checked". - if (!passed(checks.get(CHECK.validityPeriod))) { - return { code: 'dates_unchecked', severity: 'unchecked', ...COULD_NOT_CHECK }; - } - return { severity: 'success', code: 'verified', diff --git a/src/verifier-credential.ts b/src/verifier-credential.ts index 7ab4249..ba0d838 100644 --- a/src/verifier-credential.ts +++ b/src/verifier-credential.ts @@ -538,9 +538,9 @@ export class VerifierCredential extends HTMLElement { if (failed) { return `

We couldn’t check this, so there are no details to show. ${esc(VIEWS[1].label)} shows the error.

`; } - // Verification that stopped early has no rows to show — but it did not - // stop the library: a fatal failure ends only its own suite, and the - // others still run and report. So this says there is no breakdown, not + // Verification that stopped early has no rows to show — but the library + // still reports every check: the ones that ran, and the ones a fatal + // failure stopped, as "Not run". So this says there is no breakdown, not // that nothing was looked at, and the developer view has every check. return this.#checks.length ? `
${this.#checks.map(checkHtml).join('')}
` diff --git a/src/verify.ts b/src/verify.ts index 3d4f49c..ea5f6f8 100644 --- a/src/verify.ts +++ b/src/verify.ts @@ -59,8 +59,8 @@ export interface VerifyOptions { * reachable from the library or the published demo site, whose builds replace * `import.meta.env.DEV` with `false` and drop this along with it. * - * But the dev page and the browser tests serve their registry, withdrawal list - * and schema from localhost, so under the dev server they need a fetcher that + * But the dev page and the browser tests serve their registry and withdrawal + * list from localhost, so under the dev server they need a fetcher that * will go there. It is a bare `fetch(url)`, like the built-in one: no headers, * so no CORS preflight, which is what lets a withdrawal list on another site * load at all (see below). It returns parsed JSON when the body is JSON and the diff --git a/test/browser/states.spec.ts b/test/browser/states.spec.ts index c3d2ec5..794d935 100644 --- a/test/browser/states.spec.ts +++ b/test/browser/states.spec.ts @@ -695,9 +695,9 @@ test.describe('lifecycle, from review', () => { /** * A did:web issuer publishes its signing key in a did.json on its own website. - * When that won't load, verifier-core calls the signature invalid - * (verifier-core#65), and verify.ts's namingDidWebOutages turns it back into - * "couldn't reach". The credential is vc-test-fixtures' badDidWeb.json (see + * When that won't load, verifier-core reports DID_WEB_UNRESOLVED, which reads + * "couldn't reach" (verifier-core#65, fixed in 2.0.0-beta.1; until then it + * called the signature invalid, and verify.ts carried a stopgap). The credential is vc-test-fixtures' badDidWeb.json (see * test/did-web-outages.test.ts). Its issuer's did.json is answered here, so * nothing reaches the real site. Against a published copy, this exercises * the built-in fetcher that builds use. @@ -743,7 +743,11 @@ test.describe('a withdrawal list on another site', () => { // No routes here: any active Playwright route stops Chromium refusing // preflighted requests across sites (checked 1 October 2026), which would - // hide exactly the failure these tests exist to catch. + // hide exactly the failure these tests exist to catch. So support.ts's + // refusal of purl goes; its watch, which only listens, stays. + test.beforeEach(async ({ page }) => { + await page.unroute('https://purl.imsglobal.org/**'); + }); /** Gives the card a credential from the other site; returns the library's seal and status checks. */ const fromOtherSite = async (page: Page, file: string) => { diff --git a/test/browser/support.ts b/test/browser/support.ts index 446605a..c84cfdf 100644 --- a/test/browser/support.ts +++ b/test/browser/support.ts @@ -28,8 +28,9 @@ export const card = (page: Page) => * Until 2.0.0-beta.1 verifier-core fetched a schema from there on every * verification, uncached; on 1 October 2026 one fetch took 52s and timed out * 11 tests, and the tests served a copy. It now validates against schemas - * bundled with it, so any request there fails the test: a new dependency on - * that host shows up straight away rather than as a slow run. + * bundled with it, so any request there fails the test, and is refused + * rather than left to the network: a new dependency on that host shows up + * straight away rather than as a slow run. */ export const purl: string[] = []; @@ -44,6 +45,7 @@ export const preparePages = () => { page.on('request', (r) => { if (new URL(r.url()).hostname === 'purl.imsglobal.org') purl.push(r.url()); }); + await page.route('https://purl.imsglobal.org/**', (route) => route.abort('blockedbyclient')); await page.addInitScript(() => { window.__done = 0; document.addEventListener('verification-complete', (e) => { diff --git a/test/consistency.test.ts b/test/consistency.test.ts index 9d0bb1e..8d6d711 100644 --- a/test/consistency.test.ts +++ b/test/consistency.test.ts @@ -192,7 +192,7 @@ type Sch = 'valid' | 'invalid' | 'no_schema' | 'unavailable' | 'missing' | 'halt * stops after a failed signature, so after it none of these run. Expiry and * a withdrawal are fatal too, but don't stop anything. */ -const NOT_RUN = 'Not run: cryptographic.proof.signature failed'; +const NOT_RUN = 'Not run: proof.signature failed'; const SIGNATURES: Sig[] = ['passed', 'tampered', 'key_mismatch', 'unattributable', 'missing']; const VALIDITIES: Val[] = ['in_date', 'expired', 'not_yet_valid', 'halted', 'missing']; @@ -691,6 +691,15 @@ describe(`the headline and the breakdown agree (${cases.length} combinations)`, expect(row(CHECK.status)?.value, `${where}: the verdict and the Status row give different reasons`).toBe(expected); } + // Dates the seal vouched for but nobody checked can't sit under a verdict + // that settles everything else: not "Verified", and not one about who + // issued it. Both rows read "unchecked" there, so severities can't see it. + if (row(CHECK.signature)?.severity === 'success' && row(DATES_ROW)?.severity === 'unchecked') { + expect(['verified', 'issuer_unconfirmed', 'registry_unreachable'], `${where}: the dates went unchecked`).not.toContain( + out.code, + ); + } + if (CLAIMS_UNCHANGED.test(claims)) { expect( row(CHECK.signature)?.severity, diff --git a/test/outcomes.test.ts b/test/outcomes.test.ts index f6adbcf..adbda84 100644 --- a/test/outcomes.test.ts +++ b/test/outcomes.test.ts @@ -1794,6 +1794,51 @@ describe('an expired credential, now that expiry stops nothing after it', () => }); }); +describe('dates nobody checked, though the seal held', () => { + // A check that throws comes back as a failure carrying CHECK_ERROR, with no + // date named; a run limited to some phases leaves no row at all. + const datesThrew = (): CheckResult => + fail( + CHECK.validityPeriod, + [{ type: 'https://www.w3.org/TR/vc-data-model#CHECK_ERROR', title: 'Check Error', detail: 'Check "proof.validity-period" threw: x' }], + true, + ); + + it.each([ + ['the dates check threw', (r: VerificationResponse) => set(r, datesThrew())], + ['no dates check reported', (r: VerificationResponse) => remove(r, CHECK.validityPeriod)], + ])('says it could not check, when %s', (_, spoil) => { + const r = ok(); + spoil(r); + expect(summarise(r)).toEqual({ + code: 'dates_unchecked', + severity: 'unchecked', + headline: "We couldn't check this right now", + detail: "Something went wrong while we were checking, so we can't tell you yet whether it's valid.", + action: 'Try again in a little while.', + }); + expect(row(r, CHECK.validityPeriod)).toMatchObject({ severity: 'unchecked', value: 'not checked' }); + // The seal still held, and the Tampering row says so. + expect(row(r, CHECK.signature)).toMatchObject({ severity: 'success', value: 'none detected' }); + }); + + it('outranks who issued it', () => { + // "Genuine, but we can't confirm who issued it" would settle the dates by + // saying nothing of them. + const r = ok(); + set(r, datesThrew()); + set(r, notRegistered()); + expect(summarise(r).code).toBe('dates_unchecked'); + }); + + it('yields to a withdrawal, which the issuer has already decided', () => { + const r = ok(); + set(r, datesThrew()); + set(r, revokedBy(PROBLEM.revoked, 'Credential Revoked')); + expect(summarise(r).code).toBe('withdrawn'); + }); +}); + describe('findings from the review of the verifier-core main adaptation', () => { const NOT_RUN = (id: string) => `Not run: ${id} failed`; From f77157be29abc1e53c62a2696bea29035e4eafa4 Mon Sep 17 00:00:00 2001 From: Sunny Lee <1424627+threeqube@users.noreply.github.com> Date: Wed, 21 Oct 2026 11:17:12 -0700 Subject: [PATCH 05/17] Second review: a date is a finding only behind a held seal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Expiry and a start date to come count only while the seal held. Until 2.0.0-beta.1 that came free, since the dates were judged inside the signature check; now an expired dates row beside an unchecked seal read "Expired on …" as fact. - dates_unchecked yields to the withdrawal list's unknowns, whose words are more specific and say whose it is to fix, and still leads over who issued it. - The seal is tested once: below that branch it held, so the repeated seal conditions and the unreachable signature_unchecked tail go. Tests: the "date nothing vouched for" test now reports an expiry and checks it isn't believed, rather than skipping the check. The consistency suite gains a dates check that threw, a rule that a date is reported only behind a held seal, one that an expiry the library didn't report never appears (the end date now tested), and an allow-list for unchecked dates. Each net was mutation-checked: 2517, 1, 226 and 9019 failures for the four regressions it guards. Co-Authored-By: Claude Opus 5.5 --- src/outcomes.ts | 58 ++++++++++++++++++++-------------------- test/consistency.test.ts | 50 ++++++++++++++++++++++++++++------ test/outcomes.test.ts | 20 +++++++++++++- 3 files changed, 90 insertions(+), 38 deletions(-) diff --git a/src/outcomes.ts b/src/outcomes.ts index 353d5bd..85dce86 100644 --- a/src/outcomes.ts +++ b/src/outcomes.ts @@ -760,11 +760,17 @@ const reassurance = ( * credential's own end date and the library's prose to tell them apart. Now * the library judges the dates against its own clock and says which, and our * clock second-guessing it could only produce a headline it disagrees with. + * + * Only while the seal held. The library checks the dates only after it, but + * the rule must not depend on that staying true: without the seal the dates + * are the credential's own claim, and "Expired on …" would state as fact + * something nothing vouched for. Until 2.0.0-beta.1 this came free, since the + * dates were judged inside the signature check. */ const isExpired = (checks: Map): boolean => - hasProblem(checks.get(CHECK.validityPeriod), PROBLEM.credentialExpired); + sealHeld(checks) && hasProblem(checks.get(CHECK.validityPeriod), PROBLEM.credentialExpired); const isNotYetValid = (checks: Map): boolean => - hasProblem(checks.get(CHECK.validityPeriod), PROBLEM.credentialNotYetValid); + sealHeld(checks) && hasProblem(checks.get(CHECK.validityPeriod), PROBLEM.credentialNotYetValid); /** The end date, written out, or nothing if there isn't a readable one. */ const expiryDate = (r: VerificationResponse): string | undefined => @@ -958,8 +964,9 @@ export const summarise = (r: VerificationResponse, options: OutcomeOptions = {}) // // An unchecked seal outranks all of these: it is the more serious unknown, // and verifier-core halts the withdrawal check after it, so "the withdrawal - // check never ran" would be true but beside the point. - if (!passed(signature) && !sealHeld(checks)) { + // check never ran" would be true but beside the point. Everything below + // this has a seal that held. + if (!sealHeld(checks)) { const code = hasProblem(signature, PROBLEM.didWebUnresolved) ? 'did_web_unresolved' : hasProblem(signature, PROBLEM.httpError) @@ -968,16 +975,6 @@ export const summarise = (r: VerificationResponse, options: OutcomeOptions = {}) return code ? { code, ...FATAL[code]! } : { code: 'signature_unchecked', ...UNCHECKED_SIGNATURE }; } - // The seal held, so the dates check ran — unless something stopped it, or it - // failed without naming a date (a check that throws reports CHECK_ERROR). - // Then we don't know whether this is in force, which is as much of an - // unknown as the withdrawal list, and outranks who issued it: "Genuine, but - // we can't confirm who issued it" would say nothing of the dates the Dates - // row reports unchecked. - if (sealHeld(checks) && !passed(checks.get(CHECK.validityPeriod))) { - return { code: 'dates_unchecked', severity: 'unchecked', ...COULD_NOT_CHECK }; - } - const revocationError = withdrawalUnavailable(revocation); if (hasStatusList(r) && !notWithdrawn(checks)) { if (listNotIssuers(checks)) { @@ -1014,6 +1011,16 @@ export const summarise = (r: VerificationResponse, options: OutcomeOptions = {}) }; } + // The seal held, so the dates check ran — unless something stopped it, or it + // failed without naming a date (a check that throws reports CHECK_ERROR). + // Then we don't know whether this is in force. Below the withdrawal list, + // whose words are more specific and can say whose it is to fix; above who + // issued it, since "Genuine, but we can't confirm who issued it" would say + // nothing of the dates the Dates row reports unchecked. + if (!passed(checks.get(CHECK.validityPeriod))) { + return { code: 'dates_unchecked', severity: 'unchecked', ...COULD_NOT_CHECK }; + } + // An unreachable registry only leaves us in doubt if nothing else answered. // A match and an unreachable registry are not mutually exclusive: with more // than one registry the check passes and still reports the one it could not @@ -1024,11 +1031,9 @@ export const summarise = (r: VerificationResponse, options: OutcomeOptions = {}) // through to `issuer_unconfirmed` stated a negative we never tested — // "they aren't on our list of known issuers" — which is the §5 mistake. // - // Only while the seal held. Without it the issuer row says the seal is - // the problem, not the list, and so must the headline: the seal is the - // more serious unknown, and it falls through to `signature_unchecked`. + // Only while the seal held, which it has by here. Without it the issuer row + // says the seal is the problem, not the list, and so does the headline. if ( - sealHeld(checks) && (issuer.registriesUnreachable || issuer.source === 'unknown') && !passed(checks.get(CHECK.registeredIssuer)) ) { @@ -1052,13 +1057,12 @@ export const summarise = (r: VerificationResponse, options: OutcomeOptions = {}) }; } - // `passed(signature)` is a condition of this outcome, not an afterthought: + // A passed signature is a condition of this outcome, not an afterthought: // its first word is "Genuine", and the sentence under it says nothing has // changed since issue. Neither is ours to say until the signature check has - // reported. Without it we fall through to `signature_unchecked` below, - // which is the more serious unknown and should lead anyway — §5 is explicit + // reported, which is why the unchecked seal returned above. §5 is explicit // that an unlisted issuer is common and means nothing is wrong. - if (!passed(checks.get(CHECK.registeredIssuer)) && passed(signature)) { + if (!passed(checks.get(CHECK.registeredIssuer))) { // requirements.md §5: one sentence, not two verdicts, and ⓘ rather than ⚠. // Nothing is wrong here. Something is unknown. const says = @@ -1077,13 +1081,9 @@ export const summarise = (r: VerificationResponse, options: OutcomeOptions = {}) }; } - // Everything left is a pass — but only for the checks that actually - // reported. A step missing from the log establishes nothing, and listChecks - // shows it as "not checked"; the headline must not say otherwise. - if (!passed(signature)) { - return { code: 'signature_unchecked', ...UNCHECKED_SIGNATURE }; - } - + // Everything left is a pass, and only for checks that actually reported: + // the seal, the dates and the issuer each returned above unless they + // passed, and the withdrawal list unless it passed or there was none. return { severity: 'success', code: 'verified', diff --git a/test/consistency.test.ts b/test/consistency.test.ts index 8d6d711..bec91de 100644 --- a/test/consistency.test.ts +++ b/test/consistency.test.ts @@ -151,7 +151,7 @@ type Sig = 'passed' | 'tampered' | 'key_mismatch' | 'unattributable' | 'missing' * dimension of its own too, because the headline names it and must not * invent an expiry the library didn't report. */ -type Val = 'in_date' | 'expired' | 'not_yet_valid' | 'halted' | 'missing'; +type Val = 'in_date' | 'expired' | 'not_yet_valid' | 'errored' | 'halted' | 'missing'; /** * `unreadable`: a status type the library does not recognise, so it skips. * `+other`: the list loaded and was read, but the issuer didn't sign it — the @@ -195,7 +195,7 @@ type Sch = 'valid' | 'invalid' | 'no_schema' | 'unavailable' | 'missing' | 'halt const NOT_RUN = 'Not run: proof.signature failed'; const SIGNATURES: Sig[] = ['passed', 'tampered', 'key_mismatch', 'unattributable', 'missing']; -const VALIDITIES: Val[] = ['in_date', 'expired', 'not_yet_valid', 'halted', 'missing']; +const VALIDITIES: Val[] = ['in_date', 'expired', 'not_yet_valid', 'errored', 'halted', 'missing']; const REVOCATIONS: Rev[] = [ 'passed', 'revoked', @@ -287,6 +287,22 @@ const validityCheck = (val: Val): CheckResult | undefined => { return undefined; case 'halted': return check(CHECK.validityPeriod, { status: 'skipped', reason: NOT_RUN }, true); + // A check that throws comes back as a failure naming no date. + case 'errored': + return check( + CHECK.validityPeriod, + { + status: 'failure', + problems: [ + { + type: 'https://www.w3.org/TR/vc-data-model#CHECK_ERROR', + title: 'Check Error', + detail: 'Check "proof.validity-period" threw: x', + }, + ], + }, + true, + ); case 'in_date': return check( CHECK.validityPeriod, @@ -691,13 +707,31 @@ describe(`the headline and the breakdown agree (${cases.length} combinations)`, expect(row(CHECK.status)?.value, `${where}: the verdict and the Status row give different reasons`).toBe(expected); } - // Dates the seal vouched for but nobody checked can't sit under a verdict - // that settles everything else: not "Verified", and not one about who - // issued it. Both rows read "unchecked" there, so severities can't see it. + // Dates the seal vouched for but nobody checked leave only verdicts that + // outrank them: a finding, or an unknown about the withdrawal list. Not + // "Verified", and not one about who issued it. Both rows read "unchecked" + // there, so severities can't see it. Listed by what is allowed, so a new + // or reordered verdict fails here rather than slipping past. if (row(CHECK.signature)?.severity === 'success' && row(DATES_ROW)?.severity === 'unchecked') { - expect(['verified', 'issuer_unconfirmed', 'registry_unreachable'], `${where}: the dates went unchecked`).not.toContain( - out.code, - ); + expect( + ['dates_unchecked', 'withdrawn', 'suspended', 'malformed', 'withdrawal_unknown'], + `${where}: the dates went unchecked`, + ).toContain(out.code); + } + + // A date is a finding only behind a seal that held: otherwise it is the + // credential's own claim. The library halts after a failed seal, but the + // rule must not depend on that. + if (out.code === 'expired' || out.code === 'not_yet_valid') { + expect(row(CHECK.signature)?.severity, `${where}: a date reported without the seal`).toBe('success'); + } + + // Only the library says a credential has run out. An end date in the + // past that it didn't report must not become an "Expired" headline: our + // clock second-guessing its own is what this rules out. + if (val !== 'expired') { + expect(out.code, `${where}: an expiry the library didn't report`).not.toBe('expired'); + expect(`${out.headline} ${out.detail}`, `${where}: an expiry the library didn't report`).not.toMatch(/expired/i); } if (CLAIMS_UNCHANGED.test(claims)) { diff --git a/test/outcomes.test.ts b/test/outcomes.test.ts index adbda84..af06fa5 100644 --- a/test/outcomes.test.ts +++ b/test/outcomes.test.ts @@ -1272,8 +1272,18 @@ describe('findings from the review of the 2.x migration', () => { true, ), ); - set(r, skip(CHECK.validityPeriod, 'Not run: proof.signature failed')); + // Reported expired all the same: the library halts after a failed seal, + // but the rule must not depend on that staying true. + set( + r, + fail( + CHECK.validityPeriod, + [{ type: PROBLEM.credentialExpired, title: 'Credential Expired', detail: 'after "validUntil"' }], + true, + ), + ); expect(summarise(r).code).not.toBe('expired'); + expect(summarise(r).headline).not.toMatch(/expired/i); expect(listChecks(r).find((c) => c.id === CHECK.validityPeriod)?.severity).toBe( 'unchecked', ); @@ -1831,6 +1841,14 @@ describe('dates nobody checked, though the seal held', () => { expect(summarise(r).code).toBe('dates_unchecked'); }); + it('yields to a withdrawal list we could not rely on, whose words say whose it is to fix', () => { + const r = ok(); + set(r, datesThrew()); + add(r, listSignedByAnother()); + expect(summarise(r).code).toBe('withdrawal_unknown'); + expect(summarise(r).action).toContain('know'); + }); + it('yields to a withdrawal, which the issuer has already decided', () => { const r = ok(); set(r, datesThrew()); From 14d3aac3220f43f786ad18758599152a9258ca79 Mon Sep 17 00:00:00 2001 From: Sunny Lee <1424627+threeqube@users.noreply.github.com> Date: Wed, 21 Oct 2026 11:22:12 -0700 Subject: [PATCH 06/17] Third review: no date behind a seal that didn't hold, in the row too - The Dates row believes the dates check only while the seal held, as the headline now does: behind an unheld seal it reads "not checked", "in date" included, as it did before 2.0.0-beta.1. - Comments: the expired branch's seal always holds now; dates_unchecked's ordering note says what the withdrawal unknowns give that it doesn't. Tests: consistency rules that the Dates row reads "not checked" behind an unheld seal, and that no verdict but "Expired" talks of expiry (7021 and 11600 failures under mutation); unit tests for a start date and an "in date" behind a missing seal, and for dates_unchecked yielding to a withdrawal check that couldn't finish. Co-Authored-By: Claude Opus 5.5 --- src/outcomes.ts | 17 +++++++++-------- test/consistency.test.ts | 11 ++++++++++- test/outcomes.test.ts | 28 ++++++++++++++++++++++++++++ 3 files changed, 47 insertions(+), 9 deletions(-) diff --git a/src/outcomes.ts b/src/outcomes.ts index 85dce86..760acd6 100644 --- a/src/outcomes.ts +++ b/src/outcomes.ts @@ -889,8 +889,8 @@ export const summarise = (r: VerificationResponse, options: OutcomeOptions = {}) severity: 'warning', code: 'expired', headline: on ? `Expired on ${on}` : 'This has passed its end date', - // Only what reported: the seal held (the library checks dates only after - // it), so this normally says it hasn't been tampered with. It is assembled + // Only what reported: the seal held (isExpired requires it), so this + // always says it hasn't been tampered with. It is assembled // rather than written out so that it appears when the checks behind it // did pass, and stays absent when they did not. Nothing fills the gap // otherwise: "Its dates have run out" only restated the headline. @@ -1013,9 +1013,9 @@ export const summarise = (r: VerificationResponse, options: OutcomeOptions = {}) // The seal held, so the dates check ran — unless something stopped it, or it // failed without naming a date (a check that throws reports CHECK_ERROR). - // Then we don't know whether this is in force. Below the withdrawal list, - // whose words are more specific and can say whose it is to fix; above who - // issued it, since "Genuine, but we can't confirm who issued it" would say + // Then we don't know whether this is in force. Below the withdrawal list's + // unknowns, which name what we couldn't check and, for a list someone else + // signed, whose it is to fix; above who issued it, since "Genuine, but we can't confirm who issued it" would say // nothing of the dates the Dates row reports unchecked. if (!passed(checks.get(CHECK.validityPeriod))) { return { code: 'dates_unchecked', severity: 'unchecked', ...COULD_NOT_CHECK }; @@ -1215,9 +1215,10 @@ export const listChecks = (r: VerificationResponse, options: OutcomeOptions = {} } // Read off the same check as the verdict, which is what keeps the two from - // disagreeing. It runs only after the signature passed, so after a seal - // that failed it reads "not checked". - const dates = checks.get(CHECK.validityPeriod); + // disagreeing. It runs only after the signature passed, and is believed only + // then: behind a seal that failed the dates are the credential's own claim, + // so the row reads "not checked", "in date" included. + const dates = sealHeld(checks) ? checks.get(CHECK.validityPeriod) : undefined; const expiredNow = isExpired(checks); const notYet = !expiredNow && isNotYetValid(checks); const end = expiryDate(r); diff --git a/test/consistency.test.ts b/test/consistency.test.ts index bec91de..68a2569 100644 --- a/test/consistency.test.ts +++ b/test/consistency.test.ts @@ -726,12 +726,21 @@ describe(`the headline and the breakdown agree (${cases.length} combinations)`, expect(row(CHECK.signature)?.severity, `${where}: a date reported without the seal`).toBe('success'); } + // ...and the Dates row says the same: no date at all, "in date" + // included, behind a seal that didn't hold. + if (row(CHECK.signature)?.severity !== 'success') { + expect(row(DATES_ROW)?.value, `${where}: the Dates row behind an unheld seal`).toBe('not checked'); + } + // Only the library says a credential has run out. An end date in the // past that it didn't report must not become an "Expired" headline: our // clock second-guessing its own is what this rules out. if (val !== 'expired') { expect(out.code, `${where}: an expiry the library didn't report`).not.toBe('expired'); - expect(`${out.headline} ${out.detail}`, `${where}: an expiry the library didn't report`).not.toMatch(/expired/i); + } + // And no verdict but "Expired" talks of expiry, whatever was reported. + if (out.code !== 'expired') { + expect(`${out.headline} ${out.detail}`, `${where}: talks of expiry under ${out.code}`).not.toMatch(/expired/i); } if (CLAIMS_UNCHANGED.test(claims)) { diff --git a/test/outcomes.test.ts b/test/outcomes.test.ts index af06fa5..f937196 100644 --- a/test/outcomes.test.ts +++ b/test/outcomes.test.ts @@ -1284,6 +1284,27 @@ describe('findings from the review of the 2.x migration', () => { ); expect(summarise(r).code).not.toBe('expired'); expect(summarise(r).headline).not.toMatch(/expired/i); + expect(listChecks(r).find((c) => c.id === CHECK.validityPeriod)).toMatchObject({ + severity: 'unchecked', + value: 'not checked', + }); + }); + + it.each([ + ['a start date to come', PROBLEM.credentialNotYetValid, 'not_yet_valid'], + ['no date problem at all', undefined, 'verified'], + ])('does not assert %s behind a seal nobody checked', (_, type, code) => { + // No signature step at all, and dates the library says are wrong or fine: + // either way they are the credential's own claim. + const r = ok(); + remove(r, CHECK.signature); + if (type) set(r, fail(CHECK.validityPeriod, [{ type, title: 't', detail: 'd' }], true)); + expect(summarise(r).code).not.toBe(code); + expect(summarise(r).code).toBe('signature_unchecked'); + expect(listChecks(r).find((c) => c.id === CHECK.validityPeriod)).toMatchObject({ + severity: 'unchecked', + value: 'not checked', + }); expect(listChecks(r).find((c) => c.id === CHECK.validityPeriod)?.severity).toBe( 'unchecked', ); @@ -1841,6 +1862,13 @@ describe('dates nobody checked, though the seal held', () => { expect(summarise(r).code).toBe('dates_unchecked'); }); + it('yields to a withdrawal check that could not finish, which names what we could not check', () => { + const r = ok(); + set(r, datesThrew()); + set(r, skip(CHECK.status, 'Status type "StatusList2021Entry" is not BitstringStatusListEntry.')); + expect(summarise(r).code).toBe('withdrawal_unknown'); + }); + it('yields to a withdrawal list we could not rely on, whose words say whose it is to fix', () => { const r = ok(); set(r, datesThrew()); From 4efceab34a9a12d2ace79dbc9088b595b40e061e Mon Sep 17 00:00:00 2001 From: Sunny Lee <1424627+threeqube@users.noreply.github.com> Date: Wed, 21 Oct 2026 11:24:19 -0700 Subject: [PATCH 07/17] Fourth review: comment, wrapping, and a wider expiry net The expired branch's comment no longer says its reassurance can be absent; the dates_unchecked note is re-wrapped. "No verdict but Expired talks of expiry" now reads the action too, and matches "run out", "end date" and "renew" as well as "expired" (caught a "renewed" action under signature_unchecked by mutation). A duplicate assertion goes. Co-Authored-By: Claude Opus 5.5 --- src/outcomes.ts | 12 ++++++------ test/consistency.test.ts | 4 +++- test/outcomes.test.ts | 3 --- 3 files changed, 9 insertions(+), 10 deletions(-) diff --git a/src/outcomes.ts b/src/outcomes.ts index 760acd6..7a170ac 100644 --- a/src/outcomes.ts +++ b/src/outcomes.ts @@ -890,10 +890,9 @@ export const summarise = (r: VerificationResponse, options: OutcomeOptions = {}) code: 'expired', headline: on ? `Expired on ${on}` : 'This has passed its end date', // Only what reported: the seal held (isExpired requires it), so this - // always says it hasn't been tampered with. It is assembled - // rather than written out so that it appears when the checks behind it - // did pass, and stays absent when they did not. Nothing fills the gap - // otherwise: "Its dates have run out" only restated the headline. + // always says it hasn't been tampered with, and adds that the issuer + // hasn't deactivated it only when the withdrawal list came back clear. + // "Its dates have run out" only restated the headline. detail: reassurance(r, checks).trim(), action: `Ask ${named ?? 'the issuer'} whether it can be renewed.`, }; @@ -1015,8 +1014,9 @@ export const summarise = (r: VerificationResponse, options: OutcomeOptions = {}) // failed without naming a date (a check that throws reports CHECK_ERROR). // Then we don't know whether this is in force. Below the withdrawal list's // unknowns, which name what we couldn't check and, for a list someone else - // signed, whose it is to fix; above who issued it, since "Genuine, but we can't confirm who issued it" would say - // nothing of the dates the Dates row reports unchecked. + // signed, whose it is to fix; above who issued it, since "Genuine, but we + // can't confirm who issued it" would say nothing of the dates the Dates row + // reports unchecked. if (!passed(checks.get(CHECK.validityPeriod))) { return { code: 'dates_unchecked', severity: 'unchecked', ...COULD_NOT_CHECK }; } diff --git a/test/consistency.test.ts b/test/consistency.test.ts index 68a2569..95eaf3e 100644 --- a/test/consistency.test.ts +++ b/test/consistency.test.ts @@ -740,7 +740,9 @@ describe(`the headline and the breakdown agree (${cases.length} combinations)`, } // And no verdict but "Expired" talks of expiry, whatever was reported. if (out.code !== 'expired') { - expect(`${out.headline} ${out.detail}`, `${where}: talks of expiry under ${out.code}`).not.toMatch(/expired/i); + expect(`${out.headline} ${out.detail} ${out.action ?? ''}`, `${where}: talks of expiry under ${out.code}`).not.toMatch( + /expir|run out|end date|renew/i, + ); } if (CLAIMS_UNCHANGED.test(claims)) { diff --git a/test/outcomes.test.ts b/test/outcomes.test.ts index f937196..0de235d 100644 --- a/test/outcomes.test.ts +++ b/test/outcomes.test.ts @@ -1305,9 +1305,6 @@ describe('findings from the review of the 2.x migration', () => { severity: 'unchecked', value: 'not checked', }); - expect(listChecks(r).find((c) => c.id === CHECK.validityPeriod)?.severity).toBe( - 'unchecked', - ); }); it('separates a malformed credential from one whose vocabulary cannot be read', () => { From 487b553fba69b9d60a152f233d7ba929e657e7cd Mon Sep 17 00:00:00 2001 From: Sunny Lee <1424627+threeqube@users.noreply.github.com> Date: Wed, 21 Oct 2026 12:22:07 -0700 Subject: [PATCH 08/17] Test a credential with several seals against the real library verifier-core#67, fixed in 2.0.0-beta.1: each seal in a proof set is checked and all must hold. The demo's verified credential, given a second copy of its seal with one character changed, reads as tampered whichever of the two is broken; with two good copies it holds. Making isTampered return false fails both tampered cases. Co-Authored-By: Claude Opus 5.5 --- test/problem-types.test.ts | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/test/problem-types.test.ts b/test/problem-types.test.ts index 7fa9972..93895e3 100644 --- a/test/problem-types.test.ts +++ b/test/problem-types.test.ts @@ -108,6 +108,36 @@ describe('what a person is told, end to end', () => { }); }); +/** + * A credential can carry several seals, a proof set. Since 2.0.0-beta.1 + * (verifier-core#67) each is checked and all must hold: before, one good + * seal beside a broken one read as verified. A broken one reads as + * tampering, whichever position it is in. + */ +describe('a credential with several seals', () => { + const sealed = fixture('verified'); + const seal = sealed['proof'] as Record; + const value = seal['proofValue'] as string; + // One character changed near the end, so the seal still parses but no longer matches. + const broken = { ...seal, proofValue: `${value.slice(0, -2)}${value.at(-2) === 'A' ? 'B' : 'A'}${value.at(-1)}` }; + + it.each([ + ['second', [seal, broken]], + ['first', [broken, seal]], + ])('reads as tampered when the %s of two is broken', async (_, proof) => { + const r = await verify({ ...sealed, proof }); + expect(problemTypes(r, CHECK.signature)).toEqual([PROBLEM.invalidSignature]); + expect(summarise(r).code).toBe('invalid_signature'); + expect(listChecks(r).find((c) => c.id === CHECK.signature)?.value).toBe('detected'); + }); + + it('holds when both seals do', async () => { + const r = await verify({ ...sealed, proof: [seal, { ...seal }] }); + expect(status(r, CHECK.signature)).toBe('success'); + expect(listChecks(r).find((c) => c.id === CHECK.signature)?.value).toBe('none detected'); + }); +}); + describe('a credential the library rejects for its shape', () => { it('stops at the structure check, and says so rather than blaming us', async () => { const c = fixture('withdrawn'); From 46a45f75120b0551a72afcef1eba66fa9cc909b7 Mon Sep 17 00:00:00 2001 From: Sunny Lee <1424627+threeqube@users.noreply.github.com> Date: Thu, 22 Oct 2026 10:53:41 -0700 Subject: [PATCH 09/17] Read a status list someone else signed like the issuer's own DCC's own status service (status-list-lambda) signs every list with its own key, and the batch issuer signs each batch with a fresh did:key, so their lists never match their credentials' issuer. Since #22 we refused such a list, so every batch-issued credential read "We couldn't check whether this copy is still active", and with 2.0.0-beta.1 a deactivated one did too, instead of "deactivated". Nate Otto, 22 October 2026: a mismatch is a warning, not recommended practice; show it as one "until we get our productionized issuer capabilities set up in the next couple months". So by default the list is read like any other, with the same words, and the mismatch is the library's warning in the Developer view. The strict rule stays behind OutcomeOptions.requireIssuerSignedStatusList, for when DCC's issuers sign their own lists. Meanwhile we can't notice a list re-signed by someone else, which for these credentials the check couldn't tell from the normal case anyway. One switch, believedChecks: summarise and listChecks both act on the checks less the list-issuer one unless asked, so they can't disagree. Tests: the strict tests run with the setting on; new ones show a list someone else signed reads exactly as the issuer's own, clear, deactivated or on hold. The consistency suite runs those combinations both ways, with rules that by default they read exactly as the issuer's own list and, strict, believe nothing. Mutation: ignoring the setting fails 14568 tests, dropping the default 14571, the rows not following 14571. Demo: "Untrusted list" goes, since it would read like "Not withdrawn"; its fixture stays, and the browser test loads it directly and checks Verified, with "Failed: list-issuer" in the Developer view. Co-Authored-By: Claude Opus 5.5 --- dev/main.ts | 1 - scripts/make-fixtures.js | 9 +++--- src/outcomes.ts | 54 ++++++++++++++++++++++++++------- src/types.ts | 6 ++-- test/browser/states.spec.ts | 44 +++++++++++++++++---------- test/consistency.test.ts | 60 ++++++++++++++++++++++++++++--------- test/outcomes.test.ts | 49 ++++++++++++++++++++++-------- 7 files changed, 163 insertions(+), 60 deletions(-) diff --git a/dev/main.ts b/dev/main.ts index e93de95..544cc39 100644 --- a/dev/main.ts +++ b/dev/main.ts @@ -49,7 +49,6 @@ const SITUATIONS: Situation[] = [ { label: 'Built wrong', file: 'malformed', registries: LOCAL_REGISTRY, note: 'genuine, but missing a field its standard requires' }, { label: 'Changed', file: 'tampered', registries: LOCAL_REGISTRY, note: 'altered after issuing' }, { label: 'Not their seal', file: 'not-their-seal', registries: LOCAL_REGISTRY, note: "names the issuer, signed with someone else's key" }, - { label: 'Untrusted list', file: 'list-not-issuers', registries: LOCAL_REGISTRY, note: "its withdrawal list isn't signed by the issuer" }, { label: 'No signature', file: 'unsigned', registries: LOCAL_REGISTRY, note: 'nothing to check' }, ]; diff --git a/scripts/make-fixtures.js b/scripts/make-fixtures.js index 7083183..d626a5d 100644 --- a/scripts/make-fixtures.js +++ b/scripts/make-fixtures.js @@ -282,11 +282,10 @@ const main = async () => { ); // --- withdrawal list not the issuer's ------------------------------------- - // Not marked on it: the case verifier-core reports. A mark would end its - // status suite at the withdrawal check, which is fatal, before the check - // that compares the list's signer runs — so a stranger's "withdrawn" still - // reads as the issuer's, and nothing in the result says otherwise (raised - // upstream). outcomes.ts handles that case too, once the library reports it. + // Clear on a list signed by another key. Read like the issuer's own by + // default, with the library's mismatch warning in the Developer view; only + // `requireIssuerSignedStatusList` refuses it. Not a demo button, since it + // reads like "Not withdrawn"; the browser tests load it directly. await write( 'list-not-issuers', await sign( diff --git a/src/outcomes.ts b/src/outcomes.ts index 7a170ac..40a3216 100644 --- a/src/outcomes.ts +++ b/src/outcomes.ts @@ -49,9 +49,26 @@ export interface Check { * the issuer was never looked up (see `VerifyOptions`). A lookup that then * never answered reads as "our list of known issuers didn't load", which is * exactly what happened, rather than the vaguer "we couldn't finish checking". + * + * `requireIssuerSignedStatusList`: believe a withdrawal list only when the + * credential's own issuer signed it. Off, which is the default, a list signed + * by someone else is read like any other, as verifier-core does, and the + * mismatch shows only in the Developer view as the library's warning. + * + * Off because DCC's own status service signs every list with its own key + * (status-list-lambda), and the batch issuer signs each batch with a fresh + * did:key, so their lists never match their credentials' issuer: on, every + * one of those reads "we couldn't check whether this copy is still active". + * Nate Otto, 22 October 2026: a mismatch is a warning, not recommended + * practice, to be shown as one "until we get our productionized issuer + * capabilities set up in the next couple months". Turn it on, or make it the + * default, once DCC's issuers sign their own lists. Meanwhile we can't notice + * a list re-signed by someone else, which for these credentials the check + * couldn't tell from the normal case anyway. */ export interface OutcomeOptions { registriesUnavailable?: boolean; + requireIssuerSignedStatusList?: boolean; } /** @@ -246,20 +263,34 @@ const anyUnreachable = (check: CheckResult | undefined): boolean => (check?.outcome.status === 'success' && unreachableNames(check).length > 0); /** - * Whether the withdrawal list is someone else's. + * Whether the withdrawal list is someone else's, and we were asked to care. * * verifier-core reports it from its own check, after the list has loaded and - * been read, and leaves the withdrawal check's own result as it was. See - * PROBLEM.statusListIssuerMismatch for why we repeat neither answer. + * been read, and leaves the withdrawal check's own result as it was. With + * `requireIssuerSignedStatusList` we repeat neither answer (see + * PROBLEM.statusListIssuerMismatch); without it, `believedChecks` drops that + * check, so this is false and the list is read like any other. * - * Both halves reach us since 2.0.0-beta.1. A clean pass from a stranger's - * list reads "we couldn't check", and so now does a *mark* on one: until - * then the withdrawal check halted on a mark, the signer was never compared, - * and a stranger's "withdrawn" read as the issuer's (raised 7 October 2026). + * Both halves reach us since 2.0.0-beta.1: a clean pass from a stranger's + * list, and a *mark* on one. Until then the withdrawal check halted on a + * mark, and the signer was never compared (raised 7 October 2026). */ const listNotIssuers = (checks: Map): boolean => hasProblem(checks.get(CHECK.statusListIssuer), PROBLEM.statusListIssuerMismatch); +/** + * The checks the headline and the breakdown act on: all of them, less the + * list-issuer check unless `requireIssuerSignedStatusList` asks for it. Done + * once, here, so the two can't disagree about whose list is believed. The + * Developer view reads the library's results directly, so it still shows the + * warning either way. + */ +const believedChecks = (r: VerificationResponse, options: OutcomeOptions): Map => { + const checks = checksById(r); + if (!options.requireIssuerSignedStatusList) checks.delete(CHECK.statusListIssuer); + return checks; +}; + /** * The issuer has actually withdrawn this. * @@ -808,7 +839,7 @@ const dateText = (raw: unknown): string | undefined => { * the issuer has already decided. */ export const summarise = (r: VerificationResponse, options: OutcomeOptions = {}): Outcome => { - const checks = checksById(r); + const checks = believedChecks(r, options); if (stoppedEarly(r)) { // Our codes are ours, and stable. Which of the four core checks failed is @@ -958,8 +989,9 @@ export const summarise = (r: VerificationResponse, options: OutcomeOptions = {}) // green "Verified" whose detail said the issuer had not withdrawn it above // a row saying we could not check. // - // A list signed by someone other than the issuer lands here too, whatever it - // said: withdrawn, suspended or clear, it isn't the issuer's word. + // With requireIssuerSignedStatusList, a list signed by someone other than + // the issuer lands here too, whatever it said: withdrawn, suspended or + // clear, it isn't the issuer's word. // // An unchecked seal outranks all of these: it is the more serious unknown, // and verifier-core halts the withdrawal check after it, so "the withdrawal @@ -1099,7 +1131,7 @@ export const summarise = (r: VerificationResponse, options: OutcomeOptions = {}) export const listChecks = (r: VerificationResponse, options: OutcomeOptions = {}): Check[] => { if (stoppedEarly(r)) return []; - const checks = checksById(r); + const checks = believedChecks(r, options); const issuer = issuerIdentity(r, options); const rows: Check[] = []; diff --git a/src/types.ts b/src/types.ts index 2ba616a..88aec57 100644 --- a/src/types.ts +++ b/src/types.ts @@ -172,9 +172,11 @@ export const PROBLEM = { * The withdrawal list was signed by someone other than the credential's * issuer. verifier-core reports it as a warning and leaves `verified` alone, * because some status services sign every list with their own DID, and says - * consumers who need the binding should treat it as decisive. We do: whoever + * consumers who need the binding should treat it as decisive. Whoever * controls the list's host could otherwise mark a credential withdrawn, or - * un-withdraw it, so its answer is not ours to repeat either way. + * un-withdraw it. We treat it as decisive only with + * `requireIssuerSignedStatusList`, off for now because DCC's own status + * service is one of those services: see OutcomeOptions. */ statusListIssuerMismatch: 'https://www.w3.org/TR/vc-data-model#STATUS_LIST_ISSUER_MISMATCH', } as const; diff --git a/test/browser/states.spec.ts b/test/browser/states.spec.ts index 794d935..24691dc 100644 --- a/test/browser/states.spec.ts +++ b/test/browser/states.spec.ts @@ -159,21 +159,35 @@ test("a seal made with someone else's key is not called tampering", async ({ pag }); /** - * Clear on that list. Read on trust, this would be Verified — "the issuer - * hasn't withdrawn it" — on the word of whoever signed the list, who could - * as easily have un-withdrawn it. - * - * Not tested the other way round: a mark on a stranger's list ends - * verifier-core's status suite before the signer is compared, so the library - * reports a plain withdrawal (raised upstream). + * A list signed by someone other than the issuer, and clear. Read like the + * issuer's own by default (Nate Otto, 22 October 2026: DCC's own status + * service signs every list with its own key), so it reads Verified; the + * mismatch is the library's warning, in the Developer view. Not a demo button + * any more, since it would look exactly like "Not withdrawn". */ -test("a withdrawal list the issuer didn't sign is not believed", async ({ page }) => { - const c = await pick(page, 'Untrusted list'); - expect(c.severity).toBe('unchecked'); - expect(c.headline).toContain("We couldn't check whether this copy is still active"); - expect(c.detail).toContain('The status information we found may not be from the issuer'); - expect(c.severity).not.toBe('success'); - expect(await detailRows(page)).toContainEqual(expect.stringContaining('the information we found may not be from the issuer')); +test("a withdrawal list the issuer didn't sign is read like theirs, and flagged for developers", async ({ page }) => { + await settle(page, async () => { + await page.evaluate(async () => { + const el = document.getElementById('vc') as HTMLElement & { credential?: unknown; registries?: unknown }; + const credential = await (await fetch('./fixtures/list-not-issuers.json')).json(); + el.registries = [ + { name: 'Local Dev Registry', type: 'dcc-legacy', url: new URL('./fixtures/registry.json', location.href).href }, + ]; + el.credential = credential; + }); + }); + const c = await card(page); + expect(c.severity).toBe('success'); + expect(c.headline).toContain('Verified'); + expect(c.detail).toContain("the issuer hasn't deactivated it"); + expect(await detailRows(page)).toContainEqual(expect.stringContaining('not deactivated or on hold')); + + await viewButton(page, 'Developer view').click(); + const text = await page.evaluate( + () => document.getElementById('vc')!.shadowRoot!.querySelector('.dev')!.textContent!.replace(/\s+/g, ' '), + ); + expect(text).toContain('Failed: list-issuer'); + expect(text).toContain('STATUS_LIST_ISSUER_MISMATCH'); }); test('an unrecognised issuer is never called fake', async ({ page }) => { @@ -222,7 +236,7 @@ const rowLayout = (page: Page) => }), ); -for (const situation of ['Registry offline', 'Untrusted list']) { +for (const situation of ['Registry offline', 'Not their seal']) { test(`each Details mark stays beside its text when the text wraps (${situation})`, async ({ page }) => { await page.setViewportSize({ width: 360, height: 900 }); await pick(page, situation); diff --git a/test/consistency.test.ts b/test/consistency.test.ts index 95eaf3e..46cd383 100644 --- a/test/consistency.test.ts +++ b/test/consistency.test.ts @@ -558,6 +558,15 @@ const build = (sig: Sig, val: Val, rev: Rev, end: End, iss: Iss, sch: Sch): Veri */ const HOST_LIST: ('loaded' | 'unavailable')[] = ['loaded', 'unavailable']; +/** + * Whose withdrawal lists are believed. `any` is the default; `issuers` is + * `requireIssuerSignedStatusList`, the stricter rule kept for when DCC's + * issuers sign their own lists. It only changes anything for a list someone + * else signed, so only those combinations run both ways. + */ +type Lists = 'any' | 'issuers'; +const listsFor = (rev: Rev): Lists[] => (rev.endsWith('+other') ? ['any', 'issuers'] : ['any']); + /** * A check is only ever skipped as "not run" because one before it failed * fatally and halted: since 2.0.0-beta.1 that is the signature, for @@ -576,16 +585,19 @@ const cases = SIGNATURES.flatMap((sig) => ENDS.flatMap((end) => ISSUERS.flatMap((iss) => SCHEMAS.filter((sch) => coherent(sig, val, rev, iss, sch)).flatMap((sch) => - HOST_LIST.map((list) => ({ - sig, - val, - rev, - end, - iss, - sch, - list, - name: `${sig}/${val}/${rev}/${end}/${iss}/${sch}/${list}`, - })), + HOST_LIST.flatMap((list) => + listsFor(rev).map((lists) => ({ + sig, + val, + rev, + end, + iss, + sch, + list, + lists, + name: `${sig}/${val}/${rev}/${end}/${iss}/${sch}/${list}${lists === 'issuers' ? '/issuers-lists-only' : ''}`, + })), + ), ), ), ), @@ -596,9 +608,9 @@ const cases = SIGNATURES.flatMap((sig) => const DATES_ROW = CHECK.validityPeriod; describe(`the headline and the breakdown agree (${cases.length} combinations)`, () => { - it.each(cases)('$name', ({ sig, val, rev, end, iss, sch, list, name }) => { + it.each(cases)('$name', ({ sig, val, rev, end, iss, sch, list, lists, name }) => { const r = build(sig, val, rev, end, iss, sch); - const options = { registriesUnavailable: list === 'unavailable' }; + const options = { registriesUnavailable: list === 'unavailable', requireIssuerSignedStatusList: lists === 'issuers' }; const out = summarise(r, options); const rows = listChecks(r, options); const row = (id: string) => rows.find((c) => c.id === id); @@ -606,6 +618,26 @@ describe(`the headline and the breakdown agree (${cases.length} combinations)`, expect(rows.length, `${where}: verification ran, so there must be rows`).toBeGreaterThan(0); + // By default a list someone else signed is read like the issuer's own: + // the same headline and the same rows as without the mismatch, whatever + // the list said and whatever else happened. + if (lists === 'any' && rev.endsWith('+other')) { + const own = build(sig, val, rev.replace('+other', '') as Rev, end, iss, sch); + expect(out, `${where}: reads differently from the issuer's own list`).toEqual(summarise(own, options)); + expect(rows, `${where}: rows differ from the issuer's own list`).toEqual(listChecks(own, options)); + } + + // With only the issuer's own lists believed, a list someone else signed + // settles nothing either way: not "deactivated", not "on hold", not "the + // issuer hasn't deactivated it". + if (lists === 'issuers' && rev.endsWith('+other')) { + expect(['withdrawn', 'suspended'], `${where}: believed a list the issuer didn't sign`).not.toContain(out.code); + expect(row(CHECK.status)?.severity, `${where}: the Status row believed a list the issuer didn't sign`).toBe('unchecked'); + expect(`${out.headline} ${out.detail}`, `${where}: believed a list the issuer didn't sign`).not.toMatch( + /hasn['’]t (?:withdrawn|deactivated)/, + ); + } + // A clean verdict cannot sit above a row reporting a problem. if (out.severity === 'success') { for (const c of rows) { @@ -829,10 +861,10 @@ const NAMELESS: [string, unknown][] = [ describe(`an issuer with no name is never addressed by its identifier (${cases.length} combinations each)`, () => { it.each(NAMELESS)('%s', (_, issuer) => { - for (const { sig, val, rev, end, iss, sch, list, name } of cases) { + for (const { sig, val, rev, end, iss, sch, list, lists, name } of cases) { const r = build(sig, val, rev, end, iss, sch); r.verifiableCredential = { ...r.verifiableCredential, issuer }; - const out = summarise(r, { registriesUnavailable: list === 'unavailable' }); + const out = summarise(r, { registriesUnavailable: list === 'unavailable', requireIssuerSignedStatusList: lists === 'issuers' }); // Two spaces in a row: an empty name dropped into a sentence. for (const sentence of [out.headline, out.detail, out.action ?? '']) { expect(sentence, `${name} → ${out.code}`).not.toMatch(/did:|Unknown issuer|\s\s/); diff --git a/test/outcomes.test.ts b/test/outcomes.test.ts index 0de235d..e6060ed 100644 --- a/test/outcomes.test.ts +++ b/test/outcomes.test.ts @@ -1578,7 +1578,32 @@ describe('a suspended credential', () => { }); }); -describe('a withdrawal list signed by someone other than the issuer', () => { +/** Only a list the credential's own issuer signed is believed: off by default (see OutcomeOptions). */ +const STRICT = { requireIssuerSignedStatusList: true } as const; +const strictRow = (r: VerificationResponse, id: string) => listChecks(r, STRICT).find((c) => c.id === id); + +describe('a withdrawal list signed by someone other than the issuer, by default', () => { + // Read like any other (Nate Otto, 22 October 2026): DCC's own status + // service signs every list with its own key. The mismatch is the library's + // warning in the Developer view; the headline and rows don't change. + it.each([ + ['clear', () => pass(CHECK.status, 'Credential status is valid (not revoked or suspended).'), 'verified', 'not deactivated or on hold'], + ['deactivated', () => revokedBy(PROBLEM.revoked, 'Credential Revoked'), 'withdrawn', 'deactivated by the issuer'], + ['on hold', () => revokedBy(PROBLEM.suspended, 'Credential Suspended'), 'suspended', 'put on hold by the issuer'], + ])('reads a list that says %s exactly as the issuer\'s own', (_, status, code, value) => { + const own = ok(); + set(own, status()); + const others = ok(); + set(others, status()); + add(others, listSignedByAnother()); + expect(summarise(others)).toEqual(summarise(own)); + expect(listChecks(others)).toEqual(listChecks(own)); + expect(summarise(others).code).toBe(code); + expect(row(others, CHECK.status)?.value).toBe(value); + }); +}); + +describe('a withdrawal list signed by someone other than the issuer, when only the issuer\'s own is believed', () => { // A mismatch on a list that marked the credential arrives since // 2.0.0-beta.1: before, a mark ended the status suite before the signer was // compared, and only a list that passed reported it. @@ -1586,25 +1611,25 @@ describe('a withdrawal list signed by someone other than the issuer', () => { const r = ok(); set(r, revokedBy(PROBLEM.revoked, 'Credential Revoked')); add(r, listSignedByAnother()); - const out = summarise(r); + const out = summarise(r, STRICT); expect(out).toMatchObject({ severity: 'unchecked', code: 'withdrawal_unknown' }); expect(out.detail).toContain('The status information we found may not be from the issuer'); // Retrying cannot fix a list signed by the wrong key. expect(out.action).not.toContain('Try again'); - expect(row(r, CHECK.status)).toMatchObject({ + expect(strictRow(r, CHECK.status)).toMatchObject({ severity: 'unchecked', value: "couldn't check — the information we found may not be from the issuer", }); expect(out.action).toBe("Let Springfield College know we couldn't check whether this copy is still active."); // The earner doesn't know there is a list; that belongs in the Developer view. - expect(`${out.detail} ${out.action} ${row(r, CHECK.status)?.value}`).not.toMatch(/\blist(s|ed)?\b/i); + expect(`${out.detail} ${out.action} ${strictRow(r, CHECK.status)?.value}`).not.toMatch(/\blist(s|ed)?\b/i); }); it('does not put a bare identifier mid-sentence when the issuer gives no name', () => { const r = ok(); r.verifiableCredential = { ...r.verifiableCredential, issuer: 'did:key:z6Mkn' }; add(r, listSignedByAnother()); - const out = summarise(r); + const out = summarise(r, STRICT); expect(out.code).toBe('withdrawal_unknown'); expect(out.detail).toContain('may not be from the issuer'); expect(out.detail).not.toContain('did:key'); @@ -1614,7 +1639,7 @@ describe('a withdrawal list signed by someone other than the issuer', () => { const r = ok(); set(r, revokedBy(PROBLEM.suspended, 'Credential Suspended')); add(r, listSignedByAnother()); - expect(summarise(r).code).toBe('withdrawal_unknown'); + expect(summarise(r, STRICT).code).toBe('withdrawal_unknown'); }); it('is not repeated when it says the credential is clear, either', () => { @@ -1622,20 +1647,20 @@ describe('a withdrawal list signed by someone other than the issuer', () => { // issuer hasn't withdrawn it" would take a stranger's word for it. const r = ok(); add(r, listSignedByAnother()); - const out = summarise(r); + const out = summarise(r, STRICT); expect(out.code).toBe('withdrawal_unknown'); expect(`${out.headline} ${out.detail}`).not.toMatch(/hasn['’]t (?:withdrawn|deactivated)/); - expect(row(r, CHECK.status)?.severity).toBe('unchecked'); + expect(strictRow(r, CHECK.status)?.severity).toBe('unchecked'); // The case verifier-core reports today, so pin its wording here too. expect(out.detail).toContain('The status information we found may not be from the issuer'); - expect(`${out.detail} ${out.action} ${row(r, CHECK.status)?.value}`).not.toMatch(/\blist(s|ed)?\b/i); + expect(`${out.detail} ${out.action} ${strictRow(r, CHECK.status)?.value}`).not.toMatch(/\blist(s|ed)?\b/i); }); it("keeps an expired credential's reassurance to the seal", () => { const r = ok(); expire(r); add(r, listSignedByAnother()); - const out = summarise(r); + const out = summarise(r, STRICT); expect(out.code).toBe('expired'); expect(out.detail).toBe("It hasn't been tampered with."); }); @@ -1870,8 +1895,8 @@ describe('dates nobody checked, though the seal held', () => { const r = ok(); set(r, datesThrew()); add(r, listSignedByAnother()); - expect(summarise(r).code).toBe('withdrawal_unknown'); - expect(summarise(r).action).toContain('know'); + expect(summarise(r, STRICT).code).toBe('withdrawal_unknown'); + expect(summarise(r, STRICT).action).toContain('know'); }); it('yields to a withdrawal, which the issuer has already decided', () => { From 4d52dfd5cb069fd38b37bcd50cfb952b2d1c828a Mon Sep 17 00:00:00 2001 From: Sunny Lee <1424627+threeqube@users.noreply.github.com> Date: Thu, 22 Oct 2026 10:58:23 -0700 Subject: [PATCH 10/17] Review: strict lists fail closed, and the issuer's own list is tested - With requireIssuerSignedStatusList, a list that was read is believed only when the library confirmed its signer: a list-issuer check that errored, was skipped or is missing now counts as someone else's, rather than the issuer's by default. (Off by default, and so it was before.) - The consistency suite's lists the issuer signed now carry the passing list-issuer check the library reports for them, and run both ways, with the rule that strict reads them exactly as the default does. - Unit tests: strict still believes the issuer's own list; believes nothing when the signer check errored, was skipped, or is absent. - Comments: the Developer view shows the mismatch as the library's "Failed: list-issuer", not a warning; the setting is ours to flip, not a property of the element; one ordering note qualified. Mutation: failing open fails 3 tests (the three written for it); refusing the issuer's own list under strict fails 14569. Co-Authored-By: Claude Opus 5.5 --- src/outcomes.ts | 53 +++++++++++++++++++++++++++++++--------- test/consistency.test.ts | 22 ++++++++++++++--- test/outcomes.test.ts | 26 ++++++++++++++++++++ 3 files changed, 86 insertions(+), 15 deletions(-) diff --git a/src/outcomes.ts b/src/outcomes.ts index 40a3216..30c4856 100644 --- a/src/outcomes.ts +++ b/src/outcomes.ts @@ -51,9 +51,10 @@ export interface Check { * exactly what happened, rather than the vaguer "we couldn't finish checking". * * `requireIssuerSignedStatusList`: believe a withdrawal list only when the - * credential's own issuer signed it. Off, which is the default, a list signed - * by someone else is read like any other, as verifier-core does, and the - * mismatch shows only in the Developer view as the library's warning. + * library confirmed the credential's own issuer signed it. Off, which is the + * default, a list signed by someone else is read like any other, as + * verifier-core does: its check of the signer is non-fatal, and shows only in + * the Developer view, in the library's words, as "Failed: list-issuer". * * Off because DCC's own status service signs every list with its own key * (status-list-lambda), and the batch issuer signs each batch with a fresh @@ -65,6 +66,9 @@ export interface Check { * default, once DCC's issuers sign their own lists. Meanwhile we can't notice * a list re-signed by someone else, which for these credentials the check * couldn't tell from the normal case anyway. + * + * Not a property of : no host needs to choose this, and + * switching back is ours to do, by changing the default here. */ export interface OutcomeOptions { registriesUnavailable?: boolean; @@ -279,15 +283,41 @@ const listNotIssuers = (checks: Map): boolean => hasProblem(checks.get(CHECK.statusListIssuer), PROBLEM.statusListIssuerMismatch); /** - * The checks the headline and the breakdown act on: all of them, less the - * list-issuer check unless `requireIssuerSignedStatusList` asks for it. Done - * once, here, so the two can't disagree about whose list is believed. The - * Developer view reads the library's results directly, so it still shows the - * warning either way. + * The checks the headline and the breakdown act on. Done once, here, so the + * two can't disagree about whose list is believed. The Developer view reads + * the library's results directly, so it shows the list-issuer check either way. + * + * By default, all of them less the list-issuer check, so a list is read + * whoever signed it. With `requireIssuerSignedStatusList`, a list that was + * read (it passed, or marked the credential) is believed only when the + * list-issuer check passed: one that errored, was skipped or is missing + * confirms nothing, and counts as someone else's, rather than as the issuer's + * by default. */ const believedChecks = (r: VerificationResponse, options: OutcomeOptions): Map => { const checks = checksById(r); - if (!options.requireIssuerSignedStatusList) checks.delete(CHECK.statusListIssuer); + if (!options.requireIssuerSignedStatusList) { + checks.delete(CHECK.statusListIssuer); + return checks; + } + const status = checks.get(CHECK.status); + const listRead = passed(status) || hasProblem(status, PROBLEM.revoked) || hasProblem(status, PROBLEM.suspended); + if (listRead && !passed(checks.get(CHECK.statusListIssuer))) { + checks.set(CHECK.statusListIssuer, { + id: CHECK.statusListIssuer, + suite: 'status', + outcome: { + status: 'failure', + problems: [ + { + type: PROBLEM.statusListIssuerMismatch, + title: 'Status List Issuer Not Confirmed', + detail: "The library didn't confirm that the credential's issuer signed its status list.", + }, + ], + }, + }); + } return checks; }; @@ -1045,8 +1075,9 @@ export const summarise = (r: VerificationResponse, options: OutcomeOptions = {}) // The seal held, so the dates check ran — unless something stopped it, or it // failed without naming a date (a check that throws reports CHECK_ERROR). // Then we don't know whether this is in force. Below the withdrawal list's - // unknowns, which name what we couldn't check and, for a list someone else - // signed, whose it is to fix; above who issued it, since "Genuine, but we + // unknowns, which name what we couldn't check and, under + // requireIssuerSignedStatusList for a list someone else signed, whose it + // is to fix; above who issued it, since "Genuine, but we // can't confirm who issued it" would say nothing of the dates the Dates row // reports unchecked. if (!passed(checks.get(CHECK.validityPeriod))) { diff --git a/test/consistency.test.ts b/test/consistency.test.ts index 46cd383..4275c60 100644 --- a/test/consistency.test.ts +++ b/test/consistency.test.ts @@ -522,7 +522,11 @@ const build = (sig: Sig, val: Val, rev: Rev, end: End, iss: Iss, sch: Sch): Veri signatureCheck(sig), validityCheck(val), revocationCheck(rev), - rev.endsWith('+other') + // The library compares the list's signer for every list it read, and + // reports a pass for the issuer's own. + ['passed', 'revoked', 'suspended'].includes(rev) + ? check(CHECK.statusListIssuer, { status: 'success', message: 'The status list was issued by the credential issuer.' }) + : rev.endsWith('+other') ? check(CHECK.statusListIssuer, { status: 'failure', problems: [ @@ -561,11 +565,13 @@ const HOST_LIST: ('loaded' | 'unavailable')[] = ['loaded', 'unavailable']; /** * Whose withdrawal lists are believed. `any` is the default; `issuers` is * `requireIssuerSignedStatusList`, the stricter rule kept for when DCC's - * issuers sign their own lists. It only changes anything for a list someone - * else signed, so only those combinations run both ways. + * issuers sign their own lists. Every list that was read runs both ways: a + * list someone else signed, where it matters, and the issuer's own, where it + * must change nothing. */ type Lists = 'any' | 'issuers'; -const listsFor = (rev: Rev): Lists[] => (rev.endsWith('+other') ? ['any', 'issuers'] : ['any']); +const listsFor = (rev: Rev): Lists[] => + rev.endsWith('+other') || ['passed', 'revoked', 'suspended'].includes(rev) ? ['any', 'issuers'] : ['any']; /** * A check is only ever skipped as "not run" because one before it failed @@ -627,6 +633,14 @@ describe(`the headline and the breakdown agree (${cases.length} combinations)`, expect(rows, `${where}: rows differ from the issuer's own list`).toEqual(listChecks(own, options)); } + // With only the issuer's own lists believed, the issuer's own reads just + // as it does by default. + if (lists === 'issuers' && !rev.endsWith('+other')) { + const loose = { ...options, requireIssuerSignedStatusList: false }; + expect(out, `${where}: refused the issuer's own list`).toEqual(summarise(r, loose)); + expect(rows, `${where}: refused the issuer's own list`).toEqual(listChecks(r, loose)); + } + // With only the issuer's own lists believed, a list someone else signed // settles nothing either way: not "deactivated", not "on hold", not "the // issuer hasn't deactivated it". diff --git a/test/outcomes.test.ts b/test/outcomes.test.ts index e6060ed..3f0c27e 100644 --- a/test/outcomes.test.ts +++ b/test/outcomes.test.ts @@ -1604,6 +1604,32 @@ describe('a withdrawal list signed by someone other than the issuer, by default' }); describe('a withdrawal list signed by someone other than the issuer, when only the issuer\'s own is believed', () => { + it('still believes the issuer\'s own list', () => { + const r = ok(); + set(r, revokedBy(PROBLEM.revoked, 'Credential Revoked')); + add(r, pass(CHECK.statusListIssuer, 'The status list was issued by the credential issuer.')); + expect(summarise(r, STRICT).code).toBe('withdrawn'); + expect(strictRow(r, CHECK.status)?.value).toBe('deactivated by the issuer'); + }); + + it.each([ + ['errored', () => fail(CHECK.statusListIssuer, [{ type: 'https://www.w3.org/TR/vc-data-model#CHECK_ERROR', title: 'Check Error', detail: 'x' }])], + ['was skipped', () => skip(CHECK.statusListIssuer, 'No status list was checked.')], + ])('believes nothing when the check of the list\'s signer %s', (_, listIssuer) => { + // Fail closed: an unconfirmed signer is not the issuer's by default. + const r = ok(); + add(r, listIssuer()); + const out = summarise(r, STRICT); + expect(out.code).toBe('withdrawal_unknown'); + expect(`${out.headline} ${out.detail}`).not.toMatch(/hasn['’]t (?:withdrawn|deactivated)/); + }); + + it('believes nothing when there is no check of the list\'s signer at all', () => { + const r = ok(); + set(r, revokedBy(PROBLEM.revoked, 'Credential Revoked')); + expect(summarise(r, STRICT).code).toBe('withdrawal_unknown'); + }); + // A mismatch on a list that marked the credential arrives since // 2.0.0-beta.1: before, a mark ended the status suite before the signer was // compared, and only a list that passed reported it. From 43cc44425395bab4f27dfc0d6d01ca80d573f205 Mon Sep 17 00:00:00 2001 From: Sunny Lee <1424627+threeqube@users.noreply.github.com> Date: Thu, 22 Oct 2026 11:03:26 -0700 Subject: [PATCH 11/17] Review: an unconfirmed list signer is an unfinished check, not a stranger Under requireIssuerSignedStatusList, a list whose signer check errored, was skipped or is missing had a mismatch made up for it, so it read as someone else's list: "may not be from the issuer", "let them know", though nothing was wrong at the issuer's end and a retry may well work. Now its withdrawal answer is set aside, as if that check hadn't finished, which reads "we couldn't finish that check" with a Status row of "not checked". A mismatch the library reports keeps the stranger's wording. Nothing is added to the checks: only what the library reported. The consistency suite gains lists whose signer check threw (passed+errored, revoked+errored), run both ways: by default they read as the issuer's own; strict, they settle nothing. The lists the issuer signed are one constant. Unit tests cover a clear, deactivated and on-hold list with an errored signer check; the "skipped" case, which the library can't produce for a list it read, goes. Mutation: strict believing an unconfirmed list fails 9716 tests; the default refusing one fails 9712. Unit suite: 95,984 tests, 25s. Co-Authored-By: Claude Opus 5.5 --- src/outcomes.ts | 27 +++++++++------------------ test/consistency.test.ts | 38 +++++++++++++++++++++++++++++--------- test/outcomes.test.ts | 19 ++++++++++++------- 3 files changed, 50 insertions(+), 34 deletions(-) diff --git a/src/outcomes.ts b/src/outcomes.ts index 30c4856..558da08 100644 --- a/src/outcomes.ts +++ b/src/outcomes.ts @@ -290,9 +290,12 @@ const listNotIssuers = (checks: Map): boolean => * By default, all of them less the list-issuer check, so a list is read * whoever signed it. With `requireIssuerSignedStatusList`, a list that was * read (it passed, or marked the credential) is believed only when the - * list-issuer check passed: one that errored, was skipped or is missing - * confirms nothing, and counts as someone else's, rather than as the issuer's - * by default. + * list-issuer check passed. A reported mismatch is someone else's list, and + * says so. A check of the signer that errored, was skipped or is missing + * confirms nothing either way, so the withdrawal check's answer is set aside, + * as if it hadn't finished: "we couldn't finish that check", which is true, + * rather than the issuer's word by default or a stranger's by guess. Nothing + * is added: the map only ever holds what the library reported. */ const believedChecks = (r: VerificationResponse, options: OutcomeOptions): Map => { const checks = checksById(r); @@ -301,22 +304,10 @@ const believedChecks = (r: VerificationResponse, options: OutcomeOptions): Map { return revocationCheck('revoked'); case 'suspended+other': return revocationCheck('suspended'); + case 'passed+errored': + return revocationCheck('passed'); + case 'revoked+errored': + return revocationCheck('revoked'); case 'halted': return check(CHECK.status, { status: 'skipped', reason: NOT_RUN }); case 'suspended': @@ -523,9 +531,17 @@ const build = (sig: Sig, val: Val, rev: Rev, end: End, iss: Iss, sch: Sch): Veri validityCheck(val), revocationCheck(rev), // The library compares the list's signer for every list it read, and - // reports a pass for the issuer's own. - ['passed', 'revoked', 'suspended'].includes(rev) + // reports a pass for the issuer's own. `+errored`: the comparison itself + // threw, so it confirms nothing either way. + ISSUERS_OWN_LIST.includes(rev) ? check(CHECK.statusListIssuer, { status: 'success', message: 'The status list was issued by the credential issuer.' }) + : rev.endsWith('+errored') + ? check(CHECK.statusListIssuer, { + status: 'failure', + problems: [ + { type: 'https://www.w3.org/TR/vc-data-model#CHECK_ERROR', title: 'Check Error', detail: 'Check "status.list-issuer" threw: x' }, + ], + }) : rev.endsWith('+other') ? check(CHECK.statusListIssuer, { status: 'failure', @@ -566,12 +582,16 @@ const HOST_LIST: ('loaded' | 'unavailable')[] = ['loaded', 'unavailable']; * Whose withdrawal lists are believed. `any` is the default; `issuers` is * `requireIssuerSignedStatusList`, the stricter rule kept for when DCC's * issuers sign their own lists. Every list that was read runs both ways: a - * list someone else signed, where it matters, and the issuer's own, where it - * must change nothing. + * list someone else signed, or whose signer couldn't be confirmed, where it + * matters, and the issuer's own, where it must change nothing. */ type Lists = 'any' | 'issuers'; +/** Lists the issuer signed, as the library reports them: read, and the signer confirmed. */ +const ISSUERS_OWN_LIST: Rev[] = ['passed', 'revoked', 'suspended']; +/** Lists read, whose signer the library reported as someone else, or couldn't confirm. */ +const notConfirmedOwn = (rev: Rev): boolean => rev.endsWith('+other') || rev.endsWith('+errored'); const listsFor = (rev: Rev): Lists[] => - rev.endsWith('+other') || ['passed', 'revoked', 'suspended'].includes(rev) ? ['any', 'issuers'] : ['any']; + ISSUERS_OWN_LIST.includes(rev) || notConfirmedOwn(rev) ? ['any', 'issuers'] : ['any']; /** * A check is only ever skipped as "not run" because one before it failed @@ -627,15 +647,15 @@ describe(`the headline and the breakdown agree (${cases.length} combinations)`, // By default a list someone else signed is read like the issuer's own: // the same headline and the same rows as without the mismatch, whatever // the list said and whatever else happened. - if (lists === 'any' && rev.endsWith('+other')) { - const own = build(sig, val, rev.replace('+other', '') as Rev, end, iss, sch); + if (lists === 'any' && notConfirmedOwn(rev)) { + const own = build(sig, val, rev.replace(/\+(other|errored)$/, '') as Rev, end, iss, sch); expect(out, `${where}: reads differently from the issuer's own list`).toEqual(summarise(own, options)); expect(rows, `${where}: rows differ from the issuer's own list`).toEqual(listChecks(own, options)); } // With only the issuer's own lists believed, the issuer's own reads just // as it does by default. - if (lists === 'issuers' && !rev.endsWith('+other')) { + if (lists === 'issuers' && ISSUERS_OWN_LIST.includes(rev)) { const loose = { ...options, requireIssuerSignedStatusList: false }; expect(out, `${where}: refused the issuer's own list`).toEqual(summarise(r, loose)); expect(rows, `${where}: refused the issuer's own list`).toEqual(listChecks(r, loose)); @@ -644,7 +664,7 @@ describe(`the headline and the breakdown agree (${cases.length} combinations)`, // With only the issuer's own lists believed, a list someone else signed // settles nothing either way: not "deactivated", not "on hold", not "the // issuer hasn't deactivated it". - if (lists === 'issuers' && rev.endsWith('+other')) { + if (lists === 'issuers' && notConfirmedOwn(rev)) { expect(['withdrawn', 'suspended'], `${where}: believed a list the issuer didn't sign`).not.toContain(out.code); expect(row(CHECK.status)?.severity, `${where}: the Status row believed a list the issuer didn't sign`).toBe('unchecked'); expect(`${out.headline} ${out.detail}`, `${where}: believed a list the issuer didn't sign`).not.toMatch( diff --git a/test/outcomes.test.ts b/test/outcomes.test.ts index 3f0c27e..a8470f1 100644 --- a/test/outcomes.test.ts +++ b/test/outcomes.test.ts @@ -1613,15 +1613,20 @@ describe('a withdrawal list signed by someone other than the issuer, when only t }); it.each([ - ['errored', () => fail(CHECK.statusListIssuer, [{ type: 'https://www.w3.org/TR/vc-data-model#CHECK_ERROR', title: 'Check Error', detail: 'x' }])], - ['was skipped', () => skip(CHECK.statusListIssuer, 'No status list was checked.')], - ])('believes nothing when the check of the list\'s signer %s', (_, listIssuer) => { - // Fail closed: an unconfirmed signer is not the issuer's by default. - const r = ok(); - add(r, listIssuer()); + ['came back clear', () => pass(CHECK.status, 'Credential status is valid (not revoked or suspended).')], + ['says deactivated', () => revokedBy(PROBLEM.revoked, 'Credential Revoked')], + ['says on hold', () => revokedBy(PROBLEM.suspended, 'Credential Suspended')], + ])('believes nothing, and says it couldn\'t finish, when the list %s but its signer check errored', (_, status) => { + // Fail closed, and say what happened: the signer is unknown, not a + // stranger, so not the stranger's-list wording either. + const r = ok(); + set(r, status()); + add(r, fail(CHECK.statusListIssuer, [{ type: 'https://www.w3.org/TR/vc-data-model#CHECK_ERROR', title: 'Check Error', detail: 'x' }])); const out = summarise(r, STRICT); expect(out.code).toBe('withdrawal_unknown'); - expect(`${out.headline} ${out.detail}`).not.toMatch(/hasn['’]t (?:withdrawn|deactivated)/); + expect(out.detail).toContain("we couldn't finish that check"); + expect(out.detail).not.toContain('may not be from the issuer'); + expect(strictRow(r, CHECK.status)).toMatchObject({ severity: 'unchecked', value: 'not checked' }); }); it('believes nothing when there is no check of the list\'s signer at all', () => { From 6c842d4e1755555235b3f935fc1158cefe272804 Mon Sep 17 00:00:00 2001 From: Sunny Lee <1424627+threeqube@users.noreply.github.com> Date: Thu, 22 Oct 2026 11:06:49 -0700 Subject: [PATCH 12/17] Review: pin the right reason for a stranger's list and an unconfirmed one The consistency suite now says why, not only that, a list is refused under requireIssuerSignedStatusList: a stranger's list reads "may not be from the issuer" with its Status row; a signer nobody could confirm reads "we couldn't finish that check" with "not checked". Putting back the old made-up mismatch for an unconfirmed signer now fails 2100 combinations. It also gains suspended+errored, and the Rev doc says what +errored is. The missing-signer unit test pins its wording and row, not just its code. Not changed: "Try again" for a read list with no signer check. The library's status suite always runs that check after a list it read, so the case can't arise with it. Co-Authored-By: Claude Opus 5.5 --- test/consistency.test.ts | 21 +++++++++++++++++++++ test/outcomes.test.ts | 6 +++++- 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/test/consistency.test.ts b/test/consistency.test.ts index 5fc91a1..ecc0a25 100644 --- a/test/consistency.test.ts +++ b/test/consistency.test.ts @@ -157,6 +157,9 @@ type Val = 'in_date' | 'expired' | 'not_yet_valid' | 'errored' | 'halted' | 'mis * `+other`: the list loaded and was read, but the issuer didn't sign it — the * status check's own result is untouched, and a second check reports it, * after a mark as well as after a pass since 2.0.0-beta.1. + * `+errored`: the list was read, but the check comparing its signer threw, + * so nothing is confirmed either way. Not the same as `+other`: strict, it + * reads "we couldn't finish that check", not "may not be from the issuer". */ type Rev = | 'passed' @@ -167,6 +170,7 @@ type Rev = | 'suspended+other' | 'passed+errored' | 'revoked+errored' + | 'suspended+errored' | 'list_error' | 'none' | 'unreadable' @@ -207,6 +211,7 @@ const REVOCATIONS: Rev[] = [ 'suspended+other', 'passed+errored', 'revoked+errored', + 'suspended+errored', 'list_error', 'none', 'unreadable', @@ -358,6 +363,8 @@ const revocationCheck = (rev: Rev): CheckResult | undefined => { return revocationCheck('passed'); case 'revoked+errored': return revocationCheck('revoked'); + case 'suspended+errored': + return revocationCheck('suspended'); case 'halted': return check(CHECK.status, { status: 'skipped', reason: NOT_RUN }); case 'suspended': @@ -670,6 +677,20 @@ describe(`the headline and the breakdown agree (${cases.length} combinations)`, expect(`${out.headline} ${out.detail}`, `${where}: believed a list the issuer didn't sign`).not.toMatch( /hasn['’]t (?:withdrawn|deactivated)/, ); + // ...and says why in the right words: a stranger's list is "may not be + // from the issuer"; a signer nobody could confirm is an unfinished + // check, not a stranger, and retrying may well work. + const stranger = rev.endsWith('+other'); + if (out.code === 'withdrawal_unknown') { + expect(out.detail, `${where}: the wrong reason`).toMatch( + stranger ? /may not be from the issuer/ : /couldn't finish that check/, + ); + } + if (row(CHECK.signature)?.severity === 'success') { + expect(row(CHECK.status)?.value, `${where}: the wrong Status row`).toBe( + stranger ? "couldn't check — the information we found may not be from the issuer" : 'not checked', + ); + } } // A clean verdict cannot sit above a row reporting a problem. diff --git a/test/outcomes.test.ts b/test/outcomes.test.ts index a8470f1..ed41c97 100644 --- a/test/outcomes.test.ts +++ b/test/outcomes.test.ts @@ -1632,7 +1632,11 @@ describe('a withdrawal list signed by someone other than the issuer, when only t it('believes nothing when there is no check of the list\'s signer at all', () => { const r = ok(); set(r, revokedBy(PROBLEM.revoked, 'Credential Revoked')); - expect(summarise(r, STRICT).code).toBe('withdrawal_unknown'); + const out = summarise(r, STRICT); + expect(out.code).toBe('withdrawal_unknown'); + expect(out.detail).toContain("we couldn't finish that check"); + expect(out.detail).not.toContain('may not be from the issuer'); + expect(strictRow(r, CHECK.status)).toMatchObject({ severity: 'unchecked', value: 'not checked' }); }); // A mismatch on a list that marked the credential arrives since From 1d41cea8e1c9896ada3ee932dcbf5ab824a103d1 Mon Sep 17 00:00:00 2001 From: Sunny Lee <1424627+threeqube@users.noreply.github.com> Date: Thu, 22 Oct 2026 11:10:02 -0700 Subject: [PATCH 13/17] Review: pin the action too, say why the row rule needs a held seal The strict-lists rule now checks the action as well as the reason: a stranger's list tells the earner to let the issuer know, an unconfirmed signer to try again (giving the stranger's case "Try again" fails 1008 combinations). Its Status-row half is limited to a held seal on purpose, and now says so: behind a broken seal that row is about the seal (#32). A unit test covers a skipped signer check, which the docs already said confirms nothing. Co-Authored-By: Claude Opus 5.5 --- test/consistency.test.ts | 8 ++++++++ test/outcomes.test.ts | 12 ++++++++++++ 2 files changed, 20 insertions(+) diff --git a/test/consistency.test.ts b/test/consistency.test.ts index ecc0a25..849abe6 100644 --- a/test/consistency.test.ts +++ b/test/consistency.test.ts @@ -685,7 +685,15 @@ describe(`the headline and the breakdown agree (${cases.length} combinations)`, expect(out.detail, `${where}: the wrong reason`).toMatch( stranger ? /may not be from the issuer/ : /couldn't finish that check/, ); + // ...and the right thing to do: tell the issuer about their list, or + // try again later. + expect(out.action, `${where}: the wrong action`).toMatch( + stranger ? /^Let .+ know we couldn't check whether this copy is still active\.$/ : /^Try again in a little while\.$/, + ); } + // Only behind a seal that held: behind a broken one the Status row is + // about the seal, and reads "not checked" whatever the list said + // (verifier-plugin#32). if (row(CHECK.signature)?.severity === 'success') { expect(row(CHECK.status)?.value, `${where}: the wrong Status row`).toBe( stranger ? "couldn't check — the information we found may not be from the issuer" : 'not checked', diff --git a/test/outcomes.test.ts b/test/outcomes.test.ts index ed41c97..eb3ab94 100644 --- a/test/outcomes.test.ts +++ b/test/outcomes.test.ts @@ -1629,6 +1629,18 @@ describe('a withdrawal list signed by someone other than the issuer, when only t expect(strictRow(r, CHECK.status)).toMatchObject({ severity: 'unchecked', value: 'not checked' }); }); + it('believes nothing when the check of the list\'s signer was skipped', () => { + // The library doesn't skip it for a list it read; if that ever changes, a + // skip confirms nothing either. + const r = ok(); + set(r, revokedBy(PROBLEM.revoked, 'Credential Revoked')); + add(r, skip(CHECK.statusListIssuer, 'Skipped.')); + const out = summarise(r, STRICT); + expect(out.code).toBe('withdrawal_unknown'); + expect(out.detail).toContain("we couldn't finish that check"); + expect(strictRow(r, CHECK.status)).toMatchObject({ severity: 'unchecked', value: 'not checked' }); + }); + it('believes nothing when there is no check of the list\'s signer at all', () => { const r = ok(); set(r, revokedBy(PROBLEM.revoked, 'Credential Revoked')); From ab12878d2e010ec1cfbe114fc6e28de7d95868a0 Mon Sep 17 00:00:00 2001 From: Sunny Lee <1424627+threeqube@users.noreply.github.com> Date: Fri, 23 Oct 2026 10:34:08 -0700 Subject: [PATCH 14/17] Run the consistency suite in four parts, under Vitest's time limit Vitest fails any test file that runs longer than 60 seconds, whatever its results ("Timeout calling onTaskUpdate"). The consistency suite grew to 105,499 tests in one file, and CI machines vary about threefold in speed: it passed in 34 s on #31 and failed twice at 70 s on #33, every test passing. Reproduced locally by slowing it past 60 s; two 40 s files run back to back on one worker pass, so the limit is per file. The combinations, their builders and the agreement rules move unchanged to consistency-combinations.ts. consistency-1 to -4 each run every fourth combination (26,358 each, 105,432 in all). consistency.test.ts keeps the claim patterns, the nameless-issuer check (whole, about 10 s) and the stopped-early cases, and gains one test that every part has its file, so a lost file can't drop a quarter of the combinations unnoticed. Checked: 105,698 tests (105,697 plus that one); four deliberate breaks fail exactly as many tests as on the single file (29,147, 14,571, 1,010, 14,573); the guard fails with a part missing or one extra; the slowdown that failed the single file at 73 s passes split, files run one at a time (each at most 17 s). The whole run takes about 10 s, not 25. Co-Authored-By: Claude Opus 5.5 --- README.md | 14 +- docs/planning/requirements.md | 2 +- src/outcomes.ts | 2 +- test/consistency-1.test.ts | 3 + test/consistency-2.test.ts | 3 + test/consistency-3.test.ts | 3 + test/consistency-4.test.ts | 3 + test/consistency-combinations.ts | 835 ++++++++++++++++++++++++++++++ test/consistency.test.ts | 836 +------------------------------ 9 files changed, 878 insertions(+), 823 deletions(-) create mode 100644 test/consistency-1.test.ts create mode 100644 test/consistency-2.test.ts create mode 100644 test/consistency-3.test.ts create mode 100644 test/consistency-4.test.ts create mode 100644 test/consistency-combinations.ts diff --git a/README.md b/README.md index ea40a6d..0c0838c 100644 --- a/README.md +++ b/README.md @@ -251,7 +251,9 @@ A first slice: a real credential in, real verification, one card out. | `test/outcomes.test.ts` | The mapping, and the ways a good credential can be made to look bad | | `test/fixtures.test.ts` | The demo's credentials are well-formed Open Badges, apart from the one built wrong on purpose | | `test/did-web-outages.test.ts` | An issuer's website that won't serve its key reads as unreachable, not tampered (verifier-core#65, fixed in 2.0.0-beta.1) | -| `test/consistency.test.ts` | 38,880 combinations asserting the headline and the breakdown can never disagree | +| `test/consistency-combinations.ts` | Every combination of check results, and the rules by which the headline and the breakdown can never disagree | +| `test/consistency-1.test.ts` … `-4` | Those rules, over a quarter of the combinations each: as one file they ran past Vitest's 60-second limit on a slow CI machine | +| `test/consistency.test.ts` | The claim patterns themselves, a nameless issuer across every combination, verification that stopped early, and a check that every part above has its file | | `test/browser/states.spec.ts` | Drives the component in a real browser, locally or against a published copy | | `test/browser/link.spec.ts` | The demo page opened from a link to a credential | | `test/browser/support.ts` | Page setup both browser specs share | @@ -310,10 +312,12 @@ found across four reviews of the first two pull requests, and six were that — a clean verdict above a row reporting a problem, or a headline claiming in prose what the breakdown said was never checked. -`test/consistency.test.ts` walks 1,500 combinations and asserts both: that the -severities agree, and that no verdict claims the credential is unchanged or -not withdrawn unless the row it rests on actually reported. Run it if you -touch either function. +The consistency suite (`test/consistency-combinations.ts`, run in parts by +`test/consistency-*.test.ts`) walks every combination of check results and +asserts both: that the severities agree, and that no verdict claims the +credential is unchanged or not withdrawn unless the row it rests on actually +reported. Run it (`npx vitest run test/consistency`) if you touch either +function. Two rules fall out of it, both learned the hard way: diff --git a/docs/planning/requirements.md b/docs/planning/requirements.md index ed6194a..b9d4170 100644 --- a/docs/planning/requirements.md +++ b/docs/planning/requirements.md @@ -433,7 +433,7 @@ hasn't been tampered with", when the signature held), and what to do, in plain words: "We couldn't check this right now. Something went wrong while we were checking, so we can't tell you yet whether it's valid. Try again in a little while." Friendly, but never comforting about what we don't know. -consistency.test.ts holds every verdict to this. +The consistency suite holds every verdict to this. The advice has to match the cause, though. For a **withdrawn** credential the issuer already made their decision, so the useful advice is to ask for a diff --git a/src/outcomes.ts b/src/outcomes.ts index 558da08..87a27de 100644 --- a/src/outcomes.ts +++ b/src/outcomes.ts @@ -782,7 +782,7 @@ const UNCHECKED_SIGNATURE: Omit = { * reporting that the very thing it asserted had never been checked. * * So it is assembled from what reported, and returns nothing at all rather - * than anything unearned. `test/consistency.test.ts` asserts the claim + * than anything unearned. The consistency suite asserts the claim * against the rows, in prose, because severities cannot see this: both * sides read "unchecked" and agree perfectly while the sentence lies. * diff --git a/test/consistency-1.test.ts b/test/consistency-1.test.ts new file mode 100644 index 0000000..e457069 --- /dev/null +++ b/test/consistency-1.test.ts @@ -0,0 +1,3 @@ +import { agreementTests } from './consistency-combinations.js'; + +agreementTests(1); diff --git a/test/consistency-2.test.ts b/test/consistency-2.test.ts new file mode 100644 index 0000000..bd3c7c3 --- /dev/null +++ b/test/consistency-2.test.ts @@ -0,0 +1,3 @@ +import { agreementTests } from './consistency-combinations.js'; + +agreementTests(2); diff --git a/test/consistency-3.test.ts b/test/consistency-3.test.ts new file mode 100644 index 0000000..c852fcc --- /dev/null +++ b/test/consistency-3.test.ts @@ -0,0 +1,3 @@ +import { agreementTests } from './consistency-combinations.js'; + +agreementTests(3); diff --git a/test/consistency-4.test.ts b/test/consistency-4.test.ts new file mode 100644 index 0000000..e162432 --- /dev/null +++ b/test/consistency-4.test.ts @@ -0,0 +1,3 @@ +import { agreementTests } from './consistency-combinations.js'; + +agreementTests(4); diff --git a/test/consistency-combinations.ts b/test/consistency-combinations.ts new file mode 100644 index 0000000..15e49cf --- /dev/null +++ b/test/consistency-combinations.ts @@ -0,0 +1,835 @@ +import { describe, it, expect } from 'vitest'; +import { summarise, listChecks, hasStatusList, issuerIdentity } from '../src/outcomes.js'; +import { CHECK, PROBLEM, STATUS_LIST_PROBLEM_PREFIX } from '../src/types.js'; +import type { VerificationResponse, CheckResult } from '../src/types.js'; + +/** + * How a verdict says the credential is unchanged, or not withdrawn — one list, + * used by every check below, so a phrasing caught in one place is caught in + * all of them. + * + * Broad on purpose: a reassurance reworded to echo the Tampering row ("no + * tampering detected") is still a reassurance. The old "changed since it was + * issued" phrasings stay, because a claim written the old way is still a + * claim. "Genuine" counts unless it is asked about — "we can't tell if this is + * genuine" is the opposite of a claim, and dropping the word instead would + * stop this seeing "Genuine, but we can't confirm who issued it". + */ +export const CLAIMS_UNCHANGED = + /(? ({ + id, + suite: id.split('.').slice(1, 2)[0]!, + outcome, + fatal, +}); + +const PAST = '2026-01-09T10:00:00Z'; +const FUTURE = '2099-01-09T10:00:00Z'; + +const signatureCheck = (sig: Sig): CheckResult | undefined => { + switch (sig) { + case 'missing': + return undefined; + case 'passed': + return check(CHECK.signature, { status: 'success', message: 'Signature verified.' }, true); + case 'tampered': + return check( + CHECK.signature, + { + status: 'failure', + problems: [ + { type: PROBLEM.invalidSignature, title: 'Invalid Signature', detail: 'Invalid signature.' }, + ], + }, + true, + ); + case 'key_mismatch': + return check( + CHECK.signature, + { + status: 'failure', + problems: [ + { + type: PROBLEM.verificationMethod, + title: 'Verification Method Error', + detail: 'The credential issuer did:key:z6Mkn does not control the verification method.', + }, + ], + }, + true, + ); + case 'unattributable': + return check( + CHECK.signature, + { + status: 'failure', + problems: [ + { + type: PROBLEM.proofVerification, + title: 'No Applicable Crypto Service', + detail: 'No registered crypto service can verify this subject.', + }, + ], + }, + true, + ); + } +}; + +const validityCheck = (val: Val): CheckResult | undefined => { + switch (val) { + case 'missing': + return undefined; + case 'halted': + return check(CHECK.validityPeriod, { status: 'skipped', reason: NOT_RUN }, true); + // A check that throws comes back as a failure naming no date. + case 'errored': + return check( + CHECK.validityPeriod, + { + status: 'failure', + problems: [ + { + type: 'https://www.w3.org/TR/vc-data-model#CHECK_ERROR', + title: 'Check Error', + detail: 'Check "proof.validity-period" threw: x', + }, + ], + }, + true, + ); + case 'in_date': + return check( + CHECK.validityPeriod, + { status: 'success', message: 'The credential is within its validity period.' }, + true, + ); + case 'expired': + return check( + CHECK.validityPeriod, + { + status: 'failure', + problems: [ + { + type: PROBLEM.credentialExpired, + title: 'Credential Expired', + detail: `The current date time (2026-09-23T00:00:00Z) is after "validUntil" (${PAST}).`, + }, + ], + }, + true, + ); + case 'not_yet_valid': + return check( + CHECK.validityPeriod, + { + status: 'failure', + problems: [ + { + type: PROBLEM.credentialNotYetValid, + title: 'Credential Not Yet Valid', + detail: `The current date time (2026-09-23T00:00:00Z) is before "validFrom" (${FUTURE}).`, + }, + ], + }, + true, + ); + } +}; + +const revocationCheck = (rev: Rev): CheckResult | undefined => { + switch (rev) { + case 'passed+other': + return revocationCheck('passed'); + case 'revoked+other': + return revocationCheck('revoked'); + case 'suspended+other': + return revocationCheck('suspended'); + case 'passed+errored': + return revocationCheck('passed'); + case 'revoked+errored': + return revocationCheck('revoked'); + case 'suspended+errored': + return revocationCheck('suspended'); + case 'halted': + return check(CHECK.status, { status: 'skipped', reason: NOT_RUN }); + case 'suspended': + return check(CHECK.status, { + status: 'failure', + problems: [ + { type: PROBLEM.suspended, title: 'Credential Suspended', detail: 'The credential has been suspended.' }, + ], + }); + case 'none': + return check(CHECK.status, { + status: 'skipped', + reason: 'Credential has no credentialStatus.', + }); + case 'unreadable': + return check(CHECK.status, { + status: 'skipped', + reason: 'Status type "StatusList2021Entry" is not BitstringStatusListEntry.', + }); + case 'passed': + return check(CHECK.status, { + status: 'success', + message: 'Credential status is valid (not revoked or suspended).', + }); + case 'revoked': + return check(CHECK.status, { + status: 'failure', + problems: [ + { + type: PROBLEM.revoked, + title: 'Credential Revoked', + detail: 'The credential has been revoked.', + }, + ], + }); + case 'list_error': + return check(CHECK.status, { + status: 'failure', + problems: [ + { + type: `${STATUS_LIST_PROBLEM_PREFIX}NOT_FOUND`, + title: 'Status List Not Found', + detail: 'The status list could not be fetched.', + }, + ], + }); + } +}; + +const registryCheck = (iss: Iss): CheckResult | undefined => { + const unchecked = '1 registries could not be checked: Second Registry'; + // What the lookup found, as the library also gives it: as data. + const payload = (matched: boolean, unreached: boolean) => ({ + matches: matched ? [{ registry: { name: 'DCC Registry', type: 'dcc-legacy' } }] : [], + uncheckedRegistries: unreached ? [{ name: 'Second Registry', type: 'dcc-legacy' }] : [], + }); + switch (iss) { + case 'halted': + return check(CHECK.registeredIssuer, { status: 'skipped', reason: NOT_RUN }); + case 'skipped': + return check(CHECK.registeredIssuer, { + status: 'skipped', + reason: 'No registries configured in verification context.', + }); + case 'matched': + return check(CHECK.registeredIssuer, { + status: 'success', + message: 'Issuer found in registry: DCC Registry', + payload: payload(true, false), + }); + case 'matched+unreachable': + return check(CHECK.registeredIssuer, { + status: 'success', + message: `Issuer found in registry: DCC Registry. ${unchecked}`, + payload: payload(true, true), + }); + case 'unlisted': + return check(CHECK.registeredIssuer, { + status: 'failure', + problems: [ + { + type: PROBLEM.issuerNotRegistered, + title: 'Issuer Not Registered', + detail: 'Issuer did:key:z6Mkn was not found in any known DID registry.', + }, + ], + payload: payload(false, false), + }); + case 'unreachable': + return check(CHECK.registeredIssuer, { + status: 'failure', + problems: [ + { + type: PROBLEM.issuerNotRegistered, + title: 'Issuer Not Registered', + detail: 'Issuer did:key:z6Mkn was not found in any known DID registry.', + }, + { type: PROBLEM.registryUnchecked, title: 'Registry Unchecked', detail: unchecked }, + ], + payload: payload(false, true), + }); + case 'none_reached': + return check(CHECK.registeredIssuer, { + status: 'failure', + problems: [ + { + type: PROBLEM.registryUnchecked, + title: 'Registry Unchecked', + detail: `Issuer registration could not be determined: ${unchecked}`, + }, + ], + payload: payload(false, true), + }); + case 'errored': + return check(CHECK.registeredIssuer, { + status: 'failure', + problems: [ + { + type: 'https://www.w3.org/TR/vc-data-model#REGISTRY_ERROR', + title: 'Registry Error', + detail: 'Registry lookup failed.', + }, + ], + }); + } +}; + +const schemaCheck = (sch: Sch): CheckResult | undefined => { + switch (sch) { + case 'halted': + return check(CHECK.schema, { status: 'skipped', reason: NOT_RUN }); + case 'missing': + return undefined; + case 'valid': + return check(CHECK.schema, { status: 'success', message: 'Schema validation passed.' }); + case 'invalid': + return check(CHECK.schema, { + status: 'failure', + problems: [ + { + type: PROBLEM.schemaValidationFailed, + title: 'Schema Validation Failed', + detail: + "Schema validation failed for https://purl.imsglobal.org/x.json: /credentialSubject/achievement: must have required property 'id'", + }, + ], + }); + case 'no_schema': + return check(CHECK.schema, { + status: 'skipped', + reason: + 'Credential does not appear to be an OBv3 credential (OpenBadgeCredential or EndorsementCredential).', + }); + case 'unavailable': + return check(CHECK.schema, { + status: 'skipped', + reason: 'No verifiable credential found in subject.', + }); + } +}; + +export const build = (sig: Sig, val: Val, rev: Rev, end: End, iss: Iss, sch: Sch): VerificationResponse => { + const results = [ + // The four core checks pass throughout: a failure among them is + // "it stopped", which has its own describe block below. + check(CHECK.contextExists, { status: 'success', message: 'ok' }, true), + check(CHECK.vcContext, { status: 'success', message: 'ok' }, true), + check(CHECK.credentialId, { status: 'success', message: 'ok' }, true), + check(CHECK.proofExists, { status: 'success', message: 'ok' }, true), + signatureCheck(sig), + validityCheck(val), + revocationCheck(rev), + // The library compares the list's signer for every list it read, and + // reports a pass for the issuer's own. `+errored`: the comparison itself + // threw, so it confirms nothing either way. + ISSUERS_OWN_LIST.includes(rev) + ? check(CHECK.statusListIssuer, { status: 'success', message: 'The status list was issued by the credential issuer.' }) + : rev.endsWith('+errored') + ? check(CHECK.statusListIssuer, { + status: 'failure', + problems: [ + { type: 'https://www.w3.org/TR/vc-data-model#CHECK_ERROR', title: 'Check Error', detail: 'Check "status.list-issuer" threw: x' }, + ], + }) + : rev.endsWith('+other') + ? check(CHECK.statusListIssuer, { + status: 'failure', + problems: [ + { + type: PROBLEM.statusListIssuerMismatch, + title: 'Status List Issuer Mismatch', + detail: 'The status list was issued by did:key:z6Mkother, not the credential issuer did:key:z6Mkn.', + }, + ], + }) + : undefined, + registryCheck(iss), + schemaCheck(sch), + ].filter((c): c is CheckResult => c !== undefined); + + return { + verified: results.every((c) => c.outcome.status !== 'failure'), + verifiableCredential: { + issuer: { id: 'did:key:z6Mkn', name: 'Springfield College' }, + validUntil: end === 'past' ? PAST : FUTURE, + validFrom: val === 'not_yet_valid' ? FUTURE : '2025-01-09T10:00:00Z', + // "none" means the issuer never set up a way to withdraw it at all. + ...(rev === 'none' ? {} : { credentialStatus: { type: 'BitstringStatusListEntry' } }), + }, + results, + }; +}; + +/** + * Whether the host said it couldn't get its list of registries. Every + * combination runs both ways: the flag only matters to a lookup that never + * answered, and must not open a contradiction anywhere else either. + */ +const HOST_LIST: ('loaded' | 'unavailable')[] = ['loaded', 'unavailable']; + +/** + * Whose withdrawal lists are believed. `any` is the default; `issuers` is + * `requireIssuerSignedStatusList`, the stricter rule kept for when DCC's + * issuers sign their own lists. Every list that was read runs both ways: a + * list someone else signed, or whose signer couldn't be confirmed, where it + * matters, and the issuer's own, where it must change nothing. + */ +type Lists = 'any' | 'issuers'; +/** Lists the issuer signed, as the library reports them: read, and the signer confirmed. */ +const ISSUERS_OWN_LIST: Rev[] = ['passed', 'revoked', 'suspended']; +/** Lists read, whose signer the library reported as someone else, or couldn't confirm. */ +const notConfirmedOwn = (rev: Rev): boolean => rev.endsWith('+other') || rev.endsWith('+errored'); +const listsFor = (rev: Rev): Lists[] => + ISSUERS_OWN_LIST.includes(rev) || notConfirmedOwn(rev) ? ['any', 'issuers'] : ['any']; + +/** + * A check is only ever skipped as "not run" because one before it failed + * fatally and halted: since 2.0.0-beta.1 that is the signature, for + * everything after it. Expiry and the withdrawal check are fatal but halt + * nothing. A `halted` check with no failed signature ahead of it is a + * contradiction in the input, not a combination the rules owe an answer to, + * so it is left out — everything else, possible or not, stays in. + */ +const SIG_FATAL: Sig[] = ['tampered', 'key_mismatch', 'unattributable']; +const coherent = (sig: Sig, val: Val, rev: Rev, iss: Iss, sch: Sch): boolean => + SIG_FATAL.includes(sig) || ![val, rev, iss, sch].includes('halted'); + +export const cases = SIGNATURES.flatMap((sig) => + VALIDITIES.flatMap((val) => + REVOCATIONS.flatMap((rev) => + ENDS.flatMap((end) => + ISSUERS.flatMap((iss) => + SCHEMAS.filter((sch) => coherent(sig, val, rev, iss, sch)).flatMap((sch) => + HOST_LIST.flatMap((list) => + listsFor(rev).map((lists) => ({ + sig, + val, + rev, + end, + iss, + sch, + list, + lists, + name: `${sig}/${val}/${rev}/${end}/${iss}/${sch}/${list}${lists === 'issuers' ? '/issuers-lists-only' : ''}`, + })), + ), + ), + ), + ), + ), + ), +); + +const DATES_ROW = CHECK.validityPeriod; + +/** + * Run in parts, one file each (consistency-1.test.ts onwards), each taking + * every PARTS-th combination. Vitest fails any one test file that runs past + * 60 seconds, whatever its results ("Timeout calling onTaskUpdate"), and a + * CI machine can run this three times slower than a laptop: as one file it + * passed in 34 s on one run and failed at 70 s on the next (22 October + * 2026). consistency.test.ts checks that every part has its file. + */ +export const PARTS = 4; + +export const agreementTests = (part: number): void => { + const slice = cases.filter((_, i) => i % PARTS === part - 1); + describe(`the headline and the breakdown agree (part ${part} of ${PARTS}: ${slice.length} of ${cases.length} combinations)`, () => { + it.each(slice)('$name', ({ sig, val, rev, end, iss, sch, list, lists, name }) => { + const r = build(sig, val, rev, end, iss, sch); + const options = { registriesUnavailable: list === 'unavailable', requireIssuerSignedStatusList: lists === 'issuers' }; + const out = summarise(r, options); + const rows = listChecks(r, options); + const row = (id: string) => rows.find((c) => c.id === id); + const where = `${name} → ${out.code}`; + + expect(rows.length, `${where}: verification ran, so there must be rows`).toBeGreaterThan(0); + + // By default a list someone else signed is read like the issuer's own: + // the same headline and the same rows as without the mismatch, whatever + // the list said and whatever else happened. + if (lists === 'any' && notConfirmedOwn(rev)) { + const own = build(sig, val, rev.replace(/\+(other|errored)$/, '') as Rev, end, iss, sch); + expect(out, `${where}: reads differently from the issuer's own list`).toEqual(summarise(own, options)); + expect(rows, `${where}: rows differ from the issuer's own list`).toEqual(listChecks(own, options)); + } + + // With only the issuer's own lists believed, the issuer's own reads just + // as it does by default. + if (lists === 'issuers' && ISSUERS_OWN_LIST.includes(rev)) { + const loose = { ...options, requireIssuerSignedStatusList: false }; + expect(out, `${where}: refused the issuer's own list`).toEqual(summarise(r, loose)); + expect(rows, `${where}: refused the issuer's own list`).toEqual(listChecks(r, loose)); + } + + // With only the issuer's own lists believed, a list someone else signed + // settles nothing either way: not "deactivated", not "on hold", not "the + // issuer hasn't deactivated it". + if (lists === 'issuers' && notConfirmedOwn(rev)) { + expect(['withdrawn', 'suspended'], `${where}: believed a list the issuer didn't sign`).not.toContain(out.code); + expect(row(CHECK.status)?.severity, `${where}: the Status row believed a list the issuer didn't sign`).toBe('unchecked'); + expect(`${out.headline} ${out.detail}`, `${where}: believed a list the issuer didn't sign`).not.toMatch( + /hasn['’]t (?:withdrawn|deactivated)/, + ); + // ...and says why in the right words: a stranger's list is "may not be + // from the issuer"; a signer nobody could confirm is an unfinished + // check, not a stranger, and retrying may well work. + const stranger = rev.endsWith('+other'); + if (out.code === 'withdrawal_unknown') { + expect(out.detail, `${where}: the wrong reason`).toMatch( + stranger ? /may not be from the issuer/ : /couldn't finish that check/, + ); + // ...and the right thing to do: tell the issuer about their list, or + // try again later. + expect(out.action, `${where}: the wrong action`).toMatch( + stranger ? /^Let .+ know we couldn't check whether this copy is still active\.$/ : /^Try again in a little while\.$/, + ); + } + // Only behind a seal that held: behind a broken one the Status row is + // about the seal, and reads "not checked" whatever the list said + // (verifier-plugin#32). + if (row(CHECK.signature)?.severity === 'success') { + expect(row(CHECK.status)?.value, `${where}: the wrong Status row`).toBe( + stranger ? "couldn't check — the information we found may not be from the issuer" : 'not checked', + ); + } + } + + // A clean verdict cannot sit above a row reporting a problem. + if (out.severity === 'success') { + for (const c of rows) { + // Two agreed exceptions, and they are the same exception twice: + // nothing failed and there was nothing to try, so the row carries no + // information while the verdict is still a pass. Both stay visible + // for an issuer debugging their own badge, who otherwise cannot tell + // "nothing to check against" apart from "checked and clean". + const noWithdrawalList = c.id === CHECK.status && !hasStatusList(r); + // Unlike the signature, the schema check never establishes that the + // credential is authentic — it only reports how it was assembled. Not + // having one therefore does not undermine a pass the way an unchecked + // signature would, and must not drag the headline down to "we + // couldn't check this right now". + const noSchemaToCheck = c.id === CHECK.schema && sch !== 'valid'; + if (noWithdrawalList || noSchemaToCheck) { + expect(c.severity, `${where}: "${c.label}" under a pass`).toBe('unchecked'); + continue; + } + expect(c.severity, `${where}: "${c.label}" is ${c.severity} under a pass`).toBe('success'); + } + } + + // ...and a row reporting a problem cannot sit under a clean verdict. + const worst = rows.map((c) => c.severity); + if (worst.includes('error')) { + expect(out.severity, `${where}: a row is an error`).toBe('error'); + } + if (out.severity === 'error') { + expect(worst, `${where}: an error verdict needs an error row`).toContain('error'); + } + if (out.severity === 'warning') { + expect(worst, `${where}: a warning verdict needs a warning row`).toContain('warning'); + } + // A row must never report something more serious than the headline. + // "We couldn't check whether this was withdrawn" sitting above a row + // that says the credential was built wrong buries a definite finding + // under an unknown — the same bug as a pass above a problem, one tier + // down, and the tier the schema row newly made reachable. + if (out.severity === 'unchecked') { + expect(worst, `${where}: a warning row under an unchecked verdict`).not.toContain('warning'); + } + + // A verdict must not claim in prose what the breakdown says was never + // checked. This is the one contradiction severities cannot see: both + // sides read "unchecked" and agree perfectly, while the sentence above + // them asserts the credential is genuine. Three separate bugs have now + // been this same sentence, so the rule is written once, for the claim, + // rather than a third time for the instance. + const claims = `${out.headline} ${out.detail}`.toLowerCase(); + + // The earner never reads a registry's name, or the word: to them it is + // "our list of known issuers" (James, 1 October 2026). Every verdict and + // every row, in every combination. + const earnerProse = [out.headline, out.detail, out.action ?? '', ...rows.map((c) => c.value)].join(' '); + if (out.severity === 'unchecked') { + expect(earnerProse.toLowerCase(), `${where}: leans positive where we don't know`).not.toMatch(LEANS_POSITIVE); + } + expect(earnerProse, `${where}: names a registry to the earner`).not.toMatch(/registry|registries/i); + + // When the verdict blames the list, the Issuer row has to be about the + // list too — not the seal. Both read "unchecked", so severities can't see + // the two giving different reasons. + if (out.code === 'registry_unreachable') { + // "Lists", plural, where part of it didn't respond. + expect(row(CHECK.registeredIssuer)?.value, `${where}: the verdict blames the list, the row doesn't`).toMatch( + /lists? of known issuers/, + ); + // ...and for the same reason. "Didn't load" above "couldn't finish + // checking" gives two accounts of one failure, and a host that says its + // list was unavailable made that pairing reachable from a second route. + // The words follow the state, not the other way round. + expect(/didn't respond/.test(out.detail), `${where}: "didn't respond" without part of the list unreached`).toBe( + issuerIdentity(r, options).registriesPartlyReached, + ); + const reason = /didn't respond/.test(out.detail) + ? "didn't respond" + : /didn't load/.test(out.detail) + ? "couldn't load our list" + : "couldn't finish checking"; + expect(row(CHECK.registeredIssuer)?.value, `${where}: the verdict and the row give different reasons`).toContain( + reason, + ); + } + + // The same for the Status row: when the verdict says we couldn't check + // whether the copy is still active, the row gives the same reason. "Not + // checked" is the row agreed for "we couldn't finish that check" (see + // outcomes.test.ts, "reads the same way in the breakdown"). A verdict with + // none of the three reasons fails, rather than matching by default. + if (out.code === 'withdrawal_unknown') { + const reasons: [RegExp, string][] = [ + [/may not be from the issuer/, "couldn't check — the information we found may not be from the issuer"], + [/couldn't get reliable status information/, "couldn't check — no reliable status information"], + [/couldn't finish that check/, 'not checked'], + ]; + const expected = reasons.find(([said]) => said.test(out.detail))?.[1]; + expect(expected, `${where}: a reason this rule doesn't know: "${out.detail}"`).toBeDefined(); + expect(row(CHECK.status)?.value, `${where}: the verdict and the Status row give different reasons`).toBe(expected); + } + + // Dates the seal vouched for but nobody checked leave only verdicts that + // outrank them: a finding, or an unknown about the withdrawal list. Not + // "Verified", and not one about who issued it. Both rows read "unchecked" + // there, so severities can't see it. Listed by what is allowed, so a new + // or reordered verdict fails here rather than slipping past. + if (row(CHECK.signature)?.severity === 'success' && row(DATES_ROW)?.severity === 'unchecked') { + expect( + ['dates_unchecked', 'withdrawn', 'suspended', 'malformed', 'withdrawal_unknown'], + `${where}: the dates went unchecked`, + ).toContain(out.code); + } + + // A date is a finding only behind a seal that held: otherwise it is the + // credential's own claim. The library halts after a failed seal, but the + // rule must not depend on that. + if (out.code === 'expired' || out.code === 'not_yet_valid') { + expect(row(CHECK.signature)?.severity, `${where}: a date reported without the seal`).toBe('success'); + } + + // ...and the Dates row says the same: no date at all, "in date" + // included, behind a seal that didn't hold. + if (row(CHECK.signature)?.severity !== 'success') { + expect(row(DATES_ROW)?.value, `${where}: the Dates row behind an unheld seal`).toBe('not checked'); + } + + // Only the library says a credential has run out. An end date in the + // past that it didn't report must not become an "Expired" headline: our + // clock second-guessing its own is what this rules out. + if (val !== 'expired') { + expect(out.code, `${where}: an expiry the library didn't report`).not.toBe('expired'); + } + // And no verdict but "Expired" talks of expiry, whatever was reported. + if (out.code !== 'expired') { + expect(`${out.headline} ${out.detail} ${out.action ?? ''}`, `${where}: talks of expiry under ${out.code}`).not.toMatch( + /expir|run out|end date|renew/i, + ); + } + + if (CLAIMS_UNCHANGED.test(claims)) { + expect( + row(CHECK.signature)?.severity, + `${where}: claims the credential is unchanged, but the signature row says "${row(CHECK.signature)?.value}"`, + ).toBe('success'); + } + + if (CLAIMS_NOT_WITHDRAWN.test(claims)) { + const revRow = row(CHECK.status)!; + expect( + revRow.severity === 'success', + `${where}: claims it was not deactivated, but the row says "${revRow.value}"`, + ).toBe(true); + } + + // A check that never ran says so, and gives no other reason: not "the + // list didn't load", not "couldn't load the standard". Severities can't + // see this — both read "unchecked". The one exception is the Issuer row + // when the seal failed, which talks about the seal instead (James, 1 + // October 2026): that is the reason, and it outranks the lookup. + const haltedRows = [ + val === 'halted' && DATES_ROW, + rev === 'halted' && CHECK.status, + iss === 'halted' && CHECK.registeredIssuer, + sch === 'halted' && CHECK.schema, + ].filter((id): id is string => typeof id === 'string'); + for (const id of haltedRows) { + const value = row(id)?.value ?? ''; + const aboutTheSeal = id === CHECK.registeredIssuer && value.startsWith("can't confirm — "); + expect(aboutTheSeal || /(^|— )not checked$/.test(value), `${where}: "${row(id)?.label}" never ran, but reads "${value}"`).toBe(true); + } + + // Each verdict must be borne out by the row it is about. + const expectations: Record = { + verified: [CHECK.signature, 'success'], + invalid_signature: [CHECK.signature, 'error'], + signature_unchecked: [CHECK.signature, 'unchecked'], + did_web_unresolved: [CHECK.signature, 'unchecked'], + http_error_with_signature_check: [CHECK.signature, 'unchecked'], + withdrawn: [CHECK.status, 'error'], + suspended: [CHECK.status, 'error'], + withdrawal_unknown: [CHECK.status, 'unchecked'], + expired: [DATES_ROW, 'warning'], + not_yet_valid: [DATES_ROW, 'warning'], + dates_unchecked: [DATES_ROW, 'unchecked'], + key_mismatch: [CHECK.registeredIssuer, 'error'], + malformed: [CHECK.schema, 'warning'], + }; + const expected = expectations[out.code]; + if (expected) { + const [id, severity] = expected; + expect(row(id)?.severity, `${where}: "${row(id)?.label}" must agree`).toBe(severity); + } + + // The issuer verdicts must never sit above a row claiming a registry match. + if (out.code === 'issuer_unconfirmed' || out.code === 'registry_unreachable') { + expect(row(CHECK.registeredIssuer)?.severity, `${where}: issuer row`).not.toBe('success'); + } + + // Every verdict that is not a pass names one thing to do, except the + // unconfirmed-issuer case, where §5 deliberately gives no instruction + // because nothing is wrong and there is nothing to fix. + if (out.severity !== 'success' && out.code !== 'issuer_unconfirmed') { + expect(out.action, `${where}: no action offered`).toBeTruthy(); + } + }); + }); +}; diff --git a/test/consistency.test.ts b/test/consistency.test.ts index 849abe6..192bf7f 100644 --- a/test/consistency.test.ts +++ b/test/consistency.test.ts @@ -1,37 +1,17 @@ import { describe, it, expect } from 'vitest'; -import { summarise, listChecks, stoppedEarly, hasStatusList, issuerIdentity } from '../src/outcomes.js'; -import { CHECK, PROBLEM, STATUS_LIST_PROBLEM_PREFIX } from '../src/types.js'; -import type { VerificationResponse, CheckResult } from '../src/types.js'; - -/** - * How a verdict says the credential is unchanged, or not withdrawn — one list, - * used by every check below, so a phrasing caught in one place is caught in - * all of them. - * - * Broad on purpose: a reassurance reworded to echo the Tampering row ("no - * tampering detected") is still a reassurance. The old "changed since it was - * issued" phrasings stay, because a claim written the old way is still a - * claim. "Genuine" counts unless it is asked about — "we can't tell if this is - * genuine" is the opposite of a claim, and dropping the word instead would - * stop this seeing "Genuine, but we can't confirm who issued it". - */ -const CLAIMS_UNCHANGED = - /(? { it.each([ @@ -121,790 +101,14 @@ describe('the claim patterns themselves', () => { }); /** - * The headline and the breakdown must never contradict each other. - * - * Both code reviews of this file found the same shape of bug: `summarise()` - * saying one thing while `listChecks()` said another about the same - * credential. Once it was a green "Verified" above rows reporting the issuer - * was in no registry; once it was "Verified" above a signature row reading - * "not checked". A person who opens the details and finds them disagreeing - * with the verdict has no way to know which to believe. - * - * So rather than test the instances, this walks every combination of check - * results and asserts the two can never disagree — including after both of us - * have stopped looking at this code. - * - * Ported to verifier-core 2.x. The review of that migration found this file - * switched off and the port not done, and four of its twelve findings were - * contradictions this would have caught — most sharply a `warning` "Expired" - * headline above an `error` row reading "withdrawn by the issuer". - */ - -/** - * Every way the signature check can end. Since verifier-core #59 a key that - * isn't the issuer's has its own problem type, apart from tampering. - */ -type Sig = 'passed' | 'tampered' | 'key_mismatch' | 'unattributable' | 'missing'; -/** - * The validity period, a check of its own since 2.0.0-beta.1: before, the - * dates were judged inside the signature check. The end date stays a - * dimension of its own too, because the headline names it and must not - * invent an expiry the library didn't report. + * The headline and the breakdown are held to each other across every + * combination in consistency-combinations.ts, a part per file. A part whose + * file went missing would drop its share of them without a sound. */ -type Val = 'in_date' | 'expired' | 'not_yet_valid' | 'errored' | 'halted' | 'missing'; -/** - * `unreadable`: a status type the library does not recognise, so it skips. - * `+other`: the list loaded and was read, but the issuer didn't sign it — the - * status check's own result is untouched, and a second check reports it, - * after a mark as well as after a pass since 2.0.0-beta.1. - * `+errored`: the list was read, but the check comparing its signer threw, - * so nothing is confirmed either way. Not the same as `+other`: strict, it - * reads "we couldn't finish that check", not "may not be from the issuer". - */ -type Rev = - | 'passed' - | 'revoked' - | 'suspended' - | 'passed+other' - | 'revoked+other' - | 'suspended+other' - | 'passed+errored' - | 'revoked+errored' - | 'suspended+errored' - | 'list_error' - | 'none' - | 'unreadable' - | 'halted'; -type End = 'in_date' | 'past'; -/** - * `errored` and `skipped` are the cases the 2.x migration got wrong: a lookup - * that threw, or never ran, is not a confirmed "not in any registry". - * `unreachable` is some registries answering, none listing the issuer, and - * others not; `none_reached` is every registry down. The library reports - * them differently (checked 10 October 2026), and so does the card. - */ -type Iss = - | 'matched' - | 'unlisted' - | 'unreachable' - | 'none_reached' - | 'matched+unreachable' - | 'errored' - | 'skipped' - | 'halted'; -type Sch = 'valid' | 'invalid' | 'no_schema' | 'unavailable' | 'missing' | 'halted'; -/** - * `halted`: skipped because an earlier check failed fatally. verifier-core - * stops after a failed signature, so after it none of these run. Expiry and - * a withdrawal are fatal too, but don't stop anything. - */ -const NOT_RUN = 'Not run: proof.signature failed'; - -const SIGNATURES: Sig[] = ['passed', 'tampered', 'key_mismatch', 'unattributable', 'missing']; -const VALIDITIES: Val[] = ['in_date', 'expired', 'not_yet_valid', 'errored', 'halted', 'missing']; -const REVOCATIONS: Rev[] = [ - 'passed', - 'revoked', - 'suspended', - 'passed+other', - 'revoked+other', - 'suspended+other', - 'passed+errored', - 'revoked+errored', - 'suspended+errored', - 'list_error', - 'none', - 'unreadable', - 'halted', -]; -const ENDS: End[] = ['in_date', 'past']; -const ISSUERS: Iss[] = [ - 'matched', - 'unlisted', - 'unreachable', - 'none_reached', - 'matched+unreachable', - 'errored', - 'skipped', - 'halted', -]; -const SCHEMAS: Sch[] = ['valid', 'invalid', 'no_schema', 'unavailable', 'missing', 'halted']; - -const check = (id: string, outcome: CheckResult['outcome'], fatal = false): CheckResult => ({ - id, - suite: id.split('.').slice(1, 2)[0]!, - outcome, - fatal, -}); - -const PAST = '2026-01-09T10:00:00Z'; -const FUTURE = '2099-01-09T10:00:00Z'; - -const signatureCheck = (sig: Sig): CheckResult | undefined => { - switch (sig) { - case 'missing': - return undefined; - case 'passed': - return check(CHECK.signature, { status: 'success', message: 'Signature verified.' }, true); - case 'tampered': - return check( - CHECK.signature, - { - status: 'failure', - problems: [ - { type: PROBLEM.invalidSignature, title: 'Invalid Signature', detail: 'Invalid signature.' }, - ], - }, - true, - ); - case 'key_mismatch': - return check( - CHECK.signature, - { - status: 'failure', - problems: [ - { - type: PROBLEM.verificationMethod, - title: 'Verification Method Error', - detail: 'The credential issuer did:key:z6Mkn does not control the verification method.', - }, - ], - }, - true, - ); - case 'unattributable': - return check( - CHECK.signature, - { - status: 'failure', - problems: [ - { - type: PROBLEM.proofVerification, - title: 'No Applicable Crypto Service', - detail: 'No registered crypto service can verify this subject.', - }, - ], - }, - true, - ); - } -}; - -const validityCheck = (val: Val): CheckResult | undefined => { - switch (val) { - case 'missing': - return undefined; - case 'halted': - return check(CHECK.validityPeriod, { status: 'skipped', reason: NOT_RUN }, true); - // A check that throws comes back as a failure naming no date. - case 'errored': - return check( - CHECK.validityPeriod, - { - status: 'failure', - problems: [ - { - type: 'https://www.w3.org/TR/vc-data-model#CHECK_ERROR', - title: 'Check Error', - detail: 'Check "proof.validity-period" threw: x', - }, - ], - }, - true, - ); - case 'in_date': - return check( - CHECK.validityPeriod, - { status: 'success', message: 'The credential is within its validity period.' }, - true, - ); - case 'expired': - return check( - CHECK.validityPeriod, - { - status: 'failure', - problems: [ - { - type: PROBLEM.credentialExpired, - title: 'Credential Expired', - detail: `The current date time (2026-09-23T00:00:00Z) is after "validUntil" (${PAST}).`, - }, - ], - }, - true, - ); - case 'not_yet_valid': - return check( - CHECK.validityPeriod, - { - status: 'failure', - problems: [ - { - type: PROBLEM.credentialNotYetValid, - title: 'Credential Not Yet Valid', - detail: `The current date time (2026-09-23T00:00:00Z) is before "validFrom" (${FUTURE}).`, - }, - ], - }, - true, - ); - } -}; - -const revocationCheck = (rev: Rev): CheckResult | undefined => { - switch (rev) { - case 'passed+other': - return revocationCheck('passed'); - case 'revoked+other': - return revocationCheck('revoked'); - case 'suspended+other': - return revocationCheck('suspended'); - case 'passed+errored': - return revocationCheck('passed'); - case 'revoked+errored': - return revocationCheck('revoked'); - case 'suspended+errored': - return revocationCheck('suspended'); - case 'halted': - return check(CHECK.status, { status: 'skipped', reason: NOT_RUN }); - case 'suspended': - return check(CHECK.status, { - status: 'failure', - problems: [ - { type: PROBLEM.suspended, title: 'Credential Suspended', detail: 'The credential has been suspended.' }, - ], - }); - case 'none': - return check(CHECK.status, { - status: 'skipped', - reason: 'Credential has no credentialStatus.', - }); - case 'unreadable': - return check(CHECK.status, { - status: 'skipped', - reason: 'Status type "StatusList2021Entry" is not BitstringStatusListEntry.', - }); - case 'passed': - return check(CHECK.status, { - status: 'success', - message: 'Credential status is valid (not revoked or suspended).', - }); - case 'revoked': - return check(CHECK.status, { - status: 'failure', - problems: [ - { - type: PROBLEM.revoked, - title: 'Credential Revoked', - detail: 'The credential has been revoked.', - }, - ], - }); - case 'list_error': - return check(CHECK.status, { - status: 'failure', - problems: [ - { - type: `${STATUS_LIST_PROBLEM_PREFIX}NOT_FOUND`, - title: 'Status List Not Found', - detail: 'The status list could not be fetched.', - }, - ], - }); - } -}; - -const registryCheck = (iss: Iss): CheckResult | undefined => { - const unchecked = '1 registries could not be checked: Second Registry'; - // What the lookup found, as the library also gives it: as data. - const payload = (matched: boolean, unreached: boolean) => ({ - matches: matched ? [{ registry: { name: 'DCC Registry', type: 'dcc-legacy' } }] : [], - uncheckedRegistries: unreached ? [{ name: 'Second Registry', type: 'dcc-legacy' }] : [], - }); - switch (iss) { - case 'halted': - return check(CHECK.registeredIssuer, { status: 'skipped', reason: NOT_RUN }); - case 'skipped': - return check(CHECK.registeredIssuer, { - status: 'skipped', - reason: 'No registries configured in verification context.', - }); - case 'matched': - return check(CHECK.registeredIssuer, { - status: 'success', - message: 'Issuer found in registry: DCC Registry', - payload: payload(true, false), - }); - case 'matched+unreachable': - return check(CHECK.registeredIssuer, { - status: 'success', - message: `Issuer found in registry: DCC Registry. ${unchecked}`, - payload: payload(true, true), - }); - case 'unlisted': - return check(CHECK.registeredIssuer, { - status: 'failure', - problems: [ - { - type: PROBLEM.issuerNotRegistered, - title: 'Issuer Not Registered', - detail: 'Issuer did:key:z6Mkn was not found in any known DID registry.', - }, - ], - payload: payload(false, false), - }); - case 'unreachable': - return check(CHECK.registeredIssuer, { - status: 'failure', - problems: [ - { - type: PROBLEM.issuerNotRegistered, - title: 'Issuer Not Registered', - detail: 'Issuer did:key:z6Mkn was not found in any known DID registry.', - }, - { type: PROBLEM.registryUnchecked, title: 'Registry Unchecked', detail: unchecked }, - ], - payload: payload(false, true), - }); - case 'none_reached': - return check(CHECK.registeredIssuer, { - status: 'failure', - problems: [ - { - type: PROBLEM.registryUnchecked, - title: 'Registry Unchecked', - detail: `Issuer registration could not be determined: ${unchecked}`, - }, - ], - payload: payload(false, true), - }); - case 'errored': - return check(CHECK.registeredIssuer, { - status: 'failure', - problems: [ - { - type: 'https://www.w3.org/TR/vc-data-model#REGISTRY_ERROR', - title: 'Registry Error', - detail: 'Registry lookup failed.', - }, - ], - }); - } -}; - -const schemaCheck = (sch: Sch): CheckResult | undefined => { - switch (sch) { - case 'halted': - return check(CHECK.schema, { status: 'skipped', reason: NOT_RUN }); - case 'missing': - return undefined; - case 'valid': - return check(CHECK.schema, { status: 'success', message: 'Schema validation passed.' }); - case 'invalid': - return check(CHECK.schema, { - status: 'failure', - problems: [ - { - type: PROBLEM.schemaValidationFailed, - title: 'Schema Validation Failed', - detail: - "Schema validation failed for https://purl.imsglobal.org/x.json: /credentialSubject/achievement: must have required property 'id'", - }, - ], - }); - case 'no_schema': - return check(CHECK.schema, { - status: 'skipped', - reason: - 'Credential does not appear to be an OBv3 credential (OpenBadgeCredential or EndorsementCredential).', - }); - case 'unavailable': - return check(CHECK.schema, { - status: 'skipped', - reason: 'No verifiable credential found in subject.', - }); - } -}; - -const build = (sig: Sig, val: Val, rev: Rev, end: End, iss: Iss, sch: Sch): VerificationResponse => { - const results = [ - // The four core checks pass throughout: a failure among them is - // "it stopped", which has its own describe block below. - check(CHECK.contextExists, { status: 'success', message: 'ok' }, true), - check(CHECK.vcContext, { status: 'success', message: 'ok' }, true), - check(CHECK.credentialId, { status: 'success', message: 'ok' }, true), - check(CHECK.proofExists, { status: 'success', message: 'ok' }, true), - signatureCheck(sig), - validityCheck(val), - revocationCheck(rev), - // The library compares the list's signer for every list it read, and - // reports a pass for the issuer's own. `+errored`: the comparison itself - // threw, so it confirms nothing either way. - ISSUERS_OWN_LIST.includes(rev) - ? check(CHECK.statusListIssuer, { status: 'success', message: 'The status list was issued by the credential issuer.' }) - : rev.endsWith('+errored') - ? check(CHECK.statusListIssuer, { - status: 'failure', - problems: [ - { type: 'https://www.w3.org/TR/vc-data-model#CHECK_ERROR', title: 'Check Error', detail: 'Check "status.list-issuer" threw: x' }, - ], - }) - : rev.endsWith('+other') - ? check(CHECK.statusListIssuer, { - status: 'failure', - problems: [ - { - type: PROBLEM.statusListIssuerMismatch, - title: 'Status List Issuer Mismatch', - detail: 'The status list was issued by did:key:z6Mkother, not the credential issuer did:key:z6Mkn.', - }, - ], - }) - : undefined, - registryCheck(iss), - schemaCheck(sch), - ].filter((c): c is CheckResult => c !== undefined); - - return { - verified: results.every((c) => c.outcome.status !== 'failure'), - verifiableCredential: { - issuer: { id: 'did:key:z6Mkn', name: 'Springfield College' }, - validUntil: end === 'past' ? PAST : FUTURE, - validFrom: val === 'not_yet_valid' ? FUTURE : '2025-01-09T10:00:00Z', - // "none" means the issuer never set up a way to withdraw it at all. - ...(rev === 'none' ? {} : { credentialStatus: { type: 'BitstringStatusListEntry' } }), - }, - results, - }; -}; - -/** - * Whether the host said it couldn't get its list of registries. Every - * combination runs both ways: the flag only matters to a lookup that never - * answered, and must not open a contradiction anywhere else either. - */ -const HOST_LIST: ('loaded' | 'unavailable')[] = ['loaded', 'unavailable']; - -/** - * Whose withdrawal lists are believed. `any` is the default; `issuers` is - * `requireIssuerSignedStatusList`, the stricter rule kept for when DCC's - * issuers sign their own lists. Every list that was read runs both ways: a - * list someone else signed, or whose signer couldn't be confirmed, where it - * matters, and the issuer's own, where it must change nothing. - */ -type Lists = 'any' | 'issuers'; -/** Lists the issuer signed, as the library reports them: read, and the signer confirmed. */ -const ISSUERS_OWN_LIST: Rev[] = ['passed', 'revoked', 'suspended']; -/** Lists read, whose signer the library reported as someone else, or couldn't confirm. */ -const notConfirmedOwn = (rev: Rev): boolean => rev.endsWith('+other') || rev.endsWith('+errored'); -const listsFor = (rev: Rev): Lists[] => - ISSUERS_OWN_LIST.includes(rev) || notConfirmedOwn(rev) ? ['any', 'issuers'] : ['any']; - -/** - * A check is only ever skipped as "not run" because one before it failed - * fatally and halted: since 2.0.0-beta.1 that is the signature, for - * everything after it. Expiry and the withdrawal check are fatal but halt - * nothing. A `halted` check with no failed signature ahead of it is a - * contradiction in the input, not a combination the rules owe an answer to, - * so it is left out — everything else, possible or not, stays in. - */ -const SIG_FATAL: Sig[] = ['tampered', 'key_mismatch', 'unattributable']; -const coherent = (sig: Sig, val: Val, rev: Rev, iss: Iss, sch: Sch): boolean => - SIG_FATAL.includes(sig) || ![val, rev, iss, sch].includes('halted'); - -const cases = SIGNATURES.flatMap((sig) => - VALIDITIES.flatMap((val) => - REVOCATIONS.flatMap((rev) => - ENDS.flatMap((end) => - ISSUERS.flatMap((iss) => - SCHEMAS.filter((sch) => coherent(sig, val, rev, iss, sch)).flatMap((sch) => - HOST_LIST.flatMap((list) => - listsFor(rev).map((lists) => ({ - sig, - val, - rev, - end, - iss, - sch, - list, - lists, - name: `${sig}/${val}/${rev}/${end}/${iss}/${sch}/${list}${lists === 'issuers' ? '/issuers-lists-only' : ''}`, - })), - ), - ), - ), - ), - ), - ), -); - -const DATES_ROW = CHECK.validityPeriod; - -describe(`the headline and the breakdown agree (${cases.length} combinations)`, () => { - it.each(cases)('$name', ({ sig, val, rev, end, iss, sch, list, lists, name }) => { - const r = build(sig, val, rev, end, iss, sch); - const options = { registriesUnavailable: list === 'unavailable', requireIssuerSignedStatusList: lists === 'issuers' }; - const out = summarise(r, options); - const rows = listChecks(r, options); - const row = (id: string) => rows.find((c) => c.id === id); - const where = `${name} → ${out.code}`; - - expect(rows.length, `${where}: verification ran, so there must be rows`).toBeGreaterThan(0); - - // By default a list someone else signed is read like the issuer's own: - // the same headline and the same rows as without the mismatch, whatever - // the list said and whatever else happened. - if (lists === 'any' && notConfirmedOwn(rev)) { - const own = build(sig, val, rev.replace(/\+(other|errored)$/, '') as Rev, end, iss, sch); - expect(out, `${where}: reads differently from the issuer's own list`).toEqual(summarise(own, options)); - expect(rows, `${where}: rows differ from the issuer's own list`).toEqual(listChecks(own, options)); - } - - // With only the issuer's own lists believed, the issuer's own reads just - // as it does by default. - if (lists === 'issuers' && ISSUERS_OWN_LIST.includes(rev)) { - const loose = { ...options, requireIssuerSignedStatusList: false }; - expect(out, `${where}: refused the issuer's own list`).toEqual(summarise(r, loose)); - expect(rows, `${where}: refused the issuer's own list`).toEqual(listChecks(r, loose)); - } - - // With only the issuer's own lists believed, a list someone else signed - // settles nothing either way: not "deactivated", not "on hold", not "the - // issuer hasn't deactivated it". - if (lists === 'issuers' && notConfirmedOwn(rev)) { - expect(['withdrawn', 'suspended'], `${where}: believed a list the issuer didn't sign`).not.toContain(out.code); - expect(row(CHECK.status)?.severity, `${where}: the Status row believed a list the issuer didn't sign`).toBe('unchecked'); - expect(`${out.headline} ${out.detail}`, `${where}: believed a list the issuer didn't sign`).not.toMatch( - /hasn['’]t (?:withdrawn|deactivated)/, - ); - // ...and says why in the right words: a stranger's list is "may not be - // from the issuer"; a signer nobody could confirm is an unfinished - // check, not a stranger, and retrying may well work. - const stranger = rev.endsWith('+other'); - if (out.code === 'withdrawal_unknown') { - expect(out.detail, `${where}: the wrong reason`).toMatch( - stranger ? /may not be from the issuer/ : /couldn't finish that check/, - ); - // ...and the right thing to do: tell the issuer about their list, or - // try again later. - expect(out.action, `${where}: the wrong action`).toMatch( - stranger ? /^Let .+ know we couldn't check whether this copy is still active\.$/ : /^Try again in a little while\.$/, - ); - } - // Only behind a seal that held: behind a broken one the Status row is - // about the seal, and reads "not checked" whatever the list said - // (verifier-plugin#32). - if (row(CHECK.signature)?.severity === 'success') { - expect(row(CHECK.status)?.value, `${where}: the wrong Status row`).toBe( - stranger ? "couldn't check — the information we found may not be from the issuer" : 'not checked', - ); - } - } - - // A clean verdict cannot sit above a row reporting a problem. - if (out.severity === 'success') { - for (const c of rows) { - // Two agreed exceptions, and they are the same exception twice: - // nothing failed and there was nothing to try, so the row carries no - // information while the verdict is still a pass. Both stay visible - // for an issuer debugging their own badge, who otherwise cannot tell - // "nothing to check against" apart from "checked and clean". - const noWithdrawalList = c.id === CHECK.status && !hasStatusList(r); - // Unlike the signature, the schema check never establishes that the - // credential is authentic — it only reports how it was assembled. Not - // having one therefore does not undermine a pass the way an unchecked - // signature would, and must not drag the headline down to "we - // couldn't check this right now". - const noSchemaToCheck = c.id === CHECK.schema && sch !== 'valid'; - if (noWithdrawalList || noSchemaToCheck) { - expect(c.severity, `${where}: "${c.label}" under a pass`).toBe('unchecked'); - continue; - } - expect(c.severity, `${where}: "${c.label}" is ${c.severity} under a pass`).toBe('success'); - } - } - - // ...and a row reporting a problem cannot sit under a clean verdict. - const worst = rows.map((c) => c.severity); - if (worst.includes('error')) { - expect(out.severity, `${where}: a row is an error`).toBe('error'); - } - if (out.severity === 'error') { - expect(worst, `${where}: an error verdict needs an error row`).toContain('error'); - } - if (out.severity === 'warning') { - expect(worst, `${where}: a warning verdict needs a warning row`).toContain('warning'); - } - // A row must never report something more serious than the headline. - // "We couldn't check whether this was withdrawn" sitting above a row - // that says the credential was built wrong buries a definite finding - // under an unknown — the same bug as a pass above a problem, one tier - // down, and the tier the schema row newly made reachable. - if (out.severity === 'unchecked') { - expect(worst, `${where}: a warning row under an unchecked verdict`).not.toContain('warning'); - } - - // A verdict must not claim in prose what the breakdown says was never - // checked. This is the one contradiction severities cannot see: both - // sides read "unchecked" and agree perfectly, while the sentence above - // them asserts the credential is genuine. Three separate bugs have now - // been this same sentence, so the rule is written once, for the claim, - // rather than a third time for the instance. - const claims = `${out.headline} ${out.detail}`.toLowerCase(); - - // The earner never reads a registry's name, or the word: to them it is - // "our list of known issuers" (James, 1 October 2026). Every verdict and - // every row, in every combination. - const earnerProse = [out.headline, out.detail, out.action ?? '', ...rows.map((c) => c.value)].join(' '); - if (out.severity === 'unchecked') { - expect(earnerProse.toLowerCase(), `${where}: leans positive where we don't know`).not.toMatch(LEANS_POSITIVE); - } - expect(earnerProse, `${where}: names a registry to the earner`).not.toMatch(/registry|registries/i); - - // When the verdict blames the list, the Issuer row has to be about the - // list too — not the seal. Both read "unchecked", so severities can't see - // the two giving different reasons. - if (out.code === 'registry_unreachable') { - // "Lists", plural, where part of it didn't respond. - expect(row(CHECK.registeredIssuer)?.value, `${where}: the verdict blames the list, the row doesn't`).toMatch( - /lists? of known issuers/, - ); - // ...and for the same reason. "Didn't load" above "couldn't finish - // checking" gives two accounts of one failure, and a host that says its - // list was unavailable made that pairing reachable from a second route. - // The words follow the state, not the other way round. - expect(/didn't respond/.test(out.detail), `${where}: "didn't respond" without part of the list unreached`).toBe( - issuerIdentity(r, options).registriesPartlyReached, - ); - const reason = /didn't respond/.test(out.detail) - ? "didn't respond" - : /didn't load/.test(out.detail) - ? "couldn't load our list" - : "couldn't finish checking"; - expect(row(CHECK.registeredIssuer)?.value, `${where}: the verdict and the row give different reasons`).toContain( - reason, - ); - } - - // The same for the Status row: when the verdict says we couldn't check - // whether the copy is still active, the row gives the same reason. "Not - // checked" is the row agreed for "we couldn't finish that check" (see - // outcomes.test.ts, "reads the same way in the breakdown"). A verdict with - // none of the three reasons fails, rather than matching by default. - if (out.code === 'withdrawal_unknown') { - const reasons: [RegExp, string][] = [ - [/may not be from the issuer/, "couldn't check — the information we found may not be from the issuer"], - [/couldn't get reliable status information/, "couldn't check — no reliable status information"], - [/couldn't finish that check/, 'not checked'], - ]; - const expected = reasons.find(([said]) => said.test(out.detail))?.[1]; - expect(expected, `${where}: a reason this rule doesn't know: "${out.detail}"`).toBeDefined(); - expect(row(CHECK.status)?.value, `${where}: the verdict and the Status row give different reasons`).toBe(expected); - } - - // Dates the seal vouched for but nobody checked leave only verdicts that - // outrank them: a finding, or an unknown about the withdrawal list. Not - // "Verified", and not one about who issued it. Both rows read "unchecked" - // there, so severities can't see it. Listed by what is allowed, so a new - // or reordered verdict fails here rather than slipping past. - if (row(CHECK.signature)?.severity === 'success' && row(DATES_ROW)?.severity === 'unchecked') { - expect( - ['dates_unchecked', 'withdrawn', 'suspended', 'malformed', 'withdrawal_unknown'], - `${where}: the dates went unchecked`, - ).toContain(out.code); - } - - // A date is a finding only behind a seal that held: otherwise it is the - // credential's own claim. The library halts after a failed seal, but the - // rule must not depend on that. - if (out.code === 'expired' || out.code === 'not_yet_valid') { - expect(row(CHECK.signature)?.severity, `${where}: a date reported without the seal`).toBe('success'); - } - - // ...and the Dates row says the same: no date at all, "in date" - // included, behind a seal that didn't hold. - if (row(CHECK.signature)?.severity !== 'success') { - expect(row(DATES_ROW)?.value, `${where}: the Dates row behind an unheld seal`).toBe('not checked'); - } - - // Only the library says a credential has run out. An end date in the - // past that it didn't report must not become an "Expired" headline: our - // clock second-guessing its own is what this rules out. - if (val !== 'expired') { - expect(out.code, `${where}: an expiry the library didn't report`).not.toBe('expired'); - } - // And no verdict but "Expired" talks of expiry, whatever was reported. - if (out.code !== 'expired') { - expect(`${out.headline} ${out.detail} ${out.action ?? ''}`, `${where}: talks of expiry under ${out.code}`).not.toMatch( - /expir|run out|end date|renew/i, - ); - } - - if (CLAIMS_UNCHANGED.test(claims)) { - expect( - row(CHECK.signature)?.severity, - `${where}: claims the credential is unchanged, but the signature row says "${row(CHECK.signature)?.value}"`, - ).toBe('success'); - } - - if (CLAIMS_NOT_WITHDRAWN.test(claims)) { - const revRow = row(CHECK.status)!; - expect( - revRow.severity === 'success', - `${where}: claims it was not deactivated, but the row says "${revRow.value}"`, - ).toBe(true); - } - - // A check that never ran says so, and gives no other reason: not "the - // list didn't load", not "couldn't load the standard". Severities can't - // see this — both read "unchecked". The one exception is the Issuer row - // when the seal failed, which talks about the seal instead (James, 1 - // October 2026): that is the reason, and it outranks the lookup. - const haltedRows = [ - val === 'halted' && DATES_ROW, - rev === 'halted' && CHECK.status, - iss === 'halted' && CHECK.registeredIssuer, - sch === 'halted' && CHECK.schema, - ].filter((id): id is string => typeof id === 'string'); - for (const id of haltedRows) { - const value = row(id)?.value ?? ''; - const aboutTheSeal = id === CHECK.registeredIssuer && value.startsWith("can't confirm — "); - expect(aboutTheSeal || /(^|— )not checked$/.test(value), `${where}: "${row(id)?.label}" never ran, but reads "${value}"`).toBe(true); - } - - // Each verdict must be borne out by the row it is about. - const expectations: Record = { - verified: [CHECK.signature, 'success'], - invalid_signature: [CHECK.signature, 'error'], - signature_unchecked: [CHECK.signature, 'unchecked'], - did_web_unresolved: [CHECK.signature, 'unchecked'], - http_error_with_signature_check: [CHECK.signature, 'unchecked'], - withdrawn: [CHECK.status, 'error'], - suspended: [CHECK.status, 'error'], - withdrawal_unknown: [CHECK.status, 'unchecked'], - expired: [DATES_ROW, 'warning'], - not_yet_valid: [DATES_ROW, 'warning'], - dates_unchecked: [DATES_ROW, 'unchecked'], - key_mismatch: [CHECK.registeredIssuer, 'error'], - malformed: [CHECK.schema, 'warning'], - }; - const expected = expectations[out.code]; - if (expected) { - const [id, severity] = expected; - expect(row(id)?.severity, `${where}: "${row(id)?.label}" must agree`).toBe(severity); - } - - // The issuer verdicts must never sit above a row claiming a registry match. - if (out.code === 'issuer_unconfirmed' || out.code === 'registry_unreachable') { - expect(row(CHECK.registeredIssuer)?.severity, `${where}: issuer row`).not.toBe('success'); - } - - // Every verdict that is not a pass names one thing to do, except the - // unconfirmed-issuer case, where §5 deliberately gives no instruction - // because nothing is wrong and there is nothing to fix. - if (out.severity !== 'success' && out.code !== 'issuer_unconfirmed') { - expect(out.action, `${where}: no action offered`).toBeTruthy(); - } +describe('the consistency suite', () => { + it(`runs all ${PARTS} parts of the combinations`, () => { + const files = readdirSync(new URL('.', import.meta.url)).filter((f) => /^consistency-\d+\.test\.ts$/.test(f)); + expect(files.sort()).toEqual(Array.from({ length: PARTS }, (_, i) => `consistency-${i + 1}.test.ts`).sort()); }); }); From 66ab65f62ae2ca2d5098a2da38152aa26f2ae96b Mon Sep 17 00:00:00 2001 From: Sunny Lee <1424627+threeqube@users.noreply.github.com> Date: Fri, 23 Oct 2026 10:46:32 -0700 Subject: [PATCH 15/17] Review: split the nameless check too, and name each part by its file - Vitest also fails any one test past 5 seconds. The five nameless-issuer tests each looped over all 105,432 combinations, about 2 s apiece beside the parts; under CPU load (10 busy processes, the parts about 2.3 times slower) three of them timed out. They now run in each part over its quarter: 20 tests of about 0.45 s, all passing under that load (slowest 0.79 s). The total goes from 105,698 to 105,713. - Two part files could call the same part and the guard would pass, a quarter of the combinations silently unrun. Each part now takes its number from its own file name, so the files carry no number to repeat; a name outside consistency-1 to -4 stops its file with an error. - Comments that pointed "below" or "above" at code now in the other file say which file; the README rows say where the nameless check went. Checked: lint; 105,713 tests pass; with 10 busy processes alongside, all pass; an extra consistency-5 fails its file and the guard; ignoring the setting still fails 29,147 tests and the stranger told "Try again" 1,010, as on the single file; naming a nameless issuer by its identifier fails all 20 nameless tests, 5 in each part. Co-Authored-By: Claude Opus 5.5 --- README.md | 4 +-- test/consistency-1.test.ts | 2 +- test/consistency-2.test.ts | 2 +- test/consistency-3.test.ts | 2 +- test/consistency-4.test.ts | 2 +- test/consistency-combinations.ts | 43 ++++++++++++++++++++++++++++---- test/consistency.test.ts | 39 +++++------------------------ 7 files changed, 50 insertions(+), 44 deletions(-) diff --git a/README.md b/README.md index 0c0838c..7694053 100644 --- a/README.md +++ b/README.md @@ -252,8 +252,8 @@ A first slice: a real credential in, real verification, one card out. | `test/fixtures.test.ts` | The demo's credentials are well-formed Open Badges, apart from the one built wrong on purpose | | `test/did-web-outages.test.ts` | An issuer's website that won't serve its key reads as unreachable, not tampered (verifier-core#65, fixed in 2.0.0-beta.1) | | `test/consistency-combinations.ts` | Every combination of check results, and the rules by which the headline and the breakdown can never disagree | -| `test/consistency-1.test.ts` … `-4` | Those rules, over a quarter of the combinations each: as one file they ran past Vitest's 60-second limit on a slow CI machine | -| `test/consistency.test.ts` | The claim patterns themselves, a nameless issuer across every combination, verification that stopped early, and a check that every part above has its file | +| `test/consistency-1.test.ts` … `-4` | Those rules, and that a nameless issuer is never addressed by its identifier, over a quarter of the combinations each: as one file they ran past Vitest's 60-second limit on a slow CI machine | +| `test/consistency.test.ts` | The claim patterns themselves, a credential both withdrawn and expired, verification that stopped early, and a check that every part above has its file | | `test/browser/states.spec.ts` | Drives the component in a real browser, locally or against a published copy | | `test/browser/link.spec.ts` | The demo page opened from a link to a credential | | `test/browser/support.ts` | Page setup both browser specs share | diff --git a/test/consistency-1.test.ts b/test/consistency-1.test.ts index e457069..772f36e 100644 --- a/test/consistency-1.test.ts +++ b/test/consistency-1.test.ts @@ -1,3 +1,3 @@ import { agreementTests } from './consistency-combinations.js'; -agreementTests(1); +agreementTests(import.meta.url); diff --git a/test/consistency-2.test.ts b/test/consistency-2.test.ts index bd3c7c3..772f36e 100644 --- a/test/consistency-2.test.ts +++ b/test/consistency-2.test.ts @@ -1,3 +1,3 @@ import { agreementTests } from './consistency-combinations.js'; -agreementTests(2); +agreementTests(import.meta.url); diff --git a/test/consistency-3.test.ts b/test/consistency-3.test.ts index c852fcc..772f36e 100644 --- a/test/consistency-3.test.ts +++ b/test/consistency-3.test.ts @@ -1,3 +1,3 @@ import { agreementTests } from './consistency-combinations.js'; -agreementTests(3); +agreementTests(import.meta.url); diff --git a/test/consistency-4.test.ts b/test/consistency-4.test.ts index e162432..772f36e 100644 --- a/test/consistency-4.test.ts +++ b/test/consistency-4.test.ts @@ -1,3 +1,3 @@ import { agreementTests } from './consistency-combinations.js'; -agreementTests(4); +agreementTests(import.meta.url); diff --git a/test/consistency-combinations.ts b/test/consistency-combinations.ts index 15e49cf..3c7480f 100644 --- a/test/consistency-combinations.ts +++ b/test/consistency-combinations.ts @@ -5,8 +5,8 @@ import type { VerificationResponse, CheckResult } from '../src/types.js'; /** * How a verdict says the credential is unchanged, or not withdrawn — one list, - * used by every check below, so a phrasing caught in one place is caught in - * all of them. + * used by every check below and in consistency.test.ts, so a phrasing caught + * in one place is caught in all of them. * * Broad on purpose: a reassurance reworded to echo the Tampering row ("no * tampering detected") is still a reassurance. The old "changed since it was @@ -442,7 +442,7 @@ const schemaCheck = (sch: Sch): CheckResult | undefined => { export const build = (sig: Sig, val: Val, rev: Rev, end: End, iss: Iss, sch: Sch): VerificationResponse => { const results = [ // The four core checks pass throughout: a failure among them is - // "it stopped", which has its own describe block below. + // "it stopped", which has its own describe block in consistency.test.ts. check(CHECK.contextExists, { status: 'success', message: 'ok' }, true), check(CHECK.vcContext, { status: 'success', message: 'ok' }, true), check(CHECK.credentialId, { status: 'success', message: 'ok' }, true), @@ -553,17 +553,36 @@ export const cases = SIGNATURES.flatMap((sig) => const DATES_ROW = CHECK.validityPeriod; +/** + * Open Badges 3.0 requires an issuer's id and type, not its name, so a genuine + * credential can give no name at all. The verdict then says "the issuer": + * "Ask did:key:z6Mk… for a current copy" isn't something the earner can act + * on. The Issuer row may show the identifier; it is labelled, not a sentence. + */ +const NAMELESS: [string, unknown][] = [ + ['only an identifier', 'did:key:z6Mkn'], + ['an identifier and no name', { id: 'did:key:z6Mkn' }], + ['an empty name', { id: 'did:key:z6Mkn', name: '' }], + ['a blank name', { id: 'did:key:z6Mkn', name: ' ' }], + ['neither a name nor an identifier', {}], +]; + /** * Run in parts, one file each (consistency-1.test.ts onwards), each taking * every PARTS-th combination. Vitest fails any one test file that runs past * 60 seconds, whatever its results ("Timeout calling onTaskUpdate"), and a * CI machine can run this three times slower than a laptop: as one file it * passed in 34 s on one run and failed at 70 s on the next (22 October - * 2026). consistency.test.ts checks that every part has its file. + * 2026). Its own per-test limit is 5 seconds, so the nameless-issuer check, + * which loops over the combinations inside each test, is split too. Each + * part takes its number from its file's name, so no two files can run the + * same part; consistency.test.ts checks that every part has its file. */ export const PARTS = 4; -export const agreementTests = (part: number): void => { +export const agreementTests = (file: string): void => { + const part = Number(/\/consistency-([1-9]\d*)\.test\.ts$/.exec(file)?.[1]); + if (!(part >= 1 && part <= PARTS)) throw new Error(`${file} is not one of consistency-1.test.ts to -${PARTS}`); const slice = cases.filter((_, i) => i % PARTS === part - 1); describe(`the headline and the breakdown agree (part ${part} of ${PARTS}: ${slice.length} of ${cases.length} combinations)`, () => { it.each(slice)('$name', ({ sig, val, rev, end, iss, sch, list, lists, name }) => { @@ -832,4 +851,18 @@ export const agreementTests = (part: number): void => { } }); }); + + describe(`an issuer with no name is never addressed by its identifier (part ${part} of ${PARTS}: ${slice.length} combinations each)`, () => { + it.each(NAMELESS)('%s', (_, issuer) => { + for (const { sig, val, rev, end, iss, sch, list, lists, name } of slice) { + const r = build(sig, val, rev, end, iss, sch); + r.verifiableCredential = { ...r.verifiableCredential, issuer }; + const out = summarise(r, { registriesUnavailable: list === 'unavailable', requireIssuerSignedStatusList: lists === 'issuers' }); + // Two spaces in a row: an empty name dropped into a sentence. + for (const sentence of [out.headline, out.detail, out.action ?? '']) { + expect(sentence, `${name} → ${out.code}`).not.toMatch(/did:|Unknown issuer|\s\s/); + } + } + }); + }); }; diff --git a/test/consistency.test.ts b/test/consistency.test.ts index 192bf7f..92bd2bd 100644 --- a/test/consistency.test.ts +++ b/test/consistency.test.ts @@ -9,7 +9,6 @@ import { LEANS_POSITIVE, PARTS, build, - cases, check, } from './consistency-combinations.js'; @@ -103,7 +102,8 @@ describe('the claim patterns themselves', () => { /** * The headline and the breakdown are held to each other across every * combination in consistency-combinations.ts, a part per file. A part whose - * file went missing would drop its share of them without a sound. + * file went missing would drop its share of them without a sound. (A file + * can't run the wrong part: each takes its number from its own name.) */ describe('the consistency suite', () => { it(`runs all ${PARTS} parts of the combinations`, () => { @@ -112,36 +112,8 @@ describe('the consistency suite', () => { }); }); -/** - * Open Badges 3.0 requires an issuer's id and type, not its name, so a genuine - * credential can give no name at all. The verdict then says "the issuer": - * "Ask did:key:z6Mk… for a current copy" isn't something the earner can act - * on. The Issuer row may show the identifier; it is labelled, not a sentence. - */ -const NAMELESS: [string, unknown][] = [ - ['only an identifier', 'did:key:z6Mkn'], - ['an identifier and no name', { id: 'did:key:z6Mkn' }], - ['an empty name', { id: 'did:key:z6Mkn', name: '' }], - ['a blank name', { id: 'did:key:z6Mkn', name: ' ' }], - ['neither a name nor an identifier', {}], -]; - -describe(`an issuer with no name is never addressed by its identifier (${cases.length} combinations each)`, () => { - it.each(NAMELESS)('%s', (_, issuer) => { - for (const { sig, val, rev, end, iss, sch, list, lists, name } of cases) { - const r = build(sig, val, rev, end, iss, sch); - r.verifiableCredential = { ...r.verifiableCredential, issuer }; - const out = summarise(r, { registriesUnavailable: list === 'unavailable', requireIssuerSignedStatusList: lists === 'issuers' }); - // Two spaces in a row: an empty name dropped into a sentence. - for (const sentence of [out.headline, out.detail, out.action ?? '']) { - expect(sentence, `${name} → ${out.code}`).not.toMatch(/did:|Unknown issuer|\s\s/); - } - } - }); -}); - describe('a credential that is both withdrawn and expired', () => { - // The contradiction the 2.x migration introduced and this file exists to + // The contradiction the 2.x migration introduced and this suite exists to // catch: expiry moved ahead of withdrawal, so the headline was a warning // about renewal while the breakdown reported an error. it('reports the withdrawal, because the issuer has already decided', () => { @@ -193,8 +165,9 @@ describe('verification that stopped early', () => { expect(out.detail).toBeTruthy(); expect(out.action, `${code} offers no action`).toBeTruthy(); expect(['error', 'unchecked']).toContain(out.severity); - // With no breakdown, the claim check above has no rows to hold the prose - // to, so hold it to nothing: a verdict that stopped here cannot reassure. + // With no breakdown, the claim check in consistency-combinations.ts has + // no rows to hold the prose to, so hold it to nothing: a verdict that + // stopped here cannot reassure. const claims = `${out.headline} ${out.detail}`.toLowerCase(); expect(claims, `${code} reassures with nothing behind it`).not.toMatch(CLAIMS_UNCHANGED); expect(claims, `${code} reassures with nothing behind it`).not.toMatch(CLAIMS_NOT_WITHDRAWN); From be205cd34dc2d1ed3cb1f72da6edc2e1b646493c Mon Sep 17 00:00:00 2001 From: Sunny Lee <1424627+threeqube@users.noreply.github.com> Date: Sat, 24 Oct 2026 05:57:31 -0700 Subject: [PATCH 16/17] Pause after each combination, and give the nameless tests 30 s In CI the four parts took 16 to 27 s each (23 October 2026), two to four times a laptop's, as they share fewer processors: only about 2x clear of the 60 s limit. That limit turns out not to be the file's length. A worker fails the run, every test passing, when a message to the main Vitest process waits 60 s for its reply to be read, and synchronous tests never let it read one. A pause after each test (one setImmediate) lets it, however long a file runs: one part slowed to 74 s passes with the pause and fails without, and a 70 s probe file of 1,000 slow tests does the same. A microtask doesn't (the reviewer checked). The nameless-issuer tests each loop over a part's combinations and took up to 2.0 s in CI, against Vitest's 5 s limit per test; they now have 30 s. Slowed to about 7 s each, they pass with it and fail without. Cost: about 8 to 15% per part file locally (7.1 to 8 s, was 6.6 to 7 s), about 1 s on the whole run. 105,713 tests pass; lint passes. Co-Authored-By: Claude Opus 5.5 --- test/consistency-combinations.ts | 29 +++++++++++++++++++---------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/test/consistency-combinations.ts b/test/consistency-combinations.ts index 3c7480f..1eeeab9 100644 --- a/test/consistency-combinations.ts +++ b/test/consistency-combinations.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, afterEach } from 'vitest'; import { summarise, listChecks, hasStatusList, issuerIdentity } from '../src/outcomes.js'; import { CHECK, PROBLEM, STATUS_LIST_PROBLEM_PREFIX } from '../src/types.js'; import type { VerificationResponse, CheckResult } from '../src/types.js'; @@ -569,14 +569,20 @@ const NAMELESS: [string, unknown][] = [ /** * Run in parts, one file each (consistency-1.test.ts onwards), each taking - * every PARTS-th combination. Vitest fails any one test file that runs past - * 60 seconds, whatever its results ("Timeout calling onTaskUpdate"), and a - * CI machine can run this three times slower than a laptop: as one file it - * passed in 34 s on one run and failed at 70 s on the next (22 October - * 2026). Its own per-test limit is 5 seconds, so the nameless-issuer check, - * which loops over the combinations inside each test, is split too. Each - * part takes its number from its file's name, so no two files can run the - * same part; consistency.test.ts checks that every part has its file. + * every PARTS-th combination, so the files run side by side. + * + * Two Vitest limits apply here whatever the results. A test worker fails the + * run, with every test passing ("Timeout calling onTaskUpdate"), when a + * message to the main Vitest process waits 60 seconds for its reply to be + * read, and tests that never wait never read one: as one file this suite + * passed in 34 s on one CI run and failed at 70 s on the next (22 October + * 2026). The pause after each test below lets the worker read its replies + * however long a file runs; a part slowed to 74 s passes with it and fails + * without. And any one test fails past 5 seconds, so the nameless-issuer + * tests, which loop, are given longer. + * + * Each part takes its number from its file's name, so no two files can run + * the same part; consistency.test.ts checks that every part has its file. */ export const PARTS = 4; @@ -584,6 +590,7 @@ export const agreementTests = (file: string): void => { const part = Number(/\/consistency-([1-9]\d*)\.test\.ts$/.exec(file)?.[1]); if (!(part >= 1 && part <= PARTS)) throw new Error(`${file} is not one of consistency-1.test.ts to -${PARTS}`); const slice = cases.filter((_, i) => i % PARTS === part - 1); + afterEach(() => new Promise((resolve) => setImmediate(resolve))); describe(`the headline and the breakdown agree (part ${part} of ${PARTS}: ${slice.length} of ${cases.length} combinations)`, () => { it.each(slice)('$name', ({ sig, val, rev, end, iss, sch, list, lists, name }) => { const r = build(sig, val, rev, end, iss, sch); @@ -853,6 +860,8 @@ export const agreementTests = (file: string): void => { }); describe(`an issuer with no name is never addressed by its identifier (part ${part} of ${PARTS}: ${slice.length} combinations each)`, () => { + // Each loops over a part's combinations: about 0.5 s on a laptop, up to + // 2 s on CI (23 October 2026), too near Vitest's 5 s limit per test. it.each(NAMELESS)('%s', (_, issuer) => { for (const { sig, val, rev, end, iss, sch, list, lists, name } of slice) { const r = build(sig, val, rev, end, iss, sch); @@ -863,6 +872,6 @@ export const agreementTests = (file: string): void => { expect(sentence, `${name} → ${out.code}`).not.toMatch(/did:|Unknown issuer|\s\s/); } } - }); + }, 30_000); }); }; From eea9e86a957eba032a4ae4df8aed8038eaf801fd Mon Sep 17 00:00:00 2001 From: Sunny Lee <1424627+threeqube@users.noreply.github.com> Date: Sat, 24 Oct 2026 09:43:06 -0700 Subject: [PATCH 17/17] Review: comments that still said a stranger's list establishes nothing From the final review of the whole branch (24 October 2026). Comments only: the built JavaScript is byte-identical; the .d.ts files differ in these comments. - isWithdrawn, notWithdrawn and PROBLEM.revoked said a list someone else signed establishes nothing. That is now true only with requireIssuerSignedStatusList; by default it is read like the issuer's. - CHECK.statusListIssuer said the check runs only for lists that loaded. It gives one answer for all of a credential's lists, compares only those whose signature it can establish, and is skipped if any couldn't be. - OutcomeOptions gains what to know before turning the strict rule on: one answer for all lists means a stranger's second list sets aside the issuer's own "deactivated", which then reads "couldn't check". Cautious, not wrong; found by a probe in review. - A consistency comment described verifier-plugin#32's fix as if it were already here; the README credited the split, not the pause, with the 60-second limit. Checked: lint, typecheck, 105,713 tests. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- src/outcomes.ts | 18 ++++++++++++++---- src/types.ts | 12 ++++++++---- test/consistency-combinations.ts | 6 +++--- 4 files changed, 26 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 7694053..7f86f3f 100644 --- a/README.md +++ b/README.md @@ -252,7 +252,7 @@ A first slice: a real credential in, real verification, one card out. | `test/fixtures.test.ts` | The demo's credentials are well-formed Open Badges, apart from the one built wrong on purpose | | `test/did-web-outages.test.ts` | An issuer's website that won't serve its key reads as unreachable, not tampered (verifier-core#65, fixed in 2.0.0-beta.1) | | `test/consistency-combinations.ts` | Every combination of check results, and the rules by which the headline and the breakdown can never disagree | -| `test/consistency-1.test.ts` … `-4` | Those rules, and that a nameless issuer is never addressed by its identifier, over a quarter of the combinations each: as one file they ran past Vitest's 60-second limit on a slow CI machine | +| `test/consistency-1.test.ts` … `-4` | Those rules, and that a nameless issuer is never addressed by its identifier, over a quarter of the combinations each, so the files run side by side. A pause after each test keeps Vitest's 60-second limit from failing a long file on a slow CI machine | | `test/consistency.test.ts` | The claim patterns themselves, a credential both withdrawn and expired, verification that stopped early, and a check that every part above has its file | | `test/browser/states.spec.ts` | Drives the component in a real browser, locally or against a published copy | | `test/browser/link.spec.ts` | The demo page opened from a link to a credential | diff --git a/src/outcomes.ts b/src/outcomes.ts index 87a27de..5989582 100644 --- a/src/outcomes.ts +++ b/src/outcomes.ts @@ -67,6 +67,13 @@ export interface Check { * a list re-signed by someone else, which for these credentials the check * couldn't tell from the normal case anyway. * + * Before turning it on: the library's check of the signer gives one answer + * for all of a credential's lists, so where a credential has two, one the + * issuer's and one someone else's, the mismatch sets both aside. An issuer's + * own "deactivated" then reads "we couldn't check whether this copy is still + * active": cautious, not wrong, but not what the issuer's list says (found + * in review, 24 October 2026). + * * Not a property of : no host needs to choose this, and * switching back is ours to do, by changing the default here. */ @@ -319,8 +326,10 @@ const believedChecks = (r: VerificationResponse, options: OutcomeOptions): Map): boolean => hasProblem(checks.get(CHECK.status), PROBLEM.revoked) && !listNotIssuers(checks); @@ -334,8 +343,9 @@ const isSuspended = (checks: Map): boolean => hasProblem(checks.get(CHECK.status), PROBLEM.suspended) && !listNotIssuers(checks); /** - * The withdrawal list loaded, is the issuer's own, and doesn't list this. The - * only grounds for saying the issuer hasn't withdrawn it. + * The withdrawal list loaded, is believed (by default whoever signed it; with + * `requireIssuerSignedStatusList`, only the issuer's own), and doesn't list + * this. The only grounds for saying the issuer hasn't withdrawn it. */ const notWithdrawn = (checks: Map): boolean => passed(checks.get(CHECK.status)) && !listNotIssuers(checks); diff --git a/src/types.ts b/src/types.ts index 88aec57..e4cbd08 100644 --- a/src/types.ts +++ b/src/types.ts @@ -122,8 +122,11 @@ export const CHECK = { validityPeriod: 'cryptographic.proof.validity-period', status: 'cryptographic.status.bitstring', /** - * Whether the withdrawal list was signed by the credential's own issuer. - * Runs after `status`, and only for lists that loaded. + * Whether the credential's status lists were signed by its own issuer: one + * answer for all of them. Runs after `status` whenever a list was checked, + * and compares only lists whose signature it can establish. Fails if any + * one is someone else's; skipped if any couldn't be established and none + * was someone else's. */ statusListIssuer: 'cryptographic.status.list-issuer', registeredIssuer: 'trust.registry.issuer', @@ -162,8 +165,9 @@ export const PROBLEM = { registryUnchecked: 'https://www.w3.org/TR/vc-data-model#REGISTRY_UNCHECKED', schemaValidationFailed: 'https://www.w3.org/TR/vc-data-model#SCHEMA_VALIDATION_FAILED', /** - * The status list says so. The only thing that means "withdrawn" — and only - * while the list is the issuer's own (see `statusListIssuerMismatch`). + * The status list says so. The only thing that means "withdrawn" — with + * `requireIssuerSignedStatusList`, only while the list is the issuer's own + * (see `statusListIssuerMismatch`). */ revoked: 'https://www.w3.org/TR/vc-data-model#CREDENTIAL_REVOKED', /** As `revoked`, for the suspension bit: possibly temporary. */ diff --git a/test/consistency-combinations.ts b/test/consistency-combinations.ts index 1eeeab9..a01d22c 100644 --- a/test/consistency-combinations.ts +++ b/test/consistency-combinations.ts @@ -642,9 +642,9 @@ export const agreementTests = (file: string): void => { stranger ? /^Let .+ know we couldn't check whether this copy is still active\.$/ : /^Try again in a little while\.$/, ); } - // Only behind a seal that held: behind a broken one the Status row is - // about the seal, and reads "not checked" whatever the list said - // (verifier-plugin#32). + // Only behind a seal that held: behind a broken one the Status row + // should be about the seal, not the list. verifier-plugin#32 is that + // fix, making it "not checked" whatever the list said. if (row(CHECK.signature)?.severity === 'success') { expect(row(CHECK.status)?.value, `${where}: the wrong Status row`).toBe( stranger ? "couldn't check — the information we found may not be from the issuer" : 'not checked',