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
51 changes: 45 additions & 6 deletions apps/todomvc/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,31 @@ mod service {
}
}

/// `polyvisor:app/route`, thin like [`service`] above.
#[cfg(target_arch = "wasm32")]
mod route {
use crate::bindings::polyvisor::app::route;

pub fn get() -> String {
route::get()
}

pub fn set(route: &str) {
route::set(route)
}
}

/// Off the component target there is no host to answer it; same reason as
/// `service`'s native stub just below.
#[cfg(not(target_arch = "wasm32"))]
mod route {
pub fn get() -> String {
String::new()
}

pub fn set(_route: &str) {}
}

/// Off the component target there is no service and no host to answer it.
/// The stub exists only so the components below type-check under a native
/// `cargo clippy --workspace --all-targets`; it is never linked into the
Expand Down Expand Up @@ -214,7 +239,16 @@ const STYLESHEET: &str = "asset:0f827d119b7bec30534b1767e8ab8ee0f2890c98f93baa11
pub fn app() -> Element {
// The snapshot. Owned by the `tasks` service; this is a cached view of it.
let items = use_signal(Vec::<TodoItem>::new);
let filter = use_signal(|| FilterState::All);
// The route is this app's own prior output, relayed back by the visor —
// not user-typed input (`wit/app.wit` `route`: "a route this app is
// handed is one it wrote itself on one of the user's own devices"). An
// unknown value (including a plain launch's "") is simply `All`, the
// same as a value this app never wrote.
let filter = use_signal(|| match route::get().as_str() {
"active" => FilterState::Active,
"completed" => FilterState::Completed,
_ => FilterState::All,
});

// On mount: the first snapshot.
use_future(move || refresh(items));
Expand Down Expand Up @@ -450,18 +484,23 @@ fn ListFooter(
}
}
ul { class: "filters",
for (state , state_text , url) in [
(FilterState::All, "All", "#/"),
(FilterState::Active, "Active", "#/active"),
(FilterState::Completed, "Completed", "#/completed"),
// `#/`, `#/active`, `#/completed` stay as in-frame anchors
// (the frame policy allows `#`-fragment hrefs); the visor's
// *page* fragment that carries the bookmarkable route is a
// different thing, set below via `route::set`.
for (state , state_text , url , route_value) in [
(FilterState::All, "All", "#/", ""),
(FilterState::Active, "Active", "#/active", "active"),
(FilterState::Completed, "Completed", "#/completed", "completed"),
] {
li {
a {
href: url,
class: if filter() == state { "selected" },
onclick: move |evt: MouseEvent| {
evt.prevent_default();
filter.set(state)
filter.set(state);
route::set(route_value);
},
{state_text}
}
Expand Down
64 changes: 64 additions & 0 deletions docs/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -415,6 +415,70 @@ floor; predicates become bools at the WIT boundary; a Dioxus component
that writes a signal it never reads renders once forever — invisible to
native tests, so browser gates are mandatory for every visor change.

## Routing

The page URL gains one thing: a fragment, `#app/<token>`, naming a
running app and a route the app chose, so "this app, here" can be
bookmarked. The fragment is the only place for it — it never reaches the
server, and it is the one part of the URL an app frame's `href` policy
already treats as same-document (`web/policy.ts`). Two parts, two
owners: the visor owns the grammar (`<kind>/<rest>`, `app` the only kind
today; anything else is refused by `route-decode` exactly as an
unreadable token is), and the app owns the
text inside `route`, which the visor carries byte-for-byte and never
interprets.

- **The token is ciphertext, and deterministic.** `install-id ‖ route`,
zero-padded to 256 bytes, sealed under a *user* route key with
AES-256-GCM and a synthetic nonce (`HMAC(k_siv, plaintext)`), so equal
state is equal text: bookmarks dedupe, `replaceState` does not churn
the bar, and browser history sees one entry per state rather than one
per keystroke (`runtime/crates/kernel/src/route.rs`). What this buys
is both directions of the trust problem at once. Inbound, a route
that decrypts is one this user's visor wrote for this install — the
app is handed its own prior output, not attacker-typed input.
Outbound, the app never holds the key, so the URL bar, history sync,
screenshots and "recently closed" learn nothing of the route. The
fixed length closes the length channel; what remains is *that* an
update happened, when, and whether state changed — a bit or two per
update, to a reader who already holds the user's browser account. That
is the accepted residue, and it applies only to apps that import
`route` at all.
- **The key is user-level and lives sealed.** Bookmarks made on one
device should open on the user's others, so the key cannot be
device-derived; and the group document `us` is plaintext to relays by
ruling (`engine/src/vault.rs`), so it cannot live there. It lives in a
visor-owned document that rides the app-document machinery under the
reserved id `polyvisor:visor` (`engine/src/visor.rs`) — keyhive-sealed,
synced and checkpointed like any app tree, for free. The same
document holds the **install table**: a random 16-byte id per (user,
app), minted on first launch. The URL names the install, not the
package, so the concept of "an install" exists before installs are a
user-visible act; two devices that mint before pairing converge on one
key by automerge's last-writer-wins, and the loser's pre-pairing
bookmarks stop opening — stated, not fixed.
- **The glue owns the URL bar.** `shell.open-frame` writes the fragment
for the one open session, `close-frame` clears it, and an app's
`route.set` is relayed by the frame to the glue, debounced, encoded by
the kernel, and written with `history.replaceState` — never
`pushState`. The back button is the visor's; an app gets no history
entry. Routes over the cap (238 bytes) are refused and the bar does not
move.
- **Consumed once, after unseal.** The visor reads `shell.fragment`
exactly once per page load, at the first identity read that finds the
device open, and hands it to `apps.route-decode`; a sealed device
consumes it after the unseal ceremony. Nothing about the fragment runs
before the hue is painted, so a URL cannot influence the
grey-until-unseal sequence. `hashchange` is ignored: there is one
launch path.
- **Sharing is foreclosed here, by construction.** Only this user's
devices hold the key; another device answers "this link is not one
this device can open". A future shareable form is a different `kind`
with its own envelope and its own policy, not a relaxation of `app/`.
- **Struck: pairing codes in the fragment.** A code is either short
enough to type or key material that does not belong in a URL at all;
the fragment was a transport looking for a route.

## Pins, with reasons

| Dependency | Pin | Reason |
Expand Down
175 changes: 169 additions & 6 deletions e2e/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -687,7 +687,9 @@ async function pullUntilTodo(page: Page, title: string): Promise<void> {
await new Promise((r) => setTimeout(r, 2_000));
await remountTodoMvc(page);
try {
await todoFrame(page).getByText(title).first().waitFor({ timeout: 3_000 });
await todoFrame(page).getByText(title).first().waitFor({
timeout: 3_000,
});
return;
} catch {
if (performance.now() > deadline) {
Expand Down Expand Up @@ -734,11 +736,26 @@ async function addTodo(page: Page, title: string): Promise<void> {
}

/** Reload the page and put TodoMVC back on screen: a fresh mount, which is
* a fresh `tasks.items` read. */
* a fresh `tasks.items` read. A reload with the app open leaves `#app/<token>`
* in the URL, and the visor auto-restores whatever fragment it finds after
* boot (docs/design.md "Routing"), so this — and every other
* reload-with-the-app-open site below — waits for that restore rather than
* pressing "Apps" itself, which would race the visor's own click. With no
* app open (a page that arrived at the bare origin) there is no fragment,
* and the press is the only way. */
async function remountTodoMvc(page: Page): Promise<void> {
const bookmarked = await page.evaluate(() =>
location.hash.startsWith("#app/")
);
await page.reload();
await visorReady(page);
await launchTodoMvc(page);
if (bookmarked) {
await page.waitForSelector("#app-zone iframe[sandbox]", {
timeout: 30_000,
});
} else {
await launchTodoMvc(page);
}
}

/**
Expand Down Expand Up @@ -992,7 +1009,9 @@ const scenarios: Scenario[] = [
// tab here would hide exactly the failure this scenario is for.
await page.reload();
await visorReady(page);
await launchTodoMvc(page);
await page.waitForSelector("#app-zone iframe[sandbox]", {
timeout: 30_000,
});
const again = page.frameLocator("#app-zone iframe");
await again.getByText("write the gate").first().waitFor({
timeout: 30_000,
Expand All @@ -1001,6 +1020,146 @@ const scenarios: Scenario[] = [
},
},

{
// "This app, here" (docs/design.md "Routing"): the URL bar names a
// running session and the app's own route inside it, so a bookmark of
// it reopens the same app at the same filter — on this device only,
// because the token is sealed under a key only this user's devices
// hold (wit/app.wit `route`, internal.wit `apps.route-encode`).
name: "bookmark-round-trip",
async run(ctx, origin, browser) {
const page = await open(ctx, origin);
await visorReady(page);
// A device with no route key yet mints one on its first encode
// (README/dispatch: "minted on first use"); "kept" is not what that
// needs, but every other scenario that leans on a device surviving
// more than one page is kept first, and this one reloads twice.
await keepDevice(page, "the workbench");
await launchTodoMvc(page);
// TodoMVC's footer — and the filter links in it — only renders with
// at least one todo (apps/todomvc/src/lib.rs: the footer is gated on
// `!items.read().is_empty()`), and the todo persists on this device,
// so one add here is enough for the reopened app in step (c) too.
await addTodo(page, "bookmark this filter");

const h0 = await page.evaluate(() => location.hash);
check(
/^#app\/[A-Za-z0-9_-]+$/.test(h0),
`a plain launch did not write a bookmarkable fragment: ${h0}`,
);

const filters = todoFrame(page).locator("ul.filters a");
const active = filters.filter({ hasText: "Active" });
const all = filters.filter({ hasText: "All" });

await active.click();
await page.waitForFunction(
(want) => location.hash !== want,
h0,
{ timeout: 10_000 },
);
const h1 = await page.evaluate(() => location.hash);
check(h1 !== h0, "the Active filter did not change the fragment");

// Deterministic encryption: the same (install, route) pair seals to
// the same token every time, so returning to "All" returns the URL
// to exactly H0 rather than to some other equally-valid encoding of
// the same plain launch.
await all.click();
await page.waitForFunction(
(want) => location.hash === want,
h0,
{ timeout: 10_000 },
);
await active.click();
await page.waitForFunction(
(want) => location.hash === want,
h1,
{ timeout: 10_000 },
);

// Reopening H1 with no click at all: the visor decodes the fragment
// on boot, launches the app at the route it names, and the app
// starts already filtered — proving the route travelled through the
// URL and not through anything client-side kept warm.
await page.goto(origin + "/" + h1);
await page.reload();
await visorReady(page);
await page.waitForSelector("#app-zone iframe[sandbox]", {
timeout: 30_000,
});
eq(
await page.evaluate(() => location.hash),
h1,
"reopening a bookmark changed the fragment",
);
const reopenedFilters = todoFrame(page).locator("ul.filters a");
await reopenedFilters.filter({ hasText: "Active" }).evaluate((el) =>
el.className
).then((cls) =>
check(
cls.includes("selected"),
`the reopened app was not filtered to Active: class="${cls}"`,
)
);

// Closing the session is the glue's own act of clearing the bar
// (internal.wit `shell`: "clears it in close-frame") — no navigation
// involved, so this is the one place that is not also covered by the
// notice-only checks below. The strip's left half opens the running
// app's sheet, and "Close app" is in there.
await page.locator("#visor-app").click();
await paneSettled(page);
await drawer(page).getByRole("button", { name: "Close app", exact: true })
.click();
await page.waitForFunction(
() => location.hash === "",
undefined,
{ timeout: 10_000 },
);

// A fragment that is not a token this device's route key sealed:
// `not-found`, and the visor says so in its own voice rather than
// opening anything. `goto` to a URL that differs only in its fragment
// is a same-document navigation — no boot, and the visor ignores
// `hashchange` by ruling (docs/design.md "Routing") — so the reload
// is what makes this a bookmark being opened rather than a bar edit.
await page.goto(origin + "/#app/not-a-real-token");
await page.reload();
await visorReady(page);
await page.waitForTimeout(2_000);
eq(
await page.locator("#app-zone iframe").count(),
0,
"a bogus fragment opened a frame anyway",
);
await drawer(page).getByText(/link/i).waitFor({
timeout: 10_000,
});

// A second device — fresh context, fresh route key — cannot open
// H1 either: the token decrypts only under the key of the device
// (or its group) that sealed it, and this one has never paired.
const ctxB = await browser.newContext();
try {
const b = await ctxB.newPage();
await b.goto(origin + "/" + h1);
await visorReady(b);
await b.waitForTimeout(2_000);
eq(
await b.locator("#app-zone iframe").count(),
0,
"another device's route key opened this bookmark",
);
await drawer(b).getByText(/link/i).waitFor({
timeout: 10_000,
});
} finally {
await ctxB.close();
}
},
},

{
name: "frame-violation-ends-session",
async run(ctx, origin) {
Expand Down Expand Up @@ -1219,7 +1378,9 @@ const scenarios: Scenario[] = [
// fail for a reason it is not about.
await b.reload();
await visorReady(b);
await launchTodoMvc(b);
await b.waitForSelector("#app-zone iframe[sandbox]", {
timeout: 30_000,
});
for (const title of ["from A", "from B"]) {
await todoFrame(b).getByText(title).first().waitFor({
timeout: 30_000,
Expand Down Expand Up @@ -1691,7 +1852,9 @@ async function main(): Promise<void> {
// context, not just this scenario's own: a scenario with a second
// device fails at that device as often as at this one, and dumping
// only `ctx` prints nothing at all when the failure is over there.
for (const page of browser?.contexts().flatMap((c) => c.pages()) ?? []) {
for (
const page of browser?.contexts().flatMap((c) => c.pages()) ?? []
) {
const dump = await page.evaluate(() => ({
strip: document.querySelector("#visor-strip")?.textContent ??
"<none>",
Expand Down
13 changes: 13 additions & 0 deletions runtime/component/src/component.rs
Original file line number Diff line number Diff line change
Expand Up @@ -703,6 +703,19 @@ impl guest::apps::Guest for Component {
async fn asset(session: u32, handle: Vec<u8>) -> Result<Vec<u8>, Error> {
kernel()?.asset(session, &handle).await.map_err(map_error)
}
async fn route_encode(session: u32, route: String) -> Result<String, Error> {
kernel()?
.route_encode(session, route)
.await
.map_err(map_error)
}
async fn route_decode(fragment: String) -> Result<guest::apps::RouteTarget, Error> {
let (app, route) = kernel()?.route_decode(fragment).await.map_err(map_error)?;
Ok(guest::apps::RouteTarget {
app: app_info(app),
route,
})
}
async fn close(session: u32) -> Result<(), Error> {
kernel()?.close(session);
Ok(())
Expand Down
Loading
Loading