Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,7 @@ jobs:
# file" structural; the assertion below fails if it is ever lost.
- name: Assert the holder-scoped half of persona is confined to the console
run: |
for task in 'persona/attribute/put/1.0' 'persona/attribute/list/1.0' 'persona/attribute/delete/1.0' 'persona/profile/put/1.0' 'persona/profile/get/1.0' 'persona/profile/list/1.0' 'persona/profile/delete/1.0' 'persona/binding/set/1.0' 'persona/correlation/analyze/1.0' 'persona/disclosure/history/1.0'; do
for task in 'persona/attribute/put/1.0' 'persona/attribute/list/1.0' 'persona/attribute/delete/1.0' 'persona/profile/put/1.0' 'persona/profile/get/1.0' 'persona/profile/list/1.0' 'persona/profile/delete/1.0' 'persona/binding/set/1.0' 'persona/facet/put/1.0' 'persona/facet/list/1.0' 'persona/facet/delete/1.0' 'persona/correlation/analyze/1.0' 'persona/disclosure/history/1.0'; do
leaked=$(grep -rlF "$task" packages/extension/dist/ | grep -v '^packages/extension/dist/manager\.js$' || true)
if [ -n "$leaked" ]; then
echo "::error::$leaked contains $task — this half of persona/* reads or writes the holder's attribute pool, which sits above every trust context. A wallet surface naming it is the boundary being crossed the wrong way; only the console may. Check for an import of @openvtc/pnm-core/admin from a wallet entry, or a shared chunk. See packages/core/src/admin/persona.ts."
Expand All @@ -188,13 +188,13 @@ jobs:
- name: Assert the console still carries the persona surface it is the exception for
run: |
bundle=packages/extension/dist/manager.js
for task in 'persona/attribute/put/1.0' 'persona/attribute/list/1.0' 'persona/attribute/delete/1.0' 'persona/profile/put/1.0' 'persona/profile/get/1.0' 'persona/profile/list/1.0' 'persona/profile/delete/1.0' 'persona/binding/set/1.0' 'persona/correlation/analyze/1.0' 'persona/disclosure/history/1.0'; do
for task in 'persona/attribute/put/1.0' 'persona/attribute/list/1.0' 'persona/attribute/delete/1.0' 'persona/profile/put/1.0' 'persona/profile/get/1.0' 'persona/profile/list/1.0' 'persona/profile/delete/1.0' 'persona/binding/set/1.0' 'persona/facet/put/1.0' 'persona/facet/list/1.0' 'persona/facet/delete/1.0' 'persona/correlation/analyze/1.0' 'persona/disclosure/history/1.0'; do
if ! grep -qF "$task" "$bundle"; then
echo "::error::$bundle no longer contains $task. The guard above permits it here precisely because the console's persona pane calls it; a build without it is a pane that cannot do what it offers. If the pane genuinely dropped this task, remove it from BOTH lists in the same change."
exit 1
fi
done
echo "OK: the console carries all ten holder-scoped persona tasks"
echo "OK: the console carries all thirteen holder-scoped persona tasks"

