Site Worker Routes

Docs API

Site Worker Routes

The endpoints the public site Worker answers itself — the media proxy, the API forwarders, the registry, and the static JSON the front end fetches.

Everything on the other API pages is served by site-api. This page covers the routes the public site Worker owns and answers itself: a media proxy, four forwarders that hand a request to site-api, and a handful of static JSON files the front end fetches lazily.

The split matters when you are debugging. A 404 from /static/… came from a different deploy than a 404 from /api/…, and only one of them is in this repository. See who answers a request.

Media proxy

GET  /static/<encoded-target-url>
HEAD /static/<encoded-target-url>

Fetches an image, video, audio file, or font from an allowlisted upstream and re-serves it from this origin. It exists so Telegram-hosted mood media and YouTube thumbnails can be embedded without leaking a visitor’s IP to those hosts, and without the mixed-origin CSP problem that comes with hotlinking.

Rate limit: 240 requests / 60s. Successful responses get Cache-Control: public, max-age=86400, s-maxage=86400 unless the upstream sent its own.

Allowlisted hosts are the Telegram family (t.me, telegram.org, telegram.me, telegram.dog, telesco.pe, cdn-telegram.org, cdn1cdn5.telegram-cdn.org) plus i.ytimg.com for YouTube posters and yt3.googleusercontent.com / yt3.ggpht.com for channel avatars. Redirects are followed at most three deep, and every hop is re-checked against the allowlist — an allowlisted host cannot bounce the proxy to somewhere else.

Every response carries a deliberately boring set of headers:

access-control-allow-origin: *
content-disposition: inline
content-security-policy: default-src 'none'; sandbox
x-content-type-options: nosniff

set-cookie is stripped from the upstream response, as are hop-by-hop headers. Only image/*, video/*, audio/*, and font/* content types are passed through; anything else is 415 with an empty body. That is the property worth understanding: this is a media proxy, not a general-purpose fetcher, and it will not return you an HTML page or a JSON document no matter what you point it at.

Errors: 400 Invalid target URL. (not allowlisted, or unparseable); 403 Invalid static proxy signature.; 429 Too Many Requests.; 502 Upstream fetch failed.; 415 for a disallowed content type. All are plain text, not JSON.

Request signing

Proxy URLs are signed. STATIC_PROXY_MODE decides how strictly that is enforced:

Mode Unsigned URL Invalid signature
observe (default) Served, logged Served, logged
accept-both Served, logged 403
enforce 403 403

The staged rollout is intentional: observe lets an already-published page’s old unsigned URLs keep working while new ones ship signed. Build proxy URLs through the site’s own helper rather than hand-assembling them, or they will break the day the mode advances.

YouTube metadata

GET /static/youtube/<11-char-video-id>/metadata.json

A special case on the same route — not a proxy fetch but a resolved lookup. Returns {"channelName": "…", "channelUrl": "…"|null} with access-control-allow-origin: * and public, max-age=86400, s-maxage=86400. 502 YouTube channel avatar unavailable. if the lookup fails. The path must carry no query string, or it is treated as a normal proxy target instead.

API forwarders

Four routes exist only to hand a request to site-api:

Route Behavior
/api/* Forwards over the API service binding (deploy/preview) or HTTP to API_DEV_ORIGIN (dev). All methods.
/oauth, /oauth/* Same forwarder, for the OAuth hub. All methods.
/v2/* Not a proxy — a 308 redirect to /api/v2/*.

/v2/* catching people out is worth calling out: on the public site the canonical path is /api/v2/…, and a bare /v2/… only redirects there. A client that does not follow redirects, or that downgrades POST on redirect, will see a request that appears to vanish.

When neither the service binding nor a dev origin resolves, the forwarder answers 503 {"error":"API service binding unavailable"}. In production none of this runs at all — Cloudflare route patterns send /api/* straight to site-api and the site Worker never sees the request.

/oauth/login is the one exception: it is answered locally, not forwarded. It 302s to the ?next= path with Cache-Control: no-store, max-age=0, defaulting to /dev/portal. next is rejected unless it is a same-site absolute path — values starting //, containing a backslash, or naming another origin fall back to the default rather than redirecting off-site.

Dev portal

ALL /dev                    → 302 /dev/portal
ALL /dev/portal/api/*       → forwarded to site-api, admin paths only

The forwarder is registered as a catch-all but narrows itself: only paths under admin are forwarded (to site-api’s /api/admin/*), and anything else is 404 {"error":"Not found"} with no-store. It is a narrow window onto the admin API, not a second general proxy — see Internal Endpoints.

Static JSON

Prerendered at build time and served as static assets from the edge. No auth, no rate limits, no query parameters.

Path Contents
/docs/search.json The docs search index — every non-draft page’s title, description, group, H2/H3 headings, and up to 4000 characters of body text. Fetched whole on first open of the docs search dialog.
/palette.json {"posts":[{"title","path"}]} — the four newest blog posts, for the site-wide command palette. Separate from the page so /mood never pays for a Ghost fetch during SSR.
/r/<name> and /r/<name>.json Component registry items, in the shadcn registry format, so a component on /components can be installed by URL. Both paths serve the identical document; the .json variant re-exports the other. Built from the components content collection, non-draft entries whose install.type is registry, plus a generated utils item.

/mood/rss.xml is the exception in this group — it is server-rendered per request (public, max-age=0, s-maxage=300, up to 50 items, read from the D1 archive), not prerendered, because the mood feed changes between deploys. It returns a plain-text 500 Failed to generate RSS feed. if the archive read throws. The blog feed, llms.txt, and the sitemap are all build-time static — see Feeds & Machine Output.