diff --git a/.gitignore b/.gitignore index 7092072..bae0a4e 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,5 @@ Thumbs.db .vscode/ *.swp .claude/ .impeccable/ +.claude/ +.impeccable/ diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 64c9f34..17d3803 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -16,7 +16,19 @@ from it; tiles render in isolated `*.localhost` iframes. frames, so all IPC is HTTP (`/api/*`) + one WebSocket (`/api/events`). - CEF (not the platform webview) because tiles need `*.localhost` secure contexts for their service workers. -- Plain `deno task dev` runs the same app in a normal browser. +- Plain `deno task dev` runs the same app in a normal browser (the printed + URL carries the key; see below). +- **API auth**: 127.0.0.1 is reachable by every local process, so the server + mints a random bearer token at launch (`ZIRAN_KEY` overrides for tests) and + refuses any `/api/*` request or WebSocket upgrade without it + (constant-time compare, 401). The token travels only in the URLs the + server navigates its own windows to; pages send it as an `x-ziran-key` + header, or as a `?key=` query param where headers are impossible (img + icons, the tile bundle URL, WebSocket, beacons). Tile frames never see it: + they are cross-origin and no host→tile payload contains it. Static assets + are served without auth (nothing secret; the app is inert without the + key). The Origin check remains as a second gate against tile-frame + WebSockets. ## Tiles (format from Blastile, engine ported from Tenet) diff --git a/docs/SYNC-OPTIONS.md b/docs/SYNC-OPTIONS.md new file mode 100644 index 0000000..2185008 --- /dev/null +++ b/docs/SYNC-OPTIONS.md @@ -0,0 +1,250 @@ +# Ziran Collaboration Layer — Research & Options + +*August 2026. Sources: deep read of the matey codebase; two adversarially-verified +web research sweeps (atproto-native landscape; Matrix/MLS/iroh/relay +alternatives). Full agent reports available on request; load-bearing claims +were re-checked against primary sources.* + +## 0. The finding that frames everything + +**Most of what ziran needs is transport-independent.** The tile-facing sync +protocol, the rights model, runtime-attested attribution, the invitation flow, +download-before-sync — none of it depends on whether Matrix, a custom relay, +or anything else carries the bytes. Design and build that invariant core once; +the transport is a swappable adapter behind it. Every shipped atproto +collaboration app (Roomy, Tangled, Leaflet) pairs AT identity with its own +non-atproto sync backend — the shape you sketched is the ecosystem norm. + +A second framing fact: **identity/auth is a solved problem in 2026.** atproto +OAuth (`@atproto/oauth-client-node`, works for desktop backends) signs users +in with their handle; **service-auth JWTs** (`com.atproto.server.getServiceAuth`: +~60s JWT signed by the user's DID key, verifiable by anyone who can resolve +the DID document) let any service authenticate a DID with *zero* shared +infrastructure. Roomy's production pattern: OAuth for login, single-use 60s +tickets for WebSocket attach. No Matrix, no bridge, nothing else needed for +"sign in with your AT handle." + +And a third: **atproto itself will not save us this year.** Bluesky's +"Permissioned Data" proposal (0016, merged July 2026) is the official +direction for private/shared state, but it is a draft, server-enforced, and +explicitly **not E2EE** ("provides access control, not confidentiality"). +There are no realtime/ephemeral lexicons, no protocol-level inbox, no E2EE +primitive (Bluesky delegates that to Germ). Whatever we build, we build +beside atproto, using its identity layer. + +## 1. The invariant core (build this regardless) + +### 1.1 tp-sync: the tile protocol + +A sibling of tp-data/store.js, served on tile origins. Tiles get: + +- `send(bytes)` — append an opaque payload to the document's **persisted log**. +- `onMessage(handler)` — receives `{author: did, role, seq, bytes}`; total + order guaranteed (the log is server-ordered), attribution guaranteed + (the runtime attests who sent what — this is what makes tile-side + enforcement possible). +- `sendEphemeral(bytes)` / `onEphemeral` — presence lane (cursors, typing): + fanned out live, never stored. +- `roster()` / `onRosterChange` — read-only view of `{did, handle, role}` for + participants, plus the local user's own role. **No membership mutation + surface exists in the tile API, by construction** — invitations and rights + live in the runtime chrome (the connections panel finally earns its name). + +CRDT choice, message semantics, validation — all the tile's business. A chess +tile sends moves and rejects illegal ones from mis-behaving peers (it can, +because attribution is trustworthy); a text tile sends Yjs/Automerge/Loro +deltas; a counter sends `{delta: 1}`. + +### 1.2 Rights: two tiers, honestly labeled + +- **Tier 1 — runtime/transport-enforced (ships first).** The document's + manifest declares roles: `sync: { roles: { editor: {write: true}, watcher: + {write: false}, opponent: {write: true, max: 1} } }`. The relay enforces + write permission per lane and per role, membership caps, and kick/role + changes. The server checks every append against the ACL. This is what + Roomy/Tangled/everyone does; it requires trusting the relay operator. +- **Tier 2 — cryptographic (the research track).** Ink & Switch's + **Keyhive** (+ Subduction sync) is the only architecture that matches + "the document itself has the means to enforce rights" — capabilities and + membership as CRDTs, BeeKEM group encryption, revocation without + coordination, relay can't read or forge. Formally analyzed (July 2026 + paper, Kleppmann among authors) but explicitly pre-production and + unaudited. **Design our role model so its enforcement point can migrate** + from server-checked to capability-checked; do not ship on Keyhive yet. + +### 1.3 Invitations + +1. Owner picks an AT identity (handle → DID via existing resolution). +2. Runtime writes an **invitation object** on the relay: `{doc, inviteeDid, + role, token, snapshotCid}` — and, optionally, a public breadcrumb record + in the *inviter's* PDS (metadata only; PDS records are public). +3. Delivery, three channels: + - **In-Ziran inbox**: every ziran runtime keeps one standing connection to + its home relay subscribed to `inbox/` — invitations appear in + the base window (a natural fourth region or a banner). This is why a + default relay must exist even for P2P-flavored futures: *the inbox is + the rendezvous.* + - **Link**: `https://ziran.ing/i/` → web page that deep-links into + the app (`ziran://invite/` — URL-scheme registration is the + sibling of the file-type registration we already do) with a download + button for non-users. The link carries the invitation token, so it + works even if the in-app inbox is never seen. + - **Later**: Germ DM, email, whatever — all just carry the same link. +4. **Accept = download, then sync** (your requirement, and it falls out of + the design): runtime fetches the tile snapshot (relay blob store), + verifies CIDs (engine already does), writes it into `documents/`, opens + it, joins the session, replays the log from the snapshot's offset. +5. **Snapshots/compaction**: the tile file IS the snapshot. Periodically (or + on membership change) the runtime uploads the current tile bytes and the + relay truncates the log before that offset. Bounded catch-up, bounded + storage, and — later, under MLS — the encrypted-fresh-snapshot pattern is + exactly how late joiners work anyway (RFC 9750-sanctioned). + +## 2. The options + +### Option A — Matrix, via matey + +**What matey actually established** (it's much further along than "an +experiment"): a ~990-line spec + three working TS packages. The +**matey-broker** solves the exact problem the wider ecosystem hasn't — MAS +and Tuwunel can't speak atproto OAuth (no PAR, no DPoP, no per-user issuer +discovery), so the broker fronts them as a plain OIDC IdP while speaking real +atproto OAuth outward, with issuer-authority re-verification. Login is +handle-in → OAuth at your own PDS → Matrix account auto-provisioned +(DID-derived localpart, no password). Rooms-as-oplogs with power-levels as +the rights ledger; invite-by-handle with pending-invite polling and +onboarding links for not-yet-users. + +**Your setup-complexity worry is mostly answerable.** The painful part of +matey — the PLC linking ceremony with the email code — exists for *federated, +verifiable* moorings. A ziran-owned, closed-federation homeserver +(Tuwunel/continuwuity single binary, or Synapse+MAS) with deterministic +DID→localpart mapping needs no ceremony at all: the user experience is +literally "enter handle, authorize at your PDS, done." Matey's own Flow A/B +code already does this. + +**For it:** spec-guaranteed store-and-forward (invites survive any offline +period; history retained; to-device messages exactly-once) — best in class +of everything surveyed; a mature, native rights ledger (per-event-type power +levels); optional E2EE (Megolm/vodozemac, production grade; encrypted history +for late joiners just landed in v1.19); "invisible Matrix backend" proven at +scale (Beeper, German healthcare's TI-Messenger); and the matey work gets to +ship. + +**Against it:** the 64 KiB hard event cap and 0.2 msg/s default rate limits +mean CRDT traffic needs chunking, rate-limit exemptions, and media-repo +snapshots — and the spec is actively moving *away* from this usage (the +relevant MSC was rejected in Oct 2025 with "state events are fundamentally +wrong for ephemeral per-user data"; MatrixRTC pushes realtime data out of +rooms entirely). Matey's end-to-end flow has never run against real +PDSes/homeservers; E2EE was never exercised with the broker stack; homeserver +ops (with permanent-server-name footguns) is a real surface. You inherit a +large machine to run a small pattern. + +### Option B — the ziran relay (recommended start) + +A small, self-hostable service — one Deno binary, same toolchain as the app — +that is deliberately dumb: + +- **Per-document ordered log** (append → seq number → fan-out) + ephemeral + lane + blob store for snapshots + `inbox/` mailboxes for invitations. +- **Auth**: atproto OAuth once to establish the account; per-connection + service-auth JWT or minted ticket (Roomy's production pattern). The user + experience is exactly "sign in with your AT handle" — there is no second + account system *at all*, which beats even the best Matrix bridge. +- **Rights**: the Tier-1 ACL above, checked on every append. The runtime's + invitation UI is the only writer of membership. +- **Ordering**: server-assigned sequence per log — trivially correct, and + (crucially) it is *exactly* the "strongly consistent Delivery Service" + that RFC 9750 recommends for MLS, which is what makes Option C a drop-in + later rather than a rewrite. + +**For it:** smallest possible ops surface (single binary + storage; strfry/ +khatru/y-sweet demonstrate the class); no message-size or rate ceilings we +don't choose; store-and-forward and invitation mailboxes are native writes; +the design is the ecosystem norm (Roomy's appserver, Tangled's knots, Leaf +v3 — whose DID-identified multi-writer streams + module authorizers + +ephemeral events are the closest published blueprint); "credible exit" is +designable in from day one (documents name their relay; relays are +migratable; self-hosters point ziran at their own). + +**Against it:** we own uptime, retention, and abuse handling; rights are +server-enforced (trust the operator) until Tier 2 matures; E2EE is not +included in the first cut — the relay sees plaintext payloads unless/until +Option C. + +### Option C — Option B + MLS (E2EE phase) + +OpenMLS is audited (SRLabs, May 2026) and shipping in Wire/Discord/Cloudflare- +class products. The **Germ × Bluesky integration proves the identity +binding**: MLS KeyPackages and a signed declaration record stored *in the +user's own PDS*, DID as the identity root — which also means you can invite +someone who has never touched your relay. Our ordered log already provides +the one thing MLS deployments find hardest (commit sequencing). Costs: the +unglamorous Delivery Service plumbing (KeyPackage directory, welcome +mailboxes, replenishment), encrypted-snapshot late-join, and keeping MLS +membership in lockstep with the ACL. Rights become two-layer: server ACL +governs *write*, cryptographic membership governs *read*. This is a phase, +not a fork: nothing in B changes shape. + +### Option D — the watch list (not candidates now) + +- **Keyhive/Subduction** — the true endgame for "document enforces its own + rights"; track releases, prototype against `keyhive_wasm`, don't ship. +- **atproto Permissioned Data (0016)** — may eventually absorb membership/ + "spaces"; draft, not E2EE; our role model should stay mappable onto it. +- **iroh 1.0** — excellent connections layer, but stateless relays and no + store-and-forward disqualify it as the backbone; keep as an optional + live-session fast path (direct QUIC when both peers are online). +- **Germ** — closed transport today; if they open it, it's a ready-made E2EE + invitation/DM channel. Their declaration-record pattern we can adopt now. +- **Nostr relays** — credible dumb-log tech (negentropy catch-up is genuinely + good) but no server ordering, no atproto identity bridge, and self-hosting + one converges with just building Option B. + +## 3. Recommendation + +**Build the invariant core now, on Option B, with C designed-in and A kept +honest as an adapter possibility.** Concretely: + +1. tp-sync protocol + rights model + attribution in the runtime (transport + behind a `SyncTransport` interface: `append`, `subscribe`, `ephemeral`, + `inbox`, `blob`). +2. The ziran relay as the first transport (Deno, ordered log per doc, + atproto service-auth at the door, ACL enforcement, inbox, snapshot blobs). + A hosted default instance + self-host support from day one. +3. Invitation flow: relay inbox + `ziran://` deep links + web landing page + with download; accept = snapshot download → verify → open → replay. +4. Phase 2: MLS per Option C. Phase 3 (research-gated): Keyhive-style + capabilities as Tier-2 rights. +5. Reuse matey's `@matey/client` identity modules (handle/DID resolution, + verification) regardless of transport — they're SDK-free and directly + liftable. If the Matrix path is ever wanted (e.g., to interop with rooms + or to avoid running a relay), it slots in as a second `SyncTransport`, + and matey's broker + deploy stack is the way. + +**Why not Matrix first**, given matey's real progress: the deciding axes are +Robin's own. Setup compactness — B needs *no* bridge, no homeserver, no +second account concept; even matey's best case still operates a +broker+homeserver+Caddy stack to get there. Open-ended payloads — B has no +64 KiB cap or rate-limit fight, and Matrix's spec direction explicitly +discourages CRDT-delta-in-room usage. Control — the rights model stays ours +to evolve toward cryptographic enforcement, rather than being expressed +through power levels. Matrix's genuine superpower (spec-guaranteed +store-and-forward across arbitrary offline periods) is reproducible in a +relay we were going to run anyway for the inbox. + +## 4. Open questions for the next design session + +- Relay multi-tenancy: one hosted relay for all ziran users at first, or + per-document relay selection in the manifest from day one (credible exit + suggests the latter — the doc names its home)? +- Role vocabulary: fixed small set (owner/editor/watcher + caps) vs. + fully document-defined roles with declarative constraints — where exactly + is the line between runtime-enforceable and tile-semantic? +- Invitation privacy: is a public PDS breadcrumb acceptable (it leaks "X + invited someone to something"), or relay-only until 0016 lands? +- Presence semantics: per-role visibility of the roster (does a chess + watcher see other watchers)? +- History for late joiners under E2EE: accept the forward-secrecy trade of + snapshot-based catch-up (industry norm) — decide explicitly. diff --git a/main.ts b/main.ts index 5064812..63c71c1 100644 --- a/main.ts +++ b/main.ts @@ -15,6 +15,13 @@ await store.load(); store.desktop = hasDesktopWindows(); if (store.desktop) store.openOsWindow = openDocWindow; +// Launch-time bearer token: 127.0.0.1 is reachable by every local process, +// so the API only answers requests carrying this key. It reaches the UI +// exclusively through the URLs we navigate our own windows to. +// ZIRAN_KEY overrides for tests. +store.apiKey = Deno.env.get('ZIRAN_KEY') ?? + [...crypto.getRandomValues(new Uint8Array(32))].map((b) => b.toString(16).padStart(2, '0')).join(''); + const handler = createHandler(store); // Bind the main server before ANY other listener: under deno desktop the @@ -29,7 +36,8 @@ const server = Deno.env.get('DENO_SERVE_ADDRESS') ); const port = (server.addr as Deno.NetAddr).port; store.appPort = port; -console.log(`ziran on http://127.0.0.1:${port}/`); +const mainUrl = `http://127.0.0.1:${port}/?key=${store.apiKey}`; +console.log(`ziran on ${mainUrl}`); // Deno's node-compat listeners honor DENO_SERVE_ADDRESS too; without this the // express tile server would try to bind the exact same address. @@ -56,7 +64,7 @@ setupMainWindow({ console.warn('could not open picked tile:', (err as Error).message); } }, -}); +}, mainUrl); // Reopen last session's document windows (desktop mode only; in a browser, // popup rules make silent restores hostile). await store.restoreSession(); diff --git a/src/components/zn-doc-canvas.ts b/src/components/zn-doc-canvas.ts index 6c315b4..ab5168e 100644 --- a/src/components/zn-doc-canvas.ts +++ b/src/components/zn-doc-canvas.ts @@ -2,6 +2,7 @@ import { css, html, LitElement, nothing } from 'lit'; import { baseStyles } from '../styles/shared.ts'; import type { DocInfo } from '../types.ts'; import { desk } from '../state/desk.ts'; +import { bundleUrl } from '../state/backend.ts'; import { mothership } from '../ui/mothership.ts'; import { dataHub } from '../ui/data-protocol.ts'; import { icons } from './icons.ts'; @@ -151,9 +152,9 @@ export class ZnDocCanvas extends LitElement { this.phase = 'loading'; mothership.tileServerPort = desk.tileServerPort; try { - // Keyed by doc id only: self-saves bump mtime but must never reload + // Loaded per doc id only: self-saves bump mtime but must never reload // the live editor out from under the user. - const tile = await mothership.loadTile(`${location.origin}/api/docs/${this.doc.id}/bundle.tile`); + const tile = await mothership.loadTile(bundleUrl(this.doc.id)); if (token !== this.#token) return; if (!tile) throw new Error('no loader could handle this tile'); const ifr = tile.renderContent(Math.max(this.getBoundingClientRect().height, 240)); diff --git a/src/server/api.ts b/src/server/api.ts index 4086eae..85485b3 100644 --- a/src/server/api.ts +++ b/src/server/api.ts @@ -44,16 +44,32 @@ function iconResponse(tileLike: Parameters[0]): Response { }); } +/** Constant-time string comparison for the bearer token. */ +function safeEqual(a: string, b: string): boolean { + const enc = new TextEncoder(); + const ab = enc.encode(a); + const bb = enc.encode(b); + if (ab.length !== bb.length || ab.length === 0) return false; + let diff = 0; + for (let i = 0; i < ab.length; i++) diff |= ab[i]! ^ bb[i]!; + return diff === 0; +} + export function createHandler(store: ZiranStore): (req: Request) => Promise { return async (req: Request): Promise => { const url = new URL(req.url); const { pathname } = url; - // The API is for the shell UI only. Tile frames run on *.localhost - // origins; their fetches are intercepted by the tile service worker, but - // WebSocket (and any SW-registration failure) would reach us directly — - // so every /api request with a cross-origin Origin header is refused. + // The API is for the shell UI only, two gates deep: + // 1. Bearer token, minted at launch and known only to pages the server + // itself navigated to (any local process can reach 127.0.0.1 — the + // token is what keeps them out). + // 2. Origin check: tile frames run on *.localhost origins; their fetches + // are intercepted by the tile service worker, but WebSocket (and any + // SW-registration failure) would reach us directly. if (pathname.startsWith('/api/')) { + const provided = req.headers.get('x-ziran-key') ?? url.searchParams.get('key') ?? ''; + if (!safeEqual(provided, store.apiKey)) return err('unauthorized', 401); const origin = req.headers.get('origin'); if (origin && origin !== url.origin) return err('forbidden', 403); } diff --git a/src/server/store.ts b/src/server/store.ts index d6d937c..9b4bfec 100644 --- a/src/server/store.ts +++ b/src/server/store.ts @@ -79,6 +79,8 @@ export class ZiranStore { tileServerPort = 0; appPort = 0; desktop = false; + /** Launch-time bearer token; every /api request must present it. */ + apiKey = ''; /** Opens a real OS window for a document-window URL (set by main.ts). */ openOsWindow: ((winId: string, url: string, title: string, bounds?: Bounds) => boolean) | undefined; /** Last reported geometry of the base window. */ @@ -110,7 +112,7 @@ export class ZiranStore { /* ——— windows (real OS windows; the server owns their content layout) ——— */ windowUrl(winId: string): string { - return `http://127.0.0.1:${this.appPort}/?win=${winId}`; + return `http://127.0.0.1:${this.appPort}/?win=${winId}&key=${this.apiKey}`; } findWindow(winId: string): DocWindowRec | undefined { diff --git a/src/server/windows.ts b/src/server/windows.ts index 4b9948e..2ab11ba 100644 --- a/src/server/windows.ts +++ b/src/server/windows.ts @@ -150,14 +150,17 @@ function wire(win: BrowserWindowLike, winId: string): void { } } -/** Adopt/setup the main startup window and the application menu. */ -export function setupMainWindow(h: DesktopHandlers): void { +/** Adopt/setup the main startup window and the application menu. The window + is navigated to the keyed URL — the runtime's default root navigation has + no bearer token, so the page would sit unauthenticated without this. */ +export function setupMainWindow(h: DesktopHandlers, mainUrl: string): void { handlers = h; const BW = ctor(); if (!BW) return; try { const win = new BW({ title: 'Ziran', width: 1100, height: 780 }); wire(win, 'main'); + win.navigate?.(mainUrl); try { win.setApplicationMenu?.(APP_MENU); } catch (err) { diff --git a/src/state/backend.ts b/src/state/backend.ts index 5614ebc..d46857d 100644 --- a/src/state/backend.ts +++ b/src/state/backend.ts @@ -2,7 +2,7 @@ // WebSocket with auto-reconnect. All server truth arrives as full state // snapshots; the desk store consumes them. -import type { DocInfo, DocWindowRec, ModelEntry, ServerState } from '../types.ts'; +import type { Bounds, DocInfo, DocWindowRec, ModelEntry, ServerState } from '../types.ts'; export interface OpenResult { doc: DocInfo; @@ -11,10 +11,19 @@ export interface OpenResult { existing: boolean; } +/** Launch-time bearer token, delivered in the URL the server navigated this + window to. Everything the page asks of the backend must carry it. */ +export const apiKey = new URLSearchParams(location.search).get('key') ?? ''; + +/** For URLs that can't send headers (img src, WebSocket, beacons). */ +export const keyed = (url: string): string => `${url}${url.includes('?') ? '&' : '?'}key=${apiKey}`; + +const authHeaders = (extra: Record = {}) => ({ 'x-ziran-key': apiKey, ...extra }); + async function post(url: string, body?: unknown): Promise { const res = await fetch(url, { method: 'POST', - headers: body === undefined ? {} : { 'content-type': 'application/json' }, + headers: authHeaders(body === undefined ? {} : { 'content-type': 'application/json' }), body: body === undefined ? undefined : JSON.stringify(body), }); const data = await res.json(); @@ -26,7 +35,7 @@ export const backend = { onState: undefined as ((state: ServerState) => void) | undefined, connect(winId: string): void { - const ws = new WebSocket(`ws://${location.host}/api/events?win=${encodeURIComponent(winId)}`); + const ws = new WebSocket(keyed(`ws://${location.host}/api/events?win=${encodeURIComponent(winId)}`)); ws.addEventListener('message', (ev) => { try { const msg = JSON.parse(ev.data); @@ -38,7 +47,17 @@ export const backend = { ws.addEventListener('close', () => setTimeout(() => this.connect(winId), 1500)); }, - state: (): Promise => fetch('/api/state').then((r) => r.json()), + state: (): Promise => + fetch('/api/state', { headers: authHeaders() }).then((r) => r.json()), + postBounds: (winId: string, b: Bounds): Promise => + fetch(`/api/windows/${winId}/bounds`, { + method: 'POST', + headers: authHeaders({ 'content-type': 'application/json' }), + body: JSON.stringify(b), + }), + beaconBounds: (winId: string, b: Bounds): void => { + navigator.sendBeacon(keyed(`/api/windows/${winId}/bounds`), JSON.stringify(b)); + }, openPath: (path: string): Promise => post('/api/docs/open', { path }), fromModel: (modelId: string): Promise => post('/api/docs/from-model', { modelId }), winOp: (winId: string, body: Record): Promise => @@ -46,17 +65,18 @@ export const backend = { rename: (id: string, name: string): Promise => post(`/api/docs/${id}/rename`, { name }), saveAsModel: (docId: string): Promise => post(`/api/models/from/${docId}`), pick: (): Promise<{ path?: string; cancelled?: boolean; unsupported?: boolean }> => - fetch('/api/pick').then((r) => r.json()), + fetch('/api/pick', { headers: authHeaders() }).then((r) => r.json()), pickSave: (name: string): Promise<{ path?: string; cancelled?: boolean; unsupported?: boolean }> => - fetch(`/api/pick-save?name=${encodeURIComponent(name)}`).then((r) => r.json()), + fetch(`/api/pick-save?name=${encodeURIComponent(name)}`, { headers: authHeaders() }).then((r) => r.json()), moveDoc: (id: string, dest: string): Promise<{ oldId: string; info: DocInfo }> => post(`/api/docs/${id}/move`, { dest }), removeRecent: (id: string): Promise => - fetch(`/api/recents/${id}`, { method: 'DELETE' }).then((r) => r.json()), + fetch(`/api/recents/${id}`, { method: 'DELETE', headers: authHeaders() }).then((r) => r.json()), reveal: (path: string): Promise<{ ok: boolean }> => post('/api/reveal', { path }), importTile: async (name: string, bytes: ArrayBuffer): Promise => { const res = await fetch(`/api/import?name=${encodeURIComponent(name)}`, { method: 'POST', + headers: authHeaders(), body: bytes, }); const data = await res.json(); @@ -65,7 +85,7 @@ export const backend = { }, loadData: async (docId: string, name: string): Promise => { - const res = await fetch(`/api/docs/${docId}/data/${name}`); + const res = await fetch(`/api/docs/${docId}/data/${name}`, { headers: authHeaders() }); if (res.status === 404) return null; if (!res.ok) throw new Error(`loadData failed (${res.status})`); return new Uint8Array(await res.arrayBuffer()); @@ -73,18 +93,23 @@ export const backend = { putData: async (docId: string, name: string, data: Uint8Array): Promise => { const res = await fetch(`/api/docs/${docId}/data/${name}`, { method: 'PUT', + headers: authHeaders(), body: data as BodyInit, }); if (!res.ok) throw new Error(`putData failed (${res.status})`); }, }; +// Icon and bundle URLs are consumed by / the tile loader, which cannot +// send headers — the key rides in the query string. export const docIconUrl = (d: DocInfo): string | undefined => - d.hasIcon ? `/api/docs/${d.id}/icon` : undefined; + d.hasIcon ? keyed(`/api/docs/${d.id}/icon`) : undefined; export const recentIconUrl = (d: DocInfo): string | undefined => - d.hasIcon ? `/api/recents/${d.id}/icon` : undefined; + d.hasIcon ? keyed(`/api/recents/${d.id}/icon`) : undefined; export const modelIconUrl = (m: ModelEntry): string | undefined => - m.hasIcon ? `/api/models/${encodeURIComponent(m.id)}/icon` : undefined; + m.hasIcon ? keyed(`/api/models/${encodeURIComponent(m.id)}/icon`) : undefined; +export const bundleUrl = (docId: string): string => + keyed(`${location.origin}/api/docs/${docId}/bundle.tile`); const MIN = 60_000, HOUR = 3_600_000, DAY = 86_400_000; export function relativeTime(ts: number): string { diff --git a/src/state/desk.ts b/src/state/desk.ts index 103515d..aedd9d7 100644 --- a/src/state/desk.ts +++ b/src/state/desk.ts @@ -77,17 +77,11 @@ class DeskStore extends EventTarget { const key = `${b.x},${b.y},${b.w},${b.h}`; if (!b.w || key === last) return; last = key; - fetch(`/api/windows/${this.winId}/bounds`, { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify(b), - }).catch(() => {}); + backend.postBounds(this.winId, b).catch(() => {}); }; setInterval(report, 2000); addEventListener('resize', report); - addEventListener('pagehide', () => { - navigator.sendBeacon(`/api/windows/${this.winId}/bounds`, JSON.stringify(bounds())); - }); + addEventListener('pagehide', () => backend.beaconBounds(this.winId, bounds())); setTimeout(report, 1200); } diff --git a/static/app.js b/static/app.js index 2e2b4a5..cca86fc 100644 --- a/static/app.js +++ b/static/app.js @@ -625,12 +625,18 @@ var baseStyles = i` `; // src/state/backend.ts +var apiKey = new URLSearchParams(location.search).get("key") ?? ""; +var keyed = (url) => `${url}${url.includes("?") ? "&" : "?"}key=${apiKey}`; +var authHeaders = (extra = {}) => ({ + "x-ziran-key": apiKey, + ...extra +}); async function post(url, body) { const res = await fetch(url, { method: "POST", - headers: body === void 0 ? {} : { + headers: authHeaders(body === void 0 ? {} : { "content-type": "application/json" - }, + }), body: body === void 0 ? void 0 : JSON.stringify(body) }); const data = await res.json(); @@ -640,7 +646,7 @@ async function post(url, body) { var backend = { onState: void 0, connect(winId) { - const ws = new WebSocket(`ws://${location.host}/api/events?win=${encodeURIComponent(winId)}`); + const ws = new WebSocket(keyed(`ws://${location.host}/api/events?win=${encodeURIComponent(winId)}`)); ws.addEventListener("message", (ev) => { try { const msg = JSON.parse(ev.data); @@ -650,7 +656,19 @@ var backend = { }); ws.addEventListener("close", () => setTimeout(() => this.connect(winId), 1500)); }, - state: () => fetch("/api/state").then((r4) => r4.json()), + state: () => fetch("/api/state", { + headers: authHeaders() + }).then((r4) => r4.json()), + postBounds: (winId, b3) => fetch(`/api/windows/${winId}/bounds`, { + method: "POST", + headers: authHeaders({ + "content-type": "application/json" + }), + body: JSON.stringify(b3) + }), + beaconBounds: (winId, b3) => { + navigator.sendBeacon(keyed(`/api/windows/${winId}/bounds`), JSON.stringify(b3)); + }, openPath: (path) => post("/api/docs/open", { path }), @@ -662,13 +680,18 @@ var backend = { name }), saveAsModel: (docId) => post(`/api/models/from/${docId}`), - pick: () => fetch("/api/pick").then((r4) => r4.json()), - pickSave: (name) => fetch(`/api/pick-save?name=${encodeURIComponent(name)}`).then((r4) => r4.json()), + pick: () => fetch("/api/pick", { + headers: authHeaders() + }).then((r4) => r4.json()), + pickSave: (name) => fetch(`/api/pick-save?name=${encodeURIComponent(name)}`, { + headers: authHeaders() + }).then((r4) => r4.json()), moveDoc: (id, dest) => post(`/api/docs/${id}/move`, { dest }), removeRecent: (id) => fetch(`/api/recents/${id}`, { - method: "DELETE" + method: "DELETE", + headers: authHeaders() }).then((r4) => r4.json()), reveal: (path) => post("/api/reveal", { path @@ -676,6 +699,7 @@ var backend = { importTile: async (name, bytes) => { const res = await fetch(`/api/import?name=${encodeURIComponent(name)}`, { method: "POST", + headers: authHeaders(), body: bytes }); const data = await res.json(); @@ -683,7 +707,9 @@ var backend = { return data; }, loadData: async (docId, name) => { - const res = await fetch(`/api/docs/${docId}/data/${name}`); + const res = await fetch(`/api/docs/${docId}/data/${name}`, { + headers: authHeaders() + }); if (res.status === 404) return null; if (!res.ok) throw new Error(`loadData failed (${res.status})`); return new Uint8Array(await res.arrayBuffer()); @@ -691,14 +717,16 @@ var backend = { putData: async (docId, name, data) => { const res = await fetch(`/api/docs/${docId}/data/${name}`, { method: "PUT", + headers: authHeaders(), body: data }); if (!res.ok) throw new Error(`putData failed (${res.status})`); } }; -var docIconUrl = (d3) => d3.hasIcon ? `/api/docs/${d3.id}/icon` : void 0; -var recentIconUrl = (d3) => d3.hasIcon ? `/api/recents/${d3.id}/icon` : void 0; -var modelIconUrl = (m2) => m2.hasIcon ? `/api/models/${encodeURIComponent(m2.id)}/icon` : void 0; +var docIconUrl = (d3) => d3.hasIcon ? keyed(`/api/docs/${d3.id}/icon`) : void 0; +var recentIconUrl = (d3) => d3.hasIcon ? keyed(`/api/recents/${d3.id}/icon`) : void 0; +var modelIconUrl = (m2) => m2.hasIcon ? keyed(`/api/models/${encodeURIComponent(m2.id)}/icon`) : void 0; +var bundleUrl = (docId) => keyed(`${location.origin}/api/docs/${docId}/bundle.tile`); var MIN = 6e4; var HOUR = 36e5; var DAY = 864e5; @@ -783,20 +811,12 @@ var DeskStore = class extends EventTarget { const key = `${b3.x},${b3.y},${b3.w},${b3.h}`; if (!b3.w || key === last) return; last = key; - fetch(`/api/windows/${this.winId}/bounds`, { - method: "POST", - headers: { - "content-type": "application/json" - }, - body: JSON.stringify(b3) - }).catch(() => { + backend.postBounds(this.winId, b3).catch(() => { }); }; setInterval(report, 2e3); addEventListener("resize", report); - addEventListener("pagehide", () => { - navigator.sendBeacon(`/api/windows/${this.winId}/bounds`, JSON.stringify(bounds())); - }); + addEventListener("pagehide", () => backend.beaconBounds(this.winId, bounds())); setTimeout(report, 1200); } myWindow() { @@ -3332,7 +3352,7 @@ var ZnDocCanvas = class extends i4 { this.phase = "loading"; mothership.tileServerPort = desk.tileServerPort; try { - const tile = await mothership.loadTile(`${location.origin}/api/docs/${this.doc.id}/bundle.tile`); + const tile = await mothership.loadTile(bundleUrl(this.doc.id)); if (token !== this.#token) return; if (!tile) throw new Error("no loader could handle this tile"); const ifr = tile.renderContent(Math.max(this.getBoundingClientRect().height, 240));