From 051e19cf261c1fdd75e23eb8b217a25ac89e4c7d Mon Sep 17 00:00:00 2001 From: Nate Otto Date: Wed, 14 Oct 2026 16:39:36 -0700 Subject: [PATCH] feat(registry): include the recognised entity for vc-recognition matches The vc-recognition handler kept only a yes or no from the verified recognition credential's subject. It now returns the matching credentialSubject entry as the match's entity: name, url and logo from the entry's members of those names, each only where the entry's value is a string, and the whole entry as raw. The credential is verified before its subject is read, on a cache hit as well as on a fresh fetch, so the entity is as trusted as the membership itself. The mapping follows the OIDF handler's: a name that is a language map or an object yields no entity.name and stays readable through raw, as does anything else the entry holds. The RegistryEntity type comment, the README, the verification-results guide, the architecture doc and the CHANGELOG no longer say vc-recognition matches carry no entity. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 16 ++- README.md | 11 +- docs/api/verification-results.md | 26 ++-- docs/architecture.md | 10 +- .../vc-recognition-handler.ts | 69 +++++++--- src/types/registry.ts | 12 +- .../vc-recognition-handler.spec.ts | 125 +++++++++++++++++- 7 files changed, 223 insertions(+), 46 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6783b81..87bf234 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -74,15 +74,18 @@ not run after a fatal failure. - `RegistryEntity` on a match: the registry's own record of the issuer (`name`, `url`, `logo`, and the untouched registry entry as `raw`). Because it comes from the registry rather than from the credential, it is the display - name to prefer. The `dcc-legacy` and `oidf` handlers fill it in; - `vc-recognition` entities are still to come. For `oidf` it is the `metadata` - of the trust anchor's subordinate statement — `name` from + name to prefer. Every registry handler fills it in. For `oidf` it is the + `metadata` of the trust anchor's subordinate statement — `name` from `federation_entity.organization_name`, `url` from `homepage_uri`, `logo` from `logo_uri`, and the whole verified `metadata` as `raw` — read only from a statement that has just verified, on a cache hit as well as on a fresh fetch. An OIDF `logo_uri` may be a `data:` URI holding the image itself rather than a link to it, so `entity.logo` can be large, and it is cached along with the - rest of the lookup. + rest of the lookup. For `vc-recognition` it is the recognised + `credentialSubject` entry — `name`, `url` and `logo` from the entry's members + of those names, each taken only where the entry's value is a string, and the + whole entry as `raw` — read only from a recognition credential that has + verified, on a cache hit as well as on a fresh fetch. - `payload?: unknown` on `failure` outcomes of `CheckOutcome`. Additive — `success` already allowed one. - `RegistryCheckPayload`, `RegistryMatch`, `RegistryReference`, @@ -276,8 +279,9 @@ not run after a fatal failure. The whole registry entry is also available, unmodified, as `entity.raw`: for `oidf` that is the verified subordinate statement's whole `metadata`, - which is where 1.x read `issuer` from. `dcc-legacy` and `oidf` registries - populate `entity`; `vc-recognition` does not yet. The payload is on the + which is where 1.x read `issuer` from, and for `vc-recognition` it is the + recognised `credentialSubject` entry. Every registry type populates + `entity`. The payload is on the `registry.issuer` row in `results[]` whether the check passed or failed. - **Reading registry names out of the message.** Code that parsed the `registry.issuer` message — "Issuer found in registry: A. 1 registries could diff --git a/README.md b/README.md index 7f58338..b08d66d 100644 --- a/README.md +++ b/README.md @@ -125,8 +125,7 @@ configuration (`trustAnchorEC`) for `oidf`. `entity` is the registry's description of the issuer, not the credential's description of itself, so it is the display name to prefer — the credential -cannot alter it. `dcc-legacy` and `oidf` registries populate it; -`vc-recognition` matches carry no `entity` yet. +cannot alter it. Every registry type populates it. For `oidf`, the `entity` is the `metadata` of the trust anchor's subordinate statement: `name` from `federation_entity.organization_name`, `url` from @@ -137,6 +136,14 @@ time. An OIDF `logo_uri` may be a `data:` URI holding the image itself rather than a link to it, so `entity.logo` can be large, and it is cached along with the rest of the lookup. +For `vc-recognition`, the `entity` is the recognition credential's +`credentialSubject` entry that names the issuer: `name`, `url` and `logo` from +the entry's members of those names, and the whole entry as `raw`. Each of the +three is set only where the entry's value is a string, so an entry whose `name` +is a language map carries no `entity.name` and the value stays readable through +`raw`, as does anything else the entry holds. The credential is verified before +its subject is read, on a cache hit as well as on a fresh fetch. + ```typescript const outcome = result.results.find(r => r.id === 'trust.registry.issuer') ?.outcome; diff --git a/docs/api/verification-results.md b/docs/api/verification-results.md index 23b10f2..134366a 100644 --- a/docs/api/verification-results.md +++ b/docs/api/verification-results.md @@ -121,15 +121,23 @@ own name contains `, ` cannot be recovered from it. `entity` is the registry's description of the issuer rather than the credential's description of itself, which makes it the display name to prefer. -`dcc-legacy` and `oidf` matches carry an `entity`; `vc-recognition` -matches do not yet. For `oidf` it is the `metadata` of the trust anchor's -subordinate statement — `name` from `federation_entity.organization_name`, -`url` from `homepage_uri`, `logo` from `logo_uri`, and the whole verified -`metadata` under `raw` — read only from a statement that has just -verified, on a cache hit as well as on a fresh fetch. Note that an OIDF -`logo_uri` may be a `data:` URI carrying the image itself rather than a -link to it, so `entity.logo` can be large, and it is cached along with the -rest of the lookup. +Matches from every registry type carry an `entity`. For `oidf` it is the +`metadata` of the trust anchor's subordinate statement — `name` from +`federation_entity.organization_name`, `url` from `homepage_uri`, `logo` +from `logo_uri`, and the whole verified `metadata` under `raw` — read only +from a statement that has just verified, on a cache hit as well as on a +fresh fetch. Note that an OIDF `logo_uri` may be a `data:` URI carrying +the image itself rather than a link to it, so `entity.logo` can be large, +and it is cached along with the rest of the lookup. + +For `vc-recognition` it is the recognition credential's +`credentialSubject` entry naming the issuer — `name`, `url` and `logo` +from the entry's members of those names, and the whole entry under `raw` — +read only from a credential that has verified, on a cache hit as well as +on a fresh fetch. Each of the three is set only where the entry's value is +a string, so an entry whose `name` is a language map carries no +`entity.name`; that value, and anything else the entry holds such as an +`image`, remains readable through `raw`. The payload rides on the `registry.issuer` row in `results[]` whether the check passed or failed, so a consumer reading registry matches does not diff --git a/docs/architecture.md b/docs/architecture.md index a9c2346..3ad96ff 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -579,10 +579,12 @@ up **`RegistryCheckPayload`** — `matches` and `uncheckedRegistries`. A `Regist `logo`, and the untouched `raw` entry). Because the lookup stops at the first registry that matches unless run with `exhaustive`, `matches` normally holds at most one entry. The registry check returns the payload on its outcome, on success and on failure; a handler supplies the -`entity` on its `found` result. The `dcc-legacy` and `oidf` handlers fill one in; the -`vc-recognition` handler does not yet. The OIDF entity is the verified subordinate statement's -`metadata` — read only after that statement verifies, on a cache hit as well as on a fresh -fetch — and its `logo` may be a large `data:` URI, cached with the rest of the lookup. +`entity` on its `found` result, and every built-in handler fills one in. The OIDF entity is the +verified subordinate statement's `metadata` — read only after that statement verifies, on a cache +hit as well as on a fresh fetch — and its `logo` may be a large `data:` URI, cached with the rest +of the lookup. The `vc-recognition` entity is the recognised `credentialSubject` entry, likewise +read only from a credential that has verified: `name`, `url` and `logo` come from the entry's +members of those names where they are strings, and the whole entry is `raw`. A lookup result cached by an earlier version, in the shape that predates `matches`, is treated as a cache miss rather than an error: the issuer is looked up again and the current shape is cached diff --git a/src/services/registry-handlers/vc-recognition-handler.ts b/src/services/registry-handlers/vc-recognition-handler.ts index 8b6b01f..86dabc0 100644 --- a/src/services/registry-handlers/vc-recognition-handler.ts +++ b/src/services/registry-handlers/vc-recognition-handler.ts @@ -1,4 +1,7 @@ -import type { VcRecognitionEntityIdentityRegistry } from '../../types/registry.js'; +import type { + RegistryEntity, + VcRecognitionEntityIdentityRegistry +} from '../../types/registry.js'; import type { HttpGetService } from '../http-get-service/http-get-service.js'; import { DEFAULT_TTL_MS, resolveTtl, ttlFromValidUntil } from './cache-ttl.js'; import type { @@ -19,6 +22,11 @@ import type { * recursion when the recognition VC's own issuer is also looked up * through a recognition registry. * + * A match carries the recognised `credentialSubject` entry as its entity. + * It is read only from a credential that has verified, on the fresh path + * and on a cache hit alike, so it is always the recognition issuer's + * signed description of the issuer. + * * @see https://w3c.github.io/vc-recognition/ */ export const lookupVcRecognition: RegistryHandler = async ( @@ -76,10 +84,39 @@ async function lookupVcRecognitionForRegistry( credential = loaded; } - if (!subjectContainsDid(credential, did)) { + const entry = findSubjectEntry(credential, did); + if (!entry) { return { status: 'not-found' }; } - return { status: 'found', registryName: registry.name }; + return { + status: 'found', + registryName: registry.name, + entity: entityFromEntry(entry) + }; +} + +/** + * The registry's record of the issuer, read from the recognised + * `credentialSubject` entry: its name, homepage and logo as the entry + * gives them, plus the whole entry, so a consumer can read fields this + * shape does not name. + * + * Only members that are strings are named, because a recognition + * credential's subject is arbitrary JSON-LD — a `name` may be a language + * map, and is then readable only through `raw`. + */ +function entityFromEntry(entry: Record): RegistryEntity { + const entity: RegistryEntity = { raw: entry }; + if (typeof entry.name === 'string') { + entity.name = entry.name; + } + if (typeof entry.url === 'string') { + entity.url = entry.url; + } + if (typeof entry.logo === 'string') { + entity.logo = entry.logo; + } + return entity; } function cacheKeyForVcRecognitionUrl(url: string): string { @@ -118,24 +155,20 @@ function getIssuerId(credential: Record): string | undefined { return undefined; } -function subjectContainsDid( +/** The `credentialSubject` entry recognising `did`, if there is one. */ +function findSubjectEntry( credential: Record, did: string -): boolean { +): Record | undefined { const subject = credential.credentialSubject; if (subject === undefined || subject === null) { - return false; - } - if (Array.isArray(subject)) { - return subject.some( - entry => - entry !== null && - typeof entry === 'object' && - (entry as { id?: unknown }).id === did - ); - } - if (typeof subject === 'object') { - return (subject as { id?: unknown }).id === did; + return undefined; } - return false; + const entries = Array.isArray(subject) ? subject : [subject]; + return entries.find( + entry => + entry !== null && + typeof entry === 'object' && + (entry as { id?: unknown }).id === did + ) as Record | undefined; } diff --git a/src/types/registry.ts b/src/types/registry.ts index c8b6ee6..19cba85 100644 --- a/src/types/registry.ts +++ b/src/types/registry.ts @@ -79,11 +79,13 @@ export interface RegistryReference { * credential's description of itself, so it is the display name to * prefer: the credential cannot alter it. * - * `dcc-legacy` and `oidf` populate this; `vc-recognition` matches carry no - * entity yet. For `oidf` it comes from the `metadata` of the trust - * anchor's subordinate statement, read only after that statement has - * verified — on a cache hit as well, since the cached statement is - * re-verified on every hit. + * Every registry type populates this. For `oidf` it comes from the + * `metadata` of the trust anchor's subordinate statement, read only after + * that statement has verified — on a cache hit as well, since the cached + * statement is re-verified on every hit. For `vc-recognition` it is the + * recognised `credentialSubject` entry, read only from a recognition + * credential that has verified, on a cache hit as well as on a fresh + * fetch. */ export interface RegistryEntity { /** Organisation name as the registry records it. */ diff --git a/test/services/registry-handlers/vc-recognition-handler.spec.ts b/test/services/registry-handlers/vc-recognition-handler.spec.ts index 76f665e..1acbaa6 100644 --- a/test/services/registry-handlers/vc-recognition-handler.spec.ts +++ b/test/services/registry-handlers/vc-recognition-handler.spec.ts @@ -63,7 +63,15 @@ describe('lookupVcRecognition', () => { ); expect(result).toEqual({ status: 'found', - registryName: 'Recognition List' + registryName: 'Recognition List', + entity: { + name: 'Example Tech', + raw: { + id: 'did:web:university.example', + type: 'RecognizedEntity', + name: 'Example Tech' + } + } }); }); @@ -80,7 +88,120 @@ describe('lookupVcRecognition', () => { const result = await lookupVcRecognition('did:key:single', registry, ctx); expect(result).toEqual({ status: 'found', - registryName: 'Recognition List' + registryName: 'Recognition List', + entity: { raw: { id: 'did:key:single', type: 'RecognizedEntity' } } + }); + }); + + it('carries the entry url and logo when they are strings', async () => { + const vc = buildVc({ + credentialSubject: [ + { + id: 'did:web:university.example', + name: 'Example Tech', + url: 'https://university.example', + logo: 'https://university.example/logo.png', + image: { id: 'https://university.example/seal.png' } + } + ] + }); + const ctx = buildCtx({ + httpGetService: FakeHttpGetService({ [listUrl]: okJsonBody(vc) }) + }); + const result = await lookupVcRecognition( + 'did:web:university.example', + registry, + ctx + ); + expect(result).toEqual({ + status: 'found', + registryName: 'Recognition List', + entity: { + name: 'Example Tech', + url: 'https://university.example', + logo: 'https://university.example/logo.png', + raw: { + id: 'did:web:university.example', + name: 'Example Tech', + url: 'https://university.example', + logo: 'https://university.example/logo.png', + image: { id: 'https://university.example/seal.png' } + } + } + }); + }); + + it('returns an entity with no name when the entry has none', async () => { + const vc = buildVc({ + credentialSubject: [ + { id: 'did:web:university.example', type: 'RecognizedEntity' } + ] + }); + const ctx = buildCtx({ + httpGetService: FakeHttpGetService({ [listUrl]: okJsonBody(vc) }) + }); + const result = await lookupVcRecognition( + 'did:web:university.example', + registry, + ctx + ); + expect(result).toEqual({ + status: 'found', + registryName: 'Recognition List', + entity: { + raw: { id: 'did:web:university.example', type: 'RecognizedEntity' } + } + }); + }); + + it('omits name, url and logo that are not strings', async () => { + const entry = { + id: 'did:web:university.example', + name: { en: 'Example Tech', fr: 'Exemple Tech' }, + url: { id: 'https://university.example' }, + logo: ['https://university.example/logo.png'] + }; + const vc = buildVc({ credentialSubject: [entry] }); + const ctx = buildCtx({ + httpGetService: FakeHttpGetService({ [listUrl]: okJsonBody(vc) }) + }); + const result = await lookupVcRecognition( + 'did:web:university.example', + registry, + ctx + ); + expect(result).toEqual({ + status: 'found', + registryName: 'Recognition List', + entity: { raw: entry } + }); + }); + + it('returns the same entity on a lookup served from the cache', async () => { + const vc = buildVc(); + const ctx = buildCtx({ + httpGetService: FakeHttpGetService({ [listUrl]: okJsonBody(vc) }) + }); + const first = await lookupVcRecognition( + 'did:web:university.example', + registry, + ctx + ); + const second = await lookupVcRecognition( + 'did:web:university.example', + registry, + ctx + ); + expect(second).toEqual(first); + expect(second).toMatchObject({ + entity: { + name: 'Example Tech', + raw: { + id: 'did:web:university.example', + type: 'RecognizedEntity', + name: 'Example Tech' + } + } }); });