This repository has no description
TypeScript 62%
JavaScript 28%
CSS 6%
HTML 4%

README.md

⬢ Blastile #

A desktop app (a Deno server in a mollusc window) for working with DASL web tiles: open them from .tile CAR files or at:// URIs, live-preview a tile source directory with reload-on-change, and publish tiles to the AT Protocol over OAuth.

Built with Lit + refrakt for the UI, the @dasl/tiles libraries for everything tile-shaped, and atcute for AT Protocol (XRPC + OAuth).

What it does #

  • Open tiles from CAR files — pick a .tile with the native file dialog, or drop one anywhere on the window.
  • Open tiles from AT — paste an at:// URI (did: or handle authority; handles are resolved for you). Records live in the ing.dasl.masl collection.
  • Watch a tile source directory — point it at a directory with a manifest.json + index.html (the same layout atile publishes). The tile renders live and reloads whenever anything in the directory changes; the CAR is rebuilt on the fly with @dasl/tile-writer.
  • Publish to AT — log in with AT OAuth (as a spec-compliant loopback public client, no hosted client metadata needed), then publish a watched tile: every resource is uploaded as a blob (CIDs verified against the PDS), and an ing.dasl.masl record is written. The at:// URI each directory was published to is remembered in ~/.atile/identifiers.json — the same stable-id store the atile CLI uses — so republishing updates the same record, from either tool.

Deno Desktop bindings and tile frames #

Blastile used to run under deno desktop; it now runs under mollusc, where the page's bridge (window.mollusc, with only dialog.open in it) exists in the app's top frame alone, calls from any subframe are refused, and tile viewer windows get no bridge at all. What follows is what was measured under Deno Desktop, kept for the record.

Bindings (win.bind() on the Deno side, bindings.<name>() in the webview) are the one way a tile could reach the Deno runtime, so it's worth knowing how they scope. Measured under the real deno desktop CEF runtime (relaunch with BLASTILE_PROBE_BINDING=1, which registers a diagnostic probeBinding on the main window; the escape-tile has a "Call a host binding" probe):

  • The bindings global (the injected Proxy) is present in every frame — the top app frame and the cross-origin, nested tile frame both see typeof bindings === 'object'. Injection is not frame- or origin-scoped, so the absence of bindings is not a boundary you can rely on.
  • A tile calling a binding does not succeed: bindings.probeBinding() from the tile frame timed out with no response, whereas the same call from the top app frame reaches the runtime's dispatcher and gets a definitive answer. So the cross-origin tile frame's binding invocations don't round-trip.
  • Scoping is per-window, not per-frame. Deno documents that a binding on winA isn't callable from winB, but there is no documented per-frame or per-origin control, and no way to strip bindings from a specific frame.
  • The robust control is architectural, and it's what Blastile does: register no bindings at all. With nothing bound, there is nothing for a tile (or the app) to call, regardless of how injection scopes. If you ever add bindings, keep them off any window that renders untrusted tiles, and validate inputs as trust-boundary code.

(Aside: in this laufey / Deno 2.9.2 build, win.bind() on the window created by setupMainWindow never became reachable even from the top app frame — No callback bound — so the app ships with the binding off by default. The security-relevant finding above holds regardless.)

Containment note (found by escape-tile) #

What actually contains a tile's network access is the service worker: it intercepts every request from the tile frame and answers it from the tile's own resources, keyed on pathname alone (the host is ignored). So fetch, XMLHttpRequest, EventSource, external <script> and <link> all come back with tile content, never the URL — a "successful" fetch('https://…/') returns the tile's own index.html. (An earlier version of this note claimed these leaked; that was a probe bug — it read "didn't throw" as "reached the network" instead of inspecting the response body. escape-tile now reads the response and reports them as contained.)

eval / new Function run inside a tile, but that isn't a containment property: a tile already runs its own scripts, so code-eval grants it nothing and is no exfiltration path. (The execution-context CSP omits 'unsafe-eval', but that CSP is on the shuttle, and only hardens the shuttle.)

What does leak: WebSocket and WebRTC. Neither is a fetch, so the service worker can't intercept them, and the only thing that would stop them is a connect-src on the tile document — which it doesn't have. The tile document is served by the service worker (worker.js builds the response from { status, headers, body }) without the web-tiles execution-context CSP, and a per-document CSP is not inherited by a normal child iframe (only the sandbox flags are, which is why host isolation, Deno/desktop APIs, top-navigation and the powerful-feature gates all hold). So a tile can open a WebSocket to an arbitrary server and round-trip data, and can reach a STUN server over WebRTC.

This is a property of @dasl/tile-server + @dasl/tile-loader (both npm and HEAD), not of Blastile specifically. Closing the WebSocket vector wants the execution-context content-security-policy (with its connect-src) set on the tile document response; WebRTC additionally needs an explicit block, since connect-src doesn't reliably gate ICE. Blastile leaves the upstream runtime unmodified and lets the demo report the current state.

Tile isolation #

Every rendered tile gets a fresh random <20-letters>.localhost origin, served by an embedded @dasl/tile-server on a dynamic port. The client uses @dasl/tile-loader's mothership/shuttle/worker architecture: the shuttle + service worker load from the random origin with the full web-tiles CSP, and all resource loading is mediated by the mothership in the app — tiles never touch the network.