# The console's isolation rests on it being one self-contained file: the
# guard above names exactly one exception, so a second chunk would be a
Expand Down
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions packages/core/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@openvtc/pnm-core",
"version": "0.9.1",
"description": "Browser-side bridge between WebAuthn passkeys and VTA-managed DIDs. Wire types, WebAuthn ceremony helpers, COSE\u2192Multikey conversion, DID verificationMethod builder, REST + DIDComm transports, mediator client, SIOP / RP-login / provision-integration flows.",
"description": "Browser-side bridge between WebAuthn passkeys and VTA-managed DIDs. Wire types, WebAuthn ceremony helpers, COSE→Multikey conversion, DID verificationMethod builder, REST + DIDComm transports, mediator client, SIOP / RP-login / provision-integration flows.",
"license": "Apache-2.0",
"repository": {
"type": "git",
Expand Down Expand Up @@ -130,7 +130,7 @@
"dependencies": {
"@cfworker/json-schema": "^4.1.1",
"@noble/curves": "^2.4.0",
"@openvtc/trust-tasks": "^0.17.8",
"@openvtc/trust-tasks": "^0.17.9",
"@openvtc/vti-didcomm-js": "^0.7.0",
"@openvtc/vti-tsp-js": "^0.2.0",
"@scure/base": "^2.2.0",
Expand Down
166 changes: 164 additions & 2 deletions packages/core/src/admin/persona.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
// is deliberately incomplete: a wallet's holder identity is scoped to a
// context, so every task in this file would come back `e.p.msg.forbidden` if a
// wallet surface called it. That module says so in its own header, and CI greps
// the built extension bundles for the ten task URIs below to keep the statement
// the built extension bundles for the thirteen task URIs below to keep the statement
// true rather than merely written down.
//
// So this module is the other half, and it lives beside the console's other
Expand All @@ -19,7 +19,7 @@
//
// ## The gate
//
// The agent refuses all ten unless the caller is an **unscoped holder** —
// The agent refuses all thirteen unless the caller is an **unscoped holder** —
// `Admin` *and* unrestricted scope (`require_super_admin`, not `role ==
// Admin`). That distinction is the whole design: an administrator scoped to one
// context who could read the pool would be reading identity data belonging to
Expand Down Expand Up @@ -87,6 +87,25 @@ import {
type PersonaBindingSetPayload,
type PersonaBindingSetResponsePayload,
} from "@openvtc/trust-tasks/persona/binding/set/1.0/payload";
import {
TYPE_URI as FACET_PUT,
RESPONSE_TYPE_URI as FACET_PUT_RESPONSE,
type PersonaFacetPutPayload,
type PersonaFacetPutResponsePayload,
type FacetColour,
} from "@openvtc/trust-tasks/persona/facet/put/1.0/payload";
import {
TYPE_URI as FACET_LIST,
RESPONSE_TYPE_URI as FACET_LIST_RESPONSE,
type PersonaFacetListPayload,
type PersonaFacetListResponsePayload,
} from "@openvtc/trust-tasks/persona/facet/list/1.0/payload";
import {
TYPE_URI as FACET_DELETE,
RESPONSE_TYPE_URI as FACET_DELETE_RESPONSE,
type PersonaFacetDeletePayload,
type PersonaFacetDeleteResponsePayload,
} from "@openvtc/trust-tasks/persona/facet/delete/1.0/payload";
import {
TYPE_URI as CORRELATION_ANALYZE,
RESPONSE_TYPE_URI as CORRELATION_ANALYZE_RESPONSE,
Expand Down Expand Up @@ -134,6 +153,19 @@ export type AttributeSensitivity = NonNullable<PersonaAttributePutPayload["sensi
/** The holder's own answer on what it takes to let a value leave, where they
* gave one. Absence means the same as it does for {@link AttributeSensitivity}. */
export type AttributeRelease = NonNullable<PersonaAttributePutPayload["release"]>;
/** One named part of the holder's life, and what belongs to it. */
export type PoolFacet = PersonaFacetListResponsePayload["facets"][number];
/**
* The eight colour **names**. Never a literal — each surface resolves one
* against its own palette, so the same world is legible in a terminal, a light
* theme and a dark one.
*
* Re-exported from the generated bindings rather than restated: a hand-written
* copy of an enum drifts the moment a ninth colour is published, and nothing
* compares the two.
*/
export type { FacetColour };

/** One place the holder's identities link, and what can be done about it. */
export type CorrelationFinding = PersonaCorrelationAnalyzeResponsePayload["findings"][number];
/** One record of something that left, and to whom. */
Expand Down Expand Up @@ -704,3 +736,133 @@ export async function personaDisclosureHistory(
payload,
);
}

// ── Facets: the holder's arrangement of their own identity ──────────────────
//
// On screen these are **worlds** — see `design-docs/persona-vocabulary.md`. The
// wire keeps the specification's word, exactly as `profile`/face does.
//
// Membership lives on the facet rather than on the records it names, and that
// is not a filing decision. `persona/attribute/put` REPLACES the attribute, and
// this console lists without `includeSensitive` on purpose — so a client that
// arranged by writing to attributes would either have to fetch every sensitive
// value the holder owns to perform an arrangement that has nothing to do with
// values, or send a put without one and destroy them. One record here has
// neither problem.

export interface FacetPutParams extends PersonaHolderParams {
/** Omit to create. Supplying one addresses an existing facet. */
facetId?: string;
name: string;
colour: FacetColour;
/** One or two emoji. Decorative, carries no meaning, and a surface that
* cannot render it shows the name. */
icon?: string;
/**
* Profiles belonging to this facet.
*
* **Replaced, not merged.** Omitting it means an empty list — a member whose
* absence meant "keep" would make it impossible to empty one. A caller
* editing a facet sends back the membership it loaded, the same discipline
* `personaProfilePut` needs for its entries.
*/
faceIds?: string[];
/** Attributes belonging to this facet, with the same replace semantics. An
* attribute may belong to several facets; a face may not. */
attributeIds?: string[];
expectedVersion?: number;
}

/**
* Create or replace one facet.
*
* Refused with `persona/facet/put:faceAlreadyPlaced` when a listed face belongs
* to another facet — the error's `details.placed` names the facet already
* holding it, so a caller can offer to move it rather than send the holder
* looking.
*/
export async function personaFacetPut(
sender: TrustTaskSender,
params: FacetPutParams,
): Promise<PersonaFacetPutResponsePayload> {
const payload: PersonaFacetPutPayload = {
...(params.facetId !== undefined ? { facetId: params.facetId } : {}),
name: params.name,
colour: params.colour,
...(params.icon !== undefined ? { icon: params.icon } : {}),
...(params.faceIds !== undefined ? { faceIds: params.faceIds } : {}),
...(params.attributeIds !== undefined ? { attributeIds: params.attributeIds } : {}),
...(params.expectedVersion !== undefined ? { expectedVersion: params.expectedVersion } : {}),
};
return holderCall<PersonaFacetPutPayload, PersonaFacetPutResponsePayload>(
sender,
params,
FACET_PUT,
FACET_PUT_RESPONSE,
"persona/facet/put/1.0",
payload,
);
}

/**
* Every facet, following the cursor to the end.
*
* `limit` is the page size to ask for and never a cap on the result — the same
* rule the other three listings in this module follow, and for the same reason:
* a short page is indistinguishable from a complete one, so only an absent
* `nextCursor` means the end.
*/
export async function personaFacetList(
sender: TrustTaskSender,
params: PersonaHolderParams & { limit?: PersonaFacetListPayload["limit"]; cursor?: string },
): Promise<PoolFacet[]> {
const payload: PersonaFacetListPayload = {
...(params.limit !== undefined ? { limit: params.limit } : {}),
...(params.cursor !== undefined ? { cursor: params.cursor } : {}),
};
return collectPages("persona/facet/list", async (cursor) => {
const res = await holderCall<PersonaFacetListPayload, PersonaFacetListResponsePayload>(
sender,
params,
FACET_LIST,
FACET_LIST_RESPONSE,
"persona/facet/list/1.0",
cursor === undefined ? payload : { ...payload, cursor },
);
return { items: res.facets ?? [], nextCursor: res.nextCursor };
});
}

export interface FacetDeleteParams extends PersonaHolderParams {
facetId: string;
expectedVersion?: number;
}

/**
* Delete one facet.
*
* **Deletes nothing it named.** Every face and attribute survives — a facet is
* an arrangement, not a container, and there is deliberately no cascading form
* of this call anywhere on the wire. `releasedFaces` says how many faces now
* belong to no facet, which is what a surface needs to describe the result
* honestly rather than saying only "deleted".
*
* `existed: false` is a successful no-op, not a failure.
*/
export async function personaFacetDelete(
sender: TrustTaskSender,
params: FacetDeleteParams,
): Promise<PersonaFacetDeleteResponsePayload> {
const payload: PersonaFacetDeletePayload = {
facetId: params.facetId,
...(params.expectedVersion !== undefined ? { expectedVersion: params.expectedVersion } : {}),
};
return holderCall<PersonaFacetDeletePayload, PersonaFacetDeleteResponsePayload>(
sender,
params,
FACET_DELETE,
FACET_DELETE_RESPONSE,
"persona/facet/delete/1.0",
payload,
);
}
20 changes: 19 additions & 1 deletion packages/core/task-surface.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"$comment": "Generated by scripts/sync-task-surface.mjs from a vta-sdk checkout. Do not hand-edit: re-run the script. Checked by tests/task-surface.mjs.",
"source": {
"crate": "vta-sdk",
"version": "0.34.0",
"version": "0.34.1",
"scanned": "vta-sdk/src/**/*.rs"
},
"tasks": [
Expand Down Expand Up @@ -592,6 +592,24 @@
"TASK_PERSONA_DISCLOSURE_PREVIEW_1_0"
]
},
{
"uri": "https://trusttasks.org/spec/persona/facet/delete/1.0",
"consts": [
"TASK_PERSONA_FACET_DELETE_1_0"
]
},
{
"uri": "https://trusttasks.org/spec/persona/facet/list/1.0",
"consts": [
"TASK_PERSONA_FACET_LIST_1_0"
]
},
{
"uri": "https://trusttasks.org/spec/persona/facet/put/1.0",
"consts": [
"TASK_PERSONA_FACET_PUT_1_0"
]
},
{
"uri": "https://trusttasks.org/spec/persona/local/binding/set/1.0",
"consts": [
Expand Down
Loading
Loading