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
9 changes: 4 additions & 5 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,7 @@ publish = false
# KEK derivation behind a passphrase. Both with default features off: the
# `getrandom` default would pull a second randomness source into a component
# that already has one seam for it (`Rng` over `wasi:random`), and
# `password-hash` (PHC strings) is unused — the kernel stores its own
# parameter record.
# `password-hash` (PHC strings) is unused.
aes-gcm = { version = "0.11", default-features = false, features = ["aes", "alloc"] }
argon2 = { version = "0.6", default-features = false, features = ["alloc"] }
# docs/design.md "Sync engine: subduction sans-IO": the engine drives
Expand Down Expand Up @@ -85,19 +84,19 @@ stream-dom-guest = { git = "https://github.com/polymorph-components/polymorph-st
# on `digest` 0.10, which is `sha2` 0.10's — a newer hmac would pull a second
# `digest` into the graph and not accept this `Sha256` at all.
hmac = { version = "0.12", default-features = false }
# The pairing code's alphabet (PAIRING.md §1): `BASE32_NOPAD_VISUAL` is the
# The pairing code's alphabet: `BASE32_NOPAD_VISUAL` is the
# confusable-free base32 variant, which is what makes a 79-character code
# something a person can read off one screen and type into another.
# `default-features = false` + `alloc`: the `std` default buys only `Error`
# impls this never surfaces.
data-encoding = { version = "2.11", default-features = false, features = ["alloc"] }
# The pairing ceremony's hash (PAIRING.md §2): BLAKE3 for the nonce
# The pairing ceremony's hash: BLAKE3 for the nonce
# commitment and for the SAS transcript, byte-for-byte as the contract
# spells it. Not sha2 — the contract names BLAKE3 and two devices must
# derive the same six digits. `default-features = false` keeps it `no_std`
# and portable-Rust, so the wasm32-wasip2 build needs no C toolchain.
blake3 = { version = "1.8", default-features = false }
# docs/design.md Milestones M3c: every app-tree commit blob crosses the wire
# docs/design.md "Read-back and partitions": every app-tree commit blob crosses the wire
# and rests in storage as a keyhive envelope, so a relay or a store holds
# ciphertext only. Pinned by full sha (unreleased, moving) at the rev the
# design was written against. All three crates are pure Rust — ed25519-dalek,
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,13 +34,13 @@ confinement.
| `e2e/` | Playwright scenarios |
| `docs/` | `design.md`, the authority for everything above |

`docs/design.md` is the authority for rulings, contracts and milestones;
`docs/design.md` is the authority for architecture and contracts;
read it before arguing with anything here.

Run `just --list` for the available recipes; `just e2e` runs the Playwright
suite against a local iroh relay.

**Status (M0–M4 landed):** three realms with TodoMVC; devices with two
**Implemented:** three realms with TodoMVC; devices with two
tiers of rest, sealed OPFS checkpoints and a swept index; `tasks` as an
automerge document synced between devices over subduction and the iroh
relay; pairing by code + SAS into a device group that is the sync policy;
Expand Down
36 changes: 4 additions & 32 deletions apps/hostile/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,30 +1,9 @@
//! The frame-teardown fixture: an app that names a tag the policy refuses.
//!
//! docs/design.md "M1" records the gap this fills — "the frame-teardown
//! integration test waits for a hostile fixture component — M2". Every other
//! app in this repository is well-behaved, so the path from a policy
//! rejection through the receiver's stream abort, `web/frame.ts`'s error
//! report, `shell.close-frame` and `apps.abort` to a `session-ended` event in
//! the strip has never been exercised end to end. This crate is the one input
//! that exercises it.
//!
//! It is not an attack: it is a single `<script>` element, which
//! `web/policy.ts`'s tag table does not contain (`TAGS` has no `script`, and
//! it never will — a producer that could name one would be running code in
//! the frame's realm rather than describing pixels). The frame's CSP would
//! refuse to execute it anyway; the point of the fixture is that the stream
//! never gets that far, because the policy check happens on the
//! `create-element` op before the element is ever attached.
//!
//! Ships only from `deno task build:fixtures`; the production site build does
//! not carry it (`web/build.ts`).
//! Test-only fixture that emits a forbidden `<script>` element, exercising
//! policy rejection and frame teardown before the element is attached.

use dioxus::prelude::*;

/// Bindings for `polyvisor:app/app` — same shape and same reasons as
/// `apps/todomvc/src/lib.rs`'s, which is the crate this skeleton is copied
/// from. The world is `app`, not the bare stream-dom producer, because
/// `export!` must match what the kernel serves.
/// The full app world must match what the kernel serves.
#[cfg(target_arch = "wasm32")]
#[allow(clippy::empty_docs)]
mod bindings {
Expand Down Expand Up @@ -61,8 +40,7 @@ impl bindings::Guest for Component {
#[cfg(target_arch = "wasm32")]
bindings::export!(Component with_types_in bindings);

/// Keeps the root component reachable off the component target, so a native
/// `clippy -D warnings` type-checks it rather than dead-coding it away.
/// Keep the fixture type-checked by native clippy.
#[cfg(not(target_arch = "wasm32"))]
#[doc(hidden)]
pub fn __launch_root() {
Expand All @@ -71,13 +49,7 @@ pub fn __launch_root() {

pub fn app() -> Element {
rsx! {
// Rendered first so the fixture is legible in a screenshot of a run
// that somehow got further than it should. Whether it survives to the
// DOM is undefined and not asserted: the first render is one mutation
// batch, and a rejection aborts the whole batch.
div { "about to misbehave" }
// The refusal. `web/policy.ts` `checkOp` returns `tag <script>` here,
// which closes the stream and reports a `PolicyError`.
script { "1" }
}
}
98 changes: 15 additions & 83 deletions apps/todomvc/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,43 +1,17 @@
//! polyvisor's TodoMVC app: `world app` from `wit/app.wit`.
//!
//! Ported from polymorph-stream-dom's `guests/dioxus/todomvc/src/lib.rs`
//! (rev 1974923), which is itself DioxusLabs/dioxus @ v0.7.10
//! `examples/01-app-demos/todomvc.rs` (MIT/Apache-2.0). Components, names and
//! structure are kept recognisable; what changed is where the state lives.
//!
//! **The list is not ours.** The example keeps a `HashMap<u32, TodoItem>` in a
//! signal and mutates it in place. Here the schema authority is the `tasks`
//! service (`wit/app.wit`, interface `tasks`): the signal holds a *snapshot*,
//! every mutation is a service call, and the snapshot is re-fetched after it.
//! See [`refresh`] for what that costs and what is missing.
//!
//! The module is split the way `stream-dom-dioxus` splits its own: everything
//! that names WIT bindings is `#[cfg(target_arch = "wasm32")]`, so a plain
//! `cargo clippy --workspace` still type-checks the components against the
//! `#[cfg(not(target_arch = "wasm32"))]` stub in [`service`].
//! TodoMVC over `polyvisor:app/tasks`. The signal is a service snapshot;
//! mutations go to the service and then refresh it.
//! UI structure derives from DioxusLabs' Dioxus TodoMVC example (v0.7.10,
//! MIT/Apache-2.0), via polymorph-stream-dom rev 1974923.

use dioxus::prelude::*;

// ---------------------------------------------------------------------------
// The component world
// ---------------------------------------------------------------------------

/// Bindings for `polyvisor:app/app`.
///
/// Generated here rather than reached through `stream_dom_dioxus::launch!`:
/// `launch!` exports the bare `polymorph:stream-dom/producer` world, which
/// has no `tasks` import. The `with:` remaps make this world's stream-dom
/// types *the same Rust types* as `stream_dom_guest::bindings`', so the
/// `EventTarget` / `DomEvent` this world's `handle-event` receives can be
/// handed straight to `stream_dom_dioxus::driver`.
/// Generated here because the exported app world includes `tasks`; the
/// remaps keep stream-dom event types identical to the driver's.
#[cfg(target_arch = "wasm32")]
#[allow(clippy::empty_docs)]
mod bindings {
// No `async:` option, for the reason spelled out in
// `runtime/component/src/component.rs`: one blanket mode lowers WIT-sync
// functions with the async canonical option, which the canonical ABI
// forbids and only wasmtime and polyengine's translator catch. Omitting
// it makes each function follow its own WIT declaration.
// WIT annotations decide async lowering; blanket async would incorrectly
// lower synchronous resource functions.
wit_bindgen::generate!({
path: "../../wit",
world: "app",
Expand Down Expand Up @@ -71,32 +45,21 @@ impl bindings::Guest for Component {
#[cfg(target_arch = "wasm32")]
bindings::export!(Component with_types_in bindings);

/// Keeps the root component reachable off the component target, so a native
/// `clippy -D warnings` type-checks it rather than dead-coding it away. Same
/// trick, and same reason, as `stream_dom_dioxus::launch!`'s non-wasm arm.
/// Keep the UI type-checked by native clippy.
#[cfg(not(target_arch = "wasm32"))]
#[doc(hidden)]
pub fn __launch_root() {
let _ = app;
}

// ---------------------------------------------------------------------------
// The service
// ---------------------------------------------------------------------------

/// One task, as the components see it. A plain mirror of
/// `polyvisor:app/tasks.todo-item` so the components name no bindings and the
/// `id` stays what the service says it is: an opaque `string`.
/// UI-facing mirror of `polyvisor:app/tasks.todo-item`.
#[derive(Clone, PartialEq, Eq)]
pub struct TodoItem {
struct TodoItem {
pub id: String,
pub title: String,
pub completed: bool,
}

/// `polyvisor:app/tasks`, one thin layer up: the generated bindings' types
/// mapped onto [`TodoItem`], and every `result<_, string>` kept as
/// `Result<_, String>`.
#[cfg(target_arch = "wasm32")]
mod service {
use super::TodoItem;
Expand Down Expand Up @@ -132,7 +95,6 @@ mod service {
}
}

/// `polyvisor:app/route`, thin like [`service`] above.
#[cfg(target_arch = "wasm32")]
mod route {
use crate::bindings::polyvisor::app::route;
Expand All @@ -146,8 +108,6 @@ mod 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 {
Expand All @@ -157,10 +117,7 @@ mod route {
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
/// component.
/// Native stub used only to type-check the UI.
#[cfg(not(target_arch = "wasm32"))]
mod service {
use super::TodoItem;
Expand All @@ -186,43 +143,22 @@ mod service {
}
}

/// Replace the snapshot with a fresh one from the service.
///
/// This is the *only* thing that refreshes: it runs on mount and after each
/// of our own mutations. `tasks.revision` is a cheap monotonic probe and this
/// app never calls it, because there is nothing in the app world to call it
/// *from* — no timer, no wakeup, no incoming event that is not already one of
/// our own mutations. So a change applied by another session (or a sync) is
/// invisible here until the user touches something.
///
/// That is the documented shape of the contract, not an oversight:
/// `wit/app.wit`'s `tasks` doc calls the interface "poll-shaped on purpose"
/// and names "a change feed ... once apps have an event path that wants one"
/// as the expected additive next step (docs/design.md "Contracts"). When that
/// lands, this function is what it replaces.
///
/// A failed fetch leaves the previous snapshot in place. M1 has no surface to
/// report an error on.
/// Refresh after mount and local mutations. Without an app event path,
/// remote changes become visible on the next local mutation.
async fn refresh(mut items: Signal<Vec<TodoItem>>) {
if let Ok(fresh) = service::items().await {
items.set(fresh);
}
}

/// Run one mutation, then re-read the list. Every write path goes through
/// here, which is what keeps "mutate then re-fetch" from being restated six
/// times.
/// Run one mutation, then re-read the list.
fn mutate(items: Signal<Vec<TodoItem>>, work: impl Future<Output = ()> + 'static) {
spawn(async move {
work.await;
refresh(items).await;
});
}

// ---------------------------------------------------------------------------
// The app
// ---------------------------------------------------------------------------

#[derive(PartialEq, Eq, Clone, Copy)]
enum FilterState {
All,
Expand All @@ -237,7 +173,6 @@ enum FilterState {
const STYLESHEET: &str = "asset:0f827d119b7bec30534b1767e8ab8ee0f2890c98f93baa1159dcf1a46f10bc17";

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);
// 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
Expand All @@ -250,7 +185,6 @@ pub fn app() -> Element {
_ => FilterState::All,
});

// On mount: the first snapshot.
use_future(move || refresh(items));

let active_todo_count = use_memo(move || items.read().iter().filter(|i| !i.completed).count());
Expand Down Expand Up @@ -514,8 +448,6 @@ fn ListFooter(
}
}

// ---------------------------------------------------------------------------

/// The bundle's `manifest.json` and `assets/` must agree on the asset handle,
/// which is `sha256(asset bytes)` (`runtime/crates/kernel/src/apps.rs`, "Asset
/// handles"). Nothing else checks that at build time, so this does.
Expand Down
Loading
Loading