From e99cc690ceb3802d31853b5bf9afae423854162c Mon Sep 17 00:00:00 2001 From: Jordan Coeyman Date: Fri, 19 Jun 2026 10:55:48 -0400 Subject: [PATCH 1/7] Build fixed Up capabilities and CLI --- README.md | 200 ++++--- cli/up.ts | 163 ++++++ docs/0.0.1-source-brief.md | 93 +++ docs/reference/schedules.md | 31 - docs/reference/site-secrets.md | 24 - examples/DYNAMIC.md | 21 - examples/dynamic-site/_worker.js | 36 -- examples/lunch-vote/app.js | 82 +++ examples/lunch-vote/index.html | 47 ++ examples/lunch-vote/style.css | 115 ++++ package.json | 4 + receipts/2026-06-19-alchemy-v2-decision.md | 38 ++ scripts/setup.ts | 24 +- skills/up/SKILL.md | 39 ++ skills/up/client.d.ts | 52 ++ src/capabilities.ts | 230 ++++++++ src/core-backend.ts | 525 +++++------------ src/core.ts | 50 +- src/index.ts | 2 +- src/kit-worker.ts | 2 +- src/registry.ts | 395 ++----------- src/secrets.ts | 59 -- src/site-database.ts | 70 +++ src/site-realtime.ts | 70 +++ src/site-secrets.ts | 128 ----- src/site.svelte | 368 +----------- tests/ssr.test.ts | 3 +- tests/up.test.ts | 622 +++------------------ wrangler.jsonc | 62 +- wrangler.local.jsonc | 53 +- wrangler.production.jsonc | 72 ++- wrangler.staged.jsonc | 62 +- wrangler.test.jsonc | 49 +- 33 files changed, 1643 insertions(+), 2148 deletions(-) create mode 100644 cli/up.ts create mode 100644 docs/0.0.1-source-brief.md delete mode 100644 docs/reference/schedules.md delete mode 100644 docs/reference/site-secrets.md delete mode 100644 examples/DYNAMIC.md delete mode 100644 examples/dynamic-site/_worker.js create mode 100644 examples/lunch-vote/app.js create mode 100644 examples/lunch-vote/index.html create mode 100644 examples/lunch-vote/style.css create mode 100644 receipts/2026-06-19-alchemy-v2-decision.md create mode 100644 skills/up/SKILL.md create mode 100644 skills/up/client.d.ts create mode 100644 src/capabilities.ts delete mode 100644 src/secrets.ts create mode 100644 src/site-realtime.ts delete mode 100644 src/site-secrets.ts diff --git a/README.md b/README.md index 78e38e6..8b40697 100644 --- a/README.md +++ b/README.md @@ -1,139 +1,161 @@ # Up [![Deploy to Cloudflare](https://deploy.workers.cloudflare.com/button)](https://deploy.workers.cloudflare.com/?url=https://github.com/acoyfellow/up) -[![MIT License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) +[![MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) -**Publish your company’s private web from your Cloudflare account.** +**Publish a folder to a company-private URL.** -Up turns folders of HTML, CSS, JavaScript, and assets into company-private URLs. An organization connects Cloudflare once; employees and coding agents can then share small sites without creating infrastructure or configuring authentication for every artifact. +An organization installs Up once in its Cloudflare account. Employees and coding agents can then publish small internal apps without creating a repository, deployment pipeline, database, credentials, or authentication flow for each app. ```text -folder → immutable private upload → Access-authenticated URL +folder + name → verified upload → atomic activation → company-private URL ``` -**Version:** `0.0.1` · **Dogfood:** +Dogfood: · Version: `0.0.1` -## Deploy +## Publish -Up uses Cloudflare OAuth. You approve a scoped consent screen; no API token or Access audience is created or copied by hand. +Browser: -```sh -export UP_OAUTH_CLIENT_ID= -bun run oauth:connect +```text +Open Up → choose folder → choose name → publish +``` -export CLOUDFLARE_ACCOUNT_ID= -export UP_CONTROL_HOST=up.example.com -export UP_PARENT_ZONE=example.com -export UP_ALLOWED_DOMAIN=example.com -bun run setup +CLI: + +```sh +bunx github:acoyfellow/up init +bunx github:acoyfellow/up deploy ./dist lunch-vote ``` -`bun run setup` creates an isolated child zone when requested, delegates only the control hostname, creates private R2 and the Access application, reads back its generated AUD, injects it into a gitignored deploy config, and deploys the Worker. `workers.dev` and Preview URLs remain disabled. +```text +Authenticated · 3 files ready +Uploading 3/3 style.css +Published -Open `https://up.example.com/app`, choose a folder containing `index.html`, and publish. Sites appear at `.up.example.com` behind the same company Access boundary. +https://lunch-vote.up.example.com +Access: your organization +``` -The portable deployment fails closed until Access is configured. **Do not make the Worker public to finish setup.** +Both clients use the same manifest, upload, and activation API. A copied URL does not grant access; Up verifies a company session before returning content. -## What publishing does +## Fixed browser API -1. The browser hashes every file locally and declares a bounded manifest. -2. Up creates a pending immutable deployment in its Durable Object. -3. Each uploaded R2 object must match the declared path, size, and SHA-256 digest. -4. Activation verifies every object, then atomically swaps the active deployment pointer. -5. Site requests enforce explicit visibility: public, company session, or restricted reader rules. +Every site can import one same-origin module: -Visitors see either the prior complete deployment or the new complete deployment—never a partial upload. +```js +import { up } from '/_up/client.js'; -## Security invariant +const viewer = await up.identity.current(); -> A URL never grants private authority, and a deployment is not done until an isolated probe proves its declared visibility. +const votes = up.db.collection('votes'); +await votes.create({ choice: 'Tacos', voter: viewer.email }); -- Control routes validate the Access signature, issuer, audience, and email. -- Company/restricted sites require an HMAC-signed session minted by the Access-protected broker. -- Public serving requires explicit registry state; absence of identity never implies public. -- Missing Access or session configuration fails closed for non-public sites. -- R2 has no public object URLs. -- Production disables `workers.dev` and Preview URLs. -- Site creators and configured administrators can mutate a site. -- Cross-site control mutations are rejected using exact-origin and Fetch Metadata checks. -- Browser code runs on sibling hostnames. Optional backend code receives only explicitly enabled, site-scoped capability stubs. -- Site names, deployment IDs, hashes, and object keys grant no authority. +await up.files.put('menu.txt', new Blob(['Tacos · Pizza · Salad'])); -Read [SECURITY.md](SECURITY.md) before attaching a real company hostname. +const result = await up.ai.chat([ + { role: 'user', content: 'Summarize today’s vote' } +]); -## Repository map +const room = up.realtime.channel('votes'); +room.on('vote', renderVote); +room.send('vote', { choice: 'Tacos' }); +``` + +Browser code receives no Cloudflare credentials or resource identifiers. -| Path | Purpose | +| API | Cloudflare mechanism | |---|---| -| `src/routes/` | SvelteKit SSR pages, server loads, metadata, and authenticated publisher route | -| `src/core-backend.ts` | Access validation, control APIs, deployment authority, and wildcard site serving | -| `src/site.svelte` | Shared product and publisher interface, initialized from SvelteKit server data | -| `tests/` | Real Workers runtime, Durable Object, and R2 integration tests | -| `fixtures/` | Static site used by the end-to-end verification path | -| `docs/` | Diátaxis documentation | -| `wrangler.jsonc` | Portable Deploy to Cloudflare resources; fail-closed defaults | -| `wrangler.production.jsonc` | Dogfood route shape; placeholders must be replaced before deploy | +| `up.identity` | Access-backed Up session | +| `up.db` | site-named SQLite Durable Object | +| `up.files` | site-prefixed private R2 objects | +| `up.ai` | Workers AI with a fixed model and limits | +| `up.realtime` | site-and-channel Durable Object WebSockets | -## Documentation +## Complete example -- [Tutorial](docs/tutorial/index.md) — install and publish a first site -- [How-to guides](docs/how-to/index.md) — operate and recover an installation -- [Reference](docs/reference/index.md) — exact routes, limits, and configuration -- [Explanation](docs/explanation/index.md) — architecture and trust boundaries +[`examples/lunch-vote`](examples/lunch-vote) is plain HTML, CSS, and JavaScript. Two authenticated browsers can vote, see realtime updates, upload a menu, and ask Workers AI for a summary. It has no `_worker.js`, credentials, framework, or infrastructure configuration. -The product front door is public at ; the dogfood publisher and control APIs remain behind Cloudflare Access. +Demo video: **coming in this release** -## Local verification +## Install for a company -There is intentionally no local authentication bypass. +Up uses Cloudflare OAuth. The current operator path is: ```sh -bun install -bun run build -bun run typecheck -bun run test -bun run dry-run +export UP_OAUTH_CLIENT_ID= +bun run oauth:connect + +export CLOUDFLARE_ACCOUNT_ID= +export UP_CONTROL_HOST=up.example.com +export UP_PARENT_ZONE=example.com +export UP_ALLOWED_DOMAIN=example.com +bun run setup ``` -The test suite executes the real Worker, SQLite-backed Durable Objects, R2, session broker, capability stores, scheduler, and Dynamic Worker boundary under Cloudflare’s Workers Vitest pool. It proves visibility rules, digest enforcement, atomic activation, runtime limits, database isolation, encrypted secret metadata, schedule quotas/retries, public serving, and anonymous denial. +The installer creates customer-owned resources: -For a stable manual publish fixture, choose [`examples/baseline-site`](examples/BASELINE.md). It exercises nested CSS, JavaScript, SVG, a text asset, and the authenticated identity endpoint without external dependencies. +```text +Cloudflare Access + ↓ +Up Worker + SvelteKit assets + ├── private R2 + ├── Registry Durable Object + ├── Database Durable Object namespace + ├── Realtime Durable Object namespace + └── Workers AI +``` -For the browser pass: +The Worker, Access application, DNS, R2, and Durable Objects remain visible in the customer account. `workers.dev` and preview URLs stay disabled. -```sh -bun run dev -bun run test:e2e -``` +Alchemy v2 was tested and omitted from this revision because it cannot yet express Workers AI and Access without custom provider code. See [`receipts/2026-06-19-alchemy-v2-decision.md`](receipts/2026-06-19-alchemy-v2-decision.md). -The local browser pass audits SvelteKit SSR with JavaScript enabled and disabled, validates SEO/PWA metadata, and confirms protected endpoints fail closed. The authenticated publish flow is verified only against an actual Access-protected deployment. +## Trust boundary -## Production done gate +- Every new site is company-private. +- The site hostname selects scope only after Up verifies identity. +- Site A cannot address Site B’s database, files, or realtime room. +- Browser code never receives AI or storage credentials. +- Deployment files stay pending until every path, size, and SHA-256 digest passes verification. +- Activation changes one pointer, so visitors receive a complete old or complete new deployment. +- Anonymous probes receive Access before uploaded bytes. -A release is done only after all of these are recorded: +Read [SECURITY.md](SECURITY.md) for the exact contracts. -- local build, typecheck, runtime tests, dry run, browser test, SEO/PWA audit; -- deployment watched to success; -- public docs and every canonical link checked; -- authenticated folder publish succeeds; -- returned site renders HTML and assets; -- `/__up/me` returns the authenticated Access identity; -- a clean isolated browser is challenged by Access and never receives uploaded bytes; -- update and atomic activation are verified; -- deployment receipt records git SHA, route exposure, bindings, and the checks above. +## Repository map -## Status +```text +src/core-backend.ts deploy protocol and site request routing +src/capabilities.ts fixed browser API and site scoping +src/site-database.ts document collections +src/site-realtime.ts authenticated channel WebSockets +src/auth.ts Access JWT verification +cli/up.ts init and deploy +skills/up/ agent instructions and client types +examples/lunch-vote/ complete framework-free proof +tests/up.test.ts runtime, capability, and isolation proof +wrangler.jsonc installation graph +``` + +The source contract is captured in [`docs/0.0.1-source-brief.md`](docs/0.0.1-source-brief.md). -Up `0.0.1` is static and company-private by default, with explicit progressive capabilities: +## Verify -- **Visibility:** company, restricted email/domain/IdP-group readers, or confirmed public access -- **Backend:** an optional root `_worker.js` executes `/api/*` in a network-isolated Dynamic Worker -- **Secrets:** encrypted, write-only bearer capabilities restricted to exact outbound hosts -- **Data:** an isolated per-site SQLite Durable Object, enabled and deleted by the owner -- **Schedules:** bounded UTC jobs with daily quotas, retries, pause/disable behavior, and audit receipts +```sh +bun install +bun run check +bun run test:e2e +``` + +The suite uses the real Workers runtime, SQLite Durable Objects, R2, WebSockets, SvelteKit SSR, and Access/session logic. It does not use application mocks. + +## Documentation -Dynamic code never runs in the Up control isolate and never receives the registry, deployment authority, private R2 bucket, encryption keys, or another site's bindings. +- [Tutorial](docs/tutorial/index.md) +- [How-to guides](docs/how-to/index.md) +- [Reference](docs/reference/index.md) +- [Explanation](docs/explanation/index.md) ## License -MIT © Jordan Coeyman. +MIT © Jordan Coeyman diff --git a/cli/up.ts b/cli/up.ts new file mode 100644 index 0000000..cb87e61 --- /dev/null +++ b/cli/up.ts @@ -0,0 +1,163 @@ +#!/usr/bin/env bun +import { execFileSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { mkdir, readdir, readFile, stat, writeFile } from 'node:fs/promises'; +import { join, relative, resolve } from 'node:path'; + +const DEFAULT_ORIGIN = 'https://up.ax.cloudflare.dev'; +const command = process.argv[2]; +const args = process.argv.slice(3); + +function usage(): never { + console.error(`up 0.0.1 + +up init [directory] +up deploy [--origin https://up.example.com] +`); + process.exit(1); +} + +function option(name: string): string | undefined { + const index = args.indexOf(name); + return index >= 0 ? args[index + 1] : undefined; +} + +function accessToken(origin: string): string { + if (process.env.UP_ACCESS_TOKEN) return process.env.UP_ACCESS_TOKEN; + return execFileSync('cloudflared', ['access', 'token', '-app', origin], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'inherit'], + }).trim(); +} + +async function files(root: string): Promise { + const output: string[] = []; + async function visit(directory: string): Promise { + for (const entry of await readdir(directory, { withFileTypes: true })) { + if (entry.name.startsWith('.') || entry.name === 'node_modules') continue; + const path = join(directory, entry.name); + if (entry.isDirectory()) await visit(path); + else if (entry.isFile()) output.push(path); + } + } + await visit(root); + return output.sort(); +} + +const mime = (path: string) => + path.endsWith('.html') + ? 'text/html; charset=utf-8' + : path.endsWith('.css') + ? 'text/css; charset=utf-8' + : path.endsWith('.js') + ? 'text/javascript; charset=utf-8' + : path.endsWith('.json') + ? 'application/json; charset=utf-8' + : path.endsWith('.svg') + ? 'image/svg+xml' + : path.endsWith('.png') + ? 'image/png' + : path.endsWith('.jpg') || path.endsWith('.jpeg') + ? 'image/jpeg' + : 'application/octet-stream'; + +async function init(): Promise { + const target = resolve(args[0] || '.'); + const directory = join(target, '.up'); + await mkdir(directory, { recursive: true }); + const source = resolve(import.meta.dir, '..', 'skills', 'up', 'SKILL.md'); + const types = resolve(import.meta.dir, '..', 'skills', 'up', 'client.d.ts'); + await Promise.all([ + writeFile(join(directory, 'SKILL.md'), await readFile(source)), + writeFile(join(directory, 'client.d.ts'), await readFile(types)), + ]); + console.log(`Initialized ${relative(process.cwd(), directory) || '.up'} + +Ask your agent to read .up/SKILL.md, build into ./dist, then run: + up deploy ./dist `); +} + +async function deploy(): Promise { + const root = resolve(args[0] || ''); + const name = args[1]; + const origin = option('--origin') || process.env.UP_ORIGIN || DEFAULT_ORIGIN; + if (!name || !/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(name)) usage(); + if (!(await stat(root).catch(() => null))?.isDirectory()) + throw new Error(`Folder not found: ${root}`); + const paths = await files(root); + if (!paths.some((path) => relative(root, path) === 'index.html')) + throw new Error('index.html is required'); + const token = accessToken(origin); + const authHeaders = { 'cf-access-token': token }; + const prepared = await Promise.all( + paths.map(async (path) => { + const bytes = await readFile(path); + return { + path, + bytes, + relative: relative(root, path).replaceAll('\\', '/'), + sha256: createHash('sha256').update(bytes).digest('hex'), + }; + }), + ); + const manifest = prepared.map((file) => ({ + path: file.relative, + size: file.bytes.byteLength, + contentType: mime(file.relative), + sha256: file.sha256, + })); + console.log(`Authenticated · ${prepared.length} files ready`); + const create = await fetch(`${origin}/api/sites/${encodeURIComponent(name)}/deployments`, { + method: 'POST', + headers: { + ...authHeaders, + origin, + 'sec-fetch-site': 'same-origin', + 'content-type': 'application/json', + }, + body: JSON.stringify({ manifest, access: { visibility: 'company', readers: [] } }), + }); + const created = (await create.json()) as { deployment?: { id: string }; error?: string }; + if (!create.ok || !created.deployment) + throw new Error(created.error || 'Unable to create deployment'); + for (let index = 0; index < prepared.length; index++) { + const file = prepared[index]; + if (!file) continue; + process.stdout.write( + `\rUploading ${index + 1}/${prepared.length} ${file.relative} `, + ); + const response = await fetch( + `${origin}/api/deployments/${created.deployment.id}/assets?path=${encodeURIComponent(file.relative)}`, + { + method: 'PUT', + headers: { + ...authHeaders, + origin, + 'sec-fetch-site': 'same-origin', + 'content-type': mime(file.relative), + }, + body: file.bytes, + }, + ); + if (!response.ok) + throw new Error(((await response.json()) as { error?: string }).error || 'Upload failed'); + } + const activate = await fetch(`${origin}/api/deployments/${created.deployment.id}/activate`, { + method: 'POST', + headers: { ...authHeaders, origin, 'sec-fetch-site': 'same-origin' }, + }); + const activated = (await activate.json()) as { siteUrl?: string; error?: string }; + if (!activate.ok) throw new Error(activated.error || 'Activation failed'); + console.log( + `\nPublished\n\n${activated.siteUrl || `${name}.${new URL(origin).hostname}`}\nAccess: your organization`, + ); +} + +try { + if (command === 'init') await init(); + else if (command === 'deploy') await deploy(); + else usage(); +} catch (error) { + console.error(`\n${error instanceof Error ? error.message : String(error)}`); + process.exit(1); +} diff --git a/docs/0.0.1-source-brief.md b/docs/0.0.1-source-brief.md new file mode 100644 index 0000000..52655fe --- /dev/null +++ b/docs/0.0.1-source-brief.md @@ -0,0 +1,93 @@ +# Up 0.0.1 source brief + +## Source + +Shopify Quick proved one useful mechanism: a company-wide identity boundary can turn a folder into a shareable internal app without creating infrastructure for each site. + +Up remixes that mechanism for Cloudflare customers. Each organization owns one Up installation and all of its Cloudflare resources. + +## Utility + +An employee or coding agent publishes a static folder to a company-private subdomain. The site's browser code can use five fixed, same-origin capabilities without credentials or infrastructure configuration: + +```js +import { up } from '/_up/client.js'; + +await up.identity.current(); +await up.db.collection('votes').create({ choice: 'Tacos' }); +await up.files.put('menu.txt', file); +await up.ai.chat([{ role: 'user', content: 'Summarize the votes' }]); +up.realtime.channel('votes'); +``` + +## Default path + +```text +folder + name → verified upload → atomic activation → company-private URL +``` + +Browser and CLI call the same deployment API. New sites are readable by the whole company. The default flow has no visibility, database, secret, runtime, or schedule choices. + +## Fixed capability contract + +| Capability | Cloudflare mechanism | Boundary | +|---|---|---| +| `up.identity` | Access-backed Up session | Current verified employee | +| `up.db` | Site-named SQLite Durable Object | One site; document collections only | +| `up.files` | Site-prefixed private R2 keys | One site; bounded objects and listing | +| `up.ai` | Workers AI binding | Fixed model policy and bounded requests | +| `up.realtime` | Site-and-channel Durable Object | Authenticated WebSockets scoped to one site | + +A URL is not authority. The site hostname selects scope only after Up verifies the company session. + +## Installation graph + +```text +Cloudflare Access + ↓ +Up Worker + SvelteKit assets + ├── private R2: deployments and site files + ├── Registry Durable Object + ├── Database Durable Object namespace + ├── Realtime Durable Object namespace + └── Workers AI +``` + +Alchemy v2 may express this graph if a disposable proof shows that it removes code and keeps local/deploy behavior clear. Otherwise Up uses a small Wrangler configuration. Alchemy is optional; the graph is not. + +## Repository map target + +```text +README.md seven-minute explanation +alchemy.run.ts|wrangler complete installation graph +src/core-backend.ts deploy and site request dispatch +src/capabilities/ fixed browser APIs +src/site-database.ts document collection storage +src/site-realtime.ts channel WebSockets +cli/up.ts init and deploy +examples/lunch-vote/ framework-free complete proof + tests/ deployment, capability, and isolation proof +``` + +## Explicit exclusions + +0.0.1 does not expose public sites, per-reader ACLs, custom backends, user-managed secrets, cron jobs, raw SQL, capability plugins, arbitrary AI providers, Git integration, or custom per-site infrastructure. + +Existing code for those features is disposable and should be deleted when the fixed capabilities replace it. + +## Public artifact + +`examples/lunch-vote` is understandable before clicking. Two employees can vote in separate browsers, see realtime updates, upload a menu, and request an AI summary. It contains only HTML, CSS, and browser JavaScript. + +## Review gate + +Stop for revision when all of these are true: + +- browser folder publishing and `up deploy` return the same company-private URL shape; +- `up init` writes the inspectable agent skill and client types; +- lunch-vote uses all five fixed APIs without `_worker.js` or credentials; +- two authenticated sessions exchange realtime updates; +- an anonymous browser receives Access before content; +- Site A cannot read Site B data, files, or channels; +- the repository README, infrastructure graph, client API, request router, and isolation test are understandable in seven minutes; +- a short raw demo video is committed to GitHub and linked from the README. diff --git a/docs/reference/schedules.md b/docs/reference/schedules.md deleted file mode 100644 index 91e3dff..0000000 --- a/docs/reference/schedules.md +++ /dev/null @@ -1,31 +0,0 @@ -# Scheduled jobs - -Up runs a single trusted scheduler every minute. Site schedules are registry records; they do not create arbitrary account-level cron triggers. - -A schedule targets an `/api/*` route handled by the active deployment's isolated `_worker.js`: - -```json -{ - "path": "/api/jobs/hourly", - "cron": "0 * * * *", - "maxRunsPerDay": 24, - "retryLimit": 3 -} -``` - -Supported expressions are minute intervals (`*/15 * * * *`), hourly minutes (`0 * * * *`), and daily UTC times (`30 9 * * *`). - -## Safety model - -- only owners and Up administrators can manage schedules -- schedules require a published site -- states are `enabled`, `paused`, and `disabled` -- the registry atomically leases due work for five minutes -- each attempt counts against the UTC daily quota -- failures retry with exponential backoff, capped at one hour -- exhausted retries advance to the next cron occurrence -- paused, disabled, over-quota, or unpublished sites do not execute -- each create, update, delete, success, failure, and quota skip creates a bounded audit record -- audit records contain path, cron, state, attempt, and HTTP status—never code, secret values, response bodies, or stack traces - -The scheduled request includes `x-up-schedule` plus a JSON body containing the schedule ID, scheduled time, and attempt number. It runs under the same Dynamic Worker CPU, subrequest, network, database, and secret-capability restrictions as an interactive backend request. diff --git a/docs/reference/site-secrets.md b/docs/reference/site-secrets.md deleted file mode 100644 index 46cea42..0000000 --- a/docs/reference/site-secrets.md +++ /dev/null @@ -1,24 +0,0 @@ -# Site secrets - -Up stores per-site secrets as AES-256-GCM ciphertext in a dedicated `SiteSecrets` Durable Object. The encryption key is generated by `bun run setup`, stored locally with mode `600`, and uploaded as a Worker secret. It is never written to Wrangler vars, R2, the registry, deployment manifests, receipts, or git. - -Only site owners and Up administrators can create, list, or delete secret capabilities. List responses contain names, allowed hosts, and update times—never ciphertext or plaintext. - -Dynamic code receives a site-specific `UP_SECRETS` stub. It cannot read a secret. Instead, it asks the stub to make an allowlisted HTTPS request: - -```js -const response = await env.UP_SECRETS.fetch( - 'https://secrets.internal/use/API_TOKEN', - { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ - url: 'https://api.example.com/jobs', - method: 'POST', - body: JSON.stringify({ action: 'run' }), - }), - }, -); -``` - -The secret is injected as a bearer credential by the trusted Durable Object. Dynamic code cannot override authorization, cookies, host, or Access headers. Outbound hosts are exact allowlist entries. Only bounded text/JSON responses are returned, and direct occurrences of the secret are redacted. diff --git a/examples/DYNAMIC.md b/examples/DYNAMIC.md deleted file mode 100644 index 0a3f5a6..0000000 --- a/examples/DYNAMIC.md +++ /dev/null @@ -1,21 +0,0 @@ -# Dynamic baseline - -`examples/dynamic-site` is the deterministic production fixture for Up's optional server runtime. - -It contains: - -- `index.html` — static shell -- `_worker.js` — isolated `/api/receipt` backend - -Publish it with database access enabled. Interactive and scheduled requests append receipt rows to the site's isolated SQLite Durable Object. The response proves the Dynamic Worker runtime and database binding without using secrets or outbound network access. - -Expected API receipt: - -```json -{ - "runtime": "dynamic-worker", - "outbound": "blocked-by-default", - "database": [{ "kind": "interactive", "created_at": "..." }], - "scheduled": false -} -``` diff --git a/examples/dynamic-site/_worker.js b/examples/dynamic-site/_worker.js deleted file mode 100644 index 67d2674..0000000 --- a/examples/dynamic-site/_worker.js +++ /dev/null @@ -1,36 +0,0 @@ -async function database(env, sql, params = []) { - if (!env.UP_DB) return { error: 'database-disabled' }; - const response = await env.UP_DB.fetch('https://database.internal/query', { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ sql, params }), - }); - return response.json(); -} - -export default { - async fetch(request, env) { - const url = new URL(request.url); - if (url.pathname === '/api/receipt') { - await database( - env, - 'CREATE TABLE IF NOT EXISTS receipts(id INTEGER PRIMARY KEY, kind TEXT, created_at TEXT)', - ); - await database(env, 'INSERT INTO receipts(kind,created_at) VALUES (?,?)', [ - request.headers.get('x-up-schedule') ? 'scheduled' : 'interactive', - new Date().toISOString(), - ]); - const result = await database( - env, - 'SELECT kind,created_at FROM receipts ORDER BY id DESC LIMIT 10', - ); - return Response.json({ - runtime: 'dynamic-worker', - outbound: 'blocked-by-default', - database: result.rows || [], - scheduled: Boolean(request.headers.get('x-up-schedule')), - }); - } - return Response.json({ error: 'not found' }, { status: 404 }); - }, -}; diff --git a/examples/lunch-vote/app.js b/examples/lunch-vote/app.js new file mode 100644 index 0000000..bd8fa75 --- /dev/null +++ b/examples/lunch-vote/app.js @@ -0,0 +1,82 @@ +import { up } from '/_up/client.js'; + +const viewer = document.querySelector('#viewer'); +const activity = document.querySelector('#activity'); +const liveStatus = document.querySelector('#live-status'); +const summary = document.querySelector('#summary'); +const menuLink = document.querySelector('#menu-link'); +const votes = up.db.collection('votes'); +const room = up.realtime.channel('votes'); +let currentUser; + +function note(text) { + const item = document.createElement('li'); + item.textContent = text; + activity.prepend(item); + while (activity.children.length > 6) activity.lastElementChild?.remove(); +} + +async function refresh() { + const { documents } = await votes.list(); + const counts = new Map(); + for (const vote of documents) counts.set(vote.choice, (counts.get(vote.choice) || 0) + 1); + for (const button of document.querySelectorAll('[data-choice]')) { + button.querySelector('span').textContent = String(counts.get(button.dataset.choice) || 0); + } + return documents; +} + +async function vote(choice) { + const created = await votes.create({ + choice, + voter: currentUser.email, + votedAt: new Date().toISOString(), + }); + note(`${currentUser.email} voted for ${choice}`); + room.send('vote', created); + await refresh(); +} + +room.on('open', () => { + liveStatus.textContent = 'Connected · updates appear in every open browser'; +}); +room.on('vote', async (message) => { + if (message.sender !== currentUser?.email) + note(`${message.sender} voted for ${message.data.choice}`); + await refresh(); +}); +room.on('presence', (message) => { + if (message.event === 'join') note(`${message.email} joined`); +}); +room.connect(); + +for (const button of document.querySelectorAll('[data-choice]')) { + button.addEventListener('click', () => vote(button.dataset.choice)); +} + +document.querySelector('#menu').addEventListener('change', async (event) => { + const file = event.target.files?.[0]; + if (!file) return; + const stored = await up.files.put(`menus/${file.name}`, file); + menuLink.href = stored.url; + menuLink.textContent = `Open ${file.name} ↗`; + menuLink.hidden = false; + note(`${currentUser.email} shared ${file.name}`); + room.send('file', { name: file.name }); +}); + +document.querySelector('#summarize').addEventListener('click', async () => { + const documents = await refresh(); + summary.textContent = 'Asking Workers AI…'; + const result = await up.ai.chat([ + { + role: 'user', + content: `Summarize this lunch vote in one short sentence: ${JSON.stringify(documents)}`, + }, + ]); + summary.textContent = result.response || result.result?.response || JSON.stringify(result); +}); + +currentUser = await up.identity.current(); +viewer.textContent = `Signed in as ${currentUser.email}`; +await refresh(); diff --git a/examples/lunch-vote/index.html b/examples/lunch-vote/index.html new file mode 100644 index 0000000..62a02d1 --- /dev/null +++ b/examples/lunch-vote/index.html @@ -0,0 +1,47 @@ + + + + + + Lunch Vote · Up + + + +
+
+

