From 105bd2c561d011628864e5543e9108046d70043f Mon Sep 17 00:00:00 2001 From: Glenn Gore Date: Tue, 8 Sep 2026 13:14:44 +0200 Subject: [PATCH] feat(persona): read the claim-type table from the agent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The payoff for `persona/claim-types/list`: the console stops shipping its own copy of the table. The copy was correct — that was never the problem. A copy of a table two repositories do not own costs a re-sync PR against each on every change, and can only describe the tokens its own build knew about. Resolution moves to `@openvtc/pnm-core/persona` beside the served table, and the strictness orderings come from the agent, so "more protective" means the same thing on both sides. An axis value this build does not recognise is treated as most protective, never least. #195's work is preserved as-is: `treatmentFor` still applies the holder's own decision over the registry's answer, only the axis they decided moves, and a declared token's mask never does. What changed underneath it is where the registry's answer comes from — `isRegisteredType` is now the served table's walk rather than a local one over a compiled record. `null` is a real state and fails closed: masked, `unregistered`, attributed to the registry rather than the holder, with no compiled fallback — a stale copy resolving a token the agent has since tightened is the failure the registry exists to end. A render test pins it, found by leaving the fixture out and watching the reveal-control count go 3 → 4. Drawing stays here, because §3.3 gives the style and says the width is the client's. `listClaimTypes` takes no contextId: the payload is empty and the task is reachable by any authenticated caller, so the holder's own tooling can read it. Signed-off-by: Glenn Gore --- CLAUDE.md | 40 ++- packages/core/src/persona/claim-types.ts | 184 ++++++++++++++ packages/core/src/persona/index.ts | 1 + packages/core/tests/persona.claim-types.mjs | 124 ++++++++++ packages/core/tests/task-surface.mjs | 2 +- .../extension/src/manager/attribute-family.ts | 25 +- .../src/manager/claim-sensitivity.ts | 233 +++--------------- .../src/manager/panes/persona-editors.tsx | 25 +- .../src/manager/panes/persona-map.tsx | 29 ++- .../src/manager/panes/persona-setup.tsx | 23 +- .../extension/src/manager/panes/persona.tsx | 15 +- .../tests/manager-attribute-family.test.mts | 84 +++++-- .../tests/manager-claim-sensitivity.test.mts | 128 +++++++--- .../tests/persona-pane.render.test.mts | 75 ++++++ 14 files changed, 695 insertions(+), 293 deletions(-) create mode 100644 packages/core/src/persona/claim-types.ts create mode 100644 packages/core/tests/persona.claim-types.mjs diff --git a/CLAUDE.md b/CLAUDE.md index 368f741..eaa15af 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -347,8 +347,10 @@ in), up is what a context **holds** of them (the accent). `Flow` is computed in draws. The family hues (`--m-fam-*`, `manager/attribute-family.ts`) are **categorical**, the same species as the act colours in `manager-theme.css` and bound by that file's rule: `--w-ok` / `--w-warn` / `--w-danger` stay the only -colours that mean anything. `familyOf` groups **only** roots the vendored -registry declares — `profile.*` and `employer` are `unregistered`, not a +colours that mean anything. `familyOf` groups **only** roots the **agent's** +registry declares and this console has placed (`PLACED_ROOTS`) — a family the +agent serves that this build predates is `unregistered`, which is the honest +answer for one rather than a gap. It groups only roots the registry declares — `profile.*` and `employer` are `unregistered`, not a "profile" family invented here — and no family's words may claim the colour protects anything, which `manager-attribute-family.test.mts` asserts directly. @@ -372,11 +374,30 @@ border or in a pill; adding a `--m-fam-*` for something that is *state*; or giving `familyOf` a prefix rule the registry has not declared. **Sensitive values are hidden from the screen, and that is all it is.** -`manager/claim-sensitivity.ts` carries a **vendored** copy of the claim-type -registry's masking data — sensitivity and mask style per token, from -`specs/persona/_shared/0.1/claim-types.json` at `registryVersion` 0.1 — because -the agent does not serve that table: `persona/claim-types/list` is an open -question in `CLAIM-TYPES.md` §6, deferred until the first extension type ships. +The claim-type table is **read from the agent**, through +`persona/claim-types/list` — `@openvtc/pnm-core/persona`'s `listClaimTypes`, +loaded by `panes/persona.tsx` beside the pool and threaded down as a prop. +`manager/claim-sensitivity.ts` used to carry a vendored copy; the copy was +*correct*, which was never the problem. A copy of a table two repositories do +not own costs a re-sync pull request against each on every change, and can only +describe the tokens its own build knew about — an agent serving an extension +type is invisible to a client shipping its own. + +**Resolution lives in core** (`resolveTreatment`), beside the served table, and +the strictness orderings come from the agent — so "more protective" means the +same thing on both sides, and a maintainer adding a stricter mask style is +honoured without a rebuild. An axis value this build does not recognise is +treated as **most** protective. `treatmentFor` still applies the holder's +decision over that answer, unchanged: only the axis they decided moves, and a +*declared* token's mask never does. + +**`null` is a real state and it fails closed.** While the table is in flight +every value is masked and every attribute groups as `unregistered`, attributed +to the registry rather than the holder — claiming `source: "holder"` for a +default would put their name on one. There is deliberately no compiled +fallback: a stale copy resolving a token the agent has since tightened is the +failure the registry exists to end. + An unregistered or `x:` token resolves to the conservative default (`high`/`full`) per §4 rule 3. The prefix walk **is** rule 3 and it only ever *tightens*: an unregistered token takes the more protective of its longest @@ -498,8 +519,9 @@ flow; a surface that formats a value itself instead of rendering `FactValue` (the second surface is always the one added later, and a value masked on the card and printed in the strip is masked nowhere); greying a mask with `c.faint`, which is this pane's word for "the agent sent no value" and so -makes a fact the holder has look like one they do not; adding a prefix fallback -to the vendored table; or letting a UI string imply the console does not hold +makes a fact the holder has look like one they do not; reintroducing a compiled +table as a fallback for a registry that has not loaded; or letting a UI string +imply the console does not hold what it hides. **The console's components are rendered in tests, and this is how.** diff --git a/packages/core/src/persona/claim-types.ts b/packages/core/src/persona/claim-types.ts new file mode 100644 index 0000000..3bed432 --- /dev/null +++ b/packages/core/src/persona/claim-types.ts @@ -0,0 +1,184 @@ +// `persona/claim-types/list/1.0` — the claim-type registry, read from the agent +// rather than compiled in. +// +// ## Why this exists +// +// The console vendored this table, and the copy was *correct*. That is not the +// problem being solved. The problem is that a copy of a table two repositories +// do not own costs a re-sync pull request against each of them on every change +// — which is the argument `CLAIM-TYPES.md` §1 makes for the registry existing +// at all, applied one layer down — and that a compiled copy can only ever +// describe the tokens its own build knew about. An agent serving an extension +// type is invisible to a client that ships its own table. +// +// ## Resolution belongs here, not at the call site +// +// `resolveTreatment` is §4 in full, and the parts that look like detail are the +// parts that were got wrong before: the walk is on **dot boundaries**, it is a +// **proper** prefix, `x:` borrows nothing, and the prefix can only ever +// *tighten* against the floor. A caller that walked prefixes itself would have +// to get all four right, and the loosening direction fails quietly — an +// unregistered `name.somethingNew` inheriting `name`'s `none` is a value shown +// in the clear that the registry never said to show. +// +// The orderings come from the agent's `strictness`, not from a constant here. +// That is the half that makes "more protective wins" mean the same thing on +// both sides: a maintainer that adds a mask style stricter than `full` says so +// in the ordering it serves, and this resolves against it without a rebuild. + +import type { TrustTaskSender } from "../vta/channel.js"; + +import { + TYPE_URI as CLAIM_TYPES_LIST, + RESPONSE_TYPE_URI as CLAIM_TYPES_LIST_RESPONSE, + type PersonaClaimTypesListPayload, + type PersonaClaimTypesListResponsePayload, +} from "@openvtc/trust-tasks/persona/claim-types/list/1.0/payload"; + +import { call, type PersonaCallerParams } from "./call.js"; + +/** + * Who is asking, and of which agent — **without** a context. + * + * Every other task in this directory takes `PersonaCallerParams`, which + * requires one. This task must not: its payload is empty, and the agent gates + * it as reachable by any authenticated caller precisely so the two callers that + * need it can both get it. Requiring a context here would refuse the holder's + * own tooling, which has none to name — and a context invented to satisfy a + * signature is a lie in an audit trail. + */ +export type ClaimTypesCaller = Omit; + +export type ClaimTypeRegistry = PersonaClaimTypesListResponsePayload; +export type RegistryEntry = ClaimTypeRegistry["entries"][number]; + +/** How carefully a value is shown to its own holder, and how it is drawn. */ +export interface ClaimTreatment { + sensitivity: string; + mask: string; +} + +/** + * Read the agent's claim-type registry. + * + * The response is a constant for a given agent, so a caller **may** hold it for + * the life of a session, keyed on `registryVersion`. It **must not** be cached + * across agents: two agents may serve different extension types, and a table + * from one applied to the other resolves tokens it has never heard of. + */ +export async function listClaimTypes( + sender: TrustTaskSender, + params: ClaimTypesCaller, +): Promise { + return call( + sender, + params, + CLAIM_TYPES_LIST, + CLAIM_TYPES_LIST_RESPONSE, + "persona/claim-types/list", + {}, + ); +} + +/** The open extension namespace. `ClaimType` in `persona-record.schema.json`. */ +const EXTENSION_PREFIX = "x:"; + +/** + * The more protective of two values on one axis, per the agent's ordering. + * + * A value the ordering does not contain is treated as **most** protective + * rather than least. That is the unknown-value branch, and it is the difference + * between showing a value and hiding it: a maintainer serving a style this + * build has never heard of must not have it read as "no mask". + */ +function stricter(order: readonly string[], a: string, b: string): string { + const rank = (v: string) => { + const i = order.indexOf(v); + return i === -1 ? -1 : i; + }; + return rank(a) <= rank(b) ? a : b; +} + +/** + * How this type's values are treated — `CLAIM-TYPES.md` §4. + * + * Rule 1 — a holder's per-attribute override — is deliberately *not* here. It + * belongs above this call, because "the holder decided" and "the registry says" + * are different facts and a UI that wants to explain the difference needs both. + */ +export function resolveTreatment( + registry: ClaimTypeRegistry, + type: string, +): ClaimTreatment { + const floor: ClaimTreatment = { + sensitivity: registry.unregistered.sensitivity, + mask: registry.unregistered.mask, + }; + + // Rule 4 taken first: `x:` is unregistered *by construction*, so it must + // borrow neither an entry nor a family however much of a registered token it + // happens to spell. + if (type.startsWith(EXTENSION_PREFIX)) return floor; + + // Rule 2 — an exact entry, as written. + const exact = registry.entries.find((e) => e.type === type); + if (exact) return { sensitivity: exact.sensitivity, mask: exact.mask }; + + // Rule 3 — the longest registered *proper* prefix, on dot boundaries only. + // `payment` is a prefix of `payment.card`; `paymentology.card` is a member of + // nothing. + const segments = type.split("."); + let prefix: RegistryEntry | undefined; + for (let i = segments.length - 1; i > 0; i--) { + const candidate = registry.entries.find( + (e) => e.type === segments.slice(0, i).join("."), + ); + if (candidate) { + prefix = candidate; + break; + } + } + if (!prefix) return floor; + + // ...and it can only tighten. `payment.giftCard` inherits `payment`'s gating, + // because a gated family must not be leavable by inventing a token; while + // `name.somethingNew` does **not** inherit `name`'s `none`, because a family + // entry cannot make an unknown token visible. + return { + sensitivity: stricter( + registry.strictness.sensitivity ?? [], + prefix.sensitivity, + floor.sensitivity, + ), + mask: stricter(registry.strictness.mask ?? [], prefix.mask, floor.mask), + }; +} + +/** + * Whether the registry **declares** this token, or a family it belongs to. + * + * The same walk [`resolveTreatment`] performs, asked as a question — a caller + * applying a holder's override needs it, because the floor and a declared + * entry are different kinds of answer. An `x:` token is never registered, per + * §4's last rule. + */ +export function isRegisteredType(registry: ClaimTypeRegistry, type: string): boolean { + if (type.startsWith(EXTENSION_PREFIX)) return false; + if (registry.entries.some((e) => e.type === type)) return true; + const segments = type.split("."); + for (let i = segments.length - 1; i > 0; i--) { + const head = segments.slice(0, i).join("."); + if (registry.entries.some((e) => e.type === head)) return true; + } + return false; +} + +/** + * The first segment of every registered token — the roots a UI may group by. + * + * Served rather than derived from a compiled list, so a family the agent knows + * and this build does not still groups. + */ +export function registeredRoots(registry: ClaimTypeRegistry): ReadonlySet { + return new Set(registry.entries.map((e) => e.type.split(".")[0]!)); +} diff --git a/packages/core/src/persona/index.ts b/packages/core/src/persona/index.ts index 5a96b03..43c2b02 100644 --- a/packages/core/src/persona/index.ts +++ b/packages/core/src/persona/index.ts @@ -40,6 +40,7 @@ export * from "./disclosure.js"; export * from "./step-up.js"; +export * from "./claim-types.js"; export * from "./consent-view.js"; export * from "./renderers.js"; export * from "./bindings.js"; diff --git a/packages/core/tests/persona.claim-types.mjs b/packages/core/tests/persona.claim-types.mjs new file mode 100644 index 0000000..117b039 --- /dev/null +++ b/packages/core/tests/persona.claim-types.mjs @@ -0,0 +1,124 @@ +// `CLAIM-TYPES.md` §4, resolved against the table the AGENT serves. +// +// These moved here from the console when the vendored copy went. Resolution is +// not a rendering concern and never was: it is the same four rules whoever +// asks, and having it in one place is the point of reading the table rather +// than compiling one in. + +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { resolveTreatment, isRegisteredType, registeredRoots } from "../dist/persona/index.js"; + +/** A registry shaped exactly as `persona/claim-types/list` returns one. */ +const registry = { + registryVersion: "0.1", + entries: [ + // Family rows and exact rows, undistinguished — which one a row is depends + // on the token being resolved. + { type: "payment", sensitivity: "high", release: "stepUp", mask: "full" }, + { type: "gov", sensitivity: "high", release: "stepUp", mask: "full" }, + { type: "name", sensitivity: "normal", release: "consent", mask: "none" }, + { type: "name.legal", sensitivity: "normal", release: "consent", mask: "none" }, + { type: "payment.card", sensitivity: "high", release: "stepUp", mask: "last4" }, + { type: "email.work", sensitivity: "normal", release: "consent", mask: "emailLocal" }, + { type: "account.handle", sensitivity: "normal", release: "consent", mask: "none" }, + ], + unregistered: { sensitivity: "high", release: "consent", mask: "full" }, + strictness: { + sensitivity: ["high", "normal"], + release: ["stepUp", "consent"], + mask: ["full", "last2", "last4", "emailLocal", "none"], + }, +}; + +test("an exact entry is used as written, not compared against its family", () => { + // `payment.card` is `last4`, not its family's `full`. Rule 2 answers before + // rule 3 can tighten it. + assert.deepEqual(resolveTreatment(registry, "payment.card"), { + sensitivity: "high", + mask: "last4", + }); +}); + +test("a bare name is a token, not just a prefix", () => { + assert.deepEqual(resolveTreatment(registry, "name"), { sensitivity: "normal", mask: "none" }); +}); + +test("a token invented under a gated family cannot escape it", () => { + // The hole rule 3 exists for: without the walk this would take the floor's + // `consent`, weaker than every registered member of the family it plainly + // belongs to. + assert.deepEqual(resolveTreatment(registry, "payment.giftCard"), { + sensitivity: "high", + mask: "full", + }); +}); + +test("a family entry can tighten but never loosen", () => { + // `name` is `none`, but an unregistered member does NOT inherit that — the + // more protective of the prefix and the floor wins, per axis. A family entry + // cannot make an unknown token visible. + assert.deepEqual(resolveTreatment(registry, "name.somethingNew"), { + sensitivity: "high", + mask: "full", + }); +}); + +test("an x: token borrows nothing, however it is spelled", () => { + assert.deepEqual(resolveTreatment(registry, "x:payment.card"), { + sensitivity: "high", + mask: "full", + }); + assert.deepEqual(resolveTreatment(registry, "x:name"), { sensitivity: "high", mask: "full" }); +}); + +test("a prefix is matched on dot boundaries, not on characters", () => { + // `paymentology.card` merely starts with those characters and is a member of + // nothing. + assert.deepEqual(resolveTreatment(registry, "paymentology.card"), { + sensitivity: "high", + mask: "full", + }); +}); + +test("a token the registry has never seen takes the floor", () => { + assert.deepEqual(resolveTreatment(registry, "wholly.unknown"), { + sensitivity: "high", + mask: "full", + }); +}); + +test("a declared token is distinguishable from one that fell to the floor", () => { + // A holder applying their own decision needs to know which they have: a + // declared entry is a statement the registry made, the floor is one standing + // in for a decision nobody took. `treatmentFor` in the console turns on it. + assert.equal(isRegisteredType(registry, "payment.card"), true, "exact entry"); + assert.equal(isRegisteredType(registry, "payment.giftCard"), true, "via its family"); + assert.equal(isRegisteredType(registry, "wholly.unknown"), false); + assert.equal(isRegisteredType(registry, "x:payment.card"), false, "x: is never registered"); +}); + +test("an unrecognised value on an axis is treated as most protective", () => { + // A maintainer serving a style this build has never heard of must not have it + // read as "no mask" — the unknown-value branch is the difference between + // showing a value and hiding it. + const future = { + ...registry, + entries: [ + { type: "novel", sensitivity: "high", release: "consent", mask: "someFutureStyle" }, + ], + }; + // The style survives resolution rather than being rewritten — a renderer that + // knows it should use it. What must never happen is it resolving to `none`: + // that is the branch where an unknown style shows a value in the clear. + const resolved = resolveTreatment(future, "novel.thing").mask; + assert.notEqual(resolved, "none", "an unknown style must never resolve to no mask"); +}); + +test("the roots come from the served table, not a compiled list", () => { + const roots = registeredRoots(registry); + assert.ok(roots.has("payment")); + assert.ok(roots.has("email")); + assert.ok(!roots.has("wholly")); +}); diff --git a/packages/core/tests/task-surface.mjs b/packages/core/tests/task-surface.mjs index 920f121..7c9ebb9 100644 --- a/packages/core/tests/task-surface.mjs +++ b/packages/core/tests/task-surface.mjs @@ -279,7 +279,7 @@ test("coverage against the agent's surface is recorded, not discovered", () => { // the agent does not name, rather than as a deprecation warning. That is the // expected shape of a cutover here: nothing is deployed, so neither side // keeps an old version alive. - const expected = 187; + const expected = 188; assert.equal( implemented.size, expected, diff --git a/packages/extension/src/manager/attribute-family.ts b/packages/extension/src/manager/attribute-family.ts index 8c039a6..97a8067 100644 --- a/packages/extension/src/manager/attribute-family.ts +++ b/packages/extension/src/manager/attribute-family.ts @@ -35,7 +35,7 @@ // in a colour that reads as though somebody had. `unregistered` is an honest // answer and its words on screen say so. -import { REGISTERED_ROOTS } from "./claim-sensitivity.js"; +import { registeredRoots, type ClaimTypeRegistry } from "@openvtc/pnm-core/persona"; /** The families this console groups by. `unregistered` is a real member, not a * fallback bucket to be tidied away: it is the answer for every token the @@ -108,10 +108,29 @@ export function familyStyle(family: Family): FamilyStyle { * tested first so `x:name.legal` cannot borrow `name`'s group, exactly as * `treatmentOf` refuses to let it borrow `name`'s mask. */ -export function familyOf(type: string): Family { +/** + * The roots this console has words and a colour for. + * + * Exported so a test can assert the mapping is internally complete. It is + * deliberately **not** a claim about what the agent serves: a maintainer may + * declare a family this build has never heard of, and `unregistered` is the + * honest answer for one — the same answer a token nobody has classified gets, + * because from here that is exactly what it is. + */ +export const PLACED_ROOTS = [ + "name", "person", "email", "phone", "address", "account", "url", "org", + "payment", "gov", +] as const; + +export function familyOf(type: string, registry: ClaimTypeRegistry | null): Family { if (type.startsWith("x:")) return "unregistered"; const root = type.split(".")[0] ?? ""; - if (!REGISTERED_ROOTS.has(root)) return "unregistered"; + // No table yet: `unregistered` is what a token nobody has classified gets, + // and while the registry is in flight that is exactly what every token is + // from here. Colouring by a compiled-in guess would put a family on + // something this agent may never have declared. + if (!registry) return "unregistered"; + if (!registeredRoots(registry).has(root)) return "unregistered"; switch (root) { case "name": case "person": diff --git a/packages/extension/src/manager/claim-sensitivity.ts b/packages/extension/src/manager/claim-sensitivity.ts index 3cce416..70b0ba5 100644 --- a/packages/extension/src/manager/claim-sensitivity.ts +++ b/packages/extension/src/manager/claim-sensitivity.ts @@ -41,189 +41,21 @@ // here. Re-sync by comparing this table against that file, not by rewriting it // from memory. -/** How carefully a value is shown to its own holder — `CLAIM-TYPES.md` §3.1. */ -export type Sensitivity = "normal" | "high"; - -/** One of the styles `claim-types.json` enumerates under `maskStyles`. */ -export type MaskStyle = "none" | "last2" | "last4" | "emailLocal" | "full"; - -export interface ClaimTreatment { - sensitivity: Sensitivity; - mask: MaskStyle; -} - -/** The `registryVersion` the table below was taken from. Carried so a future - * re-sync against a served registry has something to compare, and so a reader - * can tell which draft this agrees with. */ -export const REGISTRY_VERSION = "0.1"; - -/** - * What an unregistered token resolves to — `CLAIM-TYPES.md` §4 rule 3. - * - * Deliberately the conservative answer, and the registry gives the reasoning - * rather than leaving it to be inferred: a vocabulary the registry has never - * seen is exactly the one nobody has reasoned about, and an unknown value - * rendered in the clear is a decision nobody made. - * - * Note what this is *not* applied to. §4 keeps "absence is the most restrictive - * answer" for an unknown **token**, never for an unset field on a known one — - * applied to the latter it would mask every legal name in every pool, which - * teaches an operator to press *Show* reflexively and leaves them less - * protected than before. So `name.legal` is `normal`/`none` because the - * registry says so, and only a token with no entry falls here. - */ -export const UNREGISTERED: ClaimTreatment = { sensitivity: "high", mask: "full" }; - -/** - * The core vocabulary, as `claim-types.json` declares it. - * - * A plain record rather than a `Map` so a reader can diff it against the JSON - * line for line, which is the maintenance operation this table actually gets. - */ -const REGISTERED: Readonly> = { - // Family entries — matched as a prefix by `treatmentOf`, and the reason a - // token invented under a gated family cannot escape it. - payment: { sensitivity: "high", mask: "full" }, - gov: { sensitivity: "high", mask: "full" }, - - // Both an exact token and a family prefix: a pool that keeps one - // undifferentiated name is using `name`, and without an entry it would mask - // in full. - name: { sensitivity: "normal", mask: "none" }, - - "name.legal": { sensitivity: "normal", mask: "none" }, - "name.given": { sensitivity: "normal", mask: "none" }, - "name.family": { sensitivity: "normal", mask: "none" }, - "name.display": { sensitivity: "normal", mask: "none" }, - "name.previous": { sensitivity: "high", mask: "full" }, - - "person.birthDate": { sensitivity: "high", mask: "full" }, - "person.pronouns": { sensitivity: "normal", mask: "none" }, - "person.locale": { sensitivity: "normal", mask: "none" }, - - "email.personal": { sensitivity: "normal", mask: "emailLocal" }, - "email.work": { sensitivity: "normal", mask: "emailLocal" }, - - "phone.mobile": { sensitivity: "high", mask: "last2" }, - "phone.landline": { sensitivity: "high", mask: "last2" }, +import { + resolveTreatment, + isRegisteredType, + type ClaimTypeRegistry, + type ClaimTreatment, +} from "@openvtc/pnm-core/persona"; - "address.postal": { sensitivity: "high", mask: "full" }, - "address.country": { sensitivity: "normal", mask: "none" }, +export type { ClaimTreatment }; - "gov.id.passport": { sensitivity: "high", mask: "last4" }, - "gov.id.driverLicence": { sensitivity: "high", mask: "last4" }, - "gov.id.national": { sensitivity: "high", mask: "last4" }, - "gov.taxId": { sensitivity: "high", mask: "last4" }, - - "payment.card": { sensitivity: "high", mask: "last4" }, - "payment.cardExpiry": { sensitivity: "high", mask: "full" }, - "payment.iban": { sensitivity: "high", mask: "last4" }, - "payment.accountNumber": { sensitivity: "high", mask: "last4" }, - - "account.handle": { sensitivity: "normal", mask: "none" }, - "url.homepage": { sensitivity: "normal", mask: "none" }, - - "org.name": { sensitivity: "normal", mask: "none" }, - "org.role": { sensitivity: "normal", mask: "none" }, -}; - -/** - * The first segment of every token the table above declares. - * - * Derived rather than written out, so it cannot drift from the table on a - * re-sync — a root that appears here without anyone editing this line is the - * registry having grown one, which is exactly what `attribute-family.ts` wants - * to be told about. It is the only thing outside this module that may ask what - * the registry *covers*: whether a token is known is a registry question, - * while what a family means on screen is a console one. - */ -export const REGISTERED_ROOTS: ReadonlySet = new Set( - Object.keys(REGISTERED).map((token) => token.split(".")[0]!), -); - -/** - * How this **type's** values are treated — `CLAIM-TYPES.md` §4, minus rule 1, - * which is about one attribute rather than a type. - * - * §4's first rule is a per-attribute override the holder set explicitly, which - * wins over the registry. It stays *above* this call, in `treatmentFor` below — - * "the holder decided" and "the registry says" are two different claims about - * one value, and a UI that wants to explain the difference needs both. This - * function is only ever the second of them. - * - * **The prefix walk is rule 3, and it only ever tightens.** An unregistered - * token takes the *more protective* of its longest registered prefix and the - * unregistered floor, per axis — never the prefix outright. That direction is - * the whole point: `payment.giftCard` inherits `payment`'s treatment because a - * gated family must not be leavable by inventing a token, while `name.somethingNew` - * does **not** inherit `name`'s `none` and stays masked, because a family entry - * cannot make an unknown token visible. - * - * This console reported the walk's absence when it first vendored this table; - * the registry gained it in trust-tasks#377, and this is that rule. - */ -export function treatmentOf(type: string): ClaimTreatment { - // `x:` is the open extension namespace (`ClaimType` in - // `persona-record.schema.json`), and §4's last rule names it alongside an - // unregistered token. Tested before anything else so an `x:`-prefixed - // spelling of a core token cannot borrow that token's entry — nor, now, - // its family's. - if (type.startsWith("x:")) return UNREGISTERED; - - const exact = REGISTERED[type]; - if (exact) return exact; - - // Longest registered prefix, on dot boundaries only: `payment.card` is under - // `payment`, but a token merely *starting with* those characters is not. - let prefix: ClaimTreatment | undefined; - const segments = type.split("."); - for (let i = segments.length - 1; i > 0; i--) { - const candidate = REGISTERED[segments.slice(0, i).join(".")]; - if (candidate) { - prefix = candidate; - break; - } - } - if (!prefix) return UNREGISTERED; - - return { - sensitivity: stricter(SENSITIVITY_ORDER, prefix.sensitivity, UNREGISTERED.sensitivity), - mask: stricter(MASK_ORDER, prefix.mask, UNREGISTERED.mask), - }; -} - -/** `strictness` in `claim-types.json`, most protective first. Kept as arrays - * rather than comparisons so the ordering is diffable against the registry. */ -const SENSITIVITY_ORDER: readonly Sensitivity[] = ["high", "normal"]; -const MASK_ORDER: readonly MaskStyle[] = ["full", "last2", "last4", "emailLocal", "none"]; - -/** The more protective of two values on one axis. A value the order does not - * know is treated as least protective, so an unrecognised entry can never win - * and quietly loosen a treatment. */ -function stricter(order: readonly T[], a: T, b: T): T { - const rank = (v: T) => { - const i = order.indexOf(v); - return i === -1 ? order.length : i; - }; - return rank(a) <= rank(b) ? a : b; -} +/** One of the styles the registry enumerates under `maskStyles`. */ +export type MaskStyle = "none" | "last2" | "last4" | "emailLocal" | "full"; -/** Whether a value of this type is hidden until asked for. - * - * Masking no longer requires `sensitivity: high` — §3.3 made the two - * independent, because they are two strengths of protection rather than one. - * `high` means *withheld from a listing that did not ask*; a mask style means - * *not shown in the clear*. An email address is worth hiding from the person - * behind you without being worth withholding from every listing, and until the - * two were separated there was no way to say so — `email.*` carried a style no - * rule could ever apply. */ -export function isSensitive(type: string): boolean { - return treatmentOf(type).mask !== "none"; -} +/** How carefully a value is shown to its own holder — `CLAIM-TYPES.md` §3.1. */ +export type Sensitivity = "normal" | "high"; -/** The character a mask is drawn with. One glyph, everywhere, so a masked value - * is recognisable as one at a glance and never reads as a value that happens - * to contain punctuation. */ const DOT = "•"; /** @@ -309,13 +141,14 @@ function emailLocal(text: string): string { * an unregistered token's mask follows it is written down. */ export function maskedFact( + registry: ClaimTypeRegistry | null, type: string, text: string, override?: Sensitivity | undefined, ): { text: string; masked: boolean } { - const { treatment } = treatmentFor(type, override); + const { treatment } = treatmentFor(registry, type, override); if (treatment.mask === "none") return { text, masked: false }; - const masked = maskText(text, treatment.mask); + const masked = maskText(text, treatment.mask as MaskStyle); // A mask that changed nothing would claim to hide while hiding nothing — the // honest answer is to draw the value plainly and offer no control, rather // than a *Show* button that does not change what is on screen. @@ -353,37 +186,41 @@ export function maskedFact( * there the registry has an opinion and this console does not overrule it. */ export function treatmentFor( + registry: ClaimTypeRegistry | null, type: string, override?: Sensitivity | undefined, ): { treatment: ClaimTreatment; source: "holder" | "registry" } { - const registry = treatmentOf(type); - if (override === undefined) return { treatment: registry, source: "registry" }; + // No table yet. Everything is drawn masked and attributed to the registry, + // which is the fail-closed answer *and* the honest one: the holder's decision + // cannot be applied over an answer that has not arrived, and claiming + // `source: "holder"` here would put their name on a default. + if (!registry) { + return { treatment: { sensitivity: "high", mask: "full" }, source: "registry" }; + } + const declared = resolveTreatment(registry, type); + if (override === undefined) return { treatment: declared, source: "registry" }; return { treatment: { sensitivity: override, - mask: isRegistered(type) ? registry.mask : override === "high" ? "full" : "none", + mask: isRegisteredType(registry, type) + ? declared.mask + : override === "high" + ? "full" + : "none", }, source: "holder", }; } -/** Whether the registry declares this token, or a family it belongs to — the - * same walk `treatmentOf` performs, asked as a question. An `x:` token is - * never registered, per §4's last rule. */ -function isRegistered(type: string): boolean { - if (type.startsWith("x:")) return false; - if (REGISTERED[type]) return true; - const segments = type.split("."); - for (let i = segments.length - 1; i > 0; i--) { - if (REGISTERED[segments.slice(0, i).join(".")]) return true; - } - return false; -} /** Whether this attribute's value is hidden until asked for, the holder's own * decision included. The `type`-only {@link isSensitive} is the registry's * answer alone and stays that way — a call site holding a whole attribute * should use this one. */ -export function isSensitiveFor(type: string, override?: Sensitivity | undefined): boolean { - return treatmentFor(type, override).treatment.mask !== "none"; +export function isSensitiveFor( + registry: ClaimTypeRegistry | null, + type: string, + override?: Sensitivity | undefined, +): boolean { + return treatmentFor(registry, type, override).treatment.mask !== "none"; } diff --git a/packages/extension/src/manager/panes/persona-editors.tsx b/packages/extension/src/manager/panes/persona-editors.tsx index d16590b..7f70faa 100644 --- a/packages/extension/src/manager/panes/persona-editors.tsx +++ b/packages/extension/src/manager/panes/persona-editors.tsx @@ -42,6 +42,7 @@ import { useAsync } from "../use-async.js"; import { contextHeading, formatInstant } from "../format.js"; import { type Authority, type Parties } from "../use-vta.js"; import { holderGate } from "../holder-gate.js"; +import type { ClaimTypeRegistry } from "@openvtc/pnm-core/persona"; import { maskedFact, treatmentFor, type Sensitivity } from "../claim-sensitivity.js"; import { composeEntries, lockedRefs, preservedEntries, tickedFrom } from "../profile-entries.js"; import { personaCandidates } from "../persona-candidates.js"; @@ -121,6 +122,7 @@ export function FactValue({ style, textStyle, reveal, + registry, }: { type: string; value: unknown; @@ -146,6 +148,9 @@ export function FactValue({ * the honest end of the sentence, and better than a *Show* that cannot. */ reveal?: () => Promise; + /** The agent's claim-type table, or `null` while it loads. A caller must not + * substitute a compiled-in one — that is the copy this replaced. */ + registry: ClaimTypeRegistry | null; }) { const [shown, setShown] = useState(false); const [revealed, setRevealed] = useState<{ value: unknown } | null>(null); @@ -153,7 +158,7 @@ export function FactValue({ const [refused, setRefused] = useState(null); const { text, withheld } = formatValue(revealed ? revealed.value : value); - const { text: hidden, masked } = maskedFact(type, text, sensitivity); + const { text: hidden, masked } = maskedFact(registry, type, text, sensitivity); // **A withheld value is never masked.** The mask is a statement that a value // is here and is being kept off the screen; drawing it over "not on this @@ -364,6 +369,7 @@ export function AttributeEditor({ onDone, onCancel, cancelLabel = "Cancel", + registry, }: { parties: Parties; authority: Authority | null; @@ -376,6 +382,9 @@ export function AttributeEditor({ * that is where its button goes. */ cancelLabel?: string | undefined; onDone: () => void; + /** The agent's claim-type table, or `null` while it loads. A caller must not + * substitute a compiled-in one — that is the copy this replaced. */ + registry: ClaimTypeRegistry | null; }) { const [type, setType] = useState(existing?.type ?? ""); const [label, setLabel] = useState(existing?.label ?? ""); @@ -558,7 +567,7 @@ export function AttributeEditor({ value={sensitivity} onChange={setSensitivity} fallback={ - treatmentFor(type.trim()).treatment.sensitivity === "high" + treatmentFor(registry, type.trim()).treatment.sensitivity === "high" ? "kept back until you ask" : "shown" } @@ -1043,11 +1052,15 @@ export function ResolvedProfile({ parties, profileId, name, + registry, }: { parties: Parties; profileId: string; /** How to name it while loading and when it holds nothing. */ name: string; + /** The agent's claim-type table, or `null` while it loads. A caller must not + * substitute a compiled-in one — that is the copy this replaced. */ + registry: ClaimTypeRegistry | null; }) { const resolved = useAsync( async () => personaProfileGet(managerSender, { ...parties, profileId, resolve: true }), @@ -1082,7 +1095,7 @@ export function ResolvedProfile({ style={{ display: "flex", gap: 10, flexWrap: "wrap", alignItems: "baseline" }} > {claim.type} - + {inline && only in this face} {claim.stale && stale} @@ -1126,11 +1139,15 @@ export function PersonaClaims({ contextId, personaDid, profileName, + registry, }: { parties: Parties; contextId: string; personaDid: string; profileName: string; + /** The agent's claim-type table, or `null` while it loads. A caller must not + * substitute a compiled-in one — that is the copy this replaced. */ + registry: ClaimTypeRegistry | null; }) { const bound = useAsync( async () => getBinding(managerSender, { ...parties, contextId, personaDid }), @@ -1151,7 +1168,7 @@ export function PersonaClaims({ } return ( - { const byFamily = new Map(); for (const a of graph.attributes) { - const family = familyOf(a.type); + const family = familyOf(a.type, registry); byFamily.set(family, [...(byFamily.get(family) ?? []), a]); } return FAMILY_ORDER.filter((f) => byFamily.has(f)).map((family) => ({ @@ -609,7 +615,7 @@ export function IdentityMap({ {f.label && ( {f.label} · )} - - + {attribute?.type ?? id} ); @@ -835,7 +841,7 @@ export function IdentityMap({ {/* ── Detail strip ── */} {selection && !editing && ( -
{attribute.type} - {attribute.value === undefined ? "Your agent has not sent this value to this page. Show asks it for this one." @@ -1116,7 +1127,7 @@ function DetailStrip({
- {showing === "claims" && } + {showing === "claims" && } , ); } @@ -1202,7 +1213,7 @@ function DetailStrip({ {showing === "claims" && p.faceId && ( - + )} , ); diff --git a/packages/extension/src/manager/panes/persona-setup.tsx b/packages/extension/src/manager/panes/persona-setup.tsx index 7b8a683..f050647 100644 --- a/packages/extension/src/manager/panes/persona-setup.tsx +++ b/packages/extension/src/manager/panes/persona-setup.tsx @@ -14,6 +14,7 @@ import { useState } from "react"; import type { PoolAttribute, PoolProfile } from "@openvtc/pnm-core/admin"; import type { ContextRecord } from "@openvtc/pnm-core"; +import type { ClaimTypeRegistry } from "@openvtc/pnm-core/persona"; import { Button, Note, Panel } from "../../ui.js"; import { c, t, font } from "../../theme.js"; import { contextHeading } from "../format.js"; @@ -82,7 +83,15 @@ function Stepper({ step, reachable, onGo }: { step: Step; reachable: (s: Step) = /** The card a stranger would receive from a face — the thing a tick list * cannot convey. Rendered from the pool directly: at this step nothing has * been pushed anywhere yet, so the pool is the only source. */ -function StrangerCard({ attributes, faceName }: { attributes: PoolAttribute[]; faceName: string }) { +function StrangerCard({ + attributes, + faceName, + registry, +}: { + attributes: PoolAttribute[]; + faceName: string; + registry: ClaimTypeRegistry | null; +}) { const name = attributes.find((f) => f.type === "name" || f.type.startsWith("name.")); const rest = attributes.filter((f) => f !== name); const provable = attributes.some((f) => f.provenance.kind === "credentialBacked" && !f.stale); @@ -99,7 +108,7 @@ function StrangerCard({ attributes, faceName }: { attributes: PoolAttribute[]; f <>
{name ? ( - + ) : ( )} @@ -110,7 +119,7 @@ function StrangerCard({ attributes, faceName }: { attributes: PoolAttribute[]; f {rest.map((f) => ( {f.label ?? f.type} - + ))}
@@ -142,6 +151,7 @@ export function GuidedSetup({ onChanged, onFinished, onSkip, + registry, }: { parties: Parties; authority: Authority | null; @@ -152,6 +162,7 @@ export function GuidedSetup({ /** The third step succeeded; the map takes over with the outcome as its banner. */ onFinished: (outcome: string) => void; onSkip: () => void; + registry: ClaimTypeRegistry | null; }) { const [step, setStep] = useState(attributes.length === 0 ? 1 : 2); const [contextId, setContextId] = useState(records[0]?.id ?? ""); @@ -181,7 +192,7 @@ export function GuidedSetup({ {step === 1 && (
- (
{a.type} - +
))}
@@ -236,7 +247,7 @@ export function GuidedSetup({ onCancel={() => setStep(1)} cancelLabel="Back — add more attributes" /> - +
)} diff --git a/packages/extension/src/manager/panes/persona.tsx b/packages/extension/src/manager/panes/persona.tsx index 872c999..f3ec731 100644 --- a/packages/extension/src/manager/panes/persona.tsx +++ b/packages/extension/src/manager/panes/persona.tsx @@ -59,6 +59,10 @@ import { useAsync } from "../use-async.js"; import { contextHeading } from "../format.js"; import type { Authority, Parties } from "../use-vta.js"; import { buildGraph, type ContextInput } from "../identity-graph.js"; +// `claim-types/list` is `Reach::Any` and describes the agent's vocabulary, not +// the holder — so it lives in the wallet half of the SDK, not the operator half +// the rest of this pane imports. +import { listClaimTypes } from "@openvtc/pnm-core/persona"; import { IdentityMap } from "./persona-map.js"; import { GuidedSetup } from "./persona-setup.js"; import { showsGuide } from "../persona-flow.js"; @@ -147,6 +151,13 @@ export function PersonaPane({ async () => loadContexts(parties, records), [parties.holder.did, parties.service.did, records.map((r) => r.id).join(" ")], ); + // The claim-type table, read from THIS agent rather than compiled in. Loaded + // beside the pool because the same agent answers both: if this refuses there + // are no attributes to mask either, so it needs no failure branch of its own. + const registry = useAsync( + async () => listClaimTypes(managerSender, parties), + [parties.holder.did, parties.service.did], + ); const history = useAsync( async () => personaDisclosureHistory(managerSender, parties), [parties.holder.did, parties.service.did], @@ -205,7 +216,7 @@ export function PersonaPane({ // lands on step two; one with nothing on step one. if (showGuide) { return ( - {profiles.error && } {contexts.error && } - ({ type, sensitivity: "normal", release: "consent", mask: "none" })), + unregistered: { sensitivity: "high", release: "consent", mask: "full" }, + strictness: { + sensitivity: ["high", "normal"], + release: ["stepUp", "consent"], + mask: ["full", "last2", "last4", "emailLocal", "none"], + }, +} as never; + test("the registry's own vocabularies land where their words say", () => { - assert.equal(familyOf("name.legal"), "identity"); - assert.equal(familyOf("person.birthDate"), "identity"); - assert.equal(familyOf("email.work"), "contact"); - assert.equal(familyOf("phone.mobile"), "contact"); - assert.equal(familyOf("address.postal"), "contact"); - assert.equal(familyOf("account.handle"), "public"); - assert.equal(familyOf("org.role"), "public"); - assert.equal(familyOf("gov.id.passport"), "gated"); - assert.equal(familyOf("payment.card"), "gated"); + assert.equal(familyOf("name.legal", REGISTRY), "identity"); + assert.equal(familyOf("person.birthDate", REGISTRY), "identity"); + assert.equal(familyOf("email.work", REGISTRY), "contact"); + assert.equal(familyOf("phone.mobile", REGISTRY), "contact"); + assert.equal(familyOf("address.postal", REGISTRY), "contact"); + assert.equal(familyOf("account.handle", REGISTRY), "public"); + assert.equal(familyOf("org.role", REGISTRY), "public"); + assert.equal(familyOf("gov.id.passport", REGISTRY), "gated"); + assert.equal(familyOf("payment.card", REGISTRY), "gated"); }); test("a token invented under a declared family stays in it", () => { // The same direction `treatmentOf` walks a prefix in: a family entry the // registry declares covers what is invented beneath it. - assert.equal(familyOf("payment.giftCard"), "gated"); - assert.equal(familyOf("gov.id.somethingNew"), "gated"); + assert.equal(familyOf("payment.giftCard", REGISTRY), "gated"); + assert.equal(familyOf("gov.id.somethingNew", REGISTRY), "gated"); }); test("a token no registry root covers is unregistered, not guessed at", () => { // `profile.*` and `employer` are what a holder actually types today, and // neither is in the table. Inventing a "profile" family here would draw a // grouping nobody has agreed to. - assert.equal(familyOf("profile.github"), "unregistered"); - assert.equal(familyOf("profile.signal"), "unregistered"); - assert.equal(familyOf("employer"), "unregistered"); - assert.equal(familyOf(""), "unregistered"); + assert.equal(familyOf("profile.github", REGISTRY), "unregistered"); + assert.equal(familyOf("profile.signal", REGISTRY), "unregistered"); + assert.equal(familyOf("employer", REGISTRY), "unregistered"); + assert.equal(familyOf("", REGISTRY), "unregistered"); }); test("the open extension namespace cannot borrow a core token's family", () => { // Tested first inside `familyOf` for the same reason `treatmentOf` tests it // first: `x:name.legal` must not inherit `name`. - assert.equal(familyOf("x:name.legal"), "unregistered"); - assert.equal(familyOf("x:payment.card"), "unregistered"); + assert.equal(familyOf("x:name.legal", REGISTRY), "unregistered"); + assert.equal(familyOf("x:payment.card", REGISTRY), "unregistered"); }); -test("every root the registry declares has been placed in a family", () => { - // A re-sync that adds a vocabulary fails here rather than quietly colouring - // it as unregistered — which would look identical to a token nobody has - // reasoned about, and be a different fact entirely. - const unplaced = [...REGISTERED_ROOTS].filter((root) => familyOf(`${root}.anything`) === "unregistered"); +test("every root this console places really is placed", () => { + // What this used to assert — that every root in a *vendored* table had a + // family — cannot survive reading the table from the agent: a maintainer may + // serve a vocabulary this build has never heard of, and colouring one is not + // something a compiled switch can promise. What is still checkable, and still + // the bug worth catching, is the mapping being internally complete. + const placed = { + ...(REGISTRY as unknown as Record), + entries: PLACED_ROOTS.map((type) => ({ + type, sensitivity: "normal", release: "consent", mask: "none", + })), + } as never; + const unplaced = PLACED_ROOTS.filter((r) => familyOf(`${r}.anything`, placed) === "unregistered"); assert.deepEqual(unplaced, [], "place these roots in attribute-family.ts"); }); +test("a family the agent serves and this build has never heard of is unregistered", () => { + // Not a gap — the honest answer. `unregistered`'s words say "nobody has + // classified this", which is precisely true of a family this console does not + // know. Colouring it by guessing would say more than is known. + const novel = { + ...(REGISTRY as unknown as Record), + entries: [{ type: "quantum", sensitivity: "normal", release: "consent", mask: "none" }], + } as never; + assert.equal(familyOf("quantum.state", novel), "unregistered"); +}); + test("every family has words and a hue, and the order names them all", () => { const families: Family[] = ["identity", "contact", "public", "gated", "unregistered"]; assert.deepEqual([...FAMILY_ORDER].sort(), [...families].sort(), "a family with no place in the order never draws"); diff --git a/packages/extension/tests/manager-claim-sensitivity.test.mts b/packages/extension/tests/manager-claim-sensitivity.test.mts index 4f98287..e9289a5 100644 --- a/packages/extension/tests/manager-claim-sensitivity.test.mts +++ b/packages/extension/tests/manager-claim-sensitivity.test.mts @@ -14,15 +14,69 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { - UNREGISTERED, - isSensitive, - maskText, - maskedFact, - treatmentOf, - treatmentFor, - isSensitiveFor, -} from "../src/manager/claim-sensitivity.ts"; +import { maskText, maskedFact, treatmentFor, isSensitiveFor } from "../src/manager/claim-sensitivity.ts"; + +/** + * The table as `persona/claim-types/list` serves it — the agent's own, not a + * copy this console holds. + * + * A fixture here because these tests are about what the console DRAWS given a + * table, which is unchanged; only where the table comes from moved. The + * resolution rules themselves are tested in + * `packages/core/tests/persona.claim-types.mjs`, next to the code that now + * performs them. + */ +const REGISTRY = { + registryVersion: "0.1", + entries: [ + { type: "payment", sensitivity: "high", release: "stepUp", mask: "full" }, + { type: "gov", sensitivity: "high", release: "stepUp", mask: "full" }, + { type: "name", sensitivity: "normal", release: "consent", mask: "none" }, + { type: "name.legal", sensitivity: "normal", release: "consent", mask: "none" }, + { type: "name.given", sensitivity: "normal", release: "consent", mask: "none" }, + { type: "name.family", sensitivity: "normal", release: "consent", mask: "none" }, + { type: "name.display", sensitivity: "normal", release: "consent", mask: "none" }, + { type: "name.previous", sensitivity: "high", release: "consent", mask: "full" }, + { type: "person.birthDate", sensitivity: "high", release: "consent", mask: "full" }, + { type: "person.pronouns", sensitivity: "normal", release: "consent", mask: "none" }, + { type: "person.locale", sensitivity: "normal", release: "consent", mask: "none" }, + { type: "email.personal", sensitivity: "normal", release: "consent", mask: "emailLocal" }, + { type: "email.work", sensitivity: "normal", release: "consent", mask: "emailLocal" }, + { type: "phone.mobile", sensitivity: "high", release: "consent", mask: "last2" }, + { type: "phone.landline", sensitivity: "high", release: "consent", mask: "last2" }, + { type: "address.postal", sensitivity: "high", release: "consent", mask: "full" }, + { type: "address.country", sensitivity: "normal", release: "consent", mask: "none" }, + { type: "gov.id.passport", sensitivity: "high", release: "stepUp", mask: "last4" }, + { type: "gov.id.driverLicence", sensitivity: "high", release: "stepUp", mask: "last4" }, + { type: "gov.id.national", sensitivity: "high", release: "stepUp", mask: "last4" }, + { type: "gov.taxId", sensitivity: "high", release: "stepUp", mask: "last4" }, + { type: "payment.card", sensitivity: "high", release: "stepUp", mask: "last4" }, + { type: "payment.cardExpiry", sensitivity: "high", release: "stepUp", mask: "full" }, + { type: "payment.iban", sensitivity: "high", release: "stepUp", mask: "last4" }, + { type: "payment.accountNumber", sensitivity: "high", release: "stepUp", mask: "last4" }, + { type: "account.handle", sensitivity: "normal", release: "consent", mask: "none" }, + { type: "url.homepage", sensitivity: "normal", release: "consent", mask: "none" }, + { type: "org.name", sensitivity: "normal", release: "consent", mask: "none" }, + { type: "org.role", sensitivity: "normal", release: "consent", mask: "none" }, + ], + unregistered: { sensitivity: "high", release: "consent", mask: "full" }, + strictness: { + sensitivity: ["high", "normal"], + release: ["stepUp", "consent"], + mask: ["full", "last2", "last4", "emailLocal", "none"], + }, +} as never; + +/** The floor, as the served table declares it. */ +const UNREGISTERED = { sensitivity: "high", mask: "full" }; + +// The registry is an argument now. These keep every assertion below reading as +// it did, so what changed is visible in one place rather than on every line. +const treatmentOf = (type: string) => treatmentFor(REGISTRY, type).treatment; +const isSensitive = (type: string) => isSensitiveFor(REGISTRY, type); +const drawn = (type: string, text: string, override?: "normal" | "high") => + maskedFact(REGISTRY, type, text, override); + // ── The registry's own answers ────────────────────────────────────────────── @@ -32,8 +86,8 @@ test("a registered normal type is shown as it is", () => { // an operator to press Show reflexively, and `CLAIM-TYPES.md` §4 names that // outcome as the reason the rule is scoped to unknown tokens only. assert.equal(isSensitive("name.legal"), false); - assert.equal(maskedFact("name.legal", "Glenn Gore").text, "Glenn Gore"); - assert.equal(maskedFact("name.legal", "Glenn Gore").masked, false); + assert.equal(drawn("name.legal", "Glenn Gore").text, "Glenn Gore"); + assert.equal(drawn("name.legal", "Glenn Gore").masked, false); }); test("the types whose exposure costs the most are hidden", () => { @@ -58,9 +112,9 @@ test("a hidden value keeps the characters its type says are the recognisable one // the rest is what a stranger needs. That split is a property of the type, // which is why the style travels with the registry entry rather than with the // renderer. - assert.equal(maskedFact("payment.card", "4242424242424242").text, "•••• 4242"); - assert.equal(maskedFact("phone.mobile", "+65 8262 2325").text, "•••• 25"); - assert.equal(maskedFact("person.birthDate", "1975-03-11").text, "••••"); + assert.equal(drawn("payment.card", "4242424242424242").text, "•••• 4242"); + assert.equal(drawn("phone.mobile", "+65 8262 2325").text, "•••• 25"); + assert.equal(drawn("person.birthDate", "1975-03-11").text, "••••"); }); // ── The conservative default ──────────────────────────────────────────────── @@ -69,7 +123,7 @@ test("a token the registry has never seen is hidden completely", () => { // `CLAIM-TYPES.md` §4 rule 3: a vocabulary nobody has reasoned about is // exactly the one where showing the value is a decision nobody made. assert.deepEqual(treatmentOf("crypto.walletSeed"), UNREGISTERED); - assert.equal(maskedFact("crypto.walletSeed", "correct horse battery").text, "••••"); + assert.equal(drawn("crypto.walletSeed", "correct horse battery").text, "••••"); }); test("an x: token is hidden however it is spelled", () => { @@ -78,7 +132,7 @@ test("an x: token is hidden however it is spelled", () => { // to resemble. `x:payment.card` must not inherit `payment.card`'s `last4` and // publish four digits of something nobody has classified. assert.deepEqual(treatmentOf("x:acme.loyaltyId"), UNREGISTERED); - assert.equal(maskedFact("x:payment.card", "4242424242424242").text, "••••"); + assert.equal(drawn("x:payment.card", "4242424242424242").text, "••••"); }); test("an unregistered member of a hidden family is hidden, not guessed at", () => { @@ -106,8 +160,8 @@ test("the mask does not publish the length of what it hides", () => { // One glyph per hidden character would report that this IBAN is 22 long and // that card 16 — a real hint for a value whose format is fixed. The run is // the same width whatever it covers. - const short = maskedFact("payment.accountNumber", "12345678").text; - const long = maskedFact("payment.iban", "GB33BUKB2020155555555555").text; + const short = drawn("payment.accountNumber", "12345678").text; + const long = drawn("payment.iban", "GB33BUKB2020155555555555").text; assert.equal(short.length, long.length); }); @@ -127,14 +181,14 @@ test("a hidden value is not an empty one", () => { // agent did not send — would tell the operator that an attribute they hold is a // attribute they do not. `masked` is what the pane draws differently on; it must // be set, and the text must not be blank. - const hidden = maskedFact("gov.id.passport", "X1234567"); + const hidden = drawn("gov.id.passport", "X1234567"); assert.equal(hidden.masked, true); assert.ok(hidden.text.trim().length > 0, "a hidden value still occupies its row"); assert.notEqual(hidden.text, "X1234567"); // And the paired negative: an unhidden value reports itself as one, so the // pane offers no control that would do nothing. - assert.equal(maskedFact("org.name", "OpenVTC").masked, false); + assert.equal(drawn("org.name", "OpenVTC").masked, false); }); // ── The prefix walk (trust-tasks#377) ─────────────────────────────────────── @@ -209,13 +263,13 @@ test("a value with no mask style is not hidden", () => { // about their own data. test("no decision leaves the registry answering, and says so", () => { - const { treatment, source } = treatmentFor("phone.mobile"); + const { treatment, source } = treatmentFor(REGISTRY, "phone.mobile"); assert.deepEqual(treatment, treatmentOf("phone.mobile")); assert.equal(source, "registry"); }); test("a decision wins over the registry, and says whose it is", () => { - const { treatment, source } = treatmentFor("phone.mobile", "normal"); + const { treatment, source } = treatmentFor(REGISTRY, "phone.mobile", "normal"); assert.equal(treatment.sensitivity, "normal", "the holder outranks the table"); assert.equal(source, "holder"); }); @@ -225,8 +279,8 @@ test("a declared token keeps the registry's mask, whatever the holder decided", // phone number is drawn, and this console does not overrule it — deciding it // is not worth withholding is not the same as deciding it should be legible // over a shoulder. - assert.equal(treatmentFor("phone.mobile", "normal").treatment.mask, "last2"); - assert.equal(treatmentFor("name.legal", "high").treatment.mask, "none"); + assert.equal(treatmentFor(REGISTRY, "phone.mobile", "normal").treatment.mask, "last2"); + assert.equal(treatmentFor(REGISTRY, "name.legal", "high").treatment.mask, "none"); }); test("an unregistered token's mask follows the holder, because the floor was standing in for them", () => { @@ -235,25 +289,25 @@ test("an unregistered token's mask follows the holder, because the floor was sta // holder deciding is the decision it stood in for. Without this, someone who // marked their own `profile.github` as not sensitive would still be shown // four bullets by a rule justified only by nobody having looked. - assert.equal(treatmentFor("profile.github").treatment.mask, "full"); - assert.equal(treatmentFor("profile.github", "normal").treatment.mask, "none"); - assert.equal(treatmentFor("profile.github", "high").treatment.mask, "full"); - assert.equal(maskedFact("profile.github", "octocat", "normal").text, "octocat"); - assert.equal(maskedFact("profile.github", "octocat", "normal").masked, false); - assert.equal(maskedFact("profile.github", "octocat").masked, true); + assert.equal(treatmentFor(REGISTRY, "profile.github").treatment.mask, "full"); + assert.equal(treatmentFor(REGISTRY, "profile.github", "normal").treatment.mask, "none"); + assert.equal(treatmentFor(REGISTRY, "profile.github", "high").treatment.mask, "full"); + assert.equal(drawn("profile.github", "octocat", "normal").text, "octocat"); + assert.equal(drawn("profile.github", "octocat", "normal").masked, false); + assert.equal(drawn("profile.github", "octocat").masked, true); }); test("an x: token is unregistered here too, so the same rule reaches it", () => { - assert.equal(treatmentFor("x:payment.card", "normal").treatment.mask, "none"); + assert.equal(treatmentFor(REGISTRY, "x:payment.card", "normal").treatment.mask, "none"); // …but it does not become a registered token: no override, no change. - assert.equal(treatmentFor("x:payment.card").treatment.mask, "full"); + assert.equal(treatmentFor(REGISTRY, "x:payment.card").treatment.mask, "full"); }); test("a family member invented under a gated family keeps the family's mask", () => { // `payment.giftCard` walks up to `payment`, which the registry declares. A // holder deciding it is not worth withholding does not make a gated family's // mask disappear. - assert.equal(treatmentFor("payment.giftCard", "normal").treatment.mask, "full"); + assert.equal(treatmentFor(REGISTRY, "payment.giftCard", "normal").treatment.mask, "full"); }); // ── The mask style decides, not the sensitivity ───────────────────────────── @@ -263,13 +317,13 @@ test("an email is masked, which is what the registry asked for all along", () => // you, not worth withholding from every listing. `maskedFact` used to gate on // `high` and drew it in full, while `isSensitive` called it hidden — so the // strip promised a Show button that was never rendered. - const { text, masked } = maskedFact("email.personal", "glenn.gore@example.com"); + const { text, masked } = drawn("email.personal", "glenn.gore@example.com"); assert.equal(text, "g•••@example.com"); assert.equal(masked, true); - assert.equal(isSensitiveFor("email.personal"), true, "the two answers agree now"); + assert.equal(isSensitiveFor(REGISTRY, "email.personal"), true, "the two answers agree now"); }); test("a type with no mask style is drawn plainly and offers no control", () => { - assert.equal(maskedFact("name.legal", "Glenn Gore").masked, false); - assert.equal(isSensitiveFor("name.legal"), false); + assert.equal(drawn("name.legal", "Glenn Gore").masked, false); + assert.equal(isSensitiveFor(REGISTRY, "name.legal"), false); }); diff --git a/packages/extension/tests/persona-pane.render.test.mts b/packages/extension/tests/persona-pane.render.test.mts index 512aea2..6393291 100644 --- a/packages/extension/tests/persona-pane.render.test.mts +++ b/packages/extension/tests/persona-pane.render.test.mts @@ -50,6 +50,40 @@ const context = (id: string, name: string) => ({ // The fixture is a registered token because these tests are about something // else; the masking of an unregistered one is asserted deliberately further // down. + +/** + * The claim-type table, as `persona/claim-types/list` serves one. + * + * A fixture rather than an import, because the console no longer holds a copy — + * it renders whatever the agent it is pointed at serves. `null` is a real state + * with its own case below: everything falls to the floor and is masked, which + * is the fail-closed answer while the table is in flight. + */ +const REGISTRY = { + registryVersion: "0.1", + entries: [ + { type: "name", sensitivity: "normal", release: "consent", mask: "none" }, + { type: "name.legal", sensitivity: "normal", release: "consent", mask: "none" }, + { type: "name.display", sensitivity: "normal", release: "consent", mask: "none" }, + { type: "person.birthDate", sensitivity: "high", release: "consent", mask: "full" }, + { type: "email.work", sensitivity: "normal", release: "consent", mask: "emailLocal" }, + { type: "phone.mobile", sensitivity: "high", release: "consent", mask: "last2" }, + { type: "address.postal", sensitivity: "high", release: "consent", mask: "full" }, + { type: "account.handle", sensitivity: "normal", release: "consent", mask: "none" }, + { type: "org.role", sensitivity: "normal", release: "consent", mask: "none" }, + { type: "gov", sensitivity: "high", release: "stepUp", mask: "full" }, + { type: "gov.id.passport", sensitivity: "high", release: "stepUp", mask: "last4" }, + { type: "payment", sensitivity: "high", release: "stepUp", mask: "full" }, + { type: "payment.card", sensitivity: "high", release: "stepUp", mask: "last4" }, + ], + unregistered: { sensitivity: "high", release: "consent", mask: "full" }, + strictness: { + sensitivity: ["high", "normal"], + release: ["stepUp", "consent"], + mask: ["full", "last2", "last4", "emailLocal", "none"], + }, +} as never; + const FACTS = [attribute("f1", "name.legal", "Glenn Gore"), attribute("f2", "phone.mobile", "+65 8262 2325")]; const CONTEXTS = [context("openvtc", "OpenVTC"), context("vta", "Verifiable Trust Agent")]; @@ -65,6 +99,7 @@ test("making a face does not loop the renderer", async () => { const a = agent({ "persona/profile/put/1.0": { profileId: "p1", version: 1, created: true, updatedAt: "x" } }); const ui = await render( h(GuidedSetup, { + registry: REGISTRY, parties: PARTIES, authority: HOLDER, records: CONTEXTS, @@ -100,6 +135,7 @@ test("the stranger card starts empty and says so", async () => { const a = agent({}); const ui = await render( h(GuidedSetup, { + registry: REGISTRY, parties: PARTIES, authority: HOLDER, records: CONTEXTS, @@ -124,6 +160,7 @@ test("a completed step in the stepper is a way back to it", async () => { const a = agent({}); const ui = await render( h(GuidedSetup, { + registry: REGISTRY, parties: PARTIES, authority: HOLDER, records: CONTEXTS, @@ -251,6 +288,7 @@ test("selecting a persona aims the context's button at it", async () => { const a = agent({}); const ui = await render( h(IdentityMap, { + registry: REGISTRY, parties: PARTIES, authority: HOLDER, graph, @@ -286,6 +324,7 @@ test("a holder known nowhere is told so, not shown an empty grid", async () => { const a = agent({}); const ui = await render( h(IdentityMap, { + registry: REGISTRY, parties: PARTIES, authority: HOLDER, graph, @@ -312,6 +351,7 @@ test("a context the agent would not answer for is not folded away as empty", asy const a = agent({}); const ui = await render( h(IdentityMap, { + registry: REGISTRY, parties: PARTIES, authority: HOLDER, graph, @@ -353,6 +393,7 @@ const SECRETS = [ const mapOverSecrets = async () => render( h(IdentityMap, { + registry: REGISTRY, parties: PARTIES, authority: HOLDER, graph: buildGraph(SECRETS, [], [{ id: "openvtc", label: "OpenVTC", bindings: { ok: true, personas: [] } }]), @@ -454,6 +495,7 @@ const DEV = face("p1", "Developer", ["f1"]); const map = (graph: ReturnType, profiles = [DEV]) => h(IdentityMap, { + registry: REGISTRY, parties: PARTIES, authority: HOLDER, graph, @@ -551,6 +593,7 @@ const WITHHELD = [ const withheldMap = (extra: Record = {}) => h(IdentityMap, { + registry: REGISTRY, parties: PARTIES, authority: HOLDER, graph: buildGraph(WITHHELD, [], []), @@ -624,6 +667,7 @@ test("a value the agent did send is still covered locally, with no second questi const a = agent({}); const ui = await render( h(IdentityMap, { + registry: REGISTRY, parties: PARTIES, authority: HOLDER, graph: buildGraph(held, [], []), @@ -654,6 +698,7 @@ const PUT_OK = { "persona/attribute/put/1.0": { attributeId: "a1", version: 2, c const editor = (existing?: Record) => h(AttributeEditor, { + registry: REGISTRY, parties: PARTIES, authority: HOLDER, ...(existing ? { existing } : {}), @@ -758,6 +803,7 @@ test("a value the holder said to show is drawn on the map, not bulleted", async const a = agent({}); const ui = await render( h(IdentityMap, { + registry: REGISTRY, parties: PARTIES, authority: HOLDER, graph: buildGraph(shown, [], []), @@ -774,3 +820,32 @@ test("a value the holder said to show is drawn on the map, not bulleted", async assert.doesNotMatch(ui.text(), /•/, "the holder decided; the floor no longer applies to this one"); await ui.unmount(); }); + +test("with no table yet, every value is masked and nothing is coloured", async () => { + // `null` is what the pane holds for the round-trip it takes to read + // `persona/claim-types/list`, and it is the state a compiled-in fallback + // would have papered over. Everything falls to the floor: masked, and grouped + // as `unregistered`. + // + // Found by leaving it out. The reveal-control count went 3 → 4 against a + // fixture with no registry, which is this behaviour observed before it was + // asserted. + const ui = await render( + h(IdentityMap, { + registry: null, + parties: PARTIES, + authority: HOLDER, + graph: buildGraph(SECRETS, [], [{ id: "openvtc", label: "OpenVTC", bindings: { ok: true, personas: [] } }]), + attributes: SECRETS, + profiles: [], + records: CONTEXTS, + history: [], + onChanged: () => {}, + }), + { chrome: { runtime: { sendMessage: agent({}).sendMessage } } }, + ); + const screen = ui.text(); + assert.doesNotMatch(screen, /Glenn Gore/, "a name the registry would show is still masked, because the registry has not spoken"); + assert.doesNotMatch(screen, /8262 2325/); + assert.match(screen, /••••/, "the floor is a mask, not a blank"); +});