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
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ jobs:
# contain this" a structural property rather than a convention.
- name: Assert agent-administration surface is confined to the console
run: |
for task in 'acl/grant/0.1' 'acl/revoke/0.1' 'acl/update/0.1' 'contexts/delete/1.0' 'keys/create/0.1' 'keys/sign/0.1' 'policy/upsert/0.2' 'device/wipe/0.1' 'config/patch/0.1' 'vta/did-templates/create/2.0' 'consent/approver-set/1.0' 'keys/import/0.1' 'did-management/did/delete/0.1' 'vta/services/enable/1.0' 'vta/services/disable/1.0' 'vta/credentials/issue/0.1' 'vta/credentials/revoke/0.1'; do
for task in 'acl/grant/0.1' 'acl/revoke/0.1' 'acl/update/0.1' 'contexts/delete/1.0' 'keys/create/0.1' 'keys/sign/0.1' 'policy/upsert/0.2' 'device/wipe/0.1' 'config/patch/0.1' 'vta/did-templates/create/2.0' 'vta/did-templates/list/2.0' 'vta/did-templates/update/2.0' 'vta/did-templates/delete/2.0' 'vta/did-templates/render/2.0' 'consent/approver-set/1.0' 'keys/import/0.1' 'did-management/did/delete/0.1' 'vta/services/enable/1.0' 'vta/services/disable/1.0' 'vta/credentials/issue/0.1' 'vta/credentials/revoke/0.1'; 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 — @openvtc/pnm-core/admin must not be reachable from any wallet surface (check for a root-barrel import, or a shared chunk)"
Expand Down
15 changes: 14 additions & 1 deletion packages/core/scripts/sync-task-surface.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,20 @@ import { readFileSync, writeFileSync, readdirSync, statSync, existsSync } from "
import { join, resolve } from "node:path";

const PKG_ROOT = resolve(import.meta.dirname, "..");
const OUT = join(PKG_ROOT, "task-surface.json");

// Where the snapshot is written. The checked-in file by default; a second
// argument overrides it.
//
// **The override is what lets `tests/task-surface-sync.mjs` run this script
// without touching the real snapshot**, and that is a correctness constraint
// rather than a convenience. `node --test` runs test FILES in parallel
// processes, and `tests/task-surface.mjs` reads the checked-in snapshot at
// module scope — so a sync test that wrote the real file and restored it
// afterwards raced the test next door, which read either the fixture's handful
// of tasks or a half-written file. It failed as "task-surface.json looks
// truncated" in CI and passed on every developer machine, because whether the
// two files overlap depends on core count.
const OUT = process.argv[3] ? resolve(process.argv[3]) : join(PKG_ROOT, "task-surface.json");

