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
10 changes: 5 additions & 5 deletions package-lock.json

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

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,6 @@
"@swc/wasm": "~1.15.47"
},
"dependencies": {
"@openvtc/trust-tasks": "^0.17.1"
"@openvtc/trust-tasks": "^0.17.8"
}
}
2 changes: 1 addition & 1 deletion packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@
"dependencies": {
"@cfworker/json-schema": "^4.1.1",
"@noble/curves": "^2.4.0",
"@openvtc/trust-tasks": "^0.17.7",
"@openvtc/trust-tasks": "^0.17.8",
"@openvtc/vti-didcomm-js": "^0.7.0",
"@openvtc/vti-tsp-js": "^0.2.0",
"@scure/base": "^2.2.0",
Expand Down
99 changes: 99 additions & 0 deletions packages/core/src/rooms/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,54 @@ export async function roomsKeysPresent(
* it is present too, so a set with a gap in it is reported here rather than at
* the first record that will not open, which reads like corruption.
*/
import {
TYPE_URI as KEYS_BACKFILL,
RESPONSE_TYPE_URI as KEYS_BACKFILL_RESPONSE,
type RoomsKeysBackfillPayload,
type RoomsKeysBackfillResponsePayload,
} from "@openvtc/trust-tasks/rooms/keys/backfill/0.1/payload";

/**
* Ask this agent to fetch the room's history from its host, and keep it.
*
* **The repair for a room that reads only from where its holder joined**, and
* the one call a surface can make for it. What it folds together is three hops
* — mint a presentation, ask the host for the rungs, store them — of which a
* browser can make the first and third and not the second. The agent makes all
* three, being the party with a channel to the host.
*
* `host` is named because nothing maps a room to one. A room is portable, so a
* host a surface remembered would go stale the moment the room moved; the agent
* holds key custody, which is a different fact. A member learned the host from
* whoever invited them.
*
* **Read the three numbers together.** `fetched` is what the host served,
* `stored` how many were new, and `earliestReadableEpoch` how far back the agent
* can now actually derive a key — which is the only one that answers the
* question. Rungs that arrive below a gap extend reach not at all, so a surface
* reporting `stored` alone would celebrate over a room that still cannot open a
* word of its history.
*/
export async function roomsKeysBackfill(
sender: TrustTaskSender,
params: RoomsCaller & { roomId: string; host: string; fromEpoch?: number; limit?: number },
): Promise<RoomsKeysBackfillResponsePayload> {
const payload: RoomsKeysBackfillPayload = {
roomId: params.roomId,
host: params.host,
...(params.fromEpoch !== undefined ? { fromEpoch: params.fromEpoch } : {}),
...(params.limit !== undefined ? { limit: params.limit } : {}),
};
return call<RoomsKeysBackfillResponsePayload>(
sender,
{ holder: params.holder, service: params.service },
KEYS_BACKFILL,
KEYS_BACKFILL_RESPONSE,
"rooms/keys/backfill/0.1",
payload,
);
}

export async function roomsKeysChain(
sender: TrustTaskSender,
params: RoomsCaller & { roomId: string; links: EpochLink[] },
Expand Down Expand Up @@ -506,6 +554,57 @@ export async function roomsEpochMint(
);
}

import {
TYPE_URI as OWNER_REGISTER,
RESPONSE_TYPE_URI as OWNER_REGISTER_RESPONSE,
type RoomsOwnerRegisterPayload,
type RoomsOwnerRegisterResponsePayload,
} from "@openvtc/trust-tasks/rooms/owner/register/0.1/payload";

/**
* Ask this agent to register a room with a host.
*
* `rooms/create` performed by the agent, and the reason to prefer it over
* calling `roomsCreate` directly is not convenience: a surface that reaches only
* its own agent **cannot** call `roomsCreate` at all, because the recipient it
* names never travels. This one is addressed to the agent, which can.
*
* The order it belongs in is unchanged: the room's identity is minted first —
* a separate act this does not perform — and a host is then told about a room
* that already exists. A host that named the room would be a host the room could
* not leave.
*
* `host` in the response is what the agent actually reached, which is the value
* worth recording. Normally identical to what was asked for; where it is not, a
* caller storing its own request would hold a host it never spoke to.
*/
export async function roomsOwnerRegister(
sender: TrustTaskSender,
params: RoomsCaller & {
roomId: string;
host: string;
visibility: "open" | "attributed" | "private";
ownerDid?: string;
retentionDays?: number;
},
): Promise<RoomsOwnerRegisterResponsePayload> {
const payload = {
roomId: params.roomId,
host: params.host,
visibility: params.visibility,
...(params.ownerDid ? { ownerDid: params.ownerDid } : {}),
...(params.retentionDays !== undefined ? { retentionDays: params.retentionDays } : {}),
} as RoomsOwnerRegisterPayload;
return call<RoomsOwnerRegisterResponsePayload>(
sender,
{ holder: params.holder, service: params.service },
OWNER_REGISTER,
OWNER_REGISTER_RESPONSE,
"rooms/owner/register/0.1",
payload,
);
}

// ── Owner issuance ───────────────────────────────────────────────────────
//
// These go to the owner's own VTA, which signs AS the room with a key it
Expand Down
12 changes: 12 additions & 0 deletions packages/core/task-surface.json
Original file line number Diff line number Diff line change
Expand Up @@ -703,6 +703,12 @@
{
"uri": "https://trusttasks.org/spec/push/wake/0.2"
},
{
"uri": "https://trusttasks.org/spec/rooms/keys/backfill/0.1",
"consts": [
"TASK_ROOMS_KEYS_BACKFILL_0_1"
]
},
{
"uri": "https://trusttasks.org/spec/rooms/keys/chain/0.1",
"consts": [
Expand Down Expand Up @@ -769,6 +775,12 @@
"TASK_ROOMS_OWNER_ISSUE_MEMBERSHIP_0_1"
]
},
{
"uri": "https://trusttasks.org/spec/rooms/owner/register/0.1",
"consts": [
"TASK_ROOMS_OWNER_REGISTER_0_1"
]
},
{
"uri": "https://trusttasks.org/spec/task-consent/decision/0.1",
"consts": [
Expand Down
14 changes: 13 additions & 1 deletion packages/core/tests/task-surface.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -328,7 +328,19 @@ test("coverage against the agent's surface is recorded, not discovered", () => {
// The three remaining `rooms/keys/*` — commit, key-package, welcome — are MLS
// group operations a browser does not perform. They belong to whatever holds
// the group state, which is the VTA, not this library.
const expected = 196;
// 196 -> 198, and the canonical total 214 -> 216, are the two tasks that let a
// surface reach a room's host without being able to address one:
// `rooms/keys/backfill` and `rooms/owner/register`
// (trustoverip/dtgwg-trust-tasks-tf#402, implemented at
// OpenVTC/verifiable-trust-infrastructure#1332). Both terminate at the
// member's own agent, which is the whole point — the agent makes the host
// call, being the party with a channel to one.
//
// Their host-served counterparts stay in NOT_IN_SDK and stay uncallable from
// the console. That is not a gap left open: `rooms/create` and
// `rooms/epoch/chain` are what the agent sends onward, and a second copy of
// that call from here would be one that never arrives.
const expected = 198;
assert.equal(
implemented.size,
expected,
Expand Down
183 changes: 183 additions & 0 deletions packages/extension/src/manager/panes/rooms-backfill.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
// Fetching a room's history — the repair for a room that reads only from where
// its holder joined.
//
// The list pane names that state and, until the agent could reach a host, could
// not fix it. What is missing there is the **epoch key chain**: each commit
// seals the outgoing epoch's storage key under the incoming one, so a holder of
// the current key can walk backwards to every earlier one. A member who joined
// at epoch 7 was handed the key for 7 and nothing below it.
//
// ## One call, and the reason it is one
//
// Three things have to happen — mint a presentation, ask the host for the rungs,
// store what comes back — and this console can do the first and third and not
// the second. Its bridge carries a task type and a payload and addresses
// everything to the wallet's own VTA, so a document naming a host never travels.
// `rooms/keys/backfill` asks the agent to do all three, being the party that has
// a channel to the host and already holds the credentials and the group state.
//
// ## Why it asks for the host
//
// The agent does not know it. `rooms/keys/list` reports key custody — what this
// VTA can open — and hosting is a different fact it has no view of. That is not
// an oversight: a room is portable, so a remembered host would go stale the
// moment the room moved, and the member learned the host from whoever invited
// them.

import { useCallback, useState } from "react";
import { roomsKeysBackfill, type HeldRoom } from "@openvtc/pnm-core/rooms";
import { Button, Note } from "../../ui.js";
import { c, t } from "../../theme.js";
import { managerSender } from "../sender.js";
import { ConsentRequiredError } from "../carrier.js";
import { ConsentCeremony, runMutation } from "../destructive.js";
import type { Parties } from "../use-vta.js";

const fieldStyle: React.CSSProperties = {
boxSizing: "border-box",
padding: "6px 9px",
background: c.ground,
color: c.text,
border: `1px solid ${c.line}`,
borderRadius: "var(--w-r-sm)",
fontSize: t.sm,
};

interface Outcome {
earliestReadableEpoch: number;
fetched: number;
stored: number;
}

/**
* What happened, read from the three numbers together.
*
* They come apart, and each combination means something different — which is
* the whole reason the response carries three rather than one. Reporting
* `stored` alone would celebrate over a room that still cannot open a word of
* its history.
*/
function Result({ outcome, room }: { outcome: Outcome; room: HeldRoom }) {
// Nothing served: the host holds no rungs below what this agent already
// reads. Either the room's history begins there or it was severed before this
// member joined — indistinguishable from here, and neither is a failure.
if (outcome.fetched === 0) {
return (
<Note>
The host served no rungs below what your agent already holds. There is
nothing further to fetch from it: either this room's history begins here,
or it was severed before your agent joined.
</Note>
);
}

if (outcome.earliestReadableEpoch <= 1) {
return (
<Note tone="accent">
The whole history is readable now — {outcome.stored} new{" "}
{outcome.stored === 1 ? "rung" : "rungs"} of {outcome.fetched} served.
</Note>
);
}

// Rungs arrived and the reach did not move: they sit below a gap. Early
// rather than wrong — they become useful the moment the gap is filled — so
// this must not read as loss.
if (outcome.earliestReadableEpoch >= room.earliestReadableEpoch) {
return (
<Note tone="warn">
{outcome.fetched} {outcome.fetched === 1 ? "rung" : "rungs"} arrived and the
readable range did not move — it still starts at epoch{" "}
{outcome.earliestReadableEpoch}. A rung only extends reach when every rung
above it is present, so this is a gap in what the host served rather than
history that is gone. Asking again is the repair.
</Note>
);
}

return (
<Note tone="accent">
Readable back to epoch {outcome.earliestReadableEpoch} now, from {outcome.stored}{" "}
new {outcome.stored === 1 ? "rung" : "rungs"}. Anything older was either
severed deliberately or has not been served — from here the two look the same.
</Note>
);
}

export function FetchHistory({
parties,
room,
onFetched,
}: {
parties: Parties;
room: HeldRoom;
onFetched: () => void;
}) {
const [hostDid, setHostDid] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [pending, setPending] = useState<ConsentRequiredError | null>(null);
const [outcome, setOutcome] = useState<Outcome | null>(null);

const run = useCallback(async () => {
const host = hostDid.trim();
if (!host) return;
setBusy(true);
setError(null);
setPending(null);
setOutcome(null);

await runMutation(
async () => {
const res = await roomsKeysBackfill(managerSender, {
...parties,
roomId: room.roomId,
host,
// Ask only for what is missing. The agent reads to
// `earliestReadableEpoch` already, so the rung below it is where the
// useful part of the chain starts.
fromEpoch: Math.max(1, room.earliestReadableEpoch - 1),
});
setOutcome({
earliestReadableEpoch: res.earliestReadableEpoch,
fetched: res.fetched ?? 0,
stored: res.stored ?? 0,
});
},
{ onConsent: setPending, onError: setError },
);
setBusy(false);
onFetched();
}, [parties, room, hostDid, onFetched]);

return (
<div style={{ display: "grid", gap: 8, marginTop: 10 }}>
<div style={{ display: "flex", gap: 8, alignItems: "flex-end", flexWrap: "wrap" }}>
<label style={{ display: "grid", gap: 4, flex: "1 1 22rem" }}>
<span style={{ fontSize: t.xs, color: c.muted }}>
HOST DID <span style={{ color: c.faint }}>— who serves this room</span>
</span>
<input
style={fieldStyle}
value={hostDid}
onChange={(e) => setHostDid(e.target.value)}
/>
</label>
<Button disabled={busy || !hostDid.trim()} onClick={() => void run()}>
{busy ? "Fetching…" : "Fetch the history"}
</Button>
</div>

<p style={{ margin: 0, fontSize: t.xs, color: c.faint }}>
Your agent knows which rooms it can open, not who stores them — a room can
change hosts without anything it holds changing, so it does not keep one.
It presents your credentials to the host itself; they do not pass through
this console.
</p>

{outcome && <Result outcome={outcome} room={room} />}
{error && <Note tone="danger">{error}</Note>}
{pending && <ConsentCeremony pending={pending} />}
</div>
);
}
Loading
Loading