⬢ 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
.tilewith 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 theing.dasl.maslcollection. - Watch a tile source directory — point it at a directory with a
manifest.json+index.html(the same layoutatilepublishes). 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.maslrecord is written. Theat://URI each directory was published to is remembered in~/.atile/identifiers.json— the same stable-id store theatileCLI 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
bindingsglobal (the injected Proxy) is present in every frame — the top app frame and the cross-origin, nested tile frame both seetypeof bindings === 'object'. Injection is not frame- or origin-scoped, so the absence ofbindingsis 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
winAisn't callable fromwinB, but there is no documented per-frame or per-origin control, and no way to stripbindingsfrom 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.localhostredirect (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 withdeno 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 viaeval/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 areWebSocketandWebRTC— 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.