const DEFAULT_SDK = resolve(
PKG_ROOT,
Expand Down
58 changes: 57 additions & 1 deletion packages/core/src/webvh/dids.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,8 +101,21 @@ export interface WebvhDidCreateParams extends WebvhCall {
serverId?: string;
/** Where the log will be served, for the serverless case. */
url?: string;
/** How the path under the host is chosen. */
/**
* How the path under the host is chosen. Absent means `autoAssign` — the
* hosting server allocates one.
*
* The specification also accepts a bare `path` as shorthand for
* `{ mode: "explicit", path }`, and **sending both is an error**. Only the
* structured member is exposed here, so there is one way to say it and no
* way to say it twice.
*/
pathMode?: WebvhPathMode;
/** Hosting domain to publish under, where the server serves more than one. */
domain?: string;
/** A human-readable label for the agent's own record. Not published — it
* does not reach the DID document or the log. */
label?: string;
/** Allow the DID to move location later. Cannot be added afterwards. */
portable?: boolean;
/**
Expand All @@ -115,6 +128,14 @@ export interface WebvhDidCreateParams extends WebvhCall {
* keys at startup — so a DID minted beside another one must say `false`.
*/
setPrimary?: boolean;
/**
* Publish a `DIDCommMessaging` entry naming the agent's own mediator.
*
* The mediator is the agent's, not the caller's to choose: this says *whether*
* the DID advertises one, and the agent fills in which. A DID with no such
* entry is reachable only by whoever already knows how to reach it.
*/
addMediatorService?: boolean;
/**
* Publish a `TSPTransport` entry at the mediator the document names for
* DIDComm, beside that entry.
Expand All @@ -124,6 +145,29 @@ export interface WebvhDidCreateParams extends WebvhCall {
* use.
*/
addTspService?: boolean;
/**
* Further service entries, written into the document verbatim.
*
* **The agent does not compose these and does not check them.** Whatever is
* here is published as-is and is then part of a log entry, which is append-only
* — a malformed entry is corrected by a further update rather than removed. A
* caller building these from a form validates before it sends.
*/
additionalServices?: Record<string, unknown>[];
/**
* How many successor keys to commit in advance.
*
* **`0` disables pre-rotation**, and disabling it is not merely a smaller
* commitment: with no successor committed, a thief holding the current key can
* rotate to their own as convincingly as the owner can, so a compromise cannot
* be recovered from. Absent leaves the agent's default, which is the answer a
* caller with no reason to differ should give.
*/
preRotationCount?: number;
/** An existing key to sign log entries with. Absent mints a fresh one. */
signingKeyId?: string;
/** An existing key-agreement key to publish. Absent mints a fresh one. */
kaKeyId?: string;
/**
* Render the document from a stored or built-in DID template.
*
Expand Down Expand Up @@ -180,7 +224,19 @@ export async function webvhDidCreate(
// keeping the context's identity, and dropping it lets the agent's `true`
// default replace it.
...(rest.setPrimary !== undefined ? { setPrimary: rest.setPrimary } : {}),
...(rest.domain ? { domain: rest.domain } : {}),
...(rest.label ? { label: rest.label } : {}),
...(rest.addMediatorService !== undefined
? { addMediatorService: rest.addMediatorService }
: {}),
...(rest.addTspService !== undefined ? { addTspService: rest.addTspService } : {}),
...(rest.additionalServices?.length ? { additionalServices: rest.additionalServices } : {}),
// `!== undefined` rather than truthy: `0` is the caller switching
// pre-rotation off, and a truthy test would drop it and silently leave the
// agent's default on.
...(rest.preRotationCount !== undefined ? { preRotationCount: rest.preRotationCount } : {}),
...(rest.signingKeyId ? { signingKeyId: rest.signingKeyId } : {}),
...(rest.kaKeyId ? { kaKeyId: rest.kaKeyId } : {}),
...(rest.template ? { template: rest.template } : {}),
...(rest.templateContext ? { templateContext: rest.templateContext } : {}),
...(rest.templateVars ? { templateVars: rest.templateVars } : {}),
Expand Down
40 changes: 30 additions & 10 deletions packages/core/tests/task-surface-sync.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -18,17 +18,29 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync } from "node:fs";
import { existsSync, mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";

const SCRIPT = resolve(import.meta.dirname, "../scripts/sync-task-surface.mjs");
const SNAPSHOT = resolve(import.meta.dirname, "../task-surface.json");

/** Build a throwaway `vta-sdk` whose `src/` holds `files`, and sync from it. */
/**
* Build a throwaway `vta-sdk` whose `src/` holds `files`, and sync from it.
*
* **Into the throwaway directory, never over the checked-in snapshot.** This
* used to write the real `task-surface.json` and restore it in a `finally`,
* which is a race rather than a cleanup: `node --test` runs test files in
* parallel processes, and `tests/task-surface.mjs` reads that same file at
* module scope. It saw either this fixture's handful of tasks — surfacing as
* "task-surface.json looks truncated" and "implements 0 of 3 canonical task
* families" — or a half-written file, as `SyntaxError: Unexpected end of JSON
* input`. Both reproduced only where the two files happened to overlap, which
* on a developer machine with cores to spare is rarely, and on a two-core CI
* runner is often.
*/
function syncFrom(files) {
const root = mkdtempSync(join(tmpdir(), "fake-sdk-"));
const saved = readFileSync(SNAPSHOT, "utf8");
const out = join(root, "task-surface.json");
mkdirSync(join(root, "src"), { recursive: true });
writeFileSync(join(root, "Cargo.toml"), 'version = "9.9.9"\n');
for (const [name, body] of Object.entries(files)) {
Expand All @@ -38,15 +50,17 @@ function syncFrom(files) {
let status = 0;
let stderr = "";
try {
execFileSync(process.execPath, [SCRIPT, root], { stdio: ["ignore", "pipe", "pipe"] });
execFileSync(process.execPath, [SCRIPT, root, out], { stdio: ["ignore", "pipe", "pipe"] });
} catch (e) {
status = e.status ?? 1;
stderr = String(e.stderr ?? "");
}
return { status, stderr, snapshot: JSON.parse(readFileSync(SNAPSHOT, "utf8")) };
// A run that stopped deliberately writes nothing, which is the property
// two of these tests are about — so an absent file is reported as `null`
// rather than throwing here and losing the status that explains it.
const snapshot = existsSync(out) ? JSON.parse(readFileSync(out, "utf8")) : null;
return { status, stderr, snapshot };
} finally {
// The script writes the real snapshot, so put it back however this ends.
writeFileSync(SNAPSHOT, saved);
rmSync(root, { recursive: true, force: true });
}
}
Expand Down Expand Up @@ -144,8 +158,14 @@ pub const ORPHAN_TYPE: &str =
// The *declaration* line, not the value line below it: that is where the
// constant is and where someone would edit it.
assert.match(stderr, /p\.rs:2/, "the message does not say where to look");
// And the snapshot on disk is untouched — a failed sync must not half-write.
assert.ok(snapshot.tasks.length > 0, "the real snapshot was overwritten by a failed run");
// And nothing was written — a failed sync must not half-write.
//
// This used to read "the real snapshot is untouched", because the script had
// no way to write anywhere else and the fixture ran against the checked-in
// file. Writing to the run's own path states the property directly: a stop
// leaves no snapshot at all, rather than leaving one whose survival is only
// evidence that this particular run did not get as far as the write.
assert.equal(snapshot, null, "a stopped sync still produced a snapshot");
});

// `include_str!` constants are `&str` and are not tasks. The stop above must
Expand Down
126 changes: 126 additions & 0 deletions packages/extension/src/manager/did-log.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
// A did:webvh log, as something the console can list.
//
// `vta/webvh/dids/get` returns the log as **one string**: JSON Lines, one entry
// per line, in the order they were appended. That is the right wire shape — it
// is what the hosting server serves and what a verifier replays — and the wrong
// shape for a screen, so this turns it into records.
//
// **Every field here is optional, and the parser never fails on a line it does
// not recognise.** The log is the DID's whole history, written by whichever
// version of whichever agent held it at the time; a screen that refuses to draw
// a log because one entry has a member it did not expect is a screen that goes
// blank exactly for the DIDs whose history is worth reading. So an entry the
// parser cannot make sense of is still an entry: its raw line is kept, and the
// caller shows that.
//
// A plain module rather than part of the pane so a test can reach it — the pane
// is `.tsx`.

/** One line of the log. */
export interface LogEntry {
/** Position in the log, from 1. Derived from order, never from the line —
* `versionId` is `<n>-<hash>` and an entry that omits it still has a place. */
index: number;
/** `versionId` as the entry spells it, e.g. `1-Qm…`. */
versionId?: string;
/** When this entry was appended, as the entry states it. */
versionTime?: string;
/** The entry's `parameters`, which is where `portable`, `scid`, `method`,
* `updateKeys` and `nextKeyHashes` live. */
parameters?: Record<string, unknown>;
/** The DID document this entry puts into effect. */
state?: Record<string, unknown>;
/** Proofs on the entry. Counted rather than rendered — an unsigned entry is
* worth noticing, and the signatures themselves are not readable. */
proofCount: number;
/** The line exactly as it arrived, for copying and for the entries this
* parser could not read. */
raw: string;
/** Why this line was not understood, or `null`. */
problem: string | null;
}

const isObject = (v: unknown): v is Record<string, unknown> =>
typeof v === "object" && v !== null && !Array.isArray(v);

/**
* Split a DID log into its entries.
*
* Blank lines are dropped — a trailing newline is normal JSONL and not an
* entry. Everything else becomes a record, understood or not.
*/
export function parseDidLog(log: string): LogEntry[] {
const lines = log.split("\n").filter((l) => l.trim() !== "");
return lines.map((raw, i) => entryFromLine(raw, i + 1));
}

function entryFromLine(raw: string, index: number): LogEntry {
const base = { index, proofCount: 0, raw };
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
return { ...base, problem: "This line is not JSON." };
}
if (!isObject(parsed)) {
return { ...base, problem: "This line is JSON, but not an object." };
}

const proof = parsed["proof"];
return {
...base,
...(typeof parsed["versionId"] === "string" ? { versionId: parsed["versionId"] } : {}),
...(typeof parsed["versionTime"] === "string" ? { versionTime: parsed["versionTime"] } : {}),
...(isObject(parsed["parameters"]) ? { parameters: parsed["parameters"] } : {}),
...(isObject(parsed["state"]) ? { state: parsed["state"] } : {}),
// A single proof may be an object rather than a one-element array — both
// are legal in the data-integrity vocabulary, and counting only the array
// form would report a signed entry as unsigned.
proofCount: Array.isArray(proof) ? proof.length : isObject(proof) ? 1 : 0,
problem: null,
};
}