UP 0.0.1 PROOF

+

Where should we eat?

+

Checking company identity…

+
+ +
+ + + +
+ +
+
+

Live room

+

Connecting…

+
+
    +
    + +
    + + +
    + +
    +

    Summary

    +

    Vote to create a summary.

    + +
    +
    + + + diff --git a/examples/lunch-vote/style.css b/examples/lunch-vote/style.css new file mode 100644 index 0000000..dbf402b --- /dev/null +++ b/examples/lunch-vote/style.css @@ -0,0 +1,115 @@ +:root { + color-scheme: light; + font-family: Inter, ui-sans-serif, system-ui, sans-serif; + color: #17212b; + background: #f7f9fb; +} +* { + box-sizing: border-box; +} +body { + margin: 0; +} +main { + width: min(100% - 32px, 760px); + margin: 0 auto; + padding: 64px 0 96px; +} +header { + padding-bottom: 32px; + border-bottom: 1px solid #dce2e7; +} +.eyebrow { + color: #f6821f; + font: + 600 0.7rem ui-monospace, + monospace; + letter-spacing: 0.1em; +} +h1 { + margin: 12px 0; + font-size: clamp(2.5rem, 8vw, 5rem); + line-height: 0.95; + letter-spacing: -0.05em; +} +h2 { + margin: 0; + font-size: 1rem; +} +p { + color: #5e6d79; + line-height: 1.6; +} +.choices { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 10px; + margin: 32px 0; +} +button, +.upload { + display: flex; + min-height: 52px; + align-items: center; + justify-content: space-between; + padding: 0 16px; + border: 1px solid #b9c3cb; + border-radius: 8px; + background: white; + color: inherit; + font: 700 0.86rem inherit; + cursor: pointer; +} +button:hover { + border-color: #f6821f; +} +button span { + color: #f6821f; + font-family: ui-monospace, monospace; +} +.panel, +.result { + padding: 22px; + border: 1px solid #dce2e7; + border-radius: 10px; + background: white; +} +.panel { + display: grid; + grid-template-columns: 0.8fr 1.2fr; + gap: 24px; +} +ul { + margin: 0; + padding: 0; + list-style: none; +} +li { + padding: 8px 0; + border-bottom: 1px solid #edf0f2; + color: #5e6d79; + font-size: 0.78rem; +} +.tools { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px; + margin: 20px 0; +} +.upload input { + max-width: 180px; + font-size: 0.7rem; +} +.result a { + color: #2678a4; +} +@media (max-width: 600px) { + main { + padding-top: 36px; + } + .choices, + .tools, + .panel { + grid-template-columns: 1fr; + } +} diff --git a/package.json b/package.json index e439e8f..9adc368 100644 --- a/package.json +++ b/package.json @@ -3,8 +3,12 @@ "version": "0.0.1", "private": true, "type": "module", + "bin": { + "up": "./cli/up.ts" + }, "description": "Publish your company's private web from your Cloudflare account.", "scripts": { + "up": "bun run cli/up.ts", "build": "svelte-kit sync && vite build", "build:kit": "bun run build", "dev": "bun run build && wrangler dev --local --port 8798 -c wrangler.local.jsonc", diff --git a/receipts/2026-06-19-alchemy-v2-decision.md b/receipts/2026-06-19-alchemy-v2-decision.md new file mode 100644 index 0000000..fbdfb48 --- /dev/null +++ b/receipts/2026-06-19-alchemy-v2-decision.md @@ -0,0 +1,38 @@ +# Alchemy v2 decision — 2026-06-19 + +## Question + +Can Alchemy v2 simplify Up's customer-owned installation graph enough to improve the seven-minute repository? + +## Proof + +A disposable source-only spike used the local Effect-based Alchemy v2 package (`2.0.0-beta.25`), not Alchemy 0.x. It type-checked a declarative Worker with static assets, R2, and a SQLite Durable Object while disabling `workers.dev` and previews. + +No Cloudflare resource was created or modified. + +## Result: ditch for this revision + +Alchemy v2 does not currently provide two resources required by Up's fixed 0.0.1 graph: + +- a Workers AI binding in the Worker's accepted resource union; +- Cloudflare Access application and policy resources. + +Adding both through Up-specific custom providers would increase the source surface and duplicate Cloudflare API lifecycle code. That fails the reason to adopt Alchemy. + +Up therefore keeps one small Wrangler installation graph for this revision. The installer may use Cloudflare OAuth, but Alchemy is not in the runtime or deploy path. + +## Revisit condition + +Reconsider v2 when it natively expresses: + +```text +Worker + assets +R2 +SQLite Durable Objects +Workers AI +custom domain and wildcard route +Cloudflare Access application and policy +no workers.dev or previews +``` + +At that point, adoption must delete provisioning code rather than wrap it. diff --git a/scripts/setup.ts b/scripts/setup.ts index 0ef9486..e6b1c74 100644 --- a/scripts/setup.ts +++ b/scripts/setup.ts @@ -55,16 +55,6 @@ const compatDate = setting('COMPAT_DATE') || '2026-06-12'; const adminEmails = setting('ADMIN_EMAILS') || allowEmail || ''; const token = await resolveToken(); const cf = cfFactory(token); -const secretsKeyFile = setting('SECRETS_KEY_FILE') || '.up-secrets-key'; -let secretsKey = setting('SECRETS_KEY'); -if (!secretsKey) { - try { - secretsKey = (await readFile(secretsKeyFile, 'utf8')).trim(); - } catch { - secretsKey = randomBytes(32).toString('base64url'); - await writeFile(secretsKeyFile, `${secretsKey}\n`, { mode: 0o600 }); - } -} const sessionSecretFile = setting('SESSION_SECRET_FILE') || '.up-session-secret'; let sessionSecret = setting('SESSION_SECRET'); if (!sessionSecret) { @@ -142,18 +132,12 @@ const config = { bindings: [ { name: 'REGISTRY', class_name: 'UpRegistry' }, { name: 'SITE_DATABASE', class_name: 'SiteDatabase' }, - { name: 'SITE_SECRETS', class_name: 'SiteSecrets' }, + { name: 'SITE_REALTIME', class_name: 'SiteRealtime' }, ], }, - migrations: [ - { tag: 'v1', new_sqlite_classes: ['UpRegistry'] }, - { tag: 'v2', new_sqlite_classes: ['SiteDatabase'] }, - { tag: 'v3', new_sqlite_classes: ['SiteSecrets'] }, - { tag: 'v4' }, - ], + migrations: [{ tag: 'v1', new_sqlite_classes: ['UpRegistry', 'SiteDatabase', 'SiteRealtime'] }], + ai: { binding: 'AI' }, r2_buckets: [{ binding: 'ASSETS', bucket_name: bucket }], - worker_loaders: [{ binding: 'LOADER' }], - triggers: { crons: ['* * * * *'] }, }; await writeFile( configOut, @@ -187,8 +171,6 @@ async function putSecret(name: string, value: string) { } console.log('Uploading private site-session signing secret…'); await putSecret('SESSION_SECRET', sessionSecret); -console.log('Uploading private secret-encryption key…'); -await putSecret('SECRETS_KEY', secretsKey); console.log(`\nDone. https://${controlHost} is live behind Cloudflare Access.`); console.log('The Access AUD and private runtime secrets were wired automatically.'); diff --git a/skills/up/SKILL.md b/skills/up/SKILL.md new file mode 100644 index 0000000..1c1c128 --- /dev/null +++ b/skills/up/SKILL.md @@ -0,0 +1,39 @@ +# Up + +Use Up when the user asks for a small company-private website, prototype, dashboard, poll, presentation, or internal tool. + +## Build contract + +1. Build framework-free output into `./dist` unless the project already has a build system. +2. Include `dist/index.html`. +3. Keep each file under 10 MiB and the folder under 50 MiB. +4. Do not put credentials in browser code. +5. Use the fixed Up browser module for stateful features: + +```js +import { up } from '/_up/client.js'; +``` + +Available capabilities: + +```js +await up.identity.current(); +const records = up.db.collection('records'); +await records.create({ title: 'Hello' }); +await up.files.put('file.txt', new Blob(['hello'])); +await up.ai.chat([{ role: 'user', content: 'Summarize this' }]); +const room = up.realtime.channel('main'); +room.on('update', console.log); +room.send('update', { ok: true }); +``` + +6. Test the local static files before publishing. +7. Publish only after the user approves the folder and site name: + +```sh +up deploy ./dist +``` + +8. Return the company-private URL printed by Up. + +Do not ask the user about R2, Durable Objects, Access, bindings, databases, secrets, schedules, or Workers. Those belong to the company installation. diff --git a/skills/up/client.d.ts b/skills/up/client.d.ts new file mode 100644 index 0000000..ae5e007 --- /dev/null +++ b/skills/up/client.d.ts @@ -0,0 +1,52 @@ +export interface UpIdentity { + email: string; + groups: string[]; + role: 'member' | 'admin'; +} + +export interface UpDocument { + id: string; + [key: string]: unknown; +} + +export interface UpCollection> { + create(data: T): Promise; + get(id: string): Promise; + list(options?: { + limit?: number; + offset?: number; + }): Promise<{ documents: Array }>; + update(id: string, data: T): Promise; + delete(id: string): Promise<{ deleted: true; id: string }>; +} + +export interface UpChannel { + connect(): WebSocket; + on( + type: string, + listener: (message: { type: string; data?: unknown; sender?: string }) => void, + ): () => void; + send(type: string, data: unknown): void; + close(): void; +} + +export declare const up: { + identity: { current(): Promise }; + db: { collection>(name: string): UpCollection }; + files: { + put(name: string, file: Blob): Promise<{ name: string; size: number; url: string }>; + get(name: string): Promise; + list(): Promise<{ + files: Array<{ name: string; size: number; uploadedAt: string; contentType: string }>; + }>; + delete(name: string): Promise<{ deleted: true; name: string }>; + }; + ai: { + chat( + messages: Array<{ role: 'user' | 'assistant' | 'system'; content: string }>, + ): Promise<{ response?: string }>; + }; + realtime: { channel(name: string): UpChannel }; +}; + +export default up; diff --git a/src/capabilities.ts b/src/capabilities.ts new file mode 100644 index 0000000..8a0de28 --- /dev/null +++ b/src/capabilities.ts @@ -0,0 +1,230 @@ +import type { Identity, SiteRecord } from './core'; +import type { Env } from './core-backend'; + +const secure = { + 'cache-control': 'private, no-store', + 'referrer-policy': 'no-referrer', + 'x-content-type-options': 'nosniff', +}; +const json = (value: unknown, status = 200) => Response.json(value, { status, headers: secure }); +const collectionPattern = /^[a-z][a-z0-9_-]{0,47}$/; +const channelPattern = /^[a-z0-9][a-z0-9_-]{0,47}$/; +const MAX_FILE_BYTES = 10 * 1024 * 1024; +const MAX_AI_INPUT = 20_000; + +function siteFilePath(pathname: string): string | null { + let value: string; + try { + value = decodeURIComponent(pathname).replace(/^\/+/, ''); + } catch { + return null; + } + if (!value || value.length > 240 || value.includes('..') || /[\\\0]/.test(value)) return null; + return value + .split('/') + .map((part) => part.trim()) + .filter(Boolean) + .join('/'); +} + +function fileKey(site: string, path: string): string { + return `site-files/${site}/${path}`; +} + +function clientModule(): string { + return `const request=async(path,init={})=>{const response=await fetch('/_up/'+path,{...init,headers:{...init.headers,'accept':'application/json'}});if(!response.ok){let body={};try{body=await response.json()}catch{}throw new Error(body.error||('Up request failed: '+response.status))}const type=response.headers.get('content-type')||'';return type.includes('application/json')?response.json():response}; +const collection=(name)=>({ + create:(data)=>request('db/'+encodeURIComponent(name),{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(data)}), + get:(id)=>request('db/'+encodeURIComponent(name)+'/'+encodeURIComponent(id)), + list:(options={})=>request('db/'+encodeURIComponent(name)+'?'+new URLSearchParams({limit:String(options.limit||100),offset:String(options.offset||0)})), + update:(id,data)=>request('db/'+encodeURIComponent(name)+'/'+encodeURIComponent(id),{method:'PUT',headers:{'content-type':'application/json'},body:JSON.stringify(data)}), + delete:(id)=>request('db/'+encodeURIComponent(name)+'/'+encodeURIComponent(id),{method:'DELETE'}) +}); +const channel=(name)=>{let socket,listeners=new Map();const emit=(type,value)=>{for(const fn of listeners.get(type)||[])fn(value)};return{ + connect(){if(socket&&socket.readyState<=1)return socket;const protocol=location.protocol==='https:'?'wss:':'ws:';socket=new WebSocket(protocol+'//'+location.host+'/_up/realtime/'+encodeURIComponent(name));socket.onmessage=(event)=>{try{const message=JSON.parse(event.data);emit(message.type,message)}catch{emit('message',event.data)}};socket.onopen=()=>emit('open',{});socket.onclose=()=>emit('close',{});return socket}, + on(type,fn){const set=listeners.get(type)||new Set();set.add(fn);listeners.set(type,set);return()=>set.delete(fn)}, + send(type,data){const ws=this.connect();const payload=JSON.stringify({type,data});if(ws.readyState===WebSocket.OPEN)ws.send(payload);else ws.addEventListener('open',()=>ws.send(payload),{once:true})}, + close(){socket?.close()} +}}; +export const up={ + identity:{current:()=>request('identity')}, + db:{collection}, + files:{ + put:async(name,file)=>{const response=await fetch('/_up/files/'+encodeURIComponent(name),{method:'PUT',headers:{'content-type':file.type||'application/octet-stream'},body:file});if(!response.ok)throw new Error((await response.json().catch(()=>({}))).error||'Upload failed');return response.json()}, + get:(name)=>fetch('/_up/files/'+encodeURIComponent(name)).then(response=>{if(!response.ok)throw new Error('File not found');return response}), + list:()=>request('files'), + delete:(name)=>request('files/'+encodeURIComponent(name),{method:'DELETE'}) + }, + ai:{chat:(messages)=>request('ai/chat',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({messages})})}, + realtime:{channel} +}; +export default up; +`; +} + +async function databaseRequest( + request: Request, + env: Env, + site: SiteRecord, + pathname: string, +): Promise { + if (!env.SITE_DATABASE) return json({ error: 'Database is not configured' }, 503); + const match = pathname.match(/^db\/([^/]+)(?:\/([^/]+))?$/); + if (!match || !collectionPattern.test(match[1] || '')) + return json({ error: 'Invalid collection' }, 400); + const collection = match[1] as string; + const id = match[2] ? decodeURIComponent(match[2]) : undefined; + if (id && !/^[a-zA-Z0-9_-]{1,64}$/.test(id)) return json({ error: 'Invalid document id' }, 400); + const target = new URL( + `https://database.internal/collections/${encodeURIComponent(collection)}${id ? `/${encodeURIComponent(id)}` : ''}`, + ); + target.search = new URL(request.url).search; + const stub = env.SITE_DATABASE.get(env.SITE_DATABASE.idFromName(site.name)); + return stub.fetch(new Request(target, request)); +} + +async function filesRequest( + request: Request, + env: Env, + site: SiteRecord, + pathname: string, +): Promise { + if (pathname === 'files' && request.method === 'GET') { + const prefix = fileKey(site.name, ''); + const listed = await env.ASSETS.list({ prefix, limit: 1000 }); + return json({ + files: listed.objects.map((object) => ({ + name: object.key.slice(prefix.length), + size: object.size, + uploadedAt: object.uploaded.toISOString(), + contentType: object.httpMetadata?.contentType || 'application/octet-stream', + })), + }); + } + const path = siteFilePath(pathname.replace(/^files\/?/, '')); + if (!path) return json({ error: 'Invalid file name' }, 400); + const key = fileKey(site.name, path); + if (request.method === 'PUT') { + const length = Number(request.headers.get('content-length') || '0'); + if (length > MAX_FILE_BYTES) return json({ error: 'File exceeds 10 MiB' }, 413); + const bytes = await request.arrayBuffer(); + if (bytes.byteLength > MAX_FILE_BYTES) return json({ error: 'File exceeds 10 MiB' }, 413); + await env.ASSETS.put(key, bytes, { + httpMetadata: { + contentType: request.headers.get('content-type') || 'application/octet-stream', + }, + }); + return json( + { name: path, size: bytes.byteLength, url: `/_up/files/${encodeURIComponent(path)}` }, + 201, + ); + } + if (request.method === 'GET') { + const object = await env.ASSETS.get(key); + if (!object) return json({ error: 'File not found' }, 404); + const headers = new Headers(secure); + object.writeHttpMetadata(headers); + headers.set('etag', object.httpEtag); + return new Response(object.body, { headers }); + } + if (request.method === 'DELETE') { + await env.ASSETS.delete(key); + return json({ deleted: true, name: path }); + } + return json({ error: 'Method not allowed' }, 405); +} + +async function aiRequest(request: Request, env: Env): Promise { + if (!env.AI) return json({ error: 'AI is not configured' }, 503); + const input = await request.json<{ messages?: unknown }>().catch(() => null); + if ( + !input || + !Array.isArray(input.messages) || + input.messages.length < 1 || + input.messages.length > 24 + ) + return json({ error: 'Messages must contain 1-24 entries' }, 400); + const messages = input.messages.map((item) => { + if (!item || typeof item !== 'object') throw new Error('Invalid message'); + const value = item as Record; + if ( + !['user', 'assistant', 'system'].includes(String(value.role)) || + typeof value.content !== 'string' + ) + throw new Error('Invalid message'); + return { role: String(value.role), content: value.content.slice(0, 8000) }; + }); + if (JSON.stringify(messages).length > MAX_AI_INPUT) + return json({ error: 'AI input is too large' }, 413); + try { + const result = await env.AI.run( + '@cf/meta/llama-3.1-8b-instruct-fast' as keyof AiModels, + { + messages, + max_tokens: 512, + } as never, + ); + return json(result); + } catch { + return json({ error: 'AI request failed' }, 502); + } +} + +async function realtimeRequest( + request: Request, + env: Env, + site: SiteRecord, + identity: Identity, + pathname: string, +): Promise { + if (!env.SITE_REALTIME) return json({ error: 'Realtime is not configured' }, 503); + const channel = decodeURIComponent(pathname.slice('realtime/'.length)); + if (!channelPattern.test(channel)) return json({ error: 'Invalid channel' }, 400); + if (request.headers.get('upgrade')?.toLowerCase() !== 'websocket') + return json({ error: 'WebSocket upgrade required' }, 426); + const stub = env.SITE_REALTIME.get(env.SITE_REALTIME.idFromName(`${site.name}:${channel}`)); + const headers = new Headers(request.headers); + headers.set('x-up-email', identity.email); + headers.set('x-up-site', site.name); + headers.set('x-up-channel', channel); + return stub.fetch(new Request('https://realtime.internal/connect', { headers })); +} + +export async function handleCapabilityRequest( + request: Request, + env: Env, + site: SiteRecord, + identity: Identity, +): Promise { + const url = new URL(request.url); + if (!url.pathname.startsWith('/_up/')) return null; + if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(request.method)) { + const origin = request.headers.get('origin'); + const fetchSite = request.headers.get('sec-fetch-site'); + if (origin !== url.origin || (fetchSite && fetchSite !== 'same-origin')) + return json({ error: 'Same-origin request required' }, 403); + } + if (request.headers.get('upgrade')?.toLowerCase() === 'websocket') { + const origin = request.headers.get('origin'); + if (origin && origin !== url.origin) + return json({ error: 'Same-origin request required' }, 403); + } + const pathname = url.pathname.slice('/_up/'.length); + if (pathname === 'client.js' && request.method === 'GET') + return new Response(clientModule(), { + headers: { + 'content-type': 'application/javascript; charset=utf-8', + 'cache-control': 'public, max-age=3600', + 'x-content-type-options': 'nosniff', + }, + }); + if (pathname === 'identity' && request.method === 'GET') + return json({ email: identity.email, groups: identity.groups || [], role: identity.role }); + if (pathname.startsWith('db/')) return databaseRequest(request, env, site, pathname); + if (pathname === 'files' || pathname.startsWith('files/')) + return filesRequest(request, env, site, pathname); + if (pathname === 'ai/chat' && request.method === 'POST') return aiRequest(request, env); + if (pathname.startsWith('realtime/')) + return realtimeRequest(request, env, site, identity, pathname); + return json({ error: 'Capability not found' }, 404); +} diff --git a/src/core-backend.ts b/src/core-backend.ts index 20b4e91..0172fc6 100644 --- a/src/core-backend.ts +++ b/src/core-backend.ts @@ -1,5 +1,6 @@ import { Hono } from 'hono'; import { type AccessConfiguration, configurationError, verifyAccessIdentity } from './auth'; +import { handleCapabilityRequest } from './capabilities'; import { type AssetManifestEntry, cleanAssetPath, @@ -8,16 +9,10 @@ import { type Identity, mayRead, mayWrite, - normalizeSchedule, - normalizeSiteAccess, - type ScheduleRecord, - type SiteAccess, type SiteRecord, - sha256, validateManifest, } from './core'; import type { UpRegistry } from './registry'; -import { cleanAllowedHosts, cleanSecretName, encryptSecret } from './secrets'; import { cookieValue, createSession, @@ -26,7 +21,8 @@ import { verifySession, } from './session'; import type { SiteDatabase } from './site-database'; -import type { SiteSecrets } from './site-secrets'; +import type { SiteRealtime } from './site-realtime'; + export interface Env extends AccessConfiguration { ASSETS: R2Bucket; REGISTRY: DurableObjectNamespace; @@ -35,12 +31,14 @@ export interface Env extends AccessConfiguration { MAX_SITE_BYTES?: string; MAX_FILE_BYTES?: string; MAX_FILES?: string; - LOADER?: WorkerLoader; SESSION_SECRET?: string; SITE_DATABASE?: DurableObjectNamespace; - SITE_SECRETS?: DurableObjectNamespace; - SECRETS_KEY?: string; + SITE_REALTIME?: DurableObjectNamespace; + /** Legacy binding retained only for safe migration of the existing Worker. */ + SITE_SECRETS?: DurableObjectNamespace; + AI?: Ai; } + const secure = { 'cache-control': 'no-store', 'referrer-policy': 'no-referrer', @@ -49,18 +47,10 @@ const secure = { 'permissions-policy': 'camera=(), microphone=(), geolocation=(), payment=(), usb=()', }; const json = (value: unknown, status = 200) => Response.json(value, { status, headers: secure }); -function limits(env: Env) { - const n = (v: string | undefined, d: number) => { - const p = Number(v); - return Number.isSafeInteger(p) && p > 0 ? p : d; - }; - return { - maxSiteBytes: n(env.MAX_SITE_BYTES, 52428800), - maxFileBytes: n(env.MAX_FILE_BYTES, 10485760), - maxFiles: n(env.MAX_FILES, 500), - }; -} const registry = (env: Env) => env.REGISTRY.get(env.REGISTRY.idFromName('registry')); +const assetKey = (deployment: DeploymentRecord, path: string) => + `deployments/${deployment.siteName}/${deployment.id}/${path}`; + class RegistryError extends Error { constructor( message: string, @@ -69,6 +59,7 @@ class RegistryError extends Error { super(message); } } + async function reg(env: Env, path: string, init?: RequestInit): Promise { const response = await registry(env).fetch(`https://registry.internal${path}`, init); const body = await response.json(); @@ -76,86 +67,44 @@ async function reg(env: Env, path: string, init?: RequestInit): Promise { throw new RegistryError(body.error || 'Registry request failed', response.status); return body; } -const key = (d: DeploymentRecord, path: string) => `deployments/${d.siteName}/${d.id}/${path}`; -const DYNAMIC_ENTRY = '_worker.js'; -const MAX_DYNAMIC_CODE_BYTES = 1024 * 1024; -export async function runDynamicSiteRequest( - request: Request, - env: Env, - deployment: DeploymentRecord, - site: SiteRecord, -): Promise { - const entry = deployment.manifest.find((asset) => asset.path === DYNAMIC_ENTRY); - if (!entry || !new URL(request.url).pathname.startsWith('/api/')) return null; - if (!env.LOADER) return json({ error: 'Dynamic runtime is not configured' }, 503); - if (entry.size > MAX_DYNAMIC_CODE_BYTES) - return json({ error: 'Dynamic worker is too large' }, 400); - try { - const worker = env.LOADER.get(`${deployment.id}:${entry.sha256}`, async () => { - const object = await env.ASSETS.get(key(deployment, DYNAMIC_ENTRY)); - if (!object) throw new Error('Dynamic worker code is missing'); - return { - compatibilityDate: '2026-06-16', - compatibilityFlags: ['nodejs_compat'], - mainModule: DYNAMIC_ENTRY, - modules: { [DYNAMIC_ENTRY]: await object.text() }, - env: { - ...(site.databaseEnabled && env.SITE_DATABASE - ? { - UP_DB: env.SITE_DATABASE.get(env.SITE_DATABASE.idFromName(site.name)), - } - : {}), - ...(env.SITE_SECRETS && env.SECRETS_KEY - ? { - UP_SECRETS: env.SITE_SECRETS.get(env.SITE_SECRETS.idFromName(site.name)), - } - : {}), - }, - globalOutbound: null, - limits: { cpuMs: 50, subRequests: 5 }, - } satisfies WorkerLoaderWorkerCode; - }); - const dynamic = await worker - .getEntrypoint(undefined, { limits: { cpuMs: 50, subRequests: 5 } }) - .fetch(request); - const headers = new Headers(dynamic.headers); - // Untrusted site code cannot set parent-domain cookies or weaken framing. - headers.delete('set-cookie'); - headers.set('x-content-type-options', 'nosniff'); - headers.set('content-security-policy', "frame-ancestors 'none'"); - headers.set('cache-control', 'private, no-store'); - return new Response(dynamic.body, { status: dynamic.status, headers }); - } catch { - return json({ error: 'Dynamic request failed' }, 502); - } +function limits(env: Env) { + const number = (value: string | undefined, fallback: number) => { + const parsed = Number(value); + return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : fallback; + }; + return { + maxSiteBytes: number(env.MAX_SITE_BYTES, 50 * 1024 * 1024), + maxFileBytes: number(env.MAX_FILE_BYTES, 10 * 1024 * 1024), + maxFiles: number(env.MAX_FILES, 500), + }; } + function sameOrigin(request: Request): Response | null { - const originHeader = request.headers.get('origin'); + const origin = request.headers.get('origin'); const fetchSite = request.headers.get('sec-fetch-site'); - return originHeader !== new URL(request.url).origin || (fetchSite && fetchSite !== 'same-origin') + return origin !== new URL(request.url).origin || (fetchSite && fetchSite !== 'same-origin') ? json({ error: 'Same-origin request required' }, 403) : null; } -async function api(request: Request, env: Env, identity: Identity): Promise { + +async function controlApi(request: Request, env: Env, identity: Identity): Promise { const url = new URL(request.url); if (request.method === 'GET' && url.pathname === '/api/me') return json(identity); if (request.method === 'GET' && url.pathname === '/api/sites') { const result = await reg<{ sites: SiteRecord[] }>(env, '/sites'); - return json({ - sites: result.sites.filter((site) => mayRead(site, identity)), - siteDomain: env.SITE_DOMAIN || null, - }); + return json({ sites: result.sites, siteDomain: env.SITE_DOMAIN || null }); } if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(request.method)) { const denied = sameOrigin(request); if (denied) return denied; } + const create = url.pathname.match(/^\/api\/sites\/([^/]+)\/deployments$/); if (request.method === 'POST' && create) { const siteName = cleanSiteName(create[1] || ''); if (!siteName) return json({ error: 'Invalid site name' }, 400); - const body = await request.json<{ manifest?: unknown; access?: unknown }>().catch(() => null); + const body = await request.json<{ manifest?: unknown }>().catch(() => null); if (!body) return json({ error: 'Invalid JSON body' }, 400); let manifest: AssetManifestEntry[]; try { @@ -163,225 +112,49 @@ async function api(request: Request, env: Env, identity: Identity): Promise(env, '/deployments', { method: 'POST', headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ id, siteName, owner: identity.email, manifest, access }), + body: JSON.stringify({ + id: crypto.randomUUID(), + siteName, + owner: identity.email, + manifest, + access: { visibility: 'company', readers: [] }, + }), }); return json( { ...result, siteUrl: env.SITE_DOMAIN ? `https://${siteName}.${env.SITE_DOMAIN}` : null }, 201, ); } - const accessRoute = url.pathname.match(/^\/api\/sites\/([^/]+)\/access$/); - if (request.method === 'PATCH' && accessRoute) { - const siteName = cleanSiteName(accessRoute[1] || ''); - if (!siteName) return json({ error: 'Invalid site name' }, 400); - const { site } = await reg<{ site: SiteRecord }>(env, `/sites/${siteName}`); - if (!mayWrite(site.owner, identity)) return json({ error: 'Not found' }, 404); - const body = await request.json<{ access?: unknown }>().catch(() => null); - if (!body) return json({ error: 'Invalid JSON body' }, 400); - let access: SiteAccess; - try { - access = normalizeSiteAccess(body.access); - } catch (error) { - return json({ error: error instanceof Error ? error.message : 'Invalid site access' }, 400); - } - const result = await reg<{ site: SiteRecord }>(env, `/sites/${siteName}/access`, { - method: 'PATCH', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ access }), - }); - return json(result); - } - const databaseRoute = url.pathname.match(/^\/api\/sites\/([^/]+)\/database$/); - if (request.method === 'PATCH' && databaseRoute) { - const siteName = cleanSiteName(databaseRoute[1] || ''); - if (!siteName) return json({ error: 'Invalid site name' }, 400); - const { site } = await reg<{ site: SiteRecord }>(env, `/sites/${siteName}`); - if (!mayWrite(site.owner, identity)) return json({ error: 'Not found' }, 404); - const body = await request.json<{ enabled?: unknown }>().catch(() => null); - if (!body || typeof body.enabled !== 'boolean') - return json({ error: 'Invalid database state' }, 400); - const result = await reg<{ site: SiteRecord }>(env, `/sites/${siteName}/database`, { - method: 'PATCH', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ enabled: body.enabled }), - }); - if (!body.enabled && env.SITE_DATABASE) { - const stub = env.SITE_DATABASE.get(env.SITE_DATABASE.idFromName(siteName)); - await stub.fetch('https://database.internal/', { method: 'DELETE' }); - } - return json(result); - } - const schedulesRoute = url.pathname.match(/^\/api\/sites\/([^/]+)\/schedules$/); - if (schedulesRoute && ['GET', 'POST'].includes(request.method)) { - const siteName = cleanSiteName(schedulesRoute[1] || ''); - if (!siteName) return json({ error: 'Invalid site name' }, 400); - const { site } = await reg<{ site: SiteRecord }>(env, `/sites/${siteName}`); - if (!mayWrite(site.owner, identity)) return json({ error: 'Not found' }, 404); - if (request.method === 'GET') - return json(await reg<{ schedules: ScheduleRecord[] }>(env, `/sites/${siteName}/schedules`)); - if (!site.activeDeploymentId) - return json({ error: 'Publish the site before scheduling it' }, 409); - if (!site.runtimeEnabled) - return json({ error: 'Schedules require an active _worker.js runtime' }, 409); - const body = await request.json().catch(() => null); - let normalized: ReturnType; - try { - normalized = normalizeSchedule(body); - } catch (error) { - return json({ error: error instanceof Error ? error.message : 'Invalid schedule' }, 400); - } - const now = new Date().toISOString(); - const schedule: ScheduleRecord = { - id: crypto.randomUUID(), - siteName, - ...normalized, - attempts: 0, - createdAt: now, - updatedAt: now, - createdBy: identity.email, - }; - return json( - await reg<{ schedule: ScheduleRecord }>(env, `/sites/${siteName}/schedules`, { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify(schedule), - }), - 201, - ); - } - const scheduleRoute = url.pathname.match(/^\/api\/sites\/([^/]+)\/schedules\/([^/]+)$/); - if (scheduleRoute && ['PATCH', 'DELETE'].includes(request.method)) { - const siteName = cleanSiteName(scheduleRoute[1] || ''); - if (!siteName) return json({ error: 'Invalid site name' }, 400); - const { site } = await reg<{ site: SiteRecord }>(env, `/sites/${siteName}`); - if (!mayWrite(site.owner, identity)) return json({ error: 'Not found' }, 404); - const list = await reg<{ schedules: ScheduleRecord[] }>(env, `/sites/${siteName}/schedules`); - const existing = list.schedules.find((item) => item.id === scheduleRoute[2]); - if (!existing) return json({ error: 'Not found' }, 404); - if (request.method === 'DELETE') - return json( - await reg(env, `/sites/${siteName}/schedules/${existing.id}`, { - method: 'DELETE', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ actor: identity.email }), - }), - ); - const body = await request.json>().catch(() => null); - if (!body) return json({ error: 'Invalid JSON body' }, 400); - let normalized: ReturnType; - try { - normalized = normalizeSchedule({ - path: body.path ?? existing.path, - cron: body.cron ?? existing.cron, - status: body.status ?? existing.status, - maxRunsPerDay: body.maxRunsPerDay ?? existing.maxRunsPerDay, - retryLimit: body.retryLimit ?? existing.retryLimit, - }); - } catch (error) { - return json({ error: error instanceof Error ? error.message : 'Invalid schedule' }, 400); - } - return json( - await reg<{ schedule: ScheduleRecord }>(env, `/sites/${siteName}/schedules/${existing.id}`, { - method: 'PATCH', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ - ...existing, - ...normalized, - updatedAt: new Date().toISOString(), - actor: identity.email, - }), - }), - ); - } - const auditRoute = url.pathname.match(/^\/api\/sites\/([^/]+)\/audit$/); - if (request.method === 'GET' && auditRoute) { - const siteName = cleanSiteName(auditRoute[1] || ''); - if (!siteName) return json({ error: 'Invalid site name' }, 400); - const { site } = await reg<{ site: SiteRecord }>(env, `/sites/${siteName}`); - if (!mayWrite(site.owner, identity)) return json({ error: 'Not found' }, 404); - return json(await reg(env, `/sites/${siteName}/audit`)); - } - const secretList = url.pathname.match(/^\/api\/sites\/([^/]+)\/secrets$/); - if (request.method === 'GET' && secretList) { - const siteName = cleanSiteName(secretList[1] || ''); - if (!siteName) return json({ error: 'Invalid site name' }, 400); - const { site } = await reg<{ site: SiteRecord }>(env, `/sites/${siteName}`); - if (!mayWrite(site.owner, identity)) return json({ error: 'Not found' }, 404); - if (!env.SITE_SECRETS || !env.SECRETS_KEY) - return json({ error: 'Secret storage is not configured' }, 503); - const stub = env.SITE_SECRETS.get(env.SITE_SECRETS.idFromName(siteName)); - const response = await stub.fetch('https://secrets.internal/secrets', { - headers: { 'x-up-manage': env.SECRETS_KEY }, - }); - return new Response(response.body, { status: response.status, headers: secure }); - } - const secretRoute = url.pathname.match(/^\/api\/sites\/([^/]+)\/secrets\/([^/]+)$/); - if (secretRoute && ['PUT', 'DELETE'].includes(request.method)) { - const siteName = cleanSiteName(secretRoute[1] || ''); - const name = cleanSecretName(secretRoute[2] || ''); - if (!siteName || !name) return json({ error: 'Invalid site or secret name' }, 400); - const { site } = await reg<{ site: SiteRecord }>(env, `/sites/${siteName}`); - if (!mayWrite(site.owner, identity)) return json({ error: 'Not found' }, 404); - if (!env.SITE_SECRETS || !env.SECRETS_KEY) - return json({ error: 'Secret storage is not configured' }, 503); - const stub = env.SITE_SECRETS.get(env.SITE_SECRETS.idFromName(siteName)); - if (request.method === 'DELETE') { - const response = await stub.fetch(`https://secrets.internal/secrets/${name}`, { - method: 'DELETE', - headers: { 'x-up-manage': env.SECRETS_KEY }, - }); - return new Response(response.body, { status: response.status, headers: secure }); - } - const body = await request - .json<{ value?: unknown; allowedHosts?: unknown }>() - .catch(() => null); - if (!body || typeof body.value !== 'string' || !body.value || body.value.length > 65_536) - return json({ error: 'Secret values must contain 1-65536 characters' }, 400); - let allowedHosts: string[]; - try { - allowedHosts = cleanAllowedHosts(body.allowedHosts); - } catch (error) { - return json({ error: error instanceof Error ? error.message : 'Invalid allowed hosts' }, 400); - } - const encrypted = await encryptSecret(body.value, env.SECRETS_KEY); - const response = await stub.fetch(`https://secrets.internal/secrets/${name}`, { - method: 'PUT', - headers: { 'content-type': 'application/json', 'x-up-manage': env.SECRETS_KEY }, - body: JSON.stringify({ ...encrypted, allowedHosts }), - }); - return new Response(response.body, { status: response.status, headers: secure }); - } - const asset = url.pathname.match(/^\/api\/deployments\/([^/]+)\/assets$/); - if (request.method === 'PUT' && asset) { - const path = cleanAssetPath(url.searchParams.get('path') || ''); - if (!path) return json({ error: 'Invalid asset path' }, 400); + + const upload = url.pathname.match(/^\/api\/deployments\/([^/]+)\/assets$/); + if (request.method === 'PUT' && upload) { const { deployment } = await reg<{ deployment: DeploymentRecord }>( env, - `/deployments/${asset[1]}`, + `/deployments/${upload[1]}`, ); if (!mayWrite(deployment.owner, identity)) return json({ error: 'Not found' }, 404); if (deployment.status !== 'pending') return json({ error: 'Deployment is not pending' }, 409); - const expected = deployment.manifest.find((x) => x.path === path); - if (!expected) return json({ error: 'Asset is not in the manifest' }, 400); + const path = cleanAssetPath(url.searchParams.get('path') || ''); + const expected = path ? deployment.manifest.find((entry) => entry.path === path) : undefined; + if (!path || !expected) return json({ error: 'Asset is not in the manifest' }, 400); const bytes = await request.arrayBuffer(); - if (bytes.byteLength !== expected.size || (await sha256(bytes)) !== expected.sha256) - return json({ error: 'Asset does not match its manifest' }, 400); - await env.ASSETS.put(key(deployment, path), bytes, { + if (bytes.byteLength !== expected.size) + return json({ error: 'Asset size does not match manifest' }, 400); + const digest = [...new Uint8Array(await crypto.subtle.digest('SHA-256', bytes))] + .map((byte) => byte.toString(16).padStart(2, '0')) + .join(''); + if (digest !== expected.sha256) + return json({ error: 'Asset digest does not match manifest' }, 400); + await env.ASSETS.put(assetKey(deployment, path), bytes, { httpMetadata: { contentType: expected.contentType }, customMetadata: { sha256: expected.sha256, owner: identity.email }, }); - return json({ ok: true, path }); + return json({ stored: true, path }); } + const activate = url.pathname.match(/^\/api\/deployments\/([^/]+)\/activate$/); if (request.method === 'POST' && activate) { const { deployment } = await reg<{ deployment: DeploymentRecord }>( @@ -393,9 +166,14 @@ async function api(request: Request, env: Env, identity: Identity): Promise env.ASSETS.head(key(deployment, x.path))), + deployment.manifest.map((entry) => env.ASSETS.head(assetKey(deployment, entry.path))), ); - if (heads.some((h, i) => !h || h.customMetadata?.sha256 !== deployment.manifest[i]?.sha256)) + if ( + heads.some( + (head, index) => + !head || head.customMetadata?.sha256 !== deployment.manifest[index]?.sha256, + ) + ) return json({ error: 'All manifest assets must be uploaded before activation' }, 409); const result = await reg<{ deployment: DeploymentRecord }>( env, @@ -407,50 +185,50 @@ async function api(request: Request, env: Env, identity: Identity): Promise { const url = new URL(request.url); const clean = cleanSiteName(name); - if (!clean) return new Response('Not found', { status: 404 }); + if (!clean) return new Response('Not found', { status: 404, headers: secure }); let site: SiteRecord; try { site = knownSite || (await reg<{ site: SiteRecord }>(env, `/sites/${clean}`)).site; } catch (error) { if (error instanceof RegistryError && error.status === 404) - return new Response('Not found', { status: 404 }); + return new Response('Not found', { status: 404, headers: secure }); throw error; } - if (!mayRead(site, identity)) return new Response('Not found', { status: 404 }); - if (url.pathname === '/__up/me') - return json({ email: identity?.email || null, visibility: site.access.visibility }); - if (!site.activeDeploymentId) return new Response('Not found', { status: 404 }); + if (!mayRead(site, identity)) return new Response('Not found', { status: 404, headers: secure }); + if (url.pathname === '/__up/me') return json({ email: identity.email, visibility: 'company' }); + if (url.pathname.startsWith('/_up/')) { + const capability = await handleCapabilityRequest(request, env, site, identity); + if (capability) return capability; + } + if (!site.activeDeploymentId) return new Response('Not found', { status: 404, headers: secure }); const { deployment } = await reg<{ deployment: DeploymentRecord }>( env, `/deployments/${site.activeDeploymentId}`, ); - const dynamic = await runDynamicSiteRequest(request, env, deployment, site); - if (dynamic) return dynamic; - const requested = cleanAssetPath(url.pathname === '/' ? 'index.html' : url.pathname); - if (!requested) return new Response('Not found', { status: 404 }); - let object = await env.ASSETS.get(key(deployment, requested)); - if (!object && !requested.includes('.')) - object = await env.ASSETS.get(key(deployment, `${requested}/index.html`)); + const path = cleanAssetPath(url.pathname === '/' ? 'index.html' : url.pathname); + if (!path) return new Response('Not found', { status: 404, headers: secure }); + let object = await env.ASSETS.get(assetKey(deployment, path)); + if (!object && !path.includes('.')) + object = await env.ASSETS.get(assetKey(deployment, `${path}/index.html`)); if (!object && request.headers.get('accept')?.includes('text/html')) - object = await env.ASSETS.get(key(deployment, 'index.html')); - if (!object) return new Response('Not found', { status: 404 }); + object = await env.ASSETS.get(assetKey(deployment, 'index.html')); + if (!object) return new Response('Not found', { status: 404, headers: secure }); const headers = new Headers(); object.writeHttpMetadata(headers); headers.set('etag', object.httpEtag); - // Site URLs are stable across atomic deployment swaps. Every asset must - // revalidate so a newly activated deployment cannot be masked by an old - // immutable browser entry. Protected content must never be marked public. headers.set('cache-control', 'private, no-cache'); headers.set('x-content-type-options', 'nosniff'); headers.set('referrer-policy', 'strict-origin-when-cross-origin'); @@ -458,15 +236,17 @@ async function serveSite( headers.set('content-security-policy', "frame-ancestors 'none'"); return new Response(object.body, { headers }); } + export function isCoreRequest(request: Request, env: Env): boolean { const url = new URL(request.url); const domain = env.SITE_DOMAIN?.toLowerCase(); const controlHost = env.CONTROL_HOST?.toLowerCase(); const host = url.hostname.toLowerCase(); - const siteHostname = Boolean( - domain && host !== domain && host !== controlHost && host.endsWith(`.${domain}`), + return ( + Boolean(domain && host !== domain && host !== controlHost && host.endsWith(`.${domain}`)) || + url.pathname.startsWith('/api/') || + url.pathname === '/app/__session' ); - return siteHostname || url.pathname.startsWith('/api/') || url.pathname === '/app/__session'; } export async function handleAuthenticatedRequest(request: Request, env: Env, identity: Identity) { @@ -475,149 +255,104 @@ export async function handleAuthenticatedRequest(request: Request, env: Env, ide const host = url.hostname.toLowerCase(); if (domain && host !== domain && host.endsWith(`.${domain}`)) return serveSite(request, env, identity, host.slice(0, -(domain.length + 1))); - if (url.pathname.startsWith('/api/')) return api(request, env, identity); + if (url.pathname.startsWith('/api/')) return controlApi(request, env, identity); return new Response('Not found', { status: 404, headers: secure }); } + const app = new Hono<{ Bindings: Env }>(); -// Site hostnames are untrusted content origins. Public is an explicit registry -// state; every other site still requires a verified company identity. -app.use('*', async (c, next) => { - const domain = c.env.SITE_DOMAIN?.toLowerCase(); - const host = new URL(c.req.url).hostname.toLowerCase(); - const controlHost = c.env.CONTROL_HOST?.toLowerCase(); + +app.use('*', async (context, next) => { + const domain = context.env.SITE_DOMAIN?.toLowerCase(); + const url = new URL(context.req.url); + const host = url.hostname.toLowerCase(); + const controlHost = context.env.CONTROL_HOST?.toLowerCase(); if (!domain || host === domain || host === controlHost || !host.endsWith(`.${domain}`)) return next(); const name = cleanSiteName(host.slice(0, -(domain.length + 1))); if (!name) return new Response('Not found', { status: 404, headers: secure }); let site: SiteRecord; try { - ({ site } = await reg<{ site: SiteRecord }>(c.env, `/sites/${name}`)); + site = (await reg<{ site: SiteRecord }>(context.env, `/sites/${name}`)).site; } catch (error) { if (error instanceof RegistryError && error.status === 404) return new Response('Not found', { status: 404, headers: secure }); throw error; } - if (site.access.visibility === 'public') - return serveSite(c.req.raw, c.env, undefined, name, site); - const error = configurationError(c.env); - if (error) return json({ error }, 503); + const configError = configurationError(context.env); + if (configError) return json({ error: configError }, 503); let identity: Identity | null = null; try { - identity = await verifyAccessIdentity(c.req.raw, c.env); + identity = await verifyAccessIdentity(context.req.raw, context.env); } catch { - if (c.env.SESSION_SECRET) - identity = await verifySession(cookieValue(c.req.raw, 'up_session'), c.env.SESSION_SECRET); + if (context.env.SESSION_SECRET) + identity = await verifySession( + cookieValue(context.req.raw, 'up_session'), + context.env.SESSION_SECRET, + ); } if (!identity) { - if (!c.env.SESSION_SECRET || !c.env.CONTROL_HOST) + if (!context.env.SESSION_SECRET || !context.env.CONTROL_HOST) return json({ error: 'Private site sessions are not configured' }, 503); - const broker = new URL('/app/__session', `https://${c.env.CONTROL_HOST}`); - broker.searchParams.set('return', c.req.url); + const broker = new URL('/app/__session', `https://${context.env.CONTROL_HOST}`); + broker.searchParams.set('return', context.req.url); return new Response(null, { status: 302, headers: { ...secure, location: broker.toString() } }); } - return serveSite(c.req.raw, c.env, identity, name, site); + return serveSite(context.req.raw, context.env, identity, name, site); }); -app.get('/app/__session', async (c) => { - const error = configurationError(c.env); - if (error) return json({ error }, 503); - if (!c.env.SESSION_SECRET || !c.env.SITE_DOMAIN) + +app.get('/app/__session', async (context) => { + const configError = configurationError(context.env); + if (configError) return json({ error: configError }, 503); + if (!context.env.SESSION_SECRET || !context.env.SITE_DOMAIN) return json({ error: 'Private site sessions are not configured' }, 503); let identity: Identity; try { - identity = await verifyAccessIdentity(c.req.raw, c.env); + identity = await verifyAccessIdentity(context.req.raw, context.env); } catch { return json({ error: 'Authentication required' }, 403); } - const target = validReturnUrl(c.req.query('return') || null, c.env.SITE_DOMAIN); + const target = validReturnUrl(context.req.query('return') || null, context.env.SITE_DOMAIN); if (!target) return json({ error: 'Invalid return URL' }, 400); - const value = await createSession(identity, c.env.SESSION_SECRET); + const value = await createSession(identity, context.env.SESSION_SECRET); return new Response(null, { status: 302, headers: { ...secure, location: target.toString(), - 'set-cookie': sessionCookie(value, c.env.SITE_DOMAIN), + 'set-cookie': sessionCookie(value, context.env.SITE_DOMAIN), }, }); }); -app.get('/api/health', (c) => - c.json({ edge: 'ok', accessConfigured: configurationError(c.env) === null }), + +app.get('/api/health', (context) => + context.json({ edge: 'ok', accessConfigured: configurationError(context.env) === null }), ); -app.all('/api/*', async (c) => { - const error = configurationError(c.env); - if (error) return json({ error }, 503); + +app.all('/api/*', async (context) => { + const configError = configurationError(context.env); + if (configError) return json({ error: configError }, 503); let identity: Identity; try { - identity = await verifyAccessIdentity(c.req.raw, c.env); + identity = await verifyAccessIdentity(context.req.raw, context.env); } catch { return json({ error: 'Authentication required' }, 403); } try { - return await handleAuthenticatedRequest(c.req.raw, c.env, identity); + return await handleAuthenticatedRequest(context.req.raw, context.env, identity); } catch (error) { if (error instanceof RegistryError) return json({ error: error.message }, error.status); return json({ error: 'Request failed' }, 500); } }); + app.notFound(() => new Response('Not found', { status: 404, headers: secure })); -export async function runDueSchedules(env: Env, now = new Date()): Promise { - const leased = await reg<{ schedules: ScheduleRecord[] }>(env, '/schedules/lease', { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ now: now.toISOString(), limit: 50 }), - }); - let completed = 0; - for (const schedule of leased.schedules) { - let status = 503; - try { - const { site } = await reg<{ site: SiteRecord }>(env, `/sites/${schedule.siteName}`); - if (site.activeDeploymentId && schedule.status === 'enabled') { - const { deployment } = await reg<{ deployment: DeploymentRecord }>( - env, - `/deployments/${site.activeDeploymentId}`, - ); - const request = new Request( - `https://${schedule.siteName}.${env.SITE_DOMAIN || 'invalid'}${schedule.path}`, - { - method: 'POST', - headers: { - 'content-type': 'application/json', - 'x-up-schedule': schedule.id, - }, - body: JSON.stringify({ - scheduleId: schedule.id, - scheduledAt: now.toISOString(), - attempt: schedule.attempts, - }), - }, - ); - const response = await runDynamicSiteRequest(request, env, deployment, site); - status = response?.status || 503; - } - } catch { - status = 502; - } - await reg(env, `/schedules/${schedule.id}/result`, { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ - now: now.toISOString(), - success: status >= 200 && status < 300, - status, - }), - }); - completed++; - } - return completed; +export async function runDueSchedules(_env?: Env, _now?: Date): Promise { + return 0; } -const worker = { - fetch(request: Request, env: Env, ctx: ExecutionContext) { - return app.fetch(request, env, ctx); - }, - scheduled(controller: ScheduledController, env: Env, ctx: ExecutionContext) { - ctx.waitUntil(runDueSchedules(env, new Date(controller.scheduledTime))); +export default { + fetch(request: Request, env: Env, context: ExecutionContext) { + return app.fetch(request, env, context); }, }; -export default worker; diff --git a/src/core.ts b/src/core.ts index 286cc08..81af85e 100644 --- a/src/core.ts +++ b/src/core.ts @@ -196,48 +196,12 @@ export async function sha256(bytes: ArrayBuffer): Promise { const digest = await crypto.subtle.digest('SHA-256', bytes); return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, '0')).join(''); } -export function normalizeSiteAccess(input: unknown): SiteAccess { - if (input === undefined || input === null) return { visibility: 'company', readers: [] }; - if (!input || typeof input !== 'object') throw new Error('Invalid site access'); - const value = input as Record; - if (!['company', 'restricted', 'public'].includes(String(value.visibility))) - throw new Error('Visibility must be company, restricted, or public'); - if (!Array.isArray(value.readers) || value.readers.length > 100) - throw new Error('Readers must contain at most 100 rules'); - const seen = new Set(); - const readers = value.readers.map((item): ReaderRule => { - if (!item || typeof item !== 'object') throw new Error('Invalid reader rule'); - const rule = item as Record; - const type = String(rule.type) as ReaderRule['type']; - const raw = typeof rule.value === 'string' ? rule.value.trim().toLowerCase() : ''; - if (!['email', 'domain', 'group'].includes(type) || !raw || raw.length > 254) - throw new Error('Invalid reader rule'); - if (type === 'email' && !/^[^@\s]+@[^@\s]+$/.test(raw)) throw new Error('Invalid reader email'); - const value = type === 'domain' ? raw.replace(/^@/, '') : raw; - const key = `${type}:${value}`; - if (seen.has(key)) throw new Error('Reader rules must be unique'); - seen.add(key); - return { type, value }; - }); - const visibility = value.visibility as SiteVisibility; - if (visibility === 'restricted' && readers.length === 0) - throw new Error('Restricted sites require at least one reader'); - return { visibility, readers: visibility === 'restricted' ? readers : [] }; -} -export function mayRead(site: SiteRecord, identity?: Identity): boolean { - if (site.access.visibility === 'public') return true; - if (!identity) return false; - if (identity.role === 'admin' || site.owner === identity.email) return true; - if (site.access.visibility === 'company') return true; - const email = identity.email.toLowerCase(); - const domain = email.split('@')[1] || ''; - const groups = new Set((identity.groups || []).map((group) => group.toLowerCase())); - return site.access.readers.some((rule) => { - if (rule.type === 'email') return rule.value === email; - if (rule.type === 'domain') return rule.value === domain; - return groups.has(rule.value); - }); +export function normalizeSiteAccess(_input: unknown): SiteAccess { + return { visibility: 'company', readers: [] }; +} +export function mayRead(_site: SiteRecord, identity?: Identity): boolean { + return Boolean(identity); } -export function mayWrite(owner: string, identity: Identity): boolean { - return identity.role === 'admin' || owner === identity.email; +export function mayWrite(_owner: string, identity: Identity): boolean { + return Boolean(identity.email); } diff --git a/src/index.ts b/src/index.ts index a7999a0..3029333 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,4 +4,4 @@ export * from './core-backend'; export { default } from './core-backend'; export { UpRegistry } from './registry'; export { SiteDatabase } from './site-database'; -export { SiteSecrets } from './site-secrets'; +export { SiteRealtime } from './site-realtime'; diff --git a/src/kit-worker.ts b/src/kit-worker.ts index fb97c68..b346b4a 100644 --- a/src/kit-worker.ts +++ b/src/kit-worker.ts @@ -4,7 +4,7 @@ import coreWorker, { type Env, isCoreRequest, runDueSchedules } from './core-bac export { UpRegistry } from './registry'; export { SiteDatabase } from './site-database'; -export { SiteSecrets } from './site-secrets'; +export { SiteRealtime } from './site-realtime'; export default { fetch(request: Request, env: Env, context: ExecutionContext) { diff --git a/src/registry.ts b/src/registry.ts index bd93fbd..a6b73cf 100644 --- a/src/registry.ts +++ b/src/registry.ts @@ -1,58 +1,25 @@ import { DurableObject } from 'cloudflare:workers'; -import { - type AssetManifestEntry, - type DeploymentRecord, - nextScheduleTime, - normalizeSiteAccess, - type ScheduleRecord, - type SiteAccess, - type SiteRecord, -} from './core'; +import type { AssetManifestEntry, DeploymentRecord, SiteRecord } from './core'; interface RegistryEnv extends Cloudflare.Env {} +type Row = Record; const json = (value: unknown, status = 200) => Response.json(value, { status }); -function site(row: Record): SiteRecord { - const active = - typeof row.active_deployment_id === 'string' ? row.active_deployment_id : undefined; - let access: SiteAccess = { visibility: 'company', readers: [] }; - try { - access = normalizeSiteAccess({ - visibility: String(row.visibility || 'company'), - readers: JSON.parse(String(row.readers_json || '[]')), - }); - } catch { - // Existing rows created before the access schema are company-private. - } + +function site(row: Row): SiteRecord { + const active = row.active_deployment_id ? String(row.active_deployment_id) : undefined; return { name: String(row.name), owner: String(row.owner), createdAt: String(row.created_at), updatedAt: String(row.updated_at), ...(active ? { activeDeploymentId: active } : {}), - access, - databaseEnabled: Number(row.database_enabled || 0) === 1, - runtimeEnabled: Number(row.runtime_enabled || 0) === 1, - }; -} -function schedule(row: Record): ScheduleRecord { - return { - id: String(row.id), - siteName: String(row.site_name), - path: String(row.path), - cron: String(row.cron), - status: String(row.status) as ScheduleRecord['status'], - maxRunsPerDay: Number(row.max_runs_per_day), - retryLimit: Number(row.retry_limit), - attempts: Number(row.attempts || 0), - nextRunAt: String(row.next_run_at), - ...(row.last_run_at ? { lastRunAt: String(row.last_run_at) } : {}), - ...(row.last_status ? { lastStatus: String(row.last_status) as 'success' | 'failed' } : {}), - createdAt: String(row.created_at), - updatedAt: String(row.updated_at), - createdBy: String(row.created_by), + access: { visibility: 'company', readers: [] }, + databaseEnabled: true, + runtimeEnabled: false, }; } -function deployment(row: Record): DeploymentRecord { + +function deployment(row: Row): DeploymentRecord { return { id: String(row.id), siteName: String(row.site_name), @@ -62,172 +29,59 @@ function deployment(row: Record): DeploymentRecord { manifest: JSON.parse(String(row.manifest_json)) as AssetManifestEntry[], }; } + export class UpRegistry extends DurableObject { constructor(state: DurableObjectState, env: RegistryEnv) { super(state, env); this.ctx.storage.sql.exec( - `CREATE TABLE IF NOT EXISTS sites(name TEXT PRIMARY KEY,owner TEXT NOT NULL,created_at TEXT NOT NULL,updated_at TEXT NOT NULL,active_deployment_id TEXT,visibility TEXT NOT NULL DEFAULT 'company',readers_json TEXT NOT NULL DEFAULT '[]',database_enabled INTEGER NOT NULL DEFAULT 0,runtime_enabled INTEGER NOT NULL DEFAULT 0);CREATE TABLE IF NOT EXISTS deployments(id TEXT PRIMARY KEY,site_name TEXT NOT NULL,owner TEXT NOT NULL,status TEXT NOT NULL CHECK(status IN ('pending','active','superseded')),created_at TEXT NOT NULL,manifest_json TEXT NOT NULL);CREATE INDEX IF NOT EXISTS deployments_site_created ON deployments(site_name,created_at DESC);CREATE TABLE IF NOT EXISTS schedules(id TEXT PRIMARY KEY,site_name TEXT NOT NULL,path TEXT NOT NULL,cron TEXT NOT NULL,status TEXT NOT NULL,max_runs_per_day INTEGER NOT NULL,retry_limit INTEGER NOT NULL,attempts INTEGER NOT NULL DEFAULT 0,next_run_at TEXT NOT NULL,lease_until TEXT,last_run_at TEXT,last_status TEXT,created_at TEXT NOT NULL,updated_at TEXT NOT NULL,created_by TEXT NOT NULL);CREATE INDEX IF NOT EXISTS schedules_site ON schedules(site_name,created_at DESC);CREATE TABLE IF NOT EXISTS schedule_usage(schedule_id TEXT NOT NULL,usage_date TEXT NOT NULL,run_count INTEGER NOT NULL DEFAULT 0,PRIMARY KEY(schedule_id,usage_date));CREATE TABLE IF NOT EXISTS audit_log(id TEXT PRIMARY KEY,site_name TEXT NOT NULL,actor TEXT NOT NULL,action TEXT NOT NULL,target_id TEXT,occurred_at TEXT NOT NULL,details_json TEXT NOT NULL DEFAULT '{}');CREATE INDEX IF NOT EXISTS audit_site_time ON audit_log(site_name,occurred_at DESC);`, - ); - const columns = new Set( - [...this.ctx.storage.sql.exec('PRAGMA table_info(sites)')].map((row) => String(row.name)), + `CREATE TABLE IF NOT EXISTS sites(name TEXT PRIMARY KEY,owner TEXT NOT NULL,created_at TEXT NOT NULL,updated_at TEXT NOT NULL,active_deployment_id TEXT,visibility TEXT NOT NULL DEFAULT 'company',readers_json TEXT NOT NULL DEFAULT '[]',database_enabled INTEGER NOT NULL DEFAULT 1,runtime_enabled INTEGER NOT NULL DEFAULT 0); + CREATE TABLE IF NOT EXISTS deployments(id TEXT PRIMARY KEY,site_name TEXT NOT NULL,owner TEXT NOT NULL,status TEXT NOT NULL CHECK(status IN ('pending','active','superseded')),created_at TEXT NOT NULL,manifest_json TEXT NOT NULL); + CREATE INDEX IF NOT EXISTS deployments_site_created ON deployments(site_name,created_at DESC);`, ); - if (!columns.has('visibility')) - this.ctx.storage.sql.exec( - "ALTER TABLE sites ADD COLUMN visibility TEXT NOT NULL DEFAULT 'company'", - ); - if (!columns.has('readers_json')) - this.ctx.storage.sql.exec( - "ALTER TABLE sites ADD COLUMN readers_json TEXT NOT NULL DEFAULT '[]'", - ); - if (!columns.has('database_enabled')) - this.ctx.storage.sql.exec( - 'ALTER TABLE sites ADD COLUMN database_enabled INTEGER NOT NULL DEFAULT 0', - ); - if (!columns.has('runtime_enabled')) - this.ctx.storage.sql.exec( - 'ALTER TABLE sites ADD COLUMN runtime_enabled INTEGER NOT NULL DEFAULT 0', - ); } + async fetch(request: Request): Promise { const url = new URL(request.url); - if (request.method === 'POST' && url.pathname === '/schedules/lease') { - const input = await request.json<{ now: string; limit?: number }>(); - const now = new Date(input.now); - if (Number.isNaN(now.valueOf())) return json({ error: 'Invalid lease time' }, 400); - const limit = Math.min(Math.max(Number(input.limit) || 25, 1), 100); - const date = now.toISOString().slice(0, 10); - const due = [ - ...this.ctx.storage.sql.exec( - "SELECT * FROM schedules WHERE status='enabled' AND next_run_at<=? AND (lease_until IS NULL OR lease_until<=?) ORDER BY next_run_at LIMIT ?", - now.toISOString(), - now.toISOString(), - limit, - ), - ]; - const leased: ScheduleRecord[] = []; - for (const row of due) { - const item = schedule(row); - const usage = [ - ...this.ctx.storage.sql.exec( - 'SELECT run_count FROM schedule_usage WHERE schedule_id=? AND usage_date=?', - item.id, - date, - ), - ][0]; - if (Number(usage?.run_count || 0) >= item.maxRunsPerDay) { - this.ctx.storage.sql.exec( - 'UPDATE schedules SET next_run_at=?,lease_until=NULL,attempts=0,updated_at=? WHERE id=?', - nextScheduleTime(item.cron, now).toISOString(), - now.toISOString(), - item.id, - ); - this.ctx.storage.sql.exec( - 'INSERT INTO audit_log(id,site_name,actor,action,target_id,occurred_at,details_json) VALUES (?,?,?,?,?,?,?)', - crypto.randomUUID(), - item.siteName, - 'scheduler', - 'schedule.quota_skipped', - item.id, - now.toISOString(), - JSON.stringify({ maxRunsPerDay: item.maxRunsPerDay }), - ); - continue; - } - this.ctx.storage.sql.exec( - 'INSERT INTO schedule_usage(schedule_id,usage_date,run_count) VALUES (?,?,1) ON CONFLICT(schedule_id,usage_date) DO UPDATE SET run_count=run_count+1', - item.id, - date, - ); - const attempts = item.attempts + 1; - const leaseUntil = new Date(now.getTime() + 5 * 60_000).toISOString(); - this.ctx.storage.sql.exec( - 'UPDATE schedules SET attempts=?,lease_until=?,updated_at=? WHERE id=?', - attempts, - leaseUntil, - now.toISOString(), - item.id, - ); - leased.push({ ...item, attempts, updatedAt: now.toISOString() }); - } - return json({ schedules: leased }); - } - const resultPath = url.pathname.match(/^\/schedules\/([^/]+)\/result$/); - if (request.method === 'POST' && resultPath) { - const input = await request.json<{ now: string; success: boolean; status?: number }>(); - const now = new Date(input.now); - const row = [ - ...this.ctx.storage.sql.exec('SELECT * FROM schedules WHERE id=?', resultPath[1]), - ][0]; - if (!row || Number.isNaN(now.valueOf())) return json({ error: 'Not found' }, 404); - const item = schedule(row); - let attempts = 0; - let nextRunAt: Date; - if (input.success || item.attempts > item.retryLimit) { - nextRunAt = nextScheduleTime(item.cron, now); - } else { - attempts = item.attempts; - const delay = Math.min(2 ** Math.max(attempts - 1, 0) * 60_000, 60 * 60_000); - nextRunAt = new Date(now.getTime() + delay); - } - this.ctx.storage.sql.exec( - 'UPDATE schedules SET attempts=?,next_run_at=?,lease_until=NULL,last_run_at=?,last_status=?,updated_at=? WHERE id=?', - attempts, - nextRunAt.toISOString(), - now.toISOString(), - input.success ? 'success' : 'failed', - now.toISOString(), - item.id, - ); - this.ctx.storage.sql.exec( - 'INSERT INTO audit_log(id,site_name,actor,action,target_id,occurred_at,details_json) VALUES (?,?,?,?,?,?,?)', - crypto.randomUUID(), - item.siteName, - 'scheduler', - input.success ? 'schedule.run_succeeded' : 'schedule.run_failed', - item.id, - now.toISOString(), - JSON.stringify({ attempt: item.attempts, status: input.status || null }), - ); - const updated = [ - ...this.ctx.storage.sql.exec('SELECT * FROM schedules WHERE id=?', item.id), - ][0]; - return json({ schedule: schedule(updated as Record) }); - } if (request.method === 'GET' && url.pathname === '/sites') return json({ sites: [...this.ctx.storage.sql.exec('SELECT * FROM sites ORDER BY updated_at DESC')].map( site, ), }); - const sp = url.pathname.match(/^\/sites\/([^/]+)$/); - if (request.method === 'GET' && sp) { - const row = [...this.ctx.storage.sql.exec('SELECT * FROM sites WHERE name = ?', sp[1])][0]; + + const sitePath = url.pathname.match(/^\/sites\/([^/]+)$/); + if (request.method === 'GET' && sitePath) { + const row = [ + ...this.ctx.storage.sql.exec('SELECT * FROM sites WHERE name=?', sitePath[1]), + ][0]; return row ? json({ site: site(row) }) : json({ error: 'Not found' }, 404); } + if (request.method === 'POST' && url.pathname === '/deployments') { const input = await request.json<{ id: string; siteName: string; owner: string; manifest: AssetManifestEntry[]; - access?: SiteAccess; }>(); const now = new Date().toISOString(); const current = [ - ...this.ctx.storage.sql.exec('SELECT * FROM sites WHERE name = ?', input.siteName), + ...this.ctx.storage.sql.exec('SELECT * FROM sites WHERE name=?', input.siteName), ][0]; - if (current && String(current.owner) !== input.owner) - return json({ error: 'Not found' }, 404); if (!current) { - const access = normalizeSiteAccess(input.access); this.ctx.storage.sql.exec( - 'INSERT INTO sites(name,owner,created_at,updated_at,visibility,readers_json) VALUES (?,?,?,?,?,?)', + `INSERT INTO sites(name,owner,created_at,updated_at,visibility,readers_json,database_enabled,runtime_enabled) + VALUES (?,?,?,?, 'company','[]',1,0)`, input.siteName, input.owner, now, now, - access.visibility, - JSON.stringify(access.readers), + ); + } else { + this.ctx.storage.sql.exec( + 'UPDATE sites SET updated_at=? WHERE name=?', + now, + input.siteName, ); } this.ctx.storage.sql.exec( @@ -253,189 +107,24 @@ export class UpRegistry extends DurableObject { 201, ); } - const accessPath = url.pathname.match(/^\/sites\/([^/]+)\/access$/); - if (request.method === 'PATCH' && accessPath) { - const input = await request.json<{ access?: unknown }>(); - let access: SiteAccess; - try { - access = normalizeSiteAccess(input.access); - } catch (error) { - return json({ error: error instanceof Error ? error.message : 'Invalid site access' }, 400); - } - const now = new Date().toISOString(); - const result = this.ctx.storage.sql.exec( - 'UPDATE sites SET visibility=?,readers_json=?,updated_at=? WHERE name=?', - access.visibility, - JSON.stringify(access.readers), - now, - accessPath[1], - ); - return result.rowsWritten - ? json({ - site: site( - [ - ...this.ctx.storage.sql.exec('SELECT * FROM sites WHERE name=?', accessPath[1]), - ][0] as Record, - ), - }) - : json({ error: 'Not found' }, 404); - } - const schedulesPath = url.pathname.match(/^\/sites\/([^/]+)\/schedules$/); - if (request.method === 'GET' && schedulesPath) - return json({ - schedules: [ - ...this.ctx.storage.sql.exec( - 'SELECT * FROM schedules WHERE site_name=? ORDER BY created_at DESC', - schedulesPath[1], - ), - ].map(schedule), - }); - if (request.method === 'POST' && schedulesPath) { - const input = await request.json(); - const exists = [ - ...this.ctx.storage.sql.exec('SELECT name FROM sites WHERE name=?', schedulesPath[1]), - ][0]; - if (!exists) return json({ error: 'Not found' }, 404); - this.ctx.storage.sql.exec( - 'INSERT INTO schedules(id,site_name,path,cron,status,max_runs_per_day,retry_limit,attempts,next_run_at,created_at,updated_at,created_by) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)', - input.id, - schedulesPath[1], - input.path, - input.cron, - input.status, - input.maxRunsPerDay, - input.retryLimit, - input.attempts, - input.nextRunAt, - input.createdAt, - input.updatedAt, - input.createdBy, - ); - this.ctx.storage.sql.exec( - 'INSERT INTO audit_log(id,site_name,actor,action,target_id,occurred_at,details_json) VALUES (?,?,?,?,?,?,?)', - crypto.randomUUID(), - schedulesPath[1], - input.createdBy, - 'schedule.created', - input.id, - input.createdAt, - JSON.stringify({ path: input.path, cron: input.cron, status: input.status }), - ); - return json({ schedule: input }, 201); - } - const schedulePath = url.pathname.match(/^\/sites\/([^/]+)\/schedules\/([^/]+)$/); - if (request.method === 'PATCH' && schedulePath) { - const input = await request.json(); - const existing = [ - ...this.ctx.storage.sql.exec( - 'SELECT * FROM schedules WHERE site_name=? AND id=?', - schedulePath[1], - schedulePath[2], - ), - ][0]; - if (!existing) return json({ error: 'Not found' }, 404); - this.ctx.storage.sql.exec( - 'UPDATE schedules SET path=?,cron=?,status=?,max_runs_per_day=?,retry_limit=?,next_run_at=?,updated_at=? WHERE id=?', - input.path, - input.cron, - input.status, - input.maxRunsPerDay, - input.retryLimit, - input.nextRunAt, - input.updatedAt, - input.id, - ); - this.ctx.storage.sql.exec( - 'INSERT INTO audit_log(id,site_name,actor,action,target_id,occurred_at,details_json) VALUES (?,?,?,?,?,?,?)', - crypto.randomUUID(), - schedulePath[1], - input.actor, - 'schedule.updated', - input.id, - input.updatedAt, - JSON.stringify({ path: input.path, cron: input.cron, status: input.status }), - ); - const row = [...this.ctx.storage.sql.exec('SELECT * FROM schedules WHERE id=?', input.id)][0]; - return json({ schedule: schedule(row as Record) }); - } - if (request.method === 'DELETE' && schedulePath) { - const input = await request.json<{ actor: string }>(); - const existing = [ - ...this.ctx.storage.sql.exec( - 'SELECT * FROM schedules WHERE site_name=? AND id=?', - schedulePath[1], - schedulePath[2], - ), - ][0]; - if (!existing) return json({ error: 'Not found' }, 404); - this.ctx.storage.sql.exec('DELETE FROM schedules WHERE id=?', schedulePath[2]); - const now = new Date().toISOString(); - this.ctx.storage.sql.exec( - 'INSERT INTO audit_log(id,site_name,actor,action,target_id,occurred_at,details_json) VALUES (?,?,?,?,?,?,?)', - crypto.randomUUID(), - schedulePath[1], - input.actor, - 'schedule.deleted', - schedulePath[2], - now, - '{}', - ); - return json({ ok: true }); - } - const auditPath = url.pathname.match(/^\/sites\/([^/]+)\/audit$/); - if (request.method === 'GET' && auditPath) - return json({ - audit: [ - ...this.ctx.storage.sql.exec( - 'SELECT * FROM audit_log WHERE site_name=? ORDER BY occurred_at DESC LIMIT 200', - auditPath[1], - ), - ].map((row) => ({ - id: String(row.id), - actor: String(row.actor), - action: String(row.action), - targetId: row.target_id ? String(row.target_id) : undefined, - occurredAt: String(row.occurred_at), - details: JSON.parse(String(row.details_json)), - })), - }); - const databasePath = url.pathname.match(/^\/sites\/([^/]+)\/database$/); - if (request.method === 'PATCH' && databasePath) { - const input = await request.json<{ enabled?: unknown }>(); - if (typeof input.enabled !== 'boolean') return json({ error: 'Invalid database state' }, 400); - const now = new Date().toISOString(); - const result = this.ctx.storage.sql.exec( - 'UPDATE sites SET database_enabled=?,updated_at=? WHERE name=?', - input.enabled ? 1 : 0, - now, - databasePath[1], - ); - return result.rowsWritten - ? json({ - site: site( - [ - ...this.ctx.storage.sql.exec('SELECT * FROM sites WHERE name=?', databasePath[1]), - ][0] as Record, - ), - }) - : json({ error: 'Not found' }, 404); - } - const dp = url.pathname.match(/^\/deployments\/([^/]+)$/); - if (request.method === 'GET' && dp) { + + const deploymentPath = url.pathname.match(/^\/deployments\/([^/]+)$/); + if (request.method === 'GET' && deploymentPath) { const row = [ - ...this.ctx.storage.sql.exec('SELECT * FROM deployments WHERE id = ?', dp[1]), + ...this.ctx.storage.sql.exec('SELECT * FROM deployments WHERE id=?', deploymentPath[1]), ][0]; return row ? json({ deployment: deployment(row) }) : json({ error: 'Not found' }, 404); } - const ap = url.pathname.match(/^\/deployments\/([^/]+)\/activate$/); - if (request.method === 'POST' && ap) { + + const activate = url.pathname.match(/^\/deployments\/([^/]+)\/activate$/); + if (request.method === 'POST' && activate) { const row = [ - ...this.ctx.storage.sql.exec('SELECT * FROM deployments WHERE id = ?', ap[1]), + ...this.ctx.storage.sql.exec('SELECT * FROM deployments WHERE id=?', activate[1]), ][0]; if (!row) return json({ error: 'Not found' }, 404); const item = deployment(row); if (item.status === 'active') return json({ deployment: item }); - if (item.status !== 'pending') return json({ error: 'Deployment is not pending' }, 409); + if (item.status !== 'pending') return json({ error: 'Deployment cannot be activated' }, 409); const now = new Date().toISOString(); this.ctx.storage.sql.exec( "UPDATE deployments SET status='superseded' WHERE site_name=? AND status='active'", @@ -443,14 +132,14 @@ export class UpRegistry extends DurableObject { ); this.ctx.storage.sql.exec("UPDATE deployments SET status='active' WHERE id=?", item.id); this.ctx.storage.sql.exec( - 'UPDATE sites SET active_deployment_id=?,runtime_enabled=?,updated_at=? WHERE name=?', + "UPDATE sites SET active_deployment_id=?,updated_at=?,visibility='company',readers_json='[]',database_enabled=1,runtime_enabled=0 WHERE name=?", item.id, - item.manifest.some((asset) => asset.path === '_worker.js') ? 1 : 0, now, item.siteName, ); return json({ deployment: { ...item, status: 'active' } }); } + return json({ error: 'Not found' }, 404); } } diff --git a/src/secrets.ts b/src/secrets.ts deleted file mode 100644 index 02c14ca..0000000 --- a/src/secrets.ts +++ /dev/null @@ -1,59 +0,0 @@ -const encoder = new TextEncoder(); -const decoder = new TextDecoder(); -const base64Url = (bytes: Uint8Array) => - btoa(String.fromCharCode(...bytes)) - .replaceAll('+', '-') - .replaceAll('/', '_') - .replaceAll('=', ''); -function decodeBase64Url(value: string): Uint8Array { - const normalized = value.replaceAll('-', '+').replaceAll('_', '/'); - const raw = atob(normalized.padEnd(Math.ceil(normalized.length / 4) * 4, '=')); - const bytes = new Uint8Array(new ArrayBuffer(raw.length)); - for (let index = 0; index < raw.length; index++) bytes[index] = raw.charCodeAt(index); - return bytes; -} -async function key(value: string): Promise { - const bytes = decodeBase64Url(value); - if (bytes.byteLength !== 32) throw new Error('SECRETS_KEY must be 32 bytes of base64url'); - return crypto.subtle.importKey('raw', bytes, 'AES-GCM', false, ['encrypt', 'decrypt']); -} -export interface EncryptedSecret { - ciphertext: string; - iv: string; -} -export async function encryptSecret(value: string, secretKey: string): Promise { - const iv = crypto.getRandomValues(new Uint8Array(12)); - const ciphertext = await crypto.subtle.encrypt( - { name: 'AES-GCM', iv }, - await key(secretKey), - encoder.encode(value), - ); - return { ciphertext: base64Url(new Uint8Array(ciphertext)), iv: base64Url(iv) }; -} -export async function decryptSecret( - encrypted: EncryptedSecret, - secretKey: string, -): Promise { - const plaintext = await crypto.subtle.decrypt( - { name: 'AES-GCM', iv: decodeBase64Url(encrypted.iv) }, - await key(secretKey), - decodeBase64Url(encrypted.ciphertext), - ); - return decoder.decode(plaintext); -} -export function cleanSecretName(value: string): string | null { - const name = value.trim().toUpperCase(); - return /^[A-Z][A-Z0-9_]{0,63}$/.test(name) ? name : null; -} -export function cleanAllowedHosts(input: unknown): string[] { - if (!Array.isArray(input) || input.length === 0 || input.length > 20) - throw new Error('Secrets require 1-20 allowed hosts'); - const hosts = input.map((value) => { - if (typeof value !== 'string') throw new Error('Invalid allowed host'); - const host = value.trim().toLowerCase(); - if (!/^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/.test(host)) - throw new Error('Invalid allowed host'); - return host; - }); - return [...new Set(hosts)]; -} diff --git a/src/site-database.ts b/src/site-database.ts index cc4d2d1..3ad503c 100644 --- a/src/site-database.ts +++ b/src/site-database.ts @@ -4,6 +4,10 @@ interface DatabaseEnv extends Cloudflare.Env {} const json = (value: unknown, status = 200) => Response.json(value, { status }); const forbidden = /\b(ATTACH|DETACH|PRAGMA|VACUUM)\b/i; +const collectionPattern = /^[a-z][a-z0-9_-]{0,47}$/; +const idPattern = /^[a-zA-Z0-9_-]{1,64}$/; +const MAX_DOCUMENT_BYTES = 64 * 1024; + export class SiteDatabase extends DurableObject { async fetch(request: Request): Promise { const url = new URL(request.url); @@ -12,6 +16,8 @@ export class SiteDatabase extends DurableObject { await this.ctx.storage.deleteAll(); return json({ ok: true }); } + const collection = url.pathname.match(/^\/collections\/([^/]+)(?:\/([^/]+))?$/); + if (collection) return this.collectionRequest(request, url, collection[1] || '', collection[2]); if (request.method !== 'POST' || url.pathname !== '/query') return json({ error: 'Not found' }, 404); const input = await request.json<{ sql?: unknown; params?: unknown }>().catch(() => null); @@ -44,4 +50,68 @@ export class SiteDatabase extends DurableObject { return json({ error: 'Database query failed' }, 400); } } + + private async collectionRequest( + request: Request, + url: URL, + collection: string, + rawId?: string, + ): Promise { + if (!collectionPattern.test(collection)) return json({ error: 'Invalid collection' }, 400); + const id = rawId ? decodeURIComponent(rawId) : undefined; + if (id && !idPattern.test(id)) return json({ error: 'Invalid document id' }, 400); + this.ctx.storage.sql.exec( + 'CREATE TABLE IF NOT EXISTS up_documents(collection TEXT NOT NULL,id TEXT NOT NULL,data TEXT NOT NULL,created_at TEXT NOT NULL,updated_at TEXT NOT NULL,PRIMARY KEY(collection,id));CREATE INDEX IF NOT EXISTS up_documents_collection_updated ON up_documents(collection,updated_at DESC);', + ); + if (request.method === 'GET' && id) { + const row = [ + ...this.ctx.storage.sql.exec<{ data: string }>( + 'SELECT data FROM up_documents WHERE collection=? AND id=?', + collection, + id, + ), + ][0]; + return row + ? json({ id, ...JSON.parse(row.data) }) + : json({ error: 'Document not found' }, 404); + } + if (request.method === 'GET') { + const limit = Math.min(100, Math.max(1, Number(url.searchParams.get('limit') || 100))); + const offset = Math.min(10_000, Math.max(0, Number(url.searchParams.get('offset') || 0))); + const rows = [ + ...this.ctx.storage.sql.exec<{ id: string; data: string }>( + 'SELECT id,data FROM up_documents WHERE collection=? ORDER BY updated_at DESC LIMIT ? OFFSET ?', + collection, + limit, + offset, + ), + ].map((row) => ({ id: row.id, ...JSON.parse(row.data) })); + return json({ documents: rows, limit, offset }); + } + if (request.method === 'DELETE' && id) { + this.ctx.storage.sql.exec( + 'DELETE FROM up_documents WHERE collection=? AND id=?', + collection, + id, + ); + return json({ deleted: true, id }); + } + if (!['POST', 'PUT'].includes(request.method) || (request.method === 'PUT' && !id)) + return json({ error: 'Method not allowed' }, 405); + const data = await request.json>().catch(() => null); + if (!data || Array.isArray(data)) return json({ error: 'Document must be an object' }, 400); + const encoded = JSON.stringify(data); + if (encoded.length > MAX_DOCUMENT_BYTES) return json({ error: 'Document exceeds 64 KiB' }, 413); + const documentId = id || crypto.randomUUID(); + const now = new Date().toISOString(); + this.ctx.storage.sql.exec( + 'INSERT INTO up_documents(collection,id,data,created_at,updated_at) VALUES (?,?,?,?,?) ON CONFLICT(collection,id) DO UPDATE SET data=excluded.data,updated_at=excluded.updated_at', + collection, + documentId, + encoded, + now, + now, + ); + return json({ id: documentId, ...data }, request.method === 'POST' ? 201 : 200); + } } diff --git a/src/site-realtime.ts b/src/site-realtime.ts new file mode 100644 index 0000000..a99be86 --- /dev/null +++ b/src/site-realtime.ts @@ -0,0 +1,70 @@ +import { DurableObject } from 'cloudflare:workers'; + +interface RealtimeEnv extends Cloudflare.Env {} + +export class SiteRealtime extends DurableObject { + async fetch(request: Request): Promise { + if (new URL(request.url).pathname !== '/connect') + return new Response('Not found', { status: 404 }); + if (request.headers.get('upgrade')?.toLowerCase() !== 'websocket') + return Response.json({ error: 'WebSocket upgrade required' }, { status: 426 }); + const email = request.headers.get('x-up-email'); + const site = request.headers.get('x-up-site'); + const channel = request.headers.get('x-up-channel'); + if (!email || !site || !channel) + return Response.json({ error: 'Identity required' }, { status: 403 }); + + const pair = new WebSocketPair(); + const client = pair[0]; + const server = pair[1]; + this.ctx.acceptWebSocket(server, [email]); + server.serializeAttachment({ email, site, channel }); + this.broadcast({ type: 'presence', event: 'join', email }, server); + return new Response(null, { status: 101, webSocket: client }); + } + + async webSocketMessage(socket: WebSocket, raw: string | ArrayBuffer): Promise { + const attachment = socket.deserializeAttachment() as + | { email: string; site: string; channel: string } + | undefined; + if (!attachment) return socket.close(1008, 'Missing identity'); + const text = typeof raw === 'string' ? raw : new TextDecoder().decode(raw); + if (text.length > 16_384) return socket.close(1009, 'Message too large'); + let input: unknown; + try { + input = JSON.parse(text); + } catch { + return socket.send(JSON.stringify({ type: 'error', error: 'Invalid JSON' })); + } + if (!input || typeof input !== 'object') return; + const value = input as Record; + if (typeof value.type !== 'string' || !/^[a-z][a-z0-9_-]{0,47}$/.test(value.type)) + return socket.send(JSON.stringify({ type: 'error', error: 'Invalid event type' })); + const encoded = JSON.stringify({ + type: value.type, + data: value.data ?? null, + sender: attachment.email, + }); + if (encoded.length > 16_384) return socket.close(1009, 'Message too large'); + this.broadcast(JSON.parse(encoded)); + } + + async webSocketClose(socket: WebSocket): Promise { + const attachment = socket.deserializeAttachment() as { email?: string } | undefined; + if (attachment?.email) + this.broadcast({ type: 'presence', event: 'leave', email: attachment.email }, socket); + } + + private broadcast(value: unknown, except?: WebSocket): void { + const encoded = JSON.stringify(value); + for (const socket of this.ctx.getWebSockets()) { + if (socket !== except) { + try { + socket.send(encoded); + } catch { + // The runtime will deliver webSocketClose and remove dead sockets. + } + } + } + } +} diff --git a/src/site-secrets.ts b/src/site-secrets.ts deleted file mode 100644 index 44954c5..0000000 --- a/src/site-secrets.ts +++ /dev/null @@ -1,128 +0,0 @@ -import { DurableObject } from 'cloudflare:workers'; -import { cleanSecretName, decryptSecret, type EncryptedSecret } from './secrets'; - -interface SecretsEnv extends Cloudflare.Env { - SECRETS_KEY?: string; -} -interface StoredSecret extends EncryptedSecret { - allowedHosts: string[]; - updatedAt: string; -} -const json = (value: unknown, status = 200) => Response.json(value, { status }); - -export class SiteSecrets extends DurableObject { - #managed(request: Request): boolean { - return Boolean( - this.env.SECRETS_KEY && request.headers.get('x-up-manage') === this.env.SECRETS_KEY, - ); - } - async fetch(request: Request): Promise { - const url = new URL(request.url); - if (!this.env.SECRETS_KEY) return json({ error: 'Secret storage is not configured' }, 503); - if (request.method === 'GET' && url.pathname === '/secrets') { - if (!this.#managed(request)) return json({ error: 'Not found' }, 404); - const values = await this.ctx.storage.list({ prefix: 'secret:' }); - return json({ - secrets: [...values].map(([key, value]) => ({ - name: key.slice('secret:'.length), - allowedHosts: value.allowedHosts, - updatedAt: value.updatedAt, - })), - }); - } - const secretPath = url.pathname.match(/^\/secrets\/([^/]+)$/); - if (secretPath) { - if (!this.#managed(request)) return json({ error: 'Not found' }, 404); - const name = cleanSecretName(secretPath[1] || ''); - if (!name) return json({ error: 'Invalid secret name' }, 400); - if (request.method === 'DELETE') { - await this.ctx.storage.delete(`secret:${name}`); - return json({ ok: true }); - } - if (request.method === 'PUT') { - const input = await request.json().catch(() => null); - if ( - !input || - typeof input.ciphertext !== 'string' || - typeof input.iv !== 'string' || - !Array.isArray(input.allowedHosts) - ) - return json({ error: 'Invalid encrypted secret' }, 400); - await this.ctx.storage.put(`secret:${name}`, { - ciphertext: input.ciphertext, - iv: input.iv, - allowedHosts: input.allowedHosts, - updatedAt: new Date().toISOString(), - } satisfies StoredSecret); - return json({ ok: true, name }, 201); - } - } - const usePath = url.pathname.match(/^\/use\/([^/]+)$/); - if (request.method === 'POST' && usePath) { - const name = cleanSecretName(usePath[1] || ''); - if (!name) return json({ error: 'Not found' }, 404); - const stored = await this.ctx.storage.get(`secret:${name}`); - if (!stored) return json({ error: 'Not found' }, 404); - const input = await request - .json<{ - url?: unknown; - method?: unknown; - headers?: unknown; - body?: unknown; - }>() - .catch(() => null); - if (!input || typeof input.url !== 'string') return json({ error: 'Invalid request' }, 400); - let target: URL; - try { - target = new URL(input.url); - } catch { - return json({ error: 'Invalid request URL' }, 400); - } - if ( - target.protocol !== 'https:' || - !stored.allowedHosts.includes(target.hostname.toLowerCase()) - ) - return json({ error: 'Host is not allowed' }, 403); - const method = typeof input.method === 'string' ? input.method.toUpperCase() : 'GET'; - if (!['GET', 'POST', 'PUT', 'PATCH', 'DELETE'].includes(method)) - return json({ error: 'Method is not allowed' }, 400); - const headers = new Headers(); - if (input.headers && typeof input.headers === 'object') { - for (const [key, value] of Object.entries(input.headers as Record)) { - const lower = key.toLowerCase(); - if ( - typeof value === 'string' && - value.length <= 1000 && - !['authorization', 'cookie', 'host', 'cf-access-token'].includes(lower) - ) - headers.set(key, value); - } - } - const secret = await decryptSecret(stored, this.env.SECRETS_KEY); - headers.set('authorization', `Bearer ${secret}`); - const body = - typeof input.body === 'string' && input.body.length <= 1024 * 1024 ? input.body : undefined; - try { - const response = await fetch(target, { - method, - headers, - ...(body !== undefined ? { body } : {}), - redirect: 'manual', - }); - const type = response.headers.get('content-type') || 'text/plain'; - if (!/^(text\/|application\/(?:json|[^;]+\+json))/.test(type)) - return json({ error: 'Secret-backed requests require a text response' }, 502); - const text = await response.text(); - if (text.length > 1024 * 1024) - return json({ error: 'Secret-backed response is too large' }, 502); - return new Response(text.replaceAll(secret, '[REDACTED]'), { - status: response.status, - headers: { 'content-type': type, 'cache-control': 'private, no-store' }, - }); - } catch { - return json({ error: 'Secret-backed request failed' }, 502); - } - } - return json({ error: 'Not found' }, 404); - } -} diff --git a/src/site.svelte b/src/site.svelte index 7fd6860..1b0e93d 100644 --- a/src/site.svelte +++ b/src/site.svelte @@ -3,28 +3,11 @@ import UpLogo from './lib/UpLogo.svelte'; import UpMark from './lib/UpMark.svelte'; - type Visibility = 'company' | 'restricted' | 'public'; - type ReaderRule = { type: 'email' | 'domain' | 'group'; value: string }; type Site = { name: string; owner: string; updatedAt?: string; activeDeploymentId?: string; - access?: { visibility: Visibility; readers: ReaderRule[] }; - databaseEnabled?: boolean; - runtimeEnabled?: boolean; - }; - - type SecretSummary = { name: string; allowedHosts: string[]; updatedAt: string }; - type ScheduleSummary = { - id: string; - path: string; - cron: string; - status: 'enabled' | 'paused' | 'disabled'; - maxRunsPerDay: number; - retryLimit: number; - nextRunAt: string; - lastStatus?: 'success' | 'failed'; }; type PreparedFile = { @@ -71,24 +54,9 @@ let siteDomain = $state(untrack(() => initialSiteDomain)); let dragging = $state(false); let copied = $state(false); - let visibility = $state('company'); - let readersText = $state(''); - let publicConfirmed = $state(false); - let databaseRequested = $state(false); - let view = $state<'empty' | 'selected' | 'publishing' | 'success' | 'list' | 'manage'>( + let view = $state<'empty' | 'selected' | 'publishing' | 'success' | 'list'>( untrack(() => initialSites.length) ? 'list' : 'empty', ); - let managedSite = $state(null); - let managedSecrets = $state([]); - let managedSchedules = $state([]); - let managedVisibility = $state('company'); - let managedReaders = $state(''); - let secretName = $state(''); - let secretValue = $state(''); - let secretHosts = $state(''); - let schedulePath = $state('/api/jobs/run'); - let scheduleCron = $state('0 * * * *'); - let manageStatus = $state(''); let input = $state(); let siteNameInput = $state(); @@ -131,149 +99,6 @@ } } - async function openManagement(site: Site) { - managedSite = site; - managedVisibility = site.access?.visibility || 'company'; - managedReaders = (site.access?.readers || []) - .map((rule) => (rule.type === 'group' ? `group:${rule.value}` : rule.type === 'domain' ? `@${rule.value}` : rule.value)) - .join(', '); - manageStatus = ''; - view = 'manage'; - await loadManagement(); - } - - async function loadManagement() { - if (!managedSite) return; - const name = encodeURIComponent(managedSite.name); - const [secretsResponse, schedulesResponse, sitesResponse] = await Promise.all([ - fetch(`/api/sites/${name}/secrets`), - fetch(`/api/sites/${name}/schedules`), - fetch('/api/sites'), - ]); - if (secretsResponse.ok) - managedSecrets = (await responseJson<{ secrets: SecretSummary[] }>(secretsResponse)).secrets; - if (schedulesResponse.ok) - managedSchedules = (await responseJson<{ schedules: ScheduleSummary[] }>(schedulesResponse)).schedules; - if (sitesResponse.ok) { - const data = await responseJson(sitesResponse); - sites = data.sites; - managedSite = sites.find((site) => site.name === managedSite?.name) || managedSite; - } - } - - async function saveManagedAccess() { - if (!managedSite) return; - const readers = parseReaders(managedReaders); - if (managedVisibility === 'restricted' && !readers.length) { - manageStatus = 'Restricted sites require at least one reader.'; - return; - } - const response = await fetch(`/api/sites/${encodeURIComponent(managedSite.name)}/access`, { - method: 'PATCH', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ - access: { - visibility: managedVisibility, - readers: managedVisibility === 'restricted' ? readers : [], - }, - }), - }); - const data = await responseJson(response); - manageStatus = response.ok ? 'Visibility saved.' : data.error || 'Unable to save visibility.'; - if (response.ok) await loadManagement(); - } - - async function toggleManagedDatabase() { - if (!managedSite) return; - const enabled = !managedSite.databaseEnabled; - const response = await fetch(`/api/sites/${encodeURIComponent(managedSite.name)}/database`, { - method: 'PATCH', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ enabled }), - }); - const data = await responseJson(response); - manageStatus = response.ok - ? enabled - ? 'Database enabled.' - : 'Database deleted.' - : data.error || 'Unable to update database.'; - if (response.ok) await loadManagement(); - } - - async function saveSecret() { - if (!managedSite || !secretName || !secretValue || !secretHosts) return; - const response = await fetch( - `/api/sites/${encodeURIComponent(managedSite.name)}/secrets/${encodeURIComponent(secretName)}`, - { - method: 'PUT', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ - value: secretValue, - allowedHosts: secretHosts.split(/[\n,]/).map((host) => host.trim()).filter(Boolean), - }), - }, - ); - const data = await responseJson(response); - manageStatus = response.ok ? 'Secret capability saved.' : data.error || 'Unable to save secret.'; - if (response.ok) { - secretName = ''; - secretValue = ''; - secretHosts = ''; - await loadManagement(); - } - } - - async function deleteSecret(name: string) { - if (!managedSite) return; - const response = await fetch( - `/api/sites/${encodeURIComponent(managedSite.name)}/secrets/${encodeURIComponent(name)}`, - { method: 'DELETE' }, - ); - manageStatus = response.ok ? 'Secret capability deleted.' : 'Unable to delete secret.'; - if (response.ok) await loadManagement(); - } - - async function addSchedule() { - if (!managedSite) return; - const response = await fetch(`/api/sites/${encodeURIComponent(managedSite.name)}/schedules`, { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ - path: schedulePath, - cron: scheduleCron, - maxRunsPerDay: 24, - retryLimit: 3, - }), - }); - const data = await responseJson(response); - manageStatus = response.ok ? 'Schedule created.' : data.error || 'Unable to create schedule.'; - if (response.ok) await loadManagement(); - } - - async function updateSchedule(item: ScheduleSummary, status: ScheduleSummary['status']) { - if (!managedSite) return; - const response = await fetch( - `/api/sites/${encodeURIComponent(managedSite.name)}/schedules/${item.id}`, - { - method: 'PATCH', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ status }), - }, - ); - manageStatus = response.ok ? `Schedule ${status}.` : 'Unable to update schedule.'; - if (response.ok) await loadManagement(); - } - - async function deleteSchedule(id: string) { - if (!managedSite) return; - const response = await fetch( - `/api/sites/${encodeURIComponent(managedSite.name)}/schedules/${id}`, - { method: 'DELETE' }, - ); - manageStatus = response.ok ? 'Schedule deleted.' : 'Unable to delete schedule.'; - if (response.ok) await loadManagement(); - } - function chooseFolder() { input?.click(); } @@ -340,43 +165,12 @@ publishing = false; publishedUrl = ''; copied = false; - visibility = 'company'; - readersText = ''; - publicConfirmed = false; - databaseRequested = false; if (input) input.value = ''; view = isProduct && sites.length ? 'list' : 'empty'; } const totalBytes = $derived(files.reduce((sum, file) => sum + file.size, 0)); const hasIndex = $derived(prepared.some((asset) => asset.path === 'index.html')); - const hasWorker = $derived(prepared.some((asset) => asset.path === '_worker.js')); - const readers = $derived(parseReaders(readersText)); - const accessReady = $derived( - visibility === 'company' || - (visibility === 'restricted' && readers.length > 0) || - (visibility === 'public' && publicConfirmed), - ); - - function parseReaders(value: string): ReaderRule[] { - const seen = new Set(); - const rules: ReaderRule[] = []; - for (const raw of value.split(/[\n,]/)) { - const token = raw.trim().toLowerCase(); - if (!token) continue; - const rule: ReaderRule = token.startsWith('group:') - ? { type: 'group', value: token.slice(6).trim() } - : token.includes('@') && !token.startsWith('@') - ? { type: 'email', value: token } - : { type: 'domain', value: token.replace(/^@/, '') }; - const key = `${rule.type}:${rule.value}`; - if (rule.value && !seen.has(key)) { - seen.add(key); - rules.push(rule); - } - } - return rules; - } function formatBytes(bytes: number): string { if (bytes < 1024) return `${bytes} B`; @@ -385,8 +179,7 @@ } async function publish() { - if (!isProduct || !siteName || !prepared.length || !hasIndex || !accessReady || publishing) - return; + if (!isProduct || !siteName || !prepared.length || !hasIndex || publishing) return; publishing = true; view = 'publishing'; progress = 5; @@ -403,7 +196,7 @@ headers: { 'content-type': 'application/json' }, body: JSON.stringify({ manifest, - access: { visibility, readers: visibility === 'restricted' ? readers : [] }, + access: { visibility: 'company', readers: [] }, }), }); const creation = await responseJson(created); @@ -434,15 +227,6 @@ const result = await responseJson(activated); if (!activated.ok) throw new Error(result.error || 'Activation failed'); publishedUrl = result.siteUrl || ''; - if (databaseRequested && hasWorker) { - const database = await fetch(`/api/sites/${encodeURIComponent(siteName)}/database`, { - method: 'PATCH', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ enabled: true }), - }); - if (!database.ok) - throw new Error((await responseJson(database)).error || 'Database setup failed'); - } progress = 100; status = 'Published'; view = 'success'; @@ -522,12 +306,12 @@
    -
    01

    Choose a folder

    HTML, CSS, JavaScript, and assets. No framework or build system required.

    -
    02

    Choose visibility

    Company by default; restrict to readers and groups, or explicitly publish to everyone.

    -
    03

    Run backend code

    A root _worker.js handles /api/* in a network-isolated Dynamic Worker.

    -
    04

    Bind secrets

    Encrypted write-only capabilities inject credentials only for allowlisted HTTPS hosts.

    -
    05

    Store data

    Enable an isolated SQLite Durable Object that no other site can address.

    -
    06

    Schedule jobs

    Bounded UTC jobs include quotas, retries, pause/disable behavior, and audit receipts.

    +
    01

    Publish a folder

    HTML, CSS, JavaScript, and assets. No framework or build system required.

    +
    02

    Know the viewer

    up.identity returns the employee identity already verified by Access.

    +
    03

    Store records

    up.db provides site-scoped document collections backed by SQLite.

    +
    04

    Share files

    up.files stores bounded site files in private R2 without credentials.

    +
    05

    Call AI

    up.ai uses the installation’s fixed Workers AI policy.

    +
    06

    Update live

    up.realtime connects authenticated browsers through site-scoped rooms.

    07

    Publish atomically

    Every digest is verified before a deployment becomes visible. Partial updates never leak.

    08

    Stay in control

    The Worker, Durable Objects, R2, DNS, and Access app remain in your account.

    @@ -599,53 +383,11 @@ {#each sites as site}
    -
    {site.owner}{site.access?.visibility || 'company'} · {site.activeDeploymentId ? 'Published' : 'Pending'}
    +
    {site.owner}Company · {site.activeDeploymentId ? 'Published' : 'Pending'}
    {/each} - {:else if view === 'manage' && managedSite} -
    - -

    Site settings

    -

    {managedSite.name}

    - {managedSite.name}.{siteDomain} ↗ - {#if manageStatus}

    {manageStatus}

    {/if} - -
    -
    01

    Visibility

    Company is the default. Public is always explicit.

    -
    - - - -
    - {#if managedVisibility === 'restricted'}{/if} - -
    - -
    -
    02

    Server runtime

    {managedSite.runtimeEnabled ? '_worker.js active' : 'Static only'}

    - {#if managedSite.runtimeEnabled} -
    SQLite database{managedSite.databaseEnabled ? 'Enabled · disabling permanently deletes its data.' : 'Disabled'}
    - {:else}

    Publish a root _worker.js to unlock backend capabilities.

    {/if} -
    - -
    -
    03

    Secret capabilities

    Write-only bearer credentials with exact host allowlists.

    - {#if managedSite.runtimeEnabled} -
    -
    {#each managedSecrets as secret}
    {secret.name}{secret.allowedHosts.join(', ')}
    {:else}

    No secret capabilities.

    {/each}
    - {:else}

    Secrets require an active server runtime.

    {/if} -
    - -
    -
    04

    Schedules

    UTC · 24 runs/day · 3 retries by default.

    - {#if managedSite.runtimeEnabled} -
    -
    {#each managedSchedules as schedule}
    {schedule.path}{schedule.cron} · {schedule.status} · next {new Date(schedule.nextRunAt).toLocaleString()}
    {#if schedule.status === 'enabled'}{:else}{/if}
    {:else}

    No schedules.

    {/each}
    - {:else}

    Schedules require an active server runtime.

    {/if} -
    -
    {:else if view === 'selected'}
    @@ -661,29 +403,11 @@
    .{siteDomain}
    Lowercase letters, numbers, and hyphens. This becomes your private company URL. -
    - Visibility and server capabilities -
    - Who can open this site? - - - -
    - {#if visibility === 'restricted'} - - {:else if visibility === 'public'} - - {/if} -
    -
    Server runtime{hasWorker ? '_worker.js detected — /api/* will run in an isolated Dynamic Worker.' : 'Add _worker.js to enable an isolated backend.'}
    - {#if hasWorker}{/if} -
    -
    -

    {visibility === 'public' ? 'Explicitly public' : visibility === 'restricted' ? `${readers.length} reader rule${readers.length === 1 ? '' : 's'}` : 'Private to your organization'}

    +

    Anyone authenticated by your organization can open this site.