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"); +});