/**
* The service entries a log entry's document publishes, flattened for a list.
*
* `serviceEndpoint` is deliberately stringified rather than rendered: it is
* `string | object | array` in the DID Core vocabulary, and a screen that
* assumed a string would print `[object Object]` for the two shapes a mediator
* entry actually uses.
*/
export interface ServiceLine {
id: string;
type: string;
endpoint: string;
}

export function servicesOf(entry: LogEntry): ServiceLine[] {
const raw = entry.state?.["service"];
if (!Array.isArray(raw)) return [];
return raw.filter(isObject).map((s) => ({
id: typeof s["id"] === "string" ? s["id"] : "",
type:
typeof s["type"] === "string"
? s["type"]
: Array.isArray(s["type"])
? s["type"].join(", ")
: "",
endpoint:
typeof s["serviceEndpoint"] === "string"
? s["serviceEndpoint"]
: s["serviceEndpoint"] === undefined
? ""
: JSON.stringify(s["serviceEndpoint"]),
}));
}

/** The verification-method ids a log entry's document declares. */
export function methodsOf(entry: LogEntry): string[] {
const raw = entry.state?.["verificationMethod"];
if (!Array.isArray(raw)) return [];
return raw
.filter(isObject)
.map((m) => m["id"])
.filter((id): id is string => typeof id === "string");
}
9 changes: 8 additions & 1 deletion packages/extension/src/manager/icons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ import type { CSSProperties } from "react";
* name, which is what keeps `shell.tsx` from carrying a second lookup table. */
export type IconName =
// Sections, one per `SectionId`.
| "contexts" | "keys" | "dids"
| "contexts" | "keys" | "dids" | "did-templates"
| "credentials" | "persona" | "memory" | "app-state" | "rooms"
| "services" | "maintenance" | "audit"
| "access" | "approvals" | "policy" | "sessions"
Expand All @@ -63,6 +63,12 @@ const PATHS: Record<IconName, string> = {
"M11.2 12h9.4M18 12v3.2M15.1 12v2.4",
dids:
"M3.9 10.9 10.9 3.9h6.6a2.6 2.6 0 0 1 2.6 2.6v6.6l-7 7a2.1 2.1 0 0 1-3 0l-6.2-6.2a2.1 2.1 0 0 1 0-3z",
// The DIDs tag with a second one behind it: a template is the shape a DID is
// stamped from, so the two sections read as kin in the rail. Both copies stay
// inside the 24-unit box — a path that runs to the edge is clipped on the
// stroke, which shows up as a glyph with one flat side.
"did-templates":
"M6.6 13.6 3.4 10.4a1.9 1.9 0 0 1 0-2.7l6.3-6.3h3M9.6 13.1l5.9-5.9h4.5a1.8 1.8 0 0 1 1.8 1.8v4.5l-5.9 5.9a1.5 1.5 0 0 1-2.1 0l-4.2-4.2a1.5 1.5 0 0 1 0-2.1z",
credentials:
"M6.9 8.2h7.2M6.9 11.6h4.6M14.9 19.7 14.3 23l2.3-1.4 2.3 1.4-.6-3.3",
persona:
Expand Down Expand Up @@ -117,6 +123,7 @@ const SHAPES: Partial<Record<IconName, string>> = {
contexts: `<circle cx="7.7" cy="8.3" r="1.25" fill="currentColor" stroke="none"/>`,
keys: `<circle cx="7.6" cy="12" r="3.6"/>`,
dids: `<circle cx="16.2" cy="7.8" r="1.35"/>`,
"did-templates": `<circle cx="18.4" cy="10.4" r="1.15"/>`,
credentials: `<rect x="3.4" y="4.2" width="17.2" height="11.6" rx="2.2"/><circle cx="16.6" cy="17.4" r="2.9"/>`,
persona: `<circle cx="12" cy="8.4" r="3.8"/>`,
"app-state": `<rect x="3.4" y="4.6" width="17.2" height="14.8" rx="2.6"/><circle cx="6.7" cy="6.85" r=".75" fill="currentColor" stroke="none"/><circle cx="9.2" cy="6.85" r=".75" fill="currentColor" stroke="none"/>`,
Expand Down
Loading