diff --git a/.changeset/brave-sites-serve.md b/.changeset/brave-sites-serve.md new file mode 100644 index 0000000..c7bfa2b --- /dev/null +++ b/.changeset/brave-sites-serve.md @@ -0,0 +1,10 @@ +--- +'@pdsjs/cloudflare': patch +--- + +`PDS_EXPERIMENTAL_CF_API_PROXY` forwards `/cf-api/` paths to +api.cloudflare.com, which answers no CORS preflight of its own. The +passthrough serves the setup wizard when it runs as a static site on the +same worker: it forwards the caller's Authorization header, allows only the +paths the wizard uses, and keeps nothing. The prefix answers on every +hostname the worker serves, so a site page reaches it same-origin. diff --git a/apps/start/scripts/copy-artifact.mjs b/apps/start/scripts/copy-artifact.mjs deleted file mode 100644 index 6d23be3..0000000 --- a/apps/start/scripts/copy-artifact.mjs +++ /dev/null @@ -1,26 +0,0 @@ -#!/usr/bin/env node - -// Copy the built worker artifact into the app's static assets. The wizard -// serves it at /artifact/ and uploads it to the visitor's account. -// `npm run build:worker` at the repo root produces the artifact. - -import { copyFileSync, existsSync, mkdirSync } from 'node:fs'; -import { dirname, join, resolve } from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const appDir = resolve(dirname(fileURLToPath(import.meta.url)), '..'); -const source = resolve(appDir, '../../dist/worker'); -const target = join(appDir, 'public/artifact'); - -if (!existsSync(join(source, 'index.js'))) { - console.error( - 'No worker artifact found. Run `npm run build:worker` at the repo root first.', - ); - process.exit(1); -} - -mkdirSync(target, { recursive: true }); -for (const file of ['index.js', 'manifest.json']) { - copyFileSync(join(source, file), join(target, file)); -} -console.log(`Copied worker artifact to ${target}`); diff --git a/apps/start/src/worker.js b/apps/start/src/worker.js deleted file mode 100644 index e874b97..0000000 --- a/apps/start/src/worker.js +++ /dev/null @@ -1,191 +0,0 @@ -// The start.pdsjs.dev worker. It serves the wizard page from static assets -// and forwards the browser's Cloudflare API calls, because api.cloudflare.com -// and dash.cloudflare.com answer no CORS preflight. The proxy holds no state -// and stores nothing: the OAuth token rides through in the Authorization -// header, and the identity secrets never pass through here at all. The -// browser sends the signing key and the account password directly to the -// visitor's own worker, which serves CORS. - -const CF_API = 'https://api.cloudflare.com'; -const OAUTH_TOKEN_URL = 'https://dash.cloudflare.com/oauth2/token'; - -// The proxy forwards only the API paths the wizard uses. Everything the -// wizard touches hangs off the visitor's account. -const CF_PATH_ALLOWED = /^\/client\/v4\/(accounts(\/|$)|oauth\/scopes$)/; - -/** - * @typedef {Object} Env - * @property {Fetcher} ASSETS - * @property {string} [OAUTH_CLIENT_ID] - * @property {string} [OAUTH_SCOPES] - * @property {string} [PLC_URL] - * @property {string} [RELAY_URL] - * @property {string} [ARTIFACT_PDS_URL] - The PDS holding the published release site, e.g. https://pds.pdsjs.dev. Unset, /artifact/ serves from this worker's own assets. A custom domain or workers.dev hostname: a worker's fetch to a same-zone route pattern bypasses the route. - * @property {string} [ARTIFACT_REPO] - DID of the publishing account. The DID, not the handle: getRecord on a single-account PDS answers RepoNotFound for anything but its own DID. - * @property {string} [ARTIFACT_SITE] - The site record key (default artifacts) - */ - -/** - * @param {Request} request - * @param {string} url - * @param {Record} headers - */ -async function forward(request, url, headers) { - const response = await fetch(url, { - method: request.method, - headers, - body: - request.method === 'GET' || request.method === 'HEAD' - ? undefined - : request.body, - }); - // A fresh Response strips Set-Cookie and lets the body stream through. - return new Response(response.body, { - status: response.status, - headers: { - 'Content-Type': response.headers.get('Content-Type') ?? 'text/plain', - }, - }); -} - -export default { - /** - * @param {Request} request - * @param {Env} env - */ - async fetch(request, env) { - const url = new URL(request.url); - const path = url.pathname; - - if (path === '/api/config') { - return Response.json({ - clientId: env.OAUTH_CLIENT_ID ?? null, - scopes: env.OAUTH_SCOPES ?? '', - }); - } - - if (path === '/api/oauth/token' && request.method === 'POST') { - return forward(request, OAUTH_TOKEN_URL, { - 'Content-Type': - request.headers.get('Content-Type') ?? - 'application/x-www-form-urlencoded', - }); - } - - if (path.startsWith('/api/cf/')) { - const apiPath = path.slice('/api/cf'.length); - if (!CF_PATH_ALLOWED.test(apiPath)) { - return Response.json( - { error: 'PathNotAllowed', message: 'Path not proxied' }, - { status: 403 }, - ); - } - const auth = request.headers.get('Authorization'); - if (!auth) { - return Response.json( - { error: 'AuthenticationRequired', message: 'Missing token' }, - { status: 401 }, - ); - } - /** @type {Record} */ - const headers = { Authorization: auth }; - const contentType = request.headers.get('Content-Type'); - if (contentType) headers['Content-Type'] = contentType; - return forward(request, `${CF_API}${apiPath}${url.search}`, headers); - } - - // PLC operations and the relay crawl request carry public data. They ride - // through here only because their origins may not serve CORS. - if (path.startsWith('/api/plc/') && request.method === 'POST') { - const did = decodeURIComponent(path.slice('/api/plc/'.length)); - if (!/^did:plc:[a-z2-7]+$/.test(did)) { - return Response.json( - { error: 'InvalidRequest', message: 'Invalid DID' }, - { status: 400 }, - ); - } - const plcUrl = env.PLC_URL ?? 'https://plc.directory'; - return forward(request, `${plcUrl}/${encodeURIComponent(did)}`, { - 'Content-Type': 'application/json', - }); - } - - if (path === '/api/crawl' && request.method === 'POST') { - const relayUrl = env.RELAY_URL ?? 'https://bsky.network'; - return forward( - request, - `${relayUrl}/xrpc/com.atproto.sync.requestCrawl`, - { 'Content-Type': 'application/json' }, - ); - } - - if (path.startsWith('/api/')) { - return Response.json( - { error: 'NotFound', message: 'Unknown API path' }, - { status: 404 }, - ); - } - - // The artifact comes from the published release record when a PDS is - // named, so a release reaches every visitor without redeploying this - // worker. Read through XRPC rather than the site hostname: the site - // rides a zone route, and a worker's fetch to a same-zone route pattern - // bypasses the route. - if (path.startsWith('/artifact/') && env.ARTIFACT_PDS_URL) { - const rest = path.slice('/artifact/'.length); - const repo = env.ARTIFACT_REPO; - if (!repo) { - return Response.json( - { - error: 'ArtifactUnavailable', - message: 'ARTIFACT_REPO is not configured', - }, - { status: 502 }, - ); - } - const site = env.ARTIFACT_SITE ?? 'artifacts'; - const recordResponse = await fetch( - `${env.ARTIFACT_PDS_URL}/xrpc/com.atproto.repo.getRecord` + - `?repo=${encodeURIComponent(repo)}` + - `&collection=dev.pdsjs.site.deploy&rkey=${encodeURIComponent(site)}`, - ); - if (!recordResponse.ok) { - return Response.json( - { - error: 'ArtifactUnavailable', - message: `No release record (upstream ${recordResponse.status})`, - }, - { status: 502 }, - ); - } - const record = await recordResponse.json(); - const entry = (record.value?.files ?? []).find( - (/** @type {{path: string}} */ f) => f.path === `latest/${rest}`, - ); - const cid = entry?.blob?.ref?.$link; - if (!cid) { - return Response.json( - { error: 'NotFound', message: 'No such artifact file' }, - { status: 404 }, - ); - } - const did = record.uri.split('/')[2]; - const blobResponse = await fetch( - `${env.ARTIFACT_PDS_URL}/xrpc/com.atproto.sync.getBlob` + - `?did=${encodeURIComponent(did)}&cid=${encodeURIComponent(cid)}`, - ); - return new Response(blobResponse.body, { - status: blobResponse.status, - headers: { - 'Content-Type': - entry.contentType ?? - entry.blob.mimeType ?? - 'application/octet-stream', - 'Cache-Control': 'no-store', - }, - }); - } - - return env.ASSETS.fetch(request); - }, -}; diff --git a/apps/start/wrangler.toml b/apps/start/wrangler.toml deleted file mode 100644 index f92768c..0000000 --- a/apps/start/wrangler.toml +++ /dev/null @@ -1,40 +0,0 @@ -# The start.pdsjs.dev wizard worker. Deployment-specific config (routes, -# custom domain, the OAuth client id) belongs in the deployment's own copy of -# this file, following the pdsjs-pds pattern. -# -# Build first: npm run build:start (repo root). It bundles the pds.js worker -# artifact into this app's assets, then builds the page. - -name = "start-pdsjs" -main = "src/worker.js" -compatibility_date = "2026-08-01" -workers_dev = true - -[assets] -directory = "./dist" -binding = "ASSETS" -not_found_handling = "single-page-application" -# The assets layer answers before the worker by default, and with this list -# set, unlisted paths get asset handling in full, the SPA fallback included. -# Both dynamic prefixes belong here: /artifact/ so the published release is -# not shadowed by the bundled copy, /api/ so the fallback page does not -# answer API calls as HTML. -run_worker_first = ["/artifact/*", "/api/*"] - -[observability] -enabled = true - -[vars] -# The OAuth client registered in the operator's Cloudflare dashboard under -# Manage account > OAuth clients. The client id is public; there is no secret -# because the wizard is a public PKCE client. -OAUTH_CLIENT_ID = "" -# Space-separated scope names, matching API token permission names. The live -# list comes from GET /client/v4/oauth/scopes. -OAUTH_SCOPES = "" -# The PDS holding the published release site (see -# .tangled/workflows/release-worker.yml). Unset, /artifact/ serves the copy -# bundled into this worker's assets. -# ARTIFACT_PDS_URL = "https://pds.pdsjs.dev" -# ARTIFACT_REPO = "did:plc:ye5hhvya5o2fwvouv3wttkkn" -# ARTIFACT_SITE = "artifacts" diff --git a/packages/cloudflare/src/cf-api-proxy.js b/packages/cloudflare/src/cf-api-proxy.js new file mode 100644 index 0000000..8cc0bee --- /dev/null +++ b/packages/cloudflare/src/cf-api-proxy.js @@ -0,0 +1,82 @@ +// @pdsjs/cloudflare/cf-api-proxy - a passthrough to api.cloudflare.com for +// the setup wizard, which runs as a static site and cannot call the API from +// the browser: api.cloudflare.com answers no CORS preflight. The proxy +// forwards the caller's Authorization header, allows only the paths the +// wizard uses, and keeps nothing. Enabled by PDS_EXPERIMENTAL_CF_API_PROXY; +// the prefix answers on every hostname the worker serves, so a site page +// reaches it same-origin. + +const CF_API = 'https://api.cloudflare.com'; + +// Everything the wizard touches hangs off the visitor's own account. +const PATH_ALLOWED = /^\/client\/v4\/(accounts(\/|$)|oauth\/scopes$)/; + +export const CF_API_PROXY_PREFIX = '/cf-api/'; + +const CORS_HEADERS = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS', + 'Access-Control-Allow-Headers': 'Authorization, Content-Type', + 'Access-Control-Max-Age': '86400', +}; + +/** + * @param {{PDS_EXPERIMENTAL_CF_API_PROXY?: string}} env + */ +export function cfApiProxyEnabled(env) { + return env.PDS_EXPERIMENTAL_CF_API_PROXY === 'true'; +} + +/** + * @param {unknown} body + * @param {number} status + */ +function json(body, status) { + return Response.json(body, { status, headers: CORS_HEADERS }); +} + +/** + * Handle a request whose path starts with the proxy prefix. + * @param {Request} request + * @returns {Promise} + */ +export async function handleCfApiProxy(request) { + if (request.method === 'OPTIONS') { + return new Response(null, { status: 204, headers: CORS_HEADERS }); + } + + const url = new URL(request.url); + const apiPath = url.pathname.slice(CF_API_PROXY_PREFIX.length - 1); + if (!PATH_ALLOWED.test(apiPath)) { + return json({ error: 'PathNotAllowed', message: 'Path not proxied' }, 403); + } + const auth = request.headers.get('Authorization'); + if (!auth) { + return json( + { error: 'AuthenticationRequired', message: 'Missing token' }, + 401, + ); + } + + /** @type {Record} */ + const headers = { Authorization: auth }; + const contentType = request.headers.get('Content-Type'); + if (contentType) headers['Content-Type'] = contentType; + + const response = await fetch(`${CF_API}${apiPath}${url.search}`, { + method: request.method, + headers, + body: + request.method === 'GET' || request.method === 'HEAD' + ? undefined + : request.body, + }); + // A fresh Response strips Set-Cookie and lets the body stream through. + return new Response(response.body, { + status: response.status, + headers: { + ...CORS_HEADERS, + 'Content-Type': response.headers.get('Content-Type') ?? 'text/plain', + }, + }); +} diff --git a/packages/cloudflare/src/index.js b/packages/cloudflare/src/index.js index 849ece0..1e76489 100644 --- a/packages/cloudflare/src/index.js +++ b/packages/cloudflare/src/index.js @@ -37,6 +37,11 @@ import { createSiteInstaller } from '@pdsjs/sites/installer'; import { createQueryEngine } from '@pdsjs/sites/query'; import { createSpaceAdmin } from '@pdsjs/spaces/admin'; import { createSpaceRoutes } from '@pdsjs/spaces/routes'; +import { + CF_API_PROXY_PREFIX, + cfApiProxyEnabled, + handleCfApiProxy, +} from './cf-api-proxy.js'; import { createSpaceStorage } from './space.js'; /** @@ -899,6 +904,7 @@ export function createWebSocket(state) { * @property {string} [PLC_URL] - PLC directory URL for identity operations * @property {string} [PDS_PASSWORD] - Password for createSession * @property {string} [PDS_UPDATE_URL] - Where a person updates this deployment; the account page links there when a newer release is published + * @property {string} [PDS_EXPERIMENTAL_CF_API_PROXY] - "true" forwards /cf-api/ paths to api.cloudflare.com for the site-hosted setup wizard * @property {string} [PDS_BLOB_UPLOAD_LIMIT] - Max blob upload size in bytes (default 5MB) * @property {string} [PDS_ENABLE_SPACES] - "true" enables permissioned data * @property {string} [PDS_EXPERIMENTAL_GIT_HTTP] - "true" serves read-only git smart HTTP under /git/ @@ -1675,6 +1681,15 @@ export default { * @returns {Promise} */ async fetch(request, env, ctx) { + // The setup wizard's Cloudflare API calls, when the proxy is enabled. + // Before site dispatch, so a site-hosted wizard reaches it same-origin. + if ( + cfApiProxyEnabled(env) && + new URL(request.url).pathname.startsWith(CF_API_PROXY_PREFIX) + ) { + return handleCfApiProxy(request); + } + const stub = env.PDS.get(env.PDS.idFromName('default')); if (!siteHost(request, env)) return stub.fetch(request); diff --git a/packages/cloudflare/test/cf-api-proxy.test.js b/packages/cloudflare/test/cf-api-proxy.test.js new file mode 100644 index 0000000..fd20711 --- /dev/null +++ b/packages/cloudflare/test/cf-api-proxy.test.js @@ -0,0 +1,106 @@ +// The Cloudflare API passthrough for the site-hosted setup wizard: open CORS +// on its own responses, an allowlist of API paths, and nothing kept. + +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + CF_API_PROXY_PREFIX, + cfApiProxyEnabled, + handleCfApiProxy, +} from '../src/cf-api-proxy.js'; + +const BASE = `https://start.example.com${CF_API_PROXY_PREFIX.slice(0, -1)}`; + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe('cf api proxy', () => { + it('is off unless the flag says true', () => { + expect(cfApiProxyEnabled({})).toBe(false); + expect(cfApiProxyEnabled({ PDS_EXPERIMENTAL_CF_API_PROXY: '1' })).toBe( + false, + ); + expect(cfApiProxyEnabled({ PDS_EXPERIMENTAL_CF_API_PROXY: 'true' })).toBe( + true, + ); + }); + + it('answers the preflight with open CORS', async () => { + const response = await handleCfApiProxy( + new Request(`${BASE}/client/v4/accounts`, { method: 'OPTIONS' }), + ); + expect(response.status).toBe(204); + expect(response.headers.get('Access-Control-Allow-Origin')).toBe('*'); + expect(response.headers.get('Access-Control-Allow-Headers')).toContain( + 'Authorization', + ); + }); + + it('refuses paths outside the allowlist', async () => { + const response = await handleCfApiProxy( + new Request(`${BASE}/client/v4/zones`, { + headers: { Authorization: 'Bearer t' }, + }), + ); + expect(response.status).toBe(403); + expect(response.headers.get('Access-Control-Allow-Origin')).toBe('*'); + }); + + it('requires a token', async () => { + const response = await handleCfApiProxy( + new Request(`${BASE}/client/v4/accounts`), + ); + expect(response.status).toBe(401); + }); + + it('forwards an allowed call with the token and the query string', async () => { + const stub = vi.fn( + async () => + new Response('{"success":true}', { + headers: { + 'Content-Type': 'application/json', + 'Set-Cookie': 'sneaky=1', + }, + }), + ); + vi.stubGlobal('fetch', stub); + + const response = await handleCfApiProxy( + new Request(`${BASE}/client/v4/accounts?page=2`, { + headers: { Authorization: 'Bearer t' }, + }), + ); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ success: true }); + expect(response.headers.get('Access-Control-Allow-Origin')).toBe('*'); + // The upstream cookie does not ride through. + expect(response.headers.get('Set-Cookie')).toBe(null); + + const [url, init] = /** @type {[string, RequestInit]} */ ( + /** @type {unknown} */ (stub.mock.calls[0]) + ); + expect(url).toBe('https://api.cloudflare.com/client/v4/accounts?page=2'); + expect( + /** @type {Record} */ (init.headers).Authorization, + ).toBe('Bearer t'); + }); + + it('forwards a PUT body', async () => { + const stub = vi.fn(async (_url, /** @type {any} */ init) => { + const echoed = await new Response(init.body).text(); + return new Response(echoed, { + headers: { 'Content-Type': 'application/json' }, + }); + }); + vi.stubGlobal('fetch', stub); + + const response = await handleCfApiProxy( + new Request(`${BASE}/client/v4/accounts/abc/workers/scripts/pds`, { + method: 'PUT', + headers: { Authorization: 'Bearer t', 'Content-Type': 'text/plain' }, + body: 'module bytes', + }), + ); + expect(await response.text()).toBe('module bytes'); + }); +});