Two local adaptations (the stock pieces assume https:// on port 443):

  • the mothership subclass mints http://<random>.localhost:<port>/… load sources itself (src/ui/mothership.ts);
  • a port-preserving redirect sits in front of the stock load.localhost redirect (src/server/tileserver.ts).

*.localhost resolves to loopback and counts as a secure context in Chromium, which is why the desktop app is Chromium (Electron, through mollusc) rather than the platform webview.

Tiles protocols: the data console #

Blastile implements the host side of the tp-data tiles protocol: tiles import /.well-known/web-tiles/data.js, call listen(), and exchange structured-cloned payloads with the host through the shuttle's tiles-protocol-up-*/tiles-protocol-down-* relay.

Every tile gets a data console — the Data button on a tile card (it auto-opens the preview, since data flows through the rendered tile), or the tray at the bottom of a tile window. It logs everything the tile sends (⬆ tile), lets you send it JSON payloads (⬇ host, ⌘⏎ to send), and notes protocol events like the ready signal. The host plumbing lives in src/ui/data-protocol.ts; globalThis.blastile.{app,dataHub} is exposed for poking at it from the devtools.

examples/hello-tile speaks the protocol: it echoes back whatever you send it and reports pokes as they happen.

Note: npm's @dasl/tile-server@2.0.0 predates tiles-protocols, so vendor/web-tiles/ carries shuttle.js + data.js from dasl-tiles HEAD and Blastile serves those two in front of the stock router — see vendor/web-tiles/README.md for when to drop it.

Running it #

Requires Deno ≥ 2.9, and mollusc on your PATH for the desktop app (see mollusc.json).

deno install          # fetch dependencies

deno task app         # dev: the server under deno run, the UI rebundled on
                      # change, in a native window that reloads
deno task dev         # dev without a window: serves http://127.0.0.1:4179
                      # for a regular (Chromium) browser
deno task app:build   # deno compile the server into bin/blastile-server, then
                      # dist/mac-arm64/Blastile.app, a zip and a dmg

.tile files open in Blastile from the Finder.

There are sample tiles in examples/ — add them with Watch tile dir…:

  • hello-tile/ — minimal tile; echoes tp-data payloads and reports pokes.

  • theme-tile/ — a little site whose whole theme (colours, fonts, radius) is CSS custom properties driven live over tp-data. It tells you what it accepts when it connects (with a paste-ready example payload), applies whatever tokens you send, shows the received payload and current theme in the page, and answers with { ok, applied, ignored, theme }.

  • svelte-tile/ — a blank canvas with a built-in Svelte 5 engine. Send it { "source": "<Svelte component source>", "props": {…} } (or a bare source string) and it compiles and mounts it live, answering with the rendered text or the compile error. Runes and legacy syntax both work. Build its engine bundle first with deno task build:examples — the tile embeds the Svelte compiler + runtime (tiles have no network), compiles in-page, and loads the emitted module through blob: URLs, which the web-tiles CSP permits. It sends you a starter component when it connects.

  • escape-tile/ — a red-team tile that tries every sandbox escape it can (network egress via fetch/XHR/WebSocket/SSE/beacon/WebRTC/external <script>, code exec via eval/Function/WASM, scripting the host frame, reaching Deno / Deno Desktop / Node globals, and powerful features like clipboard/geolocation/modals/popups/fullscreen) and renders a report of what was contained vs what got through, also sending it to the host over tp-data. Targets are benign public endpoints; nothing sensitive is sent. It honestly separates real breaches from capabilities the web-tiles sandbox permits by design (inline scripts, eval, WASM, allow-modals, same-origin storage, postMessage back to the host). fetch/XHR are contained by the service worker; the real leaks it finds are WebSocket and WebRTC — see "Containment note" below.

For an AT-hosted example, try Minesweeper:

at://did:plc:izttpdp3l6vss5crelt5kcux/ing.dasl.masl/3mcjwwoqjqs2v

How it hangs together #

main.ts                     Deno.serve on 127.0.0.1 (BLASTILE_PORT; mollusc's in the app)
├── src/server/api.ts       static UI, /api/* + WebSocket events, OAuth callback
├── src/server/tileserver.ts  express + @dasl/tile-server on *.localhost:<port>
│                           (+ vendored tiles-protocols runtime from vendor/)
├── src/server/tiledir.ts   tile dir → MASL manifest / .tile CAR (atile-compatible)
├── src/server/watcher.ts   Deno.watchFs → debounced tile-changed events
├── src/server/oauth.ts     @atcute/oauth-node-client loopback client,
│                           file session store in ~/.blastile
├── src/server/publish.ts   uploadBlob + putRecord (ing.dasl.masl), TID rkeys,
│                           stable ids shared with atile
└── src/ui/…                Lit + refrakt; tile rendering via @dasl/tile-loader

The backend owns all state (tile registry in ~/.blastile/state.json, sessions, watchers) and broadcasts changes over a WebSocket; the UI is a thin refrakt store plus Lit components. Tile viewer windows are pointed at /?view=<tile-id>: in the desktop app they are windows with no bridge (mollusc.openWindow(url, { privileged: false })), in a browser they are browser windows.

OAuth runs entirely in the Deno process: POST /api/login resolves the handle, runs PAR, and opens the system browser on the PDS's authorize page; the redirect lands on http://127.0.0.1:<port>/oauth/callback (loopback redirect URIs are matched port-insensitively, so the port mollusc picks is fine). Publishing needs the atproto transition:generic scope, which is what the login requests.

Smoke test #

With the app running (deno task dev) and at least one tile added, loading http://127.0.0.1:4179/?smoke[=<tile-id>] drives a real <tile-frame> through load and watch-reload, reporting into document.title (SMOKE_OK_1: <name>, then SMOKE_OK_2 after a watched file changes) — handy under headless Chromium.