diff --git a/.gitignore b/.gitignore index 6598192..5d966ac 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,7 @@ test-results/ artifacts/video/ .DS_Store wrangler.up.jsonc +wrangler.up-*.jsonc scratch/ .svelte-kit/ diff --git a/AGENTS.md b/AGENTS.md index ae8cb32..196c82e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,21 +1,38 @@ # Agent guide -Up is a security-first static publishing plane on Cloudflare. +Up is an independent anonymous-first publishing experiment on Cloudflare. ## Commands ```sh -bun run build -bun run typecheck -bun run test -bun run dry-run +bun run check +bun run test:e2e +bun cli/up.ts deploy [name] ``` -## Non-negotiables +## Default anonymous mode + +- `up deploy` intentionally creates a public `workers.dev` deployment in a Cloudflare Temporary Account. +- Treat `_worker.js` as executable server code and review the `up.json` binding graph before deploying. +- Confirm the folder contains no credentials, private data, or internal-only content. +- Expose only Worker, Static Assets, KV, D1, and Durable Objects until another Temporary Account binding has real smoke evidence. +- Require explicit human approval before an agent passes `--accept-cloudflare-terms`. +- Label the returned URL/API public and the account-wide claim URL sensitive. +- Never commit, screenshot, log, or forward the claim URL or temporary API token. +- Use pinned Wrangler's documented `--temporary` interface; do not reimplement the unpublished provisioning endpoint. +- Keep ordinary CI offline. A live anonymous smoke test is a deliberate remote mutation and must use isolated state. +- Remove inherited Cloudflare credentials and isolate Wrangler state so anonymous mode cannot touch a permanent account. +- Do not promise more than the documented 60-minute unclaimed lifetime. +- Do not position Up as an official or supported Cloudflare product. + +## Secondary company mode + +The retained `up private` path has a separate trust boundary: - Never bypass Cloudflare Access locally or in production. -- Never expose uploaded content on `workers.dev`, Preview URLs, or public R2. +- Never expose company-mode uploaded content on `workers.dev`, Preview URLs, or public R2. - Never execute uploaded code on the control-plane origin. -- Every binding read requires verified identity; every mutation also requires ownership and same-origin checks. +- Every binding read requires verified identity; every mutation also requires authorization and same-origin checks. - Keep deployment objects immutable and activation atomic. - Do not deploy placeholder Access configuration. +- Never deploy or modify the AX production Worker, routes, DNS, R2, or Access without explicit user approval. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c0b88b0..a2fbd1e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -5,9 +5,26 @@ Use Bun and Node 22 or newer. ```sh bun install bun run check -bun run dry-run +bun run test:e2e ``` -Keep the product at version `0.0.1`, preserve fail-closed Access behavior, and add runtime tests for authorization or storage changes. Do not add public preview URLs, public R2 access, local authentication bypasses, hidden bearer links, or uploaded-code bindings. +Keep the product at version `0.0.1` and preserve these boundaries. -Open focused pull requests. Explain the threat boundary when changing routes, hostnames, authentication, site serving, caching, or deployment state. +## Anonymous dynamic mode + +- Worker code, Static Assets, KV, D1, and Durable Objects are the current public contract. +- Add a live binding claim only after an isolated Temporary Account smoke proves it. +- Do not add unsupported bindings or silently proxy permanent-account resources. +- Keep claim URLs and temporary tokens out of logs, tests, fixtures, screenshots, and git. +- Keep normal CI offline; live smoke tests are deliberate rate-limited mutations. +- Preserve explicit Terms acceptance for non-interactive deployments. +- Preserve credential and filesystem isolation. + +## Secondary company mode + +- Preserve fail-closed Access behavior. +- Do not add public R2, `workers.dev`, preview URLs, or local authentication bypasses. +- Add runtime tests for authorization and storage changes. +- Never run company production scripts while reviewing anonymous-mode changes. + +Open focused pull requests. Explain the authority boundary when changing deployment, bindings, public routes, expiration, claim behavior, or credential handling. diff --git a/README.md b/README.md index b397eb4..89be6f0 100644 --- a/README.md +++ b/README.md @@ -1,142 +1,272 @@ # Up -[![Deploy to Cloudflare](https://deploy.workers.cloudflare.com/button)](https://deploy.workers.cloudflare.com/?url=https://github.com/acoyfellow/up) [![MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) -**Publish a folder to a company-private URL.** +**Deploy a dynamic Cloudflare app before signup. Keep it if it works.** -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. +Up gives coding agents a Worker, Static Assets, and platform bindings before anyone creates an account, opens OAuth, or copies an API token. ```text -folder + name → verified upload → atomic activation → company-private URL +app folder → Worker + assets + bindings → public URL → keep or disappear ``` -Dogfood: · Version: `0.0.1` +Up is an independent, user-land experiment. It is not an official Cloudflare product and is not supported by Cloudflare. -## Publish +## Deploy first -Browser: +A dynamic Up app is an ordinary folder: ```text -Open Up → choose folder → choose name → publish +my-app/ +├── index.html browser UI +├── app.js +├── _worker.js dynamic Worker entry point +└── up.json platform bindings ``` -CLI: - ```sh -bunx github:acoyfellow/up init -bunx github:acoyfellow/up deploy ./dist lunch-vote +bunx github:acoyfellow/up deploy ./my-app ``` ```text -Authenticated · 3 files ready -Uploading 3/3 style.css -Published +Deploying dynamic app with 2 assets without a Cloudflare account +Bindings: CACHE, DB, ROOMS… + +Live now + +https://up-a1b2c3d4e5.example-account.workers.dev + +Expires in about 60 minutes unless claimed. +Public: anyone with this URL can open it. + +Keep it: run `up claim --open` to open the ownership flow. +Up stores the sensitive link locally and does not print it. +``` + +No signup, login, permanent token, repository, or Up server is involved. Interactive use asks you to accept Cloudflare’s Terms and Privacy Policy. Agents and other non-interactive sessions must pass the explicit approval flag: + +```sh +up deploy ./my-app --accept-cloudflare-terms +``` + +To keep the account: + +```sh +up claim --open +``` + +If it is not claimed within about 60 minutes, Cloudflare deletes the Temporary Account, Worker, bindings, and data. -https://lunch-vote.up.example.com -Access: your organization +## After you keep it + +The browser flow gives you ownership of the Temporary Account. The Worker, bindings, and data stay there. Your source code stays in the local folder. + +Connect Wrangler with OAuth—no Up API key and no copied Cloudflare API key: + +```sh +bunx wrangler@4.103.0 login +bunx wrangler@4.103.0 whoami +``` + +If `whoami` lists more than one account, choose the account created by the ownership flow and copy its account ID. Then reconnect the exact Worker name shown in the original `workers.dev` URL: + +```sh +up handoff ./my-app exact-worker-name \ + --account-id 0123456789abcdef0123456789abcdef +``` + +Up first confirms that Worker already exists in that account. It then deploys without `--temporary`; Wrangler inherits the existing KV and D1 resources from the Worker's bindings instead of creating replacements. + +**Ownership is not authentication.** The app remains public after you keep it. Before adding sensitive data, add [Cloudflare Access](https://developers.cloudflare.com/cloudflare-one/access-controls/applications/http-apps/) or another login and verify an anonymous request is denied. + +Create a scoped Cloudflare API token only when you later add CI/CD. Local development should use `wrangler login`. + +### Copy this to your agent + +```text +Continue this existing Cloudflare Worker from ./my-app. + +1. Ask me to complete `wrangler login` in the browser. Do not ask for or copy an API key. +2. Run `wrangler whoami` and ask me which account was created by the ownership flow if several are listed. +3. Use the exact Worker name from its workers.dev URL. +4. Run `up handoff ./my-app --account-id `. +5. Do not create replacement KV or D1 resources. Preserve the existing bindings by name. +6. Test the deployed URL and every binding. +7. Remind me that the URL is public. Ask before adding Cloudflare Access or creating a scoped CI token. ``` -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. +See [After you keep an app](docs/how-to/after-claim.md) for the full checklist. -## Fixed browser API +## Bind the platform -Every site can import one same-origin module: +`up.json` declares the resources the app expects: + +```json +{ + "bindings": { + "kv": ["CACHE"], + "d1": ["DB"], + "durableObjects": [{ "binding": "ROOMS", "className": "Room" }] + } +} +``` + +Up turns that into a temporary Wrangler graph. Wrangler auto-provisions KV and D1, deploys the Durable Object class and migration, binds Static Assets as `env.ASSETS`, and uploads the Worker and browser files together. ```js -import { up } from '/_up/client.js'; +export class Room { + constructor(state) { + this.state = state; + } + + async fetch() { + const visits = ((await this.state.storage.get('visits')) || 0) + 1; + await this.state.storage.put('visits', visits); + return Response.json({ visits }); + } +} + +export default { + async fetch(request, env) { + const cached = await env.CACHE.get('key'); + const rows = await env.DB.prepare('SELECT * FROM notes').all(); + const room = env.ROOMS.get(env.ROOMS.idFromName('main')); + + if (new URL(request.url).pathname === '/api/state') + return Response.json({ cached, rows, room: await room.fetch(request).then((r) => r.json()) }); + + return env.ASSETS.fetch(request); + } +}; +``` -const viewer = await up.identity.current(); +Browser code gets a normal same-origin API. Worker code gets real Cloudflare bindings. Using the ownership flow keeps the entire Temporary Account and the supported resources created inside it. -const votes = up.db.collection('votes'); -await votes.create({ choice: 'Tacos', voter: viewer.email }); +## Binding spectrum -await up.files.put('menu.txt', new Blob(['Tacos · Pizza · Salad'])); +| Primitive | Anonymous Up 0.0.1 | Temporary Account contract | +|---|---:|---| +| Worker runtime | **Yes** | Dynamic request handling on `workers.dev` | +| Static Assets | **Yes** | 1,000 files; 5 MiB each | +| KV | **Yes** | Auto-provisioned from `up.json` | +| D1 | **Yes** | One database; 100 MB total | +| Durable Objects | **Yes** | Class binding plus SQLite migration | +| Queues | Supported upstream; Up wiring next | Temporary Accounts allow up to 10 | +| Hyperdrive | Supported upstream; Up wiring next | Two configs; requires an existing database | +| Certificates | Account operation | Supported by Temporary Accounts; configured outside `up.json` | +| R2, Workers AI, Access | **No** | Not in the current Temporary Account matrix | +| Workflows, Browser Rendering, Containers, Sandboxes | **No** | Not in the current matrix | -const result = await up.ai.chat([ - { role: 'user', content: 'Summarize today’s vote' } -]); +The boundary is deliberately upstream-shaped. Up does not fake unavailable bindings or proxy them through a permanent service account. -const room = up.realtime.channel('votes'); -room.on('vote', renderVote); -room.send('vote', { choice: 'Tacos' }); +## Third-party APIs through Capa + +[Capa](https://capa.coey.dev) turns OpenAPI specs into generated Cloudflare service-binding Workers. Capa `main` at [`382359f`](https://github.com/acoyfellow/capa/commit/382359f) contains 14 API capabilities and 5,998 generated operations—not 5,998 reviewed or equally safe actions—with `{ result, evidence }` returned from every call. + +An isolated spike proved the intended composition inside one Temporary Account: + +```text +Up app Worker + └── service binding → Capa capability Worker + └── provider credential secret ``` -Browser code receives no Cloudflare credentials or resource identifiers. +A generated read-only capability returned a real `200`/`pass` evidence receipt, survived caller redeployment, and leaked no credential. Capa `main` Stripe also reached the real upstream API with a deliberately fake token and returned a bounded `401`/`fail` receipt without exposing authorization data. -| API | Cloudflare mechanism | -|---|---| -| `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 | +**The runtime composition is proven; click-to-connect is not shipped yet.** Capa needs immutable per-capability install bundles before Up can safely offer a local connector UI. Existing permanent Capa Workers cannot be bound cross-account; each selected capability Worker must install into the same Temporary Account as the app. + +Read the [integration and local composer contract](docs/capa-integration.md) and [spike receipt](receipts/2026-06-23-capa-temporary-account-spike.md). -## Complete example +## Complete dynamic example -**[Lunch Vote ↗](https://lunch-vote.up.ax.cloudflare.dev)** is a live, employee-protected app using all five fixed APIs. Its [`three-file source`](examples/lunch-vote) is plain HTML, CSS, and JavaScript with no `_worker.js`, credentials, framework, or infrastructure configuration. Browse it from the documentation’s compact [examples page](https://up.ax.cloudflare.dev/examples). +[`examples/binding-lab`](examples/binding-lab) is a framework-free dynamic app using: -## Install for a company +- a Worker API; +- Static Assets; +- KV for an edge counter; +- D1 for notes; +- a Durable Object for one coordinated room. -Up uses Cloudflare OAuth. The current operator path is: +Deploy it anonymously: ```sh -export UP_OAUTH_CLIENT_ID= -bun run oauth:connect +up deploy examples/binding-lab binding-lab --accept-cloudflare-terms +``` + +A real isolated smoke test returned HTTP 200 from both its page and `/api/state`, with live values from all three bindings. + +## What Up actually does + +Up deliberately stays close to Cloudflare’s bleeding edge instead of recreating its deployment APIs: + +1. validates and snapshots the app into a private staging directory; +2. reads a narrow `up.json` binding manifest; +3. generates a temporary Wrangler graph for Worker, assets, KV, D1, and Durable Objects; +4. starts pinned Wrangler in an isolated Up-owned home; +5. removes every current and deprecated Cloudflare credential variable; +6. runs `wrangler deploy --temporary` with experimental resource provisioning; +7. takes the authoritative public URL from Wrangler output; +8. stores the sensitive ownership link locally without printing it. + +Wrangler owns proof-of-work, Terms acceptance, short-lived credentials, resource provisioning, upload, account reuse, and dashboard claiming. Up does not reimplement the unpublished provisioning protocol. -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 isolated state lives under `~/.up/anonymous` with private permissions. Repeated deploys during one active session reuse the Temporary Account, so **one sensitive ownership link controls every app and binding in that session**. + +## The temporary contract + +| Contract | Anonymous Up deployment | +|---|---| +| Runtime | Dynamic Worker plus same-origin Static Assets | +| Visibility | Public; URL possession is enough to read it | +| Lifetime | Up to/about 60 minutes unless claimed | +| Credentials | Existing Cloudflare credentials are removed from the child process | +| Name | Stable path fingerprint, or a strictly validated explicit name | +| Ownership | Claim URL grants the whole temporary account; treat it as a secret | +| Production | Not intended for production or CI/CD | + +Official primitive: [Claim deployments (Temporary Accounts)](https://developers.cloudflare.com/workers/platform/claim-deployments/). + +## Build with an agent + +```sh +bunx github:acoyfellow/up init ``` -The installer creates customer-owned resources: +This writes `.up/SKILL.md`. Ask an agent to read it, build the app, deploy it, fetch the real Worker URL, exercise its bindings, revise, and redeploy without stopping for browser authentication. -```text -Cloudflare Access - ↓ -Up Worker + SvelteKit assets - ├── private R2 - ├── Registry Durable Object - ├── Database Durable Object namespace - ├── Realtime Durable Object namespace - └── Workers AI +## Private company mode + +The original company-private experiment remains available as a secondary mode: + +```sh +up private ./dist team-tool --origin https://up.example.com ``` -The Worker, Access application, DNS, R2, and Durable Objects remain visible in the customer account. `workers.dev` and preview URLs stay disabled. +That path requires a customer-owned Up installation, Cloudflare Access, and browser-mediated CLI authentication. It is retained for comparison and dogfood; it is no longer the default positioning. -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). +## Why this pivot -## Trust boundary +The old product asked an organization to install infrastructure before anyone could publish. The new product moves the dynamic graph ahead of ownership: -- 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. +```text +old: install → authenticate → configure bindings → deploy +new: declare bindings → deploy → exercise the real stack → decide whether to own it +``` -Read [SECURITY.md](SECURITY.md) for the exact contracts. +The point is not anonymous static hosting. The point is letting an agent discover how far a real Cloudflare application can get before signup becomes necessary. ## Repository map ```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 +cli/up.ts staging, binding graph, anonymous deploy/claim +examples/binding-lab/ Worker + Assets + KV + D1 + Durable Object proof +tests/anonymous-cli.test.ts subprocess, binding config, isolation, claim proof +skills/up/SKILL.md agent dynamic-app contract +docs/anonymous-first-brief.md product and security boundary +docs/capa-integration.md proven composition and local composer contract +src/ retained company-mode runtime ``` -The source contract is captured in [`docs/0.0.1-source-brief.md`](docs/0.0.1-source-brief.md). - ## Verify ```sh @@ -145,14 +275,15 @@ 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. +Ordinary tests never create anonymous accounts. The CLI suite uses a real subprocess and fake Wrangler executable to verify staging, generated binding configuration, argument boundaries, credential removal, claim redaction, and public URL handling. Live Temporary Account tests are deliberate manual checks because they create rate-limited remote resources. ## Documentation -- [Tutorial](docs/tutorial/index.md) -- [How-to guides](docs/how-to/index.md) -- [Reference](docs/reference/index.md) -- [Explanation](docs/explanation/index.md) +- [Anonymous-first source brief](docs/anonymous-first-brief.md) +- [Security](SECURITY.md) +- [Capa integration contract](docs/capa-integration.md) +- [Historical company-private source brief](docs/0.0.1-source-brief.md) +- [Binding Lab](examples/binding-lab) ## License diff --git a/SECURITY.md b/SECURITY.md index f07e846..2d8b241 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -2,63 +2,99 @@ Report vulnerabilities privately through GitHub Security Advisories for `acoyfellow/up`. Do not file public issues for suspected exposure. -## Trust model +Up is an independent experiment, not an official Cloudflare product. -The product homepage and documentation are server-rendered by SvelteKit, are public, contain no customer data, and read no private bindings. The publisher's identity and site list are loaded in `+page.server.ts`, so the first authenticated HTML response already reflects authoritative server state rather than exposing an unauthenticated client shell. +## Anonymous mode -The publisher and control APIs require Cloudflare Access at: +The default command creates an intentionally public, disposable deployment: + +```sh +up deploy [name] +``` + +There is no Access policy and no viewer authentication. Anyone with the generated `workers.dev` URL can read the assets and call the Worker API until the account expires or the owner changes the deployment after claiming it. + +Dynamic code receives the Temporary Account bindings declared in `up.json`. It can mutate KV, D1, and Durable Object state and consume the bounded resources available to that account. Review `_worker.js` as executable server code, not as a static asset. + +Do not deploy: + +- credentials, API tokens, private keys, or `.env` files; +- customer or employee data; +- private source maps; +- internal documents; +- anything that should not be public for the next hour. + +Up rejects missing `index.html`, assets above 5 MiB, folders above 1,000 assets, symbolic links, special files, sensitive dotfiles, unknown manifest fields, and unsupported bindings. It snapshots files through no-follow handles before invoking Wrangler. It does not currently perform malware, phishing, copyright, semantic code, or comprehensive secret scanning. Cloudflare applies separate abuse controls. + +## Credential isolation + +Anonymous mode starts pinned Wrangler with an isolated home and config directory under: ```text -/app -/api +~/.up/anonymous ``` -Up independently verifies the Access JWT signature, issuer, audience, and email before control-plane reads or mutations. The protected session broker at `/app/__session` converts that identity into an eight-hour HMAC-signed, HttpOnly, Secure, SameSite=Lax sibling-domain session. +Up removes current and deprecated Cloudflare credential variables from the Wrangler child process, including: -Site visibility is registry authority: +- `CLOUDFLARE_API_TOKEN` / `CF_API_TOKEN` +- `CLOUDFLARE_ACCOUNT_ID` / `CF_ACCOUNT_ID` +- `CLOUDFLARE_API_KEY` / `CF_API_KEY` +- `CLOUDFLARE_EMAIL` / `CF_EMAIL` +- `CLOUDFLARE_API_USER_SERVICE_KEY` -- **company** — any valid company session; this is the default -- **restricted** — owner/admin or a matching email, domain, or trusted IdP group -- **public** — explicit anonymous serving +This is a safety boundary: `up deploy` must not silently deploy into a developer's authenticated account. The state directories are mode `0700`; Wrangler writes temporary account state as mode `0600`. -Restricted denials return `404`. Public mode is never inferred from a URL or missing identity. +Wrangler—not Up—performs proof of work, temporary-account provisioning, supported resource provisioning, upload, and claim handoff. Up wraps the documented `wrangler deploy --temporary` command instead of calling the unpublished provisioning API directly. -## Untrusted content and code +Interactive Wrangler prompts for Cloudflare's Terms and Privacy Policy. Non-interactive Up requires `--accept-cloudflare-terms` or `UP_ACCEPT_CLOUDFLARE_TERMS=yes`; an agent must receive human approval before using either. -Uploaded browser code and optional backend code are untrusted. Browser content runs on sibling site hostnames, never the control origin. State-changing control requests require the exact origin and same-site Fetch Metadata. +## Claim URL -A root `_worker.js` executes `/api/*` in a separate Dynamic Worker isolate: +The claim URL is an ownership capability for the **whole temporary account**, not just one Worker. -- no Up registry, R2 bucket, deployment authority, or encryption keys -- global outbound network blocked -- 50 ms CPU and 5 subrequests per invocation -- 1 MiB code limit -- no response cookies -- generic errors without source, stack, or logs +Anyone who receives it may be able to claim every deployment and supported resource created in the active anonymous Up session. Treat it like a password: -Optional capabilities are narrow: +- do not commit it; +- do not include it in screenshots, videos, CI logs, telemetry, or issue reports; +- do not send it to unrelated agents or people; +- do not put it in shell arguments or filenames; +- use it within about 60 minutes. -- **Database:** one site-specific SQLite Durable Object stub; the namespace is never provided. -- **Secrets:** AES-256-GCM ciphertext in a site-specific Durable Object. Dynamic code cannot read values; it can request an allowlisted HTTPS operation where trusted code injects the bearer credential. Returned text is bounded and direct secret occurrences are redacted. -- **Schedules:** trusted minute scheduler with atomic leases, UTC quotas, exponential retries, pause/disable states, and bounded audit receipts. +`up claim --open` reads the current local claim metadata and opens the Cloudflare dashboard flow. Up does not claim an account itself or confirm post-claim ownership. -## Production requirements +## Expiry and durability -- Create Access before attaching `/app` and `/api` routes. -- Do not gate the public documentation or rely on an Access Bypass policy. -- Keep new sites `company` unless the owner explicitly chooses otherwise. -- Mint site sessions only through the protected broker and use a cryptographically generated `SESSION_SECRET`. -- Generate `SECRETS_KEY` as 32 random bytes and store it only as a Worker secret and a mode-600 local recovery file. -- Keep R2 private and expose no direct object URL. -- Keep `workers_dev: false` and `preview_urls: false`. -- Verify public reachability only for explicitly public sites. -- Verify anonymous denial for company/restricted sites after every routing or Access change. -- Treat site names, deployment IDs, object keys, and URLs as public identifiers—not capabilities. +Unclaimed Temporary Accounts are documented to expire after about 60 minutes. Their Workers and supported resources are deleted. Do not use anonymous mode for production, CI/CD, backups, or durable data. -Before restoring the AX reference installation, export a fresh read-only Access application snapshot and run `bun run release:access:check` with `UP_ACCESS_APPLICATION_FILE`, `UP_EXPECTED_ACCESS_APP_ID`, `UP_EXPECTED_ACCESS_IDP`, `UP_EXPECTED_EMAIL_DOMAIN`, and `UP_CONTROL_HOST`. The guard fails if it finds an Everyone or Bypass policy, an unreviewed IdP, missing `/app` or `/api` destinations, or weakened cookie settings. `bun run deploy:up:safe` runs this guard before deployment and never modifies Access. +Cloudflare rate-limits account creation and applies additional undisclosed abuse checks. Deployment may fail even when local validation passes. -## Installation boundary +## Company mode -`bun run setup` provisions or reuses the child zone, DNS, private R2, Access application, Worker Loader, Durable Objects, scheduler, session secret, and encryption key. It reads the generated Access audience and injects it into a gitignored deployment config; an operator never copies the AUD. +The retained secondary command: + +```sh +up private --origin +``` + +uses the original customer-owned, Access-protected installation. Its trust model is separate: + +- the control API verifies Access identity; +- a sibling-domain signed session gates site bytes and capabilities; +- R2 remains private; +- site scope comes from the hostname after authentication; +- browser code receives no Cloudflare credentials; +- deployment assets remain pending until size and SHA-256 verification succeeds; +- activation changes one registry pointer. + +Company mode must keep `workers_dev: false`, preview URLs disabled, private R2, and an Access policy with no Everyone or Bypass rule. Production mutation remains a separate explicitly approved operation. + +## Release checks + +Before merging anonymous-mode changes: + +```sh +bun run check +bun run test:e2e +``` -Installation is not complete until authenticated publisher access, a private-site session, an explicit public site, and cookie-free denial for private/restricted content are all verified end to end. +Ordinary CI must not create a live Temporary Account. Live anonymous smoke tests are manual, use isolated state, redact the claim URL, and delete local temporary credentials afterward. diff --git a/SUPPORT.md b/SUPPORT.md index 7e83c57..3a22e04 100644 --- a/SUPPORT.md +++ b/SUPPORT.md @@ -1,17 +1,38 @@ # Support -Up is an open-source `0.0.1` product maintained through GitHub. +Up is an independent open-source `0.0.1` experiment maintained through GitHub. It is not an official Cloudflare product and is not supported by Cloudflare. -- Setup or usage question: open a GitHub Discussion. -- Reproducible bug: open an issue with the Up version, browser, Cloudflare resource configuration with identifiers redacted, and the failing step. -- Security concern or possible public exposure: use a private GitHub Security Advisory. Do not post tokens, Access assertions, customer content, account IDs, or private URLs. +- Usage question: open a GitHub Discussion. +- Reproducible bug: open an issue with the Up and Wrangler versions, operating system, redacted `up.json`, and failing step. +- Security concern, leaked claim URL, or unexpected account mutation: use a private GitHub Security Advisory. + +Never post: + +- Temporary Account API tokens; +- claim URLs or claim tokens; +- permanent Cloudflare tokens; +- deployed private/customer data; +- account IDs unless needed and explicitly redacted. Before requesting support, run: ```sh bun install --frozen-lockfile bun run check -bun run dry-run +bun run test:e2e ``` -For deployment problems, also confirm that Access protects both the control hostname and wildcard site hostname and that `workers.dev` and Preview URLs are disabled. +## Anonymous dynamic mode + +Confirm: + +- the app has `index.html`; +- dynamic apps have a root `_worker.js`; +- `up.json` declares only KV, D1, or Durable Object bindings; +- non-interactive runs pass `--accept-cloudflare-terms` only after human approval; +- no inherited credential entered the isolated Wrangler child; +- the app is expected to be public and temporary. + +## Secondary company mode + +Company-mode issues are separate. Confirm Access protects the control and wildcard hostnames and that `workers.dev`, preview URLs, and public R2 remain disabled for that installation. diff --git a/cli/up.ts b/cli/up.ts index a967013..edf606d 100644 --- a/cli/up.ts +++ b/cli/up.ts @@ -1,19 +1,42 @@ #!/usr/bin/env bun import { spawn } from 'node:child_process'; import { createHash, randomBytes } from 'node:crypto'; -import { mkdir, readdir, readFile, stat, writeFile } from 'node:fs/promises'; +import { constants } from 'node:fs'; +import { + chmod, + lstat, + mkdir, + mkdtemp, + open, + readdir, + readFile, + rm, + stat, + writeFile, +} from 'node:fs/promises'; import http from 'node:http'; +import { homedir } from 'node:os'; import { join, relative, resolve } from 'node:path'; +import type { Readable } from 'node:stream'; +import { stripVTControlCharacters } from 'node:util'; const DEFAULT_ORIGIN = 'https://up.ax.cloudflare.dev'; +const TEMPORARY_COMPATIBILITY_DATE = '2026-06-23'; +const MAX_TEMPORARY_FILES = 1_000; +const MAX_TEMPORARY_FILE_BYTES = 5 * 1024 * 1024; 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] +up deploy [name] Deploy now without a Cloudflare account + --accept-cloudflare-terms Required for agents and non-interactive use +up claim [--open|--show] Open or explicitly reveal the ownership link +up handoff Continue after ownership with normal Wrangler + --account-id Claimed Cloudflare account (from wrangler whoami) +up init [directory] Install instructions for a coding agent +up private Use a company-owned Up installation `); process.exit(1); } @@ -23,50 +46,671 @@ function option(name: string): string | undefined { return index >= 0 ? args[index + 1] : undefined; } +function hasFlag(name: string): boolean { + return args.includes(name); +} + +function normalizeName(value: string): string { + return value + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 63); +} + +function defaultName(root: string): string { + const fingerprint = createHash('sha256').update(resolve(root)).digest('hex').slice(0, 10); + return `up-${fingerprint}`; +} + +function validName(name: string): boolean { + return /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(name); +} + +function anonymousStateDirectory(): string { + return resolve(process.env.UP_STATE_DIR || join(homedir(), '.up', 'anonymous')); +} + +function anonymousPaths() { + const root = anonymousStateDirectory(); + return { + root, + home: join(root, 'home'), + config: join(root, 'config'), + account: join(root, 'config', '.wrangler', 'wrangler-temporary-account.toml'), + }; +} + +function wranglerBinary(): string { + if (process.env.UP_WRANGLER_BIN) return resolve(process.env.UP_WRANGLER_BIN); + const name = process.platform === 'win32' ? 'wrangler.cmd' : 'wrangler'; + return resolve(import.meta.dir, '..', 'node_modules', '.bin', name); +} + +const cloudflareCredentialVariables = [ + 'CLOUDFLARE_API_TOKEN', + 'CLOUDFLARE_ACCOUNT_ID', + 'CLOUDFLARE_API_KEY', + 'CLOUDFLARE_EMAIL', + 'CLOUDFLARE_API_USER_SERVICE_KEY', + 'CF_API_USER_SERVICE_KEY', + 'CF_API_TOKEN', + 'CF_ACCOUNT_ID', + 'CF_API_KEY', + 'CF_EMAIL', +] as const; + +function permanentEnvironment(accountId: string): NodeJS.ProcessEnv { + const env = { ...process.env, CLOUDFLARE_ACCOUNT_ID: accountId }; + delete env.CF_ACCOUNT_ID; + return env; +} + +function anonymousEnvironment( + paths: ReturnType, + termsAccepted: boolean, +): NodeJS.ProcessEnv { + const inherited = [ + 'PATH', + 'TMPDIR', + 'TMP', + 'TEMP', + 'SHELL', + 'TERM', + 'COLORTERM', + 'HTTPS_PROXY', + 'HTTP_PROXY', + 'NO_PROXY', + 'ALL_PROXY', + 'NODE_EXTRA_CA_CERTS', + 'SSL_CERT_FILE', + 'SSL_CERT_DIR', + ]; + const env = Object.fromEntries( + inherited.flatMap((name) => (process.env[name] ? [[name, process.env[name]]] : [])), + ) as NodeJS.ProcessEnv; + Object.assign(env, { + HOME: paths.home, + USERPROFILE: paths.home, + XDG_CONFIG_HOME: paths.config, + APPDATA: paths.config, + LOCALAPPDATA: paths.config, + NO_COLOR: '1', + WRANGLER_SEND_METRICS: 'false', + ...(termsAccepted ? { CI: '1' } : {}), + }); + for (const name of cloudflareCredentialVariables) delete env[name]; + return env; +} + +const claimUrlPattern = /https:\/\/dash\.cloudflare\.com\/claim-preview\?claimToken=[^\s]+/g; + +function redactClaimUrls(value: string): string { + return value.replace(claimUrlPattern, ''); +} + +async function run( + binary: string, + childArgs: string[], + options: { cwd: string; env: NodeJS.ProcessEnv }, +): Promise { + return new Promise((resolvePromise, reject) => { + const child = spawn(binary, childArgs, { + ...options, + stdio: ['inherit', 'pipe', 'pipe'], + }); + let output = ''; + const relay = (stream: Readable, destination: NodeJS.WriteStream) => { + let pending = ''; + stream.setEncoding('utf8'); + stream.on('data', (chunk: string) => { + output += chunk; + pending += chunk; + const lines = pending.split('\n'); + pending = lines.pop() || ''; + for (const line of lines) destination.write(`${redactClaimUrls(line)}\n`); + }); + stream.on('end', () => { + if (pending) destination.write(redactClaimUrls(pending)); + }); + }; + if (child.stdout) relay(child.stdout, process.stdout); + if (child.stderr) relay(child.stderr, process.stderr); + child.once('error', reject); + child.once('exit', (code, signal) => { + if (code === 0) resolvePromise(output); + else + reject(new Error(`Wrangler exited ${signal ? `with ${signal}` : `with status ${code}`}`)); + }); + }); +} + +type BindingManifest = { + kv: string[]; + d1: string[]; + durableObjects: Array<{ binding: string; className: string }>; +}; + +type StagedFolder = { + directory: string; + deployTarget: string; + config?: string; + fileCount: number; + dynamic: boolean; + bindings: BindingManifest; +}; + +const emptyBindings = (): BindingManifest => ({ kv: [], d1: [], durableObjects: [] }); +const bindingNamePattern = /^[A-Z][A-Z0-9_]{0,47}$/; +const classNamePattern = /^[A-Za-z_$][A-Za-z0-9_$]{0,63}$/; + +async function bindingManifest(root: string, dynamic: boolean): Promise { + const path = join(root, 'up.json'); + const info = await lstat(path).catch(() => null); + if (!info) return emptyBindings(); + if (info.isSymbolicLink()) throw new Error('Symbolic links are not supported: up.json'); + if (!info.isFile()) throw new Error('up.json must be a regular file.'); + if (info.size > 64 * 1024) throw new Error('up.json exceeds 64 KiB.'); + const noFollow = typeof constants.O_NOFOLLOW === 'number' ? constants.O_NOFOLLOW : 0; + const handle = await open(path, constants.O_RDONLY | noFollow); + let source: string; + try { + source = await handle.readFile('utf8'); + } finally { + await handle.close(); + } + if (!source) return emptyBindings(); + if (!dynamic) throw new Error('up.json bindings require a root _worker.js.'); + let parsed: unknown; + try { + parsed = JSON.parse(source); + } catch { + throw new Error('up.json must contain valid JSON.'); + } + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) + throw new Error('up.json must contain an object.'); + const rootValue = parsed as Record; + const unknownRoot = Object.keys(rootValue).filter((key) => key !== 'bindings'); + if (unknownRoot.length) throw new Error(`Unsupported up.json field: ${unknownRoot[0]}`); + const bindingsValue = rootValue.bindings ?? {}; + if (!bindingsValue || typeof bindingsValue !== 'object' || Array.isArray(bindingsValue)) + throw new Error('up.json bindings must contain an object.'); + const bindings = bindingsValue as Record; + const unknownBindings = Object.keys(bindings).filter( + (key) => !['kv', 'd1', 'durableObjects'].includes(key), + ); + if (unknownBindings.length) + throw new Error(`Unsupported anonymous binding: ${unknownBindings[0]}`); + const names = (key: 'kv' | 'd1'): string[] => { + const value = bindings[key] ?? []; + if (!Array.isArray(value) || value.some((name) => typeof name !== 'string')) + throw new Error(`up.json bindings.${key} must be an array of binding names.`); + const output = value as string[]; + if (output.some((name) => !bindingNamePattern.test(name))) + throw new Error(`up.json bindings.${key} contains an invalid binding name.`); + if (new Set(output).size !== output.length) + throw new Error(`up.json bindings.${key} contains a duplicate binding name.`); + return output; + }; + const durableValue = bindings.durableObjects ?? []; + if (!Array.isArray(durableValue)) + throw new Error('up.json bindings.durableObjects must be an array.'); + const durableObjects = durableValue.map((item) => { + if (!item || typeof item !== 'object' || Array.isArray(item)) + throw new Error('Each Durable Object binding must be an object.'); + const value = item as Record; + if ( + typeof value.binding !== 'string' || + !bindingNamePattern.test(value.binding) || + typeof value.className !== 'string' || + !classNamePattern.test(value.className) + ) + throw new Error('Invalid Durable Object binding or class name.'); + return { binding: value.binding, className: value.className }; + }); + const manifest = { kv: names('kv'), d1: names('d1'), durableObjects }; + const allNames = [ + ...manifest.kv, + ...manifest.d1, + ...manifest.durableObjects.map((item) => item.binding), + ]; + if (new Set(allNames).size !== allNames.length) + throw new Error('Binding names must be unique across up.json.'); + return manifest; +} + +async function copyStableFile(source: string, destination: string, root: string): Promise { + const info = await lstat(source); + if (info.isSymbolicLink()) + throw new Error(`Symbolic links are not supported: ${relative(root, source)}`); + if (!info.isFile()) throw new Error(`Unsupported file type: ${relative(root, source)}`); + if (info.size > MAX_TEMPORARY_FILE_BYTES) + throw new Error(`${relative(root, source)} exceeds the temporary 5 MiB file limit.`); + const noFollow = typeof constants.O_NOFOLLOW === 'number' ? constants.O_NOFOLLOW : 0; + const handle = await open(source, constants.O_RDONLY | noFollow); + try { + const openedInfo = await handle.stat(); + if (!openedInfo.isFile() || openedInfo.size !== info.size) + throw new Error(`File changed while staging: ${relative(root, source)}`); + const bytes = await handle.readFile(); + if (bytes.byteLength !== openedInfo.size) + throw new Error(`File changed while staging: ${relative(root, source)}`); + await writeFile(destination, bytes, { mode: 0o600 }); + return bytes.byteLength; + } finally { + await handle.close(); + } +} + +async function stageAnonymousFolder(root: string, stateRoot: string): Promise { + const directory = await mkdtemp(join(stateRoot, 'deploy-')); + const workerPath = join(root, '_worker.js'); + const workerInfo = await lstat(workerPath).catch(() => null); + if (workerInfo?.isSymbolicLink()) throw new Error('Symbolic links are not supported: _worker.js'); + if (workerInfo && !workerInfo.isFile()) throw new Error('_worker.js must be a regular file.'); + const dynamic = workerInfo?.isFile() === true; + const bindings = await bindingManifest(root, dynamic); + const assetsDirectory = dynamic ? join(directory, 'assets') : directory; + if (dynamic) await mkdir(assetsDirectory, { recursive: true, mode: 0o700 }); + let fileCount = 0; + let hasIndex = false; + + async function visit(sourceDirectory: string, destinationDirectory: string): Promise { + for (const entry of await readdir(sourceDirectory, { withFileTypes: true })) { + const sourceRelative = relative(root, join(sourceDirectory, entry.name)).replaceAll( + '\\', + '/', + ); + if ( + entry.name === '.git' || + entry.name === 'node_modules' || + (dynamic && ['_worker.js', 'up.json'].includes(sourceRelative)) + ) + continue; + if (entry.name.startsWith('.') && entry.name !== '.well-known') { + if (entry.name === '.env' || entry.name.startsWith('.env.') || entry.name === '.dev.vars') + throw new Error(`Refusing to deploy sensitive file: ${entry.name}`); + continue; + } + const source = join(sourceDirectory, entry.name); + const destination = join(destinationDirectory, entry.name); + const info = await lstat(source); + if (info.isSymbolicLink()) + throw new Error(`Symbolic links are not supported: ${relative(root, source)}`); + if (info.isDirectory()) { + await mkdir(destination, { recursive: true, mode: 0o700 }); + await visit(source, destination); + continue; + } + fileCount += 1; + if (fileCount > MAX_TEMPORARY_FILES) + throw new Error(`Temporary deployments support at most ${MAX_TEMPORARY_FILES} files.`); + await copyStableFile(source, destination, root); + if (sourceRelative === 'index.html') hasIndex = true; + } + } + + try { + await visit(root, assetsDirectory); + if (!hasIndex) throw new Error('index.html is required'); + if (!dynamic) return { directory, deployTarget: directory, fileCount, dynamic, bindings }; + + await copyStableFile(workerPath, join(directory, '_worker.js'), root); + const configPath = join(directory, 'wrangler.jsonc'); + const classes = [...new Set(bindings.durableObjects.map((item) => item.className))]; + const config = { + main: './_worker.js', + compatibility_date: TEMPORARY_COMPATIBILITY_DATE, + assets: { directory: './assets', binding: 'ASSETS', run_worker_first: true }, + ...(bindings.kv.length ? { kv_namespaces: bindings.kv.map((binding) => ({ binding })) } : {}), + ...(bindings.d1.length ? { d1_databases: bindings.d1.map((binding) => ({ binding })) } : {}), + ...(bindings.durableObjects.length + ? { + durable_objects: { + bindings: bindings.durableObjects.map(({ binding, className }) => ({ + name: binding, + class_name: className, + })), + }, + migrations: [{ tag: 'v1', new_sqlite_classes: classes }], + } + : {}), + }; + await writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 }); + return { + directory, + deployTarget: join(directory, '_worker.js'), + config: configPath, + fileCount, + dynamic, + bindings, + }; + } catch (error) { + await rm(directory, { recursive: true, force: true }); + throw error; + } +} + +function deploymentUrl(output: string, workerName: string): string { + const matches = stripVTControlCharacters(output).match(/https:\/\/[^\s]+/g) || []; + const targets = matches.flatMap((value) => { + try { + const url = new URL(value.replace(/[),.;]+$/, '')); + const labels = url.hostname.toLowerCase().split('.'); + return url.protocol === 'https:' && + labels[0] === workerName && + url.hostname.toLowerCase().endsWith('.workers.dev') + ? [url.toString().replace(/\/$/, '')] + : []; + } catch { + return []; + } + }); + const unique = [...new Set(targets)]; + if (unique.length !== 1) + throw new Error( + 'Wrangler deployed but did not return one authoritative workers.dev URL. Run `up claim` to recover the current account claim link before retrying.', + ); + return unique[0] as string; +} + +type TemporaryAccount = { + accountName: string; + accountExpiresAt: string; + claimUrl: string; + claimExpiresAt: string; +}; + +function tomlSections(source: string): Map> { + const sections = new Map>(); + let current = ''; + for (const rawLine of source.split(/\r?\n/)) { + const line = rawLine.trim(); + const heading = line.match(/^\[([^\]]+)\]$/); + if (heading) { + current = heading[1] || ''; + if (!sections.has(current)) sections.set(current, new Map()); + continue; + } + const value = line.match(/^([A-Za-z][A-Za-z0-9_]*)\s*=\s*"((?:\\.|[^"])*)"\s*$/); + if (value && current) { + const decoded = JSON.parse(`"${value[2]}"`) as string; + sections.get(current)?.set(value[1] || '', decoded); + } + } + return sections; +} + +async function readTemporaryAccount(): Promise { + const source = await readFile(anonymousPaths().account, 'utf8').catch(() => ''); + const sections = tomlSections(source); + const accountName = sections.get('account')?.get('name') || ''; + const accountExpiresAt = sections.get('account')?.get('expiresAt') || ''; + const claimUrl = sections.get('claim')?.get('url') || ''; + const claimExpiresAt = sections.get('claim')?.get('expiresAt') || ''; + if (!accountName || !accountExpiresAt || !claimUrl || !claimExpiresAt) + throw new Error('No active anonymous deployment. Run `up deploy ` first.'); + const accountExpiry = Date.parse(accountExpiresAt); + const claimExpiry = Date.parse(claimExpiresAt); + if (!Number.isFinite(accountExpiry) || !Number.isFinite(claimExpiry)) + throw new Error('The anonymous deployment state has invalid expiration data.'); + if (accountExpiry <= Date.now()) + throw new Error('The anonymous deployment expired. Run `up deploy ` again.'); + if (claimExpiry <= Date.now()) + throw new Error('The claim link expired. Run `up deploy ` again.'); + let parsedClaim: URL; + try { + parsedClaim = new URL(claimUrl); + } catch { + throw new Error('The anonymous deployment state contains an invalid claim URL.'); + } + if ( + parsedClaim.protocol !== 'https:' || + parsedClaim.hostname !== 'dash.cloudflare.com' || + parsedClaim.pathname !== '/claim-preview' || + !parsedClaim.searchParams.get('claimToken') + ) + throw new Error('The anonymous deployment state contains an untrusted claim URL.'); + return { accountName, accountExpiresAt, claimUrl: parsedClaim.toString(), claimExpiresAt }; +} + +async function openUrl(url: string): Promise { + const invocation = + process.platform === 'darwin' + ? { command: 'open', args: [url] } + : process.platform === 'win32' + ? { command: 'cmd', args: ['/c', 'start', '', url] } + : { command: 'xdg-open', args: [url] }; + return new Promise((resolvePromise) => { + const child = spawn(invocation.command, invocation.args, { detached: true, stdio: 'ignore' }); + child.once('spawn', () => { + child.unref(); + resolvePromise(true); + }); + child.once('error', () => resolvePromise(false)); + }); +} + +function minutesRemaining(timestamp: string): number { + return Math.max(0, Math.ceil((Date.parse(timestamp) - Date.now()) / 60_000)); +} + +async function deployAnonymous(): Promise { + const positionals = args.filter((value) => value !== '--accept-cloudflare-terms'); + if (positionals.some((value) => value.startsWith('-')) || positionals.length > 2) usage(); + const root = resolve(positionals[0] || ''); + if (!(await stat(root).catch(() => null))?.isDirectory()) + throw new Error(`Folder not found: ${root}`); + const explicitName = positionals[1]; + if (explicitName && !validName(explicitName)) { + const suggestion = normalizeName(explicitName); + throw new Error( + `Invalid Worker name. Use lowercase letters, numbers, and hyphens${suggestion ? `, for example: ${suggestion}` : ''}.`, + ); + } + const name = explicitName || defaultName(root); + const termsAccepted = + hasFlag('--accept-cloudflare-terms') || process.env.UP_ACCEPT_CLOUDFLARE_TERMS === 'yes'; + if ((!process.stdin.isTTY || process.env.CI) && !termsAccepted) + throw new Error( + 'Non-interactive deployment requires --accept-cloudflare-terms. This confirms acceptance of https://www.cloudflare.com/terms/ and https://www.cloudflare.com/privacypolicy/.', + ); + if (termsAccepted) + console.log( + 'Continuing means you accept Cloudflare’s Terms of Service (https://www.cloudflare.com/terms/) and Privacy Policy (https://www.cloudflare.com/privacypolicy/).\n', + ); + + const state = anonymousPaths(); + await Promise.all([ + mkdir(state.root, { recursive: true, mode: 0o700 }), + mkdir(state.home, { recursive: true, mode: 0o700 }), + mkdir(state.config, { recursive: true, mode: 0o700 }), + ]); + await Promise.all([ + chmod(state.root, 0o700), + chmod(state.home, 0o700), + chmod(state.config, 0o700), + ]); + + const staged = await stageAnonymousFolder(root, state.root); + let output: string; + try { + const bindingNames = [ + ...staged.bindings.kv, + ...staged.bindings.d1, + ...staged.bindings.durableObjects.map((item) => item.binding), + ]; + console.log( + `Deploying ${staged.dynamic ? 'dynamic app' : 'static site'} with ${staged.fileCount} assets without a Cloudflare account${bindingNames.length ? `\nBindings: ${bindingNames.join(', ')}` : ''}…\n`, + ); + output = await run( + wranglerBinary(), + [ + 'deploy', + staged.deployTarget, + ...(staged.config ? ['--config', staged.config] : []), + '--temporary', + '--name', + name, + '--compatibility-date', + TEMPORARY_COMPATIBILITY_DATE, + '--no-autoconfig', + '--experimental-provision', + '--experimental-auto-create', + ], + { cwd: state.root, env: anonymousEnvironment(state, termsAccepted) }, + ); + } finally { + await rm(staged.directory, { recursive: true, force: true }); + } + + const temporary = await readTemporaryAccount(); + const liveUrl = deploymentUrl(output, name); + console.log( + `\nLive now\n\n${liveUrl}\n\nExpires in about ${minutesRemaining(temporary.accountExpiresAt)} minutes unless claimed.\nPublic: anyone with this URL can open it.\n\nKeep it: run \`up claim --open\` to open the ownership flow,\nor \`up claim --show\` to reveal the link. Up stores it locally and does not print it.`, + ); +} + +async function handoff(): Promise { + const accountId = option('--account-id'); + const positionals: string[] = []; + for (let index = 0; index < args.length; index++) { + if (args[index] === '--account-id') { + index += 1; + continue; + } + const value = args[index]; + if (!value || value.startsWith('-')) usage(); + positionals.push(value); + } + if (positionals.length !== 2 || !accountId || !/^[a-f0-9]{32}$/i.test(accountId)) usage(); + const root = resolve(positionals[0] as string); + const name = positionals[1] as string; + if (!validName(name)) throw new Error('Use the exact Worker name shown in the temporary URL.'); + if (!(await stat(root).catch(() => null))?.isDirectory()) + throw new Error(`Folder not found: ${root}`); + + const env = permanentEnvironment(accountId); + console.log( + 'Checking the claimed account. If Wrangler is not connected yet, run `wrangler login`, then retry.\n', + ); + try { + await run(wranglerBinary(), ['deployments', 'status', '--name', name, '--json'], { + cwd: root, + env, + }); + } catch { + throw new Error( + `Could not find ${name} in account ${accountId}. Run \`wrangler whoami\`, choose the account created by the ownership flow, and use the exact Worker name from its workers.dev URL. Nothing was deployed.`, + ); + } + + const state = anonymousPaths(); + await mkdir(state.root, { recursive: true, mode: 0o700 }); + const staged = await stageAnonymousFolder(root, state.root); + let output: string; + try { + console.log(`\nContinuing ${name} from the local source folder…\n`); + output = await run( + wranglerBinary(), + [ + 'deploy', + staged.deployTarget, + ...(staged.config ? ['--config', staged.config] : []), + '--name', + name, + '--compatibility-date', + TEMPORARY_COMPATIBILITY_DATE, + '--no-autoconfig', + '--experimental-provision', + ], + { cwd: state.root, env }, + ); + } finally { + await rm(staged.directory, { recursive: true, force: true }); + } + + const liveUrl = deploymentUrl(output, name); + console.log(` +Handoff complete + +${liveUrl} + +The local folder is still the source of truth. Wrangler is now connected through OAuth; no Up API key is needed. +The URL remains public. Before adding sensitive data, add Cloudflare Access or another login and verify an anonymous request is denied. + +Agent handoff prompt: +Continue this existing Cloudflare Worker from ${root}. Use account ${accountId} and Worker ${name}. Do not create replacement KV or D1 resources: preserve the existing bindings by name. Deploy with \`up handoff ${root} ${name} --account-id ${accountId}\`, test the public URL and every binding, and ask me before adding Cloudflare Access or creating a CI API token.`); +} + +async function claim(): Promise { + const temporary = await readTemporaryAccount(); + const minutes = minutesRemaining(temporary.claimExpiresAt); + if (hasFlag('--open')) { + const opened = await openUrl(temporary.claimUrl); + console.log( + opened + ? `Opening the ownership flow in your browser. Claim within about ${minutes} minutes.` + : `Could not open a browser. Run \`up claim --show\` to reveal the link (claim within about ${minutes} minutes).`, + ); + return; + } + if (hasFlag('--show')) { + console.log( + `This ownership link claims the whole anonymous session. Treat it like a password.\nClaim within about ${minutes} minutes:\n\n${temporary.claimUrl}`, + ); + return; + } + console.log( + `Claim within about ${minutes} minutes.\nUp keeps the ownership link local and does not print it.\nRun \`up claim --open\` to open the flow, or \`up claim --show\` to reveal the link.`, + ); +} + async function cliToken(origin: string): Promise { if (process.env.UP_CLI_TOKEN) return process.env.UP_CLI_TOKEN; const verifier = randomBytes(48).toString('base64url'); const digest = createHash('sha256').update(verifier).digest('base64url'); const state = randomBytes(24).toString('base64url'); - const authorization = await new Promise<{ code: string; state: string }>((resolve, reject) => { - const server = http.createServer((request, response) => { - const url = new URL(request.url || '/', `http://${request.headers.host}`); - const code = url.searchParams.get('code'); - const returnedState = url.searchParams.get('state'); - if (!code || !returnedState) { - response.writeHead(400, { 'content-type': 'text/plain' }); - response.end('Up CLI authentication failed.'); - return; - } - response.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }); - response.end( - 'Up CLI connected

Up CLI connected.

You can close this window.

', - ); - server.close(); - resolve({ code, state: returnedState }); - }); - server.on('error', reject); - server.listen(0, '127.0.0.1', () => { - const address = server.address(); - if (!address || typeof address === 'string') - return reject(new Error('Unable to open callback')); - const redirect = `http://127.0.0.1:${address.port}/callback`; - const url = new URL('/app/cli-auth', origin); - url.searchParams.set('redirect_uri', redirect); - url.searchParams.set('state', state); - url.searchParams.set('challenge', digest); - console.log(`Opening Up in your browser…\n${url}`); - const child = spawn('open', [url.toString()], { detached: true, stdio: 'ignore' }); - child.unref(); - }); - setTimeout( - () => { + const authorization = await new Promise<{ code: string; state: string }>( + (resolvePromise, reject) => { + const server = http.createServer((request, response) => { + const url = new URL(request.url || '/', `http://${request.headers.host}`); + const code = url.searchParams.get('code'); + const returnedState = url.searchParams.get('state'); + if (!code || !returnedState) { + response.writeHead(400, { 'content-type': 'text/plain' }); + response.end('Up CLI authentication failed.'); + return; + } + response.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }); + response.end( + 'Up CLI connected

Up CLI connected.

You can close this window.

', + ); server.close(); - reject(new Error('CLI authentication timed out')); - }, - 5 * 60 * 1000, - ).unref(); - }); + resolvePromise({ code, state: returnedState }); + }); + server.on('error', reject); + server.listen(0, '127.0.0.1', () => { + const address = server.address(); + if (!address || typeof address === 'string') + return reject(new Error('Unable to open callback')); + const redirect = `http://127.0.0.1:${address.port}/callback`; + const url = new URL('/app/cli-auth', origin); + url.searchParams.set('redirect_uri', redirect); + url.searchParams.set('state', state); + url.searchParams.set('challenge', digest); + console.log(`Opening Up in your browser…\n${url}`); + void openUrl(url.toString()); + }); + setTimeout( + () => { + server.close(); + reject(new Error('CLI authentication timed out')); + }, + 5 * 60 * 1000, + ).unref(); + }, + ); if (authorization.state !== state) throw new Error('CLI authentication state mismatch'); const response = await fetch(`${origin}/cli/exchange`, { method: 'POST', @@ -79,7 +723,7 @@ async function cliToken(origin: string): Promise { return body.token; } -async function files(root: string): Promise { +async function privateFiles(root: string): Promise { const output: string[] = []; async function visit(directory: string): Promise { for (const entry of await readdir(directory, { withFileTypes: true })) { @@ -115,25 +759,29 @@ async function init(): Promise { const directory = join(target, '.up'); await mkdir(directory, { recursive: true }); const source = resolve(import.meta.dir, '..', 'skills', 'up', 'SKILL.md'); + const handoff = resolve(import.meta.dir, '..', 'skills', 'up', 'HANDOFF.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, 'HANDOFF.md'), await readFile(handoff)), 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 `); + up deploy ./dist [name] + +After you keep the app, .up/HANDOFF.md contains the continuation prompt.`); } -async function deploy(): Promise { +async function deployPrivate(): 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 (!name || !validName(name)) usage(); if (!(await stat(root).catch(() => null))?.isDirectory()) throw new Error(`Folder not found: ${root}`); - const paths = await files(root); + const paths = await privateFiles(root); if (!paths.some((path) => relative(root, path) === 'index.html')) throw new Error('index.html is required'); const token = await cliToken(origin); @@ -204,7 +852,10 @@ async function deploy(): Promise { try { if (command === 'init') await init(); - else if (command === 'deploy') await deploy(); + else if (command === 'deploy') await deployAnonymous(); + else if (command === 'claim') await claim(); + else if (command === 'handoff') await handoff(); + else if (command === 'private') await deployPrivate(); else usage(); } catch (error) { console.error(`\n${error instanceof Error ? error.message : String(error)}`); diff --git a/docs/0.0.1-source-brief.md b/docs/0.0.1-source-brief.md index d50b366..f6c29f7 100644 --- a/docs/0.0.1-source-brief.md +++ b/docs/0.0.1-source-brief.md @@ -1,4 +1,6 @@ -# Up 0.0.1 source brief +# Up 0.0.1 company-mode source brief + +> Historical/secondary mode. Up's default product is now the anonymous deploy-first flow in [`anonymous-first-brief.md`](anonymous-first-brief.md). ## Source diff --git a/docs/anonymous-first-brief.md b/docs/anonymous-first-brief.md new file mode 100644 index 0000000..407659e --- /dev/null +++ b/docs/anonymous-first-brief.md @@ -0,0 +1,152 @@ +# Up anonymous-first dynamic source brief + +## Product sentence + +Up deploys a dynamic Cloudflare app—Worker code, Static Assets, and supported bindings—before the deployer has a Cloudflare account, then gives the human about one hour to claim the whole stack. + +## Mechanism + +```text +index.html + _worker.js + up.json + ↓ validate and snapshot +pinned Wrangler in isolated state + ↓ proof of work + experimental resource provisioning +Cloudflare Temporary Account + ├── Worker runtime + ├── Static Assets + ├── KV + ├── D1 + └── Durable Objects + ↓ +public workers.dev URL + sensitive account claim URL + ↓ +claim every resource, or expire together +``` + +Up wraps the supported `wrangler deploy --temporary` interface. It does not call the unpublished `/provisioning/previews` API directly. + +## Utility + +Agents need to exercise actual platform behavior—not just render static output—inside a write → deploy → fetch → revise loop. A temporary full-stack graph is enough to validate the architecture. Ownership is deferred until the dynamic app earns it. + +## Folder contract + +```text +app/ +├── index.html +├── browser files… +├── _worker.js optional; makes the app dynamic +└── up.json optional; requires _worker.js +``` + +`up.json` version 0.0.1: + +```json +{ + "bindings": { + "kv": ["CACHE"], + "d1": ["DB"], + "durableObjects": [{ "binding": "ROOMS", "className": "Room" }] + } +} +``` + +Unknown fields fail closed. Binding names are unique uppercase identifiers. Durable Object classes are exported by `_worker.js` and receive one generated SQLite migration. + +Worker code uses `env.ASSETS.fetch(request)` for browser assets and normal binding APIs for dynamic routes. + +## Default commands + +```sh +up deploy [name] +up deploy [name] --accept-cloudflare-terms # agents/non-interactive +up claim [--open|--show] +up handoff --account-id +``` + +- The deployment is public. +- Existing and deprecated Cloudflare credentials are removed. +- Up state is isolated under `~/.up/anonymous` across supported home/config variables. +- An omitted Worker name is a stable fingerprint of the local path, not the folder name. +- Explicit invalid names are rejected rather than silently rewritten. +- The app is copied through no-follow file handles into a private staging snapshot. +- Wrangler output is filtered so its ownership link is withheld; Up stores it locally and reveals it only with explicit `up claim --show`. +- Up consumes the authoritative `workers.dev` target from Wrangler output. +- The Temporary Account expires after up to/about 60 minutes unless claimed. +- The ownership URL grants ownership of every app and resource in the active anonymous session. +- After ownership, `up handoff` verifies the existing Worker in the selected account and continues through normal Wrangler OAuth without auto-creating replacement resources. + +## After ownership + +The human completes the browser flow, runs `wrangler login`, and selects the new account from `wrangler whoami`. `up handoff` requires that account ID and the exact Worker name. It fails before deployment if the Worker does not already exist there. + +Wrangler inherits existing KV and D1 bindings from the Worker's settings by binding name. No Up API key is involved. The URL remains public until the owner adds Cloudflare Access or application authentication. Scoped API tokens are a later CI/CD choice, not part of onboarding. + +## Binding surface + +| Primitive | Up status | Boundary | +|---|---|---| +| Worker | implemented | root `_worker.js` | +| Static Assets | implemented | `env.ASSETS`; 1,000 files, 5 MiB each | +| KV | implemented | draft namespace auto-provisioned by Wrangler | +| D1 | implemented | one draft database; current Temporary Account total 100 MB | +| Durable Objects | implemented | named class binding and SQLite migration | +| Queues | supported upstream; Up wiring next | requires explicit queue lifecycle orchestration | +| Hyperdrive | supported upstream; Up wiring next | needs an existing external database and connection policy | +| Certificates | supported account operation | ownership/account operation, not app-local API | +| R2, Workers AI, Access | excluded | absent from current Temporary Account matrix | +| Workflows, Browser Rendering, Containers, Sandboxes, Dispatch | excluded | absent from current matrix | + +Up does not interpret general Wrangler configuration. The narrow manifest prevents a temporary deployment from quietly requesting permanent-account-only resources or arbitrary account mutations. + +## Security boundary + +The public URL grants read and API access. Dynamic Worker code is user-authored and can spend the Temporary Account’s bounded resources. A hard-to-guess hostname is not privacy. + +The claim URL grants ownership of the entire account and is more sensitive than the public URL. Wrangler stores a short-lived API token and claim metadata in a mode-600 file inside Up's mode-700 state directory. Up never prints the temporary API token. + +The agent must receive human approval before passing `--accept-cloudflare-terms`. This explicitly confirms Cloudflare’s Terms and Privacy Policy in non-interactive mode. + +Cloudflare applies proof-of-work, rate limits, and additional abuse controls. Up must not market this path for production or CI/CD. + +## Secondary mode + +The original company-private implementation remains behind: + +```sh +up private --origin +``` + +It is not the default product. Its Access, R2, Workers AI, Durable Object capabilities, and customer-owned installation graph are retained for comparison while the dynamic anonymous direction is evaluated. + +## Proof app + +`examples/binding-lab` must remain framework-free and demonstrate: + +- browser assets served by `env.ASSETS`; +- a Worker API route; +- KV mutation; +- D1 schema creation/read/write; +- Durable Object state. + +A live isolated smoke must return HTTP 200 from the page and `/api/state` before binding support is claimed. + +## Review gate + +- a clean machine deploys Worker code and assets without Cloudflare credentials; +- KV, D1, and Durable Object bindings are generated from one inspectable manifest; +- inherited current/deprecated credentials cannot reach Wrangler; +- non-interactive use fails without explicit Terms acceptance; +- the authoritative URL comes from Wrangler; +- the claim URL appears exactly once and never in stderr/debug output; +- expiry and account-wide claim semantics are explicit; +- file staging rejects symlinks, secret dotfiles, unsupported file types, and mutation during read; +- ordinary CI creates no remote account; +- company mode cannot be entered accidentally. + +## Sources + +- +- +- +- diff --git a/docs/capa-integration.md b/docs/capa-integration.md new file mode 100644 index 0000000..b39bfd2 --- /dev/null +++ b/docs/capa-integration.md @@ -0,0 +1,173 @@ +# Capa integration contract + +Status: **same-account runtime proof passed; user-facing installer not implemented.** + +## Product composition + +Up owns the temporary application graph: + +- Temporary Account lifecycle; +- Worker and Static Assets; +- native KV, D1, and Durable Object bindings; +- deployment, verification, expiry, and account claiming. + +Capa owns generated third-party API capabilities: + +- OpenAPI-derived methods; +- one capability Worker per provider; +- JSRPC service binding entrypoint; +- provider credential isolation; +- `{ result, evidence }` call contract. + +The two projects remain independently useful. + +## Same-account requirement + +Cloudflare service bindings do not cross account boundaries. An anonymous Up app cannot bind to an existing permanent `capa-stripe` Worker. + +Up must install the selected Capa capability Worker into the same Temporary Account: + +```text +Temporary Account +├── app Worker +├── native bindings +├── capa-github Worker +├── GITHUB_API_KEY secret on capa-github +└── app.GITHUB → capa-github.GithubCapability +``` + +Claiming then covers the app, capability Worker, secret, native resources, and data together. + +## Proven flow + +The 2026-06-23 spike proved: + +1. deploy a generated Capa Worker with `wrangler deploy --temporary`; +2. store a short-lived credential with `wrangler secret put --temporary`; +3. deploy a caller Worker in the reused account; +4. bind it to the generated Worker entrypoint; +5. execute a real read-only method; +6. receive credential-free result and evidence; +7. redeploy and preserve the service binding; +8. inventory both Workers, secret, and binding in one claimable account. + +It also proved Capa `main` Stripe reaches the upstream API and returns a bounded credential-free failure receipt for a fake token. + +See [`receipts/2026-06-23-capa-temporary-account-spike.md`](../receipts/2026-06-23-capa-temporary-account-spike.md). + +## Required Capa install artifact + +Before Up exposes a Connect button, Capa needs an immutable install artifact for each capability: + +```ts +interface CapaInstallArtifact { + schemaVersion: 1; + name: string; + revision: string; + entrypoint: string; + requiredSecrets: Array<{ + name: string; + label: string; + auth: 'bearer' | 'private-token' | 'basic'; + }>; + operations: number; + namespaces: number; + bundleUrl: string; + bundleSha256: string; +} +``` + +Up should download and verify one bundle. It should not clone the whole Capa repository, depend on the operator's local checkout, or generate a large provider from OpenAPI during every deploy. + +The first supported revision should pin Capa `main` at `382359f` or a newer revision with equal smoke evidence. The local `rewrite/distilled-runtime` branch at `cfe48ab` is not installable yet because its lazy provider operation is absent from the deployed Worker bundle. + +## Local composer UX + +The product UI should run on localhost: + +```sh +up open ./app +``` + +Source, Temporary Account state, provider credentials, and claim URLs remain local. + +### Stack screen + +```text +Runtime +✓ Worker _worker.js +✓ Static Assets 4 files + +Cloudflare native +✓ KV CACHE +✓ D1 DB +✓ Durable Object ROOMS → Room +[ + Add native binding ] + +API capabilities by Capa +✓ GitHub GITHUB +[ + Connect another API ] +``` + +### Native binding click + +A click edits the inspectable `up.json` manifest and shows the exact diff before deployment. + +### Capa capability click + +The dialog shows: + +- capability and operation count; +- binding name; +- credential type and masked input; +- external provider dependency; +- Capa source revision and bundle hash; +- explicit statement that the capability Worker and secret install into the Temporary Account; +- explicit statement that the credential never goes to `up.coey.dev` or `capa.coey.dev`. + +The provider credential must never enter `up.json`, terminal arguments, evidence, logs, or analytics. The local process pipes it directly to `wrangler secret put --temporary`. + +## Deploy confirmation + +```text +Deploy this public temporary stack? + +• app Worker + browser assets +• KV, D1, Durable Object +• Capa GitHub Worker +• one encrypted provider credential + +[ ] Make the app and API public for up to/about 60 minutes +[ ] Accept Cloudflare Terms and Privacy Policy + +Deploy stack +``` + +The post-deploy cockpit lists each real resource and lets the user run a bounded health check. Capa checks expand into the plain JSON evidence receipt. + +## Claim confirmation + +Before opening Cloudflare, Up lists every app and capability in the session. The UI must state that one claim URL transfers the entire Temporary Account, not the selected app only. + +## First product integration + +Use GitHub with a narrowly scoped read-only fine-grained token. + +The first polished demo should combine: + +- Worker and Static Assets; +- KV cache; +- D1 decisions/checklist; +- Durable Object live presence; +- Capa GitHub repository status and evidence. + +Do not begin with Stripe. Financial methods and credential risk obscure the platform composition being demonstrated. + +## Non-goals + +- centralized Up or Capa credential proxy; +- cross-account service binding; +- uploading provider credentials to either public website; +- claiming Capa capabilities transfer without installing their Workers in the Temporary Account; +- presenting all 5,998 operations as reviewed or safe; +- enabling high-risk provider mutations by default. diff --git a/docs/explanation/dynamic-runtime.md b/docs/explanation/dynamic-runtime.md deleted file mode 100644 index 28f38e9..0000000 --- a/docs/explanation/dynamic-runtime.md +++ /dev/null @@ -1,20 +0,0 @@ -# Dynamic runtime - -Up remains static by default. A deployment that includes a root `_worker.js` file opts into an isolated Dynamic Worker for requests under `/api/*`. - -## Trust boundary - -The control Worker never evaluates uploaded code. It loads the active deployment's `_worker.js` through Cloudflare's Worker Loader binding, producing a separate Dynamic Worker isolate keyed by deployment ID and code digest. - -The initial runtime profile is intentionally narrow: - -- outbound network access is blocked (`globalOutbound: null`) -- no environment variables or control-plane bindings are passed -- CPU is limited to 50 ms per invocation -- subrequests are limited to 5 -- code is limited to 1 MiB -- dynamic responses cannot set cookies -- errors return a generic `502` without source, stack, or logs -- static paths continue to use the verified R2 deployment - -This is the base on which Up adds scoped secret operations, an isolated SQLite facet, and durable scheduled workflows. Those capabilities are passed as explicit narrow bindings; the dynamic Worker never receives Up's registry, R2 bucket, Access keys, or deployment authority. diff --git a/docs/explanation/index.md b/docs/explanation/index.md index 67fdb6c..d09d597 100644 --- a/docs/explanation/index.md +++ b/docs/explanation/index.md @@ -1,15 +1,33 @@ -# Explanation: how Up decides who can open a site +# Explanation: deploy before ownership -A site name and URL are public identifiers. They do not authorize a request. Up serves company and restricted sites only after it verifies an identity; anonymous access begins only when the owner explicitly changes a site to public. +Up's default mode deliberately separates **deployment** from **ownership**. -An installation belongs to the customer’s account. Cloudflare Access protects the publisher and supplies company identity to Up's session broker. Private R2 holds immutable assets. A Durable Object serializes deployment authority. Every site declares one visibility state: company, restricted readers, or public. +Traditional cloud deployment begins with identity, an account, authorization, and credentials. That sequence blocks unattended coding agents at exactly the moment they need to inspect a real result. -The control plane, browser content, and backend code are separate trust zones: +Cloudflare Temporary Accounts invert it: -- browser JavaScript runs on sibling hostnames and cannot mutate the control origin cross-site -- optional `_worker.js` code runs in a separate Dynamic Worker with no global network or Up bindings -- database access is a single site-specific Durable Object stub -- secret values remain encrypted and are used only by a trusted allowlisted-request capability -- schedules are leased, quota-bound, retried, and audited by trusted code +```text +write → deploy → fetch → revise → decide whether to claim +``` -Identifiers are never credentials. Public is explicit state, not missing authentication. Restricted denial conceals site existence. Static/company remains the default path; dynamic code, data, secrets, schedules, and public visibility are deliberate opt-ins. +Wrangler requests and solves a proof-of-work challenge, creates a short-lived account, receives temporary credentials, and deploys to `workers.dev`. The human receives a sensitive dashboard claim URL. Claiming makes the account and its supported resources permanent; ignoring it lets Cloudflare delete the experiment. + +## Public is explicit + +The generated deployment has no Access boundary. Anyone with the URL can read it. A hard-to-guess hostname is not privacy, so Up labels the URL public and rejects the old company-private language in its default mode. + +## Existing credentials are excluded + +Anonymous behavior must be deterministic even on a developer machine already logged into Cloudflare. Up starts Wrangler in an isolated home and removes inherited Cloudflare token/account variables. It cannot silently deploy the experiment into the wrong permanent account. + +## The claim URL is authority + +The public URL grants reading. The claim URL grants ownership of the whole temporary account. It is therefore the more sensitive artifact and must stay out of git, telemetry, screenshots, CI output, and unrelated conversations. + +## Why wrap Wrangler + +The unauthenticated provisioning endpoint and proof-of-work representation are not a published third-party API contract. Up uses pinned Wrangler—the documented interface—rather than cloning implementation details that Cloudflare can change. + +## Why company mode remains + +The original Access-protected publishing plane explores a different question: what should a customer-owned private internal web look like? It remains available explicitly as `up private`, but it no longer defines Up's default product. diff --git a/docs/how-to/after-claim.md b/docs/how-to/after-claim.md new file mode 100644 index 0000000..c507386 --- /dev/null +++ b/docs/how-to/after-claim.md @@ -0,0 +1,82 @@ +# After you keep an app + +The ownership flow moves the whole Temporary Account to you. The Worker, Static Assets, KV, D1, Durable Objects, and their data remain available. Your source code stays in the folder where Up found it. + +## 1. Connect Wrangler + +Use OAuth for local work: + +```sh +bunx wrangler@4.103.0 login +bunx wrangler@4.103.0 whoami +``` + +Complete login in the browser. You do not need an Up API key or a copied Cloudflare API key. + +If `whoami` lists several accounts, identify the account created by the ownership flow and copy its 32-character account ID. + +## 2. Reconnect the local source + +Use the exact Worker name from the original `workers.dev` URL: + +```sh +up handoff ./my-app exact-worker-name \ + --account-id 0123456789abcdef0123456789abcdef +``` + +Up fails before deploying unless that Worker already exists in the selected account. This protects against choosing the wrong account or changing the Worker name. + +On deployment, Wrangler reads the existing Worker's settings and inherits its KV and D1 resources by binding name. Up does not auto-create replacements during handoff. + +## 3. Verify continuity + +Open the returned URL and test every binding that matters: + +- write and read a KV value; +- insert and query a D1 row; +- call each Durable Object route; +- confirm browser assets load; +- verify existing data is still present. + +The local folder remains the source of truth. Continue future deployments with the same `up handoff` command. + +## 4. Decide who can open it + +Keeping an app changes ownership, not visibility. The `workers.dev` URL remains public. + +Before adding sensitive data, choose one: + +- keep the app public; +- add [Cloudflare Access](https://developers.cloudflare.com/cloudflare-one/access-controls/applications/http-apps/); +- add authentication in the application. + +After adding authentication, make an anonymous request and verify it is denied or redirected. Do not rely on an unguessable URL. + +## 5. Add CI only when needed + +Local work should use `wrangler login`. For CI/CD, create a narrowly scoped Cloudflare API token for the selected account and deployment resources. Store it in the CI secret store, never in the app folder or `up.json`. + +## Copy this to an agent + +```text +Continue this existing Cloudflare Worker from ./my-app. + +1. Ask me to complete `wrangler login` in the browser. Do not ask for or copy an API key. +2. Run `wrangler whoami` and ask me which account was created by the ownership flow if several are listed. +3. Use the exact Worker name from its workers.dev URL. +4. Run `up handoff ./my-app --account-id `. +5. Do not create replacement KV or D1 resources. Preserve the existing bindings by name. +6. Test the deployed URL and every binding, including existing data. +7. Remind me that ownership did not make the URL private. Ask before adding Cloudflare Access or another login. +8. Create a scoped API token only if I explicitly ask for CI/CD. +``` + +## Recovery + +| Problem | Clean next step | +|---|---| +| Wrangler is logged out | Run `wrangler login`, then retry. | +| Several accounts are listed | Ask the human which account was created by the ownership flow. | +| Worker preflight fails | Check the account ID and exact Worker name; nothing was deployed. | +| Existing data is missing | Stop. Do not provision replacements. Recheck account, Worker name, and binding names. | +| The app should be private | Add Access or application authentication, then test anonymously. | diff --git a/docs/how-to/connect-cloudflare.md b/docs/how-to/connect-cloudflare.md index ba5a71a..4a654a8 100644 --- a/docs/how-to/connect-cloudflare.md +++ b/docs/how-to/connect-cloudflare.md @@ -1,4 +1,6 @@ -# Connect Up to Cloudflare +# Company mode: connect Cloudflare + +> Secondary mode only. Anonymous `up deploy` does not require OAuth, setup, or a Cloudflare account. Up provisions itself into *your* Cloudflare account using the OAuth Authorization Code flow. You approve a scoped consent screen — you never paste @@ -11,6 +13,7 @@ Least-privilege scopes, shown on Cloudflare's consent screen before you approve: | Scope | Why | | --- | --- | | `access.write` | Create the Access application and policy | +| `access-org.write` | Read or initialize the account Access organization | | `workers-scripts.write` | Deploy the control Worker and its Durable Object | | `workers-routes.write` | Bind the control and wildcard routes | | `workers-r2.write` | Create and manage the private assets bucket | @@ -44,7 +47,7 @@ Copy the **Client ID** (and secret, if any). ```sh export UP_OAUTH_CLIENT_ID= # export UP_OAUTH_CLIENT_SECRET= # only for a confidential client -bun run oauth:connect +bun run company:oauth:connect ``` A browser opens to Cloudflare's consent screen. Approve it. The short-lived token is stored in `.cloudflare-oauth.json` (gitignored, mode `600`). Re-run the consent command when it expires; it never asks for an API token. @@ -56,7 +59,7 @@ export CLOUDFLARE_ACCOUNT_ID= export UP_CONTROL_HOST=up.yourcompany.com export UP_PARENT_ZONE=yourcompany.com export UP_ALLOWED_EMAIL=you@yourcompany.com -bun run setup +bun run company:setup ``` `setup` delegates an isolated child zone when requested, creates private R2 and the Access application, reads back its generated audience, injects it into a gitignored deployment config, and deploys. No AUD is created or copied by hand. diff --git a/docs/how-to/index.md b/docs/how-to/index.md index 4f1ef8e..df7857f 100644 --- a/docs/how-to/index.md +++ b/docs/how-to/index.md @@ -1,20 +1,83 @@ # How-to guides -## Update a site +## Add a Worker to a static folder -Publish the same site name. Assets enter a new immutable deployment; only complete verified deployments can replace the active pointer. +Create a root `_worker.js`: -## Add administrators +```js +export default { + fetch(request, env) { + const url = new URL(request.url); + if (url.pathname === '/api/health') return Response.json({ edge: 'ok' }); + return env.ASSETS.fetch(request); + } +}; +``` -Update `ADMIN_EMAILS` with normalized comma-separated addresses. Site creators can always update their own sites. +The rest of the folder remains browser assets. -## Recover from a suspected exposure +## Add bindings -1. Disable the wildcard route or deny the Access application. -2. Confirm `workers_dev` and Preview URLs remain disabled. -3. Inspect deployment and Access logs without serving uploaded content. -4. Restore only after an isolated anonymous request proves denial. +Create `up.json`: -## Prepare an agent-authored site +```json +{ + "bindings": { + "kv": ["CACHE"], + "d1": ["DB"], + "durableObjects": [{ "binding": "ROOMS", "className": "Room" }] + } +} +``` -Tell the agent to output static files, include `index.html`, use no server secrets, and keep individual files under the configured limit. Review the folder before publishing. +Export every Durable Object class from `_worker.js`. Binding names must be unique uppercase identifiers. + +## Redeploy an active experiment + +```sh +up deploy ./app --accept-cloudflare-terms +``` + +The default Worker name is a stable fingerprint of the absolute folder path. Pinned Wrangler reuses the active Temporary Account and previously provisioned resources while its credentials and ownership link remain valid. + +## Choose a readable Worker name + +```sh +up deploy ./app crit-board --accept-cloudflare-terms +``` + +Explicit names use lowercase letters, numbers, and hyphens. Invalid names fail with a suggested correction rather than being silently rewritten. + +## Keep the whole graph + +```sh +up claim --open +``` + +The ownership link grants the whole Temporary Account, including every app, binding, and data record created in the active anonymous Up session. Up stores it locally; do not share or commit it. + +After the browser flow, continue from the same source folder: + +```sh +bunx wrangler@4.103.0 login +bunx wrangler@4.103.0 whoami +up handoff ./app exact-worker-name --account-id +``` + +See [After you keep an app](after-claim.md) for the copy-ready agent prompt and Access checklist. + +## Prepare an agent-authored app + +```sh +up init +``` + +Tell the agent to read `.up/SKILL.md`, build the dynamic app, test locally, and ask before making it public and accepting Cloudflare's Terms. After approval it deploys and exercises every declared binding. If you keep the app, `.up/HANDOFF.md` contains the continuation prompt. + +## Use an existing company installation + +```sh +up private ./dist team-tool --origin https://up.example.com +``` + +This is a separate secondary mode. It authenticates through the installation's browser flow and returns a company-private URL. diff --git a/docs/reference/index.md b/docs/reference/index.md index 9a286c1..4688c83 100644 --- a/docs/reference/index.md +++ b/docs/reference/index.md @@ -1,24 +1,94 @@ # Reference -## Cloudflare resources +## Folder -- Worker: public docs, authenticated control API, authenticated site router -- Durable Object with SQLite: site ownership and deployment authority -- private R2 bucket: immutable deployment assets -- Cloudflare Access: organization identity boundary +| File | Contract | +|---|---| +| `index.html` | Required browser entry point | +| `_worker.js` | Optional Worker entry point and Durable Object class exports | +| `up.json` | Optional anonymous binding declaration; requires `_worker.js` | +| other files | Static Assets available through `env.ASSETS` | -## Defaults +## `up.json` -- 500 files per deployment -- 10 MiB per file -- 50 MiB total -- `index.html` required -- site names are lowercase DNS labels; `www`, `app`, `api`, and `admin` are reserved +```json +{ + "bindings": { + "kv": ["CACHE"], + "d1": ["DB"], + "durableObjects": [{ "binding": "ROOMS", "className": "Room" }] + } +} +``` -## Mutation requirements +No other root or binding fields are accepted in 0.0.1. -Every mutation requires a verified Access identity, creator/admin authorization, exact `Origin`, and same-origin Fetch Metadata when the browser supplies it. +- Binding names: uppercase letter followed by up to 47 uppercase letters, digits, or `_`. +- Binding names must be unique across KV, D1, and Durable Objects. +- Durable Object class names must be valid JavaScript identifiers. +- Up generates one SQLite migration containing the distinct class names. -## Serving headers +## Commands -Objects preserve their declared content type and receive `nosniff`, immutable caching except for HTML entry points, restrictive Permissions Policy, and `frame-ancestors 'none'`. +| Command | Contract | +|---|---| +| `up deploy [name]` | Provision and deploy one public Temporary Account graph | +| `up deploy … --accept-cloudflare-terms` | Explicit non-interactive Terms acceptance | +| `up claim` | Show claim timing without printing the ownership link | +| `up claim --open` | Open the ownership flow without printing the link | +| `up claim --show` | Explicitly reveal the sensitive ownership link | +| `up handoff --account-id ` | Continue an existing claimed Worker through normal Wrangler OAuth | +| `up init [directory]` | Install `.up/SKILL.md`, `.up/HANDOFF.md`, and client types | +| `up private ` | Explicit secondary company-mode deployment | + +## Anonymous defaults + +- public `workers.dev` URL and API; +- up to/about 60 minutes unless claimed; +- 1,000 Static Asset files; +- 5 MiB per Static Asset; +- symbolic links, special files, and sensitive dotfiles rejected; +- stable path-fingerprint Worker name when omitted; +- private staging snapshot removed after deployment; +- no production or CI/CD promise. + +## Binding matrix + +| Product | Up 0.0.1 | +|---|---| +| Worker runtime | dynamic `_worker.js` | +| Static Assets | `env.ASSETS` | +| KV | draft namespace auto-provisioned by Wrangler | +| D1 | draft database auto-provisioned by Wrangler | +| Durable Objects | generated class binding and SQLite migration | +| Queues | supported by Temporary Accounts; `up.json` wiring not shipped yet | +| Hyperdrive | supported by Temporary Accounts; `up.json` wiring not shipped yet; requires an existing database | +| Certificates | supported account operation; configured outside `up.json` | +| R2, Workers AI, Access | unavailable in current Temporary Account matrix | +| Workflows, Browser Rendering, Containers, Sandboxes, Dispatch | unavailable in current matrix | + +## Connected services with Capa + +Capa is the path from platform bindings to the services an app already uses. GitHub, Stripe, and other APIs run in a separate Worker beside the app. Capa keeps the provider API key out of app code, limits the available actions, and returns both the answer and a record of what happened. + +Capa currently contains 14 generated bindings: Box, Discord, GitHub, GitLab, Jira, Kubernetes, Sentry, Slack, Stripe, Twilio, Twilio Messaging, Twilio Verify, Twitch, and Zoom. + +The same-account design passed a live Temporary Account test: app Worker → private Capa Worker → upstream API. The credential stayed out of the app and response. The simple installer is not shipped yet; it depends on immutable, hash-verified Capa bundles. + +See the [Capa integration contract](../capa-integration.md) and [live spike receipt](../../receipts/2026-06-23-capa-temporary-account-spike.md). + +## After ownership + +Run `wrangler login`, select the new account from `wrangler whoami`, then run `up handoff` with the exact Worker name and account ID. Handoff first proves that the Worker exists in that account. It deploys without `--temporary` and lets Wrangler inherit existing KV and D1 bindings from the Worker's settings. + +No Up API key is involved. A scoped Cloudflare API token is only needed later for CI/CD. Ownership does not make the public URL private; add Access or application authentication separately. + +## Credential behavior + +Up starts Wrangler under `~/.up/anonymous`, sets isolated home/config variables for Windows, macOS, and Linux, and removes current plus deprecated Cloudflare credential variables. + +Wrangler can reuse its temporary account cache during the active session. This means one claim URL transfers every deployment and supported resource in that session. + +## Secondary company mode + +Company mode retains its Access-authenticated router, private R2 deployments, document Durable Object, realtime Durable Object, Workers AI binding, and fixed same-origin client API. Those contracts do not apply to anonymous mode. diff --git a/docs/reference/site-database.md b/docs/reference/site-database.md index b6cb140..3b5f9e4 100644 --- a/docs/reference/site-database.md +++ b/docs/reference/site-database.md @@ -1,27 +1,25 @@ -# Site database +# Company-mode site database -Dynamic Up sites can opt into an isolated SQLite database. The database is a dedicated Durable Object instance named for the site; sites never receive the namespace and cannot address another site's object. +> This reference applies only to the secondary `up private` installation. Anonymous static deployments do not receive this API. -The Dynamic Worker receives one `UP_DB` stub when database access is enabled. It uses the standard binding fetch interface: +Each company-mode site resolves one SQLite Durable Object from its authenticated hostname. Browser code receives document collections rather than a raw SQL interface: ```js -const response = await env.UP_DB.fetch('https://database.internal/query', { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ - sql: 'SELECT body FROM notes WHERE id = ?', - params: [1], - }), -}); -const { rows } = await response.json(); -``` +import { up } from '/_up/client.js'; -Limits: +const notes = up.db.collection('notes'); +const created = await notes.create({ body: 'hello' }); +await notes.get(created.id); +await notes.list({ limit: 50, offset: 0 }); +await notes.update(created.id, { body: 'updated' }); +await notes.delete(created.id); +``` -- one SQL statement per call -- 20,000 SQL characters -- 100 scalar parameters -- 1,000 returned rows -- `ATTACH`, `DETACH`, `PRAGMA`, and `VACUUM` are rejected +Contracts: -Only the site owner or an Up administrator can enable or disable the database. Disabling is destructive: the Durable Object storage is deleted before the API reports success. Dynamic code receives no database binding while disabled. +- collection names are lowercase identifiers up to 48 characters; +- document IDs contain letters, numbers, `_`, or `-` and are at most 64 characters; +- documents are JSON objects up to 64 KiB; +- a list returns at most 100 documents; +- site scope comes from the authenticated hostname; +- browser code cannot select another site's Durable Object. diff --git a/docs/tutorial/index.md b/docs/tutorial/index.md index 11e6f6a..57c6d04 100644 --- a/docs/tutorial/index.md +++ b/docs/tutorial/index.md @@ -1,12 +1,83 @@ -# Tutorial: publish a company-private site - -1. Click the Deploy to Cloudflare button in the README. -2. Create `up.example.com` and `*.up.example.com` in a zone owned by the same account. -3. Create one Access application that protects both hostnames for your organization. -4. Configure `TEAM_DOMAIN`, `POLICY_AUD`, `CONTROL_HOST`, `SITE_DOMAIN`, and administrators. -5. Disable `workers.dev` and Preview URLs, then attach production routes. -6. Open `/app`, choose a folder containing `index.html`, and publish. -7. Verify the returned site while authenticated. -8. Open it in an isolated browser. Access must challenge the browser before uploaded content appears. - -You now have one private publishing plane that can host many static company sites. +# Tutorial: deploy a dynamic stack before signup + +## 1. Make one app folder + +```text +app/ +├── index.html +├── app.js +├── _worker.js +└── up.json +``` + +`_worker.js` handles dynamic routes and serves browser files through `env.ASSETS`: + +```js +export class Room { + constructor(state) { + this.state = state; + } + + async fetch() { + const visits = ((await this.state.storage.get('visits')) || 0) + 1; + await this.state.storage.put('visits', visits); + return Response.json({ visits }); + } +} + +export default { + async fetch(request, env) { + if (new URL(request.url).pathname === '/api/value') { + await env.CACHE.put('last', new Date().toISOString()); + const rows = await env.DB.prepare('SELECT 1 AS ok').all(); + const room = env.ROOMS.get(env.ROOMS.idFromName('main')); + return Response.json({ rows, room: await room.fetch(request).then((r) => r.json()) }); + } + return env.ASSETS.fetch(request); + } +}; +``` + +## 2. Declare bindings + +```json +{ + "bindings": { + "kv": ["CACHE"], + "d1": ["DB"], + "durableObjects": [{ "binding": "ROOMS", "className": "Room" }] + } +} +``` + +Up supports only this narrow manifest in anonymous mode. Unknown fields fail rather than passing arbitrary account configuration through to Wrangler. + +## 3. Deploy + +Interactive terminal: + +```sh +bunx github:acoyfellow/up deploy ./app +``` + +Agent/background session, after the user approves Cloudflare's Terms and Privacy Policy: + +```sh +up deploy ./app --accept-cloudflare-terms +``` + +Up snapshots the folder, isolates credentials, generates a temporary Wrangler configuration, and lets Wrangler provision the supported resources and deployment. + +## 4. Exercise the stack + +Open the page and fetch `/api/value`. Verify KV, D1, and Durable Object behavior. Revise and redeploy during the active session. + +## 5. Decide + +Keep the Worker, resources, and data: + +```sh +up claim --open +``` + +Or do nothing. Cloudflare deletes the unclaimed Temporary Account and whole dynamic graph after up to/about 60 minutes. diff --git a/examples/binding-lab/_worker.js b/examples/binding-lab/_worker.js new file mode 100644 index 0000000..c416bdd --- /dev/null +++ b/examples/binding-lab/_worker.js @@ -0,0 +1,54 @@ +const json = (value, status = 200) => + Response.json(value, { + status, + headers: { 'cache-control': 'no-store', 'x-content-type-options': 'nosniff' }, + }); + +export class Room { + constructor(state) { + this.state = state; + } + + async fetch() { + const visits = ((await this.state.storage.get('visits')) || 0) + 1; + await this.state.storage.put('visits', visits); + return json({ visits }); + } +} + +export default { + async fetch(request, env) { + const url = new URL(request.url); + + if (url.pathname === '/api/state' && request.method === 'GET') { + const pageViews = Number((await env.CACHE.get('page-views')) || 0) + 1; + await env.CACHE.put('page-views', String(pageViews)); + + await env.DB.prepare( + 'CREATE TABLE IF NOT EXISTS notes (id INTEGER PRIMARY KEY AUTOINCREMENT, body TEXT NOT NULL, created_at TEXT NOT NULL)', + ).run(); + const notes = await env.DB.prepare( + 'SELECT id, body, created_at AS createdAt FROM notes ORDER BY id DESC LIMIT 6', + ).all(); + + const room = env.ROOMS.get(env.ROOMS.idFromName('main')); + const roomState = await room + .fetch('https://room.internal/state') + .then((response) => response.json()); + + return json({ pageViews, roomVisits: roomState.visits, notes: notes.results || [] }); + } + + if (url.pathname === '/api/notes' && request.method === 'POST') { + const input = await request.json().catch(() => null); + const body = typeof input?.body === 'string' ? input.body.trim().slice(0, 240) : ''; + if (!body) return json({ error: 'Write a note first.' }, 400); + await env.DB.prepare('INSERT INTO notes (body, created_at) VALUES (?, ?)') + .bind(body, new Date().toISOString()) + .run(); + return json({ stored: true }, 201); + } + + return env.ASSETS.fetch(request); + }, +}; diff --git a/examples/binding-lab/app.js b/examples/binding-lab/app.js new file mode 100644 index 0000000..587e69b --- /dev/null +++ b/examples/binding-lab/app.js @@ -0,0 +1,41 @@ +const views = document.querySelector('#views'); +const room = document.querySelector('#room'); +const notesCount = document.querySelector('#notes-count'); +const notes = document.querySelector('#notes'); +const form = document.querySelector('#note-form'); +const input = document.querySelector('#note'); + +async function load() { + const response = await fetch('/api/state'); + const state = await response.json(); + views.textContent = state.pageViews; + room.textContent = state.roomVisits; + notesCount.textContent = state.notes.length; + notes.replaceChildren( + ...(state.notes.length + ? state.notes.map((note) => { + const item = document.createElement('li'); + item.textContent = note.body; + return item; + }) + : [Object.assign(document.createElement('li'), { textContent: 'No notes yet.' })]), + ); +} + +form.addEventListener('submit', async (event) => { + event.preventDefault(); + const response = await fetch('/api/notes', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ body: input.value }), + }); + if (!response.ok) return; + input.value = ''; + await load(); +}); + +load().catch(() => { + notes.replaceChildren( + Object.assign(document.createElement('li'), { textContent: 'Bindings are unavailable.' }), + ); +}); diff --git a/examples/binding-lab/index.html b/examples/binding-lab/index.html new file mode 100644 index 0000000..3db8892 --- /dev/null +++ b/examples/binding-lab/index.html @@ -0,0 +1,36 @@ + + + + + + + Binding Lab · Up + + + + +
+

UP / TEMPORARY FULL STACK

+

One claimable app.
Three platform bindings.

+

Worker code and assets deploy together. KV counts the edge, D1 stores notes, and a Durable Object coordinates one room.

+ +
+
KVpage views
+
DURABLE OBJECTroom visits
+
D1stored notes
+
+ +
+ +
+
+ +
+

Latest notes

+
  1. Loading bindings…
+
+ +
Public and disposable until someone claims the Temporary Account.
+
+ + diff --git a/examples/binding-lab/style.css b/examples/binding-lab/style.css new file mode 100644 index 0000000..6c11140 --- /dev/null +++ b/examples/binding-lab/style.css @@ -0,0 +1,133 @@ +* { + box-sizing: border-box; +} +body { + margin: 0; + background: #f4f7f9; + color: #17212b; + font-family: Inter, system-ui, sans-serif; +} +main { + width: min(100% - 40px, 980px); + margin: 0 auto; + padding: 70px 0; +} +.kicker { + color: #f6821f; + font: + 600 13px ui-monospace, + monospace; + letter-spacing: 0.12em; +} +h1 { + max-width: 840px; + margin: 24px 0; + font-size: clamp(42px, 8vw, 78px); + line-height: 0.98; + letter-spacing: -0.055em; +} +.lede { + max-width: 700px; + color: #657583; + font-size: 19px; + line-height: 1.6; +} +.metrics { + display: grid; + grid-template-columns: repeat(3, 1fr); + margin: 58px 0 36px; + border: 1px solid #becbd4; + background: white; +} +.metrics article { + display: grid; + min-height: 190px; + padding: 24px; + border-right: 1px solid #d7e0e6; +} +.metrics article:last-child { + border: 0; +} +.metrics span, +small { + color: #71818e; + font: + 600 11px ui-monospace, + monospace; + letter-spacing: 0.08em; +} +.metrics strong { + align-self: center; + font-size: 54px; +} +form, +.notes { + margin-top: 28px; + padding: 26px; + border: 1px solid #becbd4; + background: white; +} +label, +h2 { + display: block; + margin: 0 0 16px; + font-size: 17px; + font-weight: 700; +} +form div { + display: flex; + gap: 8px; +} +input { + min-width: 0; + flex: 1; + padding: 14px; + border: 1px solid #becbd4; + font: inherit; +} +button { + padding: 0 20px; + border: 0; + background: #f6821f; + color: #1f1208; + font-weight: 700; + cursor: pointer; +} +ol { + margin: 0; + padding: 0; + list-style: none; +} +li { + padding: 13px 0; + border-top: 1px solid #e1e7eb; + color: #52616d; +} +footer { + margin-top: 42px; + color: #71818e; + font: + 500 12px ui-monospace, + monospace; +} +@media (max-width: 650px) { + main { + width: min(100% - 28px, 980px); + padding-top: 42px; + } + .metrics { + grid-template-columns: 1fr; + } + .metrics article { + min-height: 130px; + border-right: 0; + border-bottom: 1px solid #d7e0e6; + } + form div { + align-items: stretch; + flex-direction: column; + } + button { + min-height: 48px; + } +} diff --git a/examples/binding-lab/up.json b/examples/binding-lab/up.json new file mode 100644 index 0000000..1b7ca9c --- /dev/null +++ b/examples/binding-lab/up.json @@ -0,0 +1,7 @@ +{ + "bindings": { + "kv": ["CACHE"], + "d1": ["DB"], + "durableObjects": [{ "binding": "ROOMS", "className": "Room" }] + } +} diff --git a/package.json b/package.json index 0b813cc..dbf88d1 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "bin": { "up": "./cli/up.ts" }, - "description": "Publish your company's private web from your Cloudflare account.", + "description": "Deploy a dynamic Cloudflare app and bindings before signup, then claim the stack.", "scripts": { "up": "bun run cli/up.ts", "build": "svelte-kit sync && vite build", @@ -15,7 +15,9 @@ "dev:kit": "bun run dev", "typecheck": "tsc --noEmit", "check:kit": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", - "test": "vitest run", + "test": "bun run test:workers && bun run test:cli", + "test:workers": "vitest run", + "test:cli": "vitest run -c vitest.cli.config.ts", "baseline:check": "bun run scripts/check-baseline.ts", "test:e2e": "bash scripts/local-e2e.sh", "dry-run": "bun run build && wrangler deploy --dry-run --outdir dist", @@ -23,14 +25,17 @@ "public:check": "bash scripts/check-public.sh", "release:access:check": "bun run scripts/check-access-release.ts", "check": "bun run build && bun run check:kit && bun run typecheck && bun run baseline:check && bun run test && biome check .", - "oauth:connect": "bun run scripts/oauth-bootstrap.ts", - "setup": "bun run scripts/setup.ts", - "access:provision": "bun run scripts/provision-access.ts", - "test:production": "node scripts/production-e2e.mjs", - "test:dynamic-production": "node scripts/dynamic-production-e2e.mjs", - "deploy:docs": "bun run check && wrangler deploy -c wrangler.docs.jsonc", - "deploy:app": "bun run check && wrangler deploy -c wrangler.production.jsonc", - "deploy:up:safe": "bun run check && bun run release:access:check && wrangler deploy -c wrangler.up.jsonc" + "company:oauth:connect": "bun run scripts/oauth-bootstrap.ts", + "company:setup": "bun run scripts/setup.ts", + "company:access:provision": "bun run scripts/provision-access.ts", + "company:test:production": "node scripts/production-e2e.mjs", + "verify:anonymous": "node scripts/anonymous-verify.mjs", + "company:verify:onboarding": "node scripts/onboarding-verify.mjs", + "company:verify:onboarding:local": "node scripts/onboarding-verify.mjs --local-only", + "company:test:dynamic-production": "node scripts/dynamic-production-e2e.mjs", + "company:deploy:docs": "bun run check && wrangler deploy -c wrangler.docs.jsonc", + "company:deploy:app": "bun run check && wrangler deploy -c wrangler.production.jsonc", + "company:deploy:up:safe": "bun run check && bun run release:access:check && wrangler deploy -c wrangler.up.jsonc" }, "dependencies": { "hono": "^4.12.19", diff --git a/public/images/capa/README.md b/public/images/capa/README.md new file mode 100644 index 0000000..f82279a --- /dev/null +++ b/public/images/capa/README.md @@ -0,0 +1,8 @@ +# Capa catalog logos + +Brand marks shown on the Up homepage for the capabilities checked into Capa. + +- Box, Discord, GitHub, GitLab, Jira, Kubernetes, Sentry, Stripe, Twitch, and Zoom: downloaded from the Simple Icons CDN on 2026-06-23. +- Slack and Twilio: pinned from `simple-icons@13.21.0` because those marks are no longer available from the live Simple Icons CDN. + +These marks identify their respective services. All trademarks belong to their owners. The catalog source of truth is `acoyfellow/capa/capabilities/*/capa.manifest.json`; Twilio represents three checked-in bindings: core, Messaging, and Verify. diff --git a/public/images/capa/box.svg b/public/images/capa/box.svg new file mode 100644 index 0000000..f0c83f2 --- /dev/null +++ b/public/images/capa/box.svg @@ -0,0 +1 @@ +Box \ No newline at end of file diff --git a/public/images/capa/discord.svg b/public/images/capa/discord.svg new file mode 100644 index 0000000..ef25142 --- /dev/null +++ b/public/images/capa/discord.svg @@ -0,0 +1 @@ +Discord \ No newline at end of file diff --git a/public/images/capa/github.svg b/public/images/capa/github.svg new file mode 100644 index 0000000..81920ca --- /dev/null +++ b/public/images/capa/github.svg @@ -0,0 +1 @@ +GitHub \ No newline at end of file diff --git a/public/images/capa/gitlab.svg b/public/images/capa/gitlab.svg new file mode 100644 index 0000000..509fa7f --- /dev/null +++ b/public/images/capa/gitlab.svg @@ -0,0 +1 @@ +GitLab \ No newline at end of file diff --git a/public/images/capa/jira.svg b/public/images/capa/jira.svg new file mode 100644 index 0000000..1bb9167 --- /dev/null +++ b/public/images/capa/jira.svg @@ -0,0 +1 @@ +Jira \ No newline at end of file diff --git a/public/images/capa/kubernetes.svg b/public/images/capa/kubernetes.svg new file mode 100644 index 0000000..b2dded3 --- /dev/null +++ b/public/images/capa/kubernetes.svg @@ -0,0 +1 @@ +Kubernetes \ No newline at end of file diff --git a/public/images/capa/sentry.svg b/public/images/capa/sentry.svg new file mode 100644 index 0000000..4788897 --- /dev/null +++ b/public/images/capa/sentry.svg @@ -0,0 +1 @@ +Sentry \ No newline at end of file diff --git a/public/images/capa/slack.svg b/public/images/capa/slack.svg new file mode 100644 index 0000000..bb2957e --- /dev/null +++ b/public/images/capa/slack.svg @@ -0,0 +1 @@ +Slack \ No newline at end of file diff --git a/public/images/capa/stripe.svg b/public/images/capa/stripe.svg new file mode 100644 index 0000000..48272a2 --- /dev/null +++ b/public/images/capa/stripe.svg @@ -0,0 +1 @@ +Stripe \ No newline at end of file diff --git a/public/images/capa/twilio.svg b/public/images/capa/twilio.svg new file mode 100644 index 0000000..2392f4b --- /dev/null +++ b/public/images/capa/twilio.svg @@ -0,0 +1 @@ +Twilio \ No newline at end of file diff --git a/public/images/capa/twitch.svg b/public/images/capa/twitch.svg new file mode 100644 index 0000000..991cbb6 --- /dev/null +++ b/public/images/capa/twitch.svg @@ -0,0 +1 @@ +Twitch \ No newline at end of file diff --git a/public/images/capa/zoom.svg b/public/images/capa/zoom.svg new file mode 100644 index 0000000..28828a3 --- /dev/null +++ b/public/images/capa/zoom.svg @@ -0,0 +1 @@ +Zoom \ No newline at end of file diff --git a/public/images/cloudflare/LICENSE.txt b/public/images/cloudflare/LICENSE.txt new file mode 100644 index 0000000..89e7502 --- /dev/null +++ b/public/images/cloudflare/LICENSE.txt @@ -0,0 +1,29 @@ +BSD 3-Clause License + +Copyright (c) Cloudflare, Inc. +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/public/images/cloudflare/README.md b/public/images/cloudflare/README.md new file mode 100644 index 0000000..250d153 --- /dev/null +++ b/public/images/cloudflare/README.md @@ -0,0 +1,18 @@ +# Cloudflare product icons + +Product glyphs used to identify native Temporary Account capabilities on the Up homepage. + +Source: `@cloudflare/component-icon@13.15.0` from the Cloudflare dashboard codebase. + +| Local file | Source component | +|---|---| +| `workers.svg` | `WorkersBundled` | +| `assets.svg` | `WorkersBundled` (Cloudflare does not expose a separate Static Assets glyph) | +| `kv.svg` | `WorkersKv` | +| `d1.svg` | `D1` | +| `durableObjects.svg` | `WorkersDurableObjects` | +| `queues.svg` | `Queues` | +| `hyperdrive.svg` | `Hyperdrive` | +| `certificates.svg` | `Certificate` | + +Extracted to standalone local SVGs on 2026-06-23. Cloudflare and its product names and marks belong to Cloudflare, Inc. Their use here identifies the platform primitives being exercised; Up remains an independent project and not an official Cloudflare product. diff --git a/public/images/cloudflare/assets.svg b/public/images/cloudflare/assets.svg new file mode 100644 index 0000000..623dc7e --- /dev/null +++ b/public/images/cloudflare/assets.svg @@ -0,0 +1,2 @@ + + diff --git a/public/images/cloudflare/certificates.svg b/public/images/cloudflare/certificates.svg new file mode 100644 index 0000000..c9364a2 --- /dev/null +++ b/public/images/cloudflare/certificates.svg @@ -0,0 +1,2 @@ + + diff --git a/public/images/cloudflare/d1.svg b/public/images/cloudflare/d1.svg new file mode 100644 index 0000000..a80e0ee --- /dev/null +++ b/public/images/cloudflare/d1.svg @@ -0,0 +1,2 @@ + + diff --git a/public/images/cloudflare/durableObjects.svg b/public/images/cloudflare/durableObjects.svg new file mode 100644 index 0000000..f8e5695 --- /dev/null +++ b/public/images/cloudflare/durableObjects.svg @@ -0,0 +1,2 @@ + + diff --git a/public/images/cloudflare/hyperdrive.svg b/public/images/cloudflare/hyperdrive.svg new file mode 100644 index 0000000..3aa2e8d --- /dev/null +++ b/public/images/cloudflare/hyperdrive.svg @@ -0,0 +1,2 @@ + + diff --git a/public/images/cloudflare/kv.svg b/public/images/cloudflare/kv.svg new file mode 100644 index 0000000..6073de5 --- /dev/null +++ b/public/images/cloudflare/kv.svg @@ -0,0 +1,2 @@ + + diff --git a/public/images/cloudflare/queues.svg b/public/images/cloudflare/queues.svg new file mode 100644 index 0000000..f8b3a67 --- /dev/null +++ b/public/images/cloudflare/queues.svg @@ -0,0 +1,2 @@ + + diff --git a/public/images/cloudflare/workers.svg b/public/images/cloudflare/workers.svg new file mode 100644 index 0000000..623dc7e --- /dev/null +++ b/public/images/cloudflare/workers.svg @@ -0,0 +1,2 @@ + + diff --git a/public/llms.txt b/public/llms.txt index 9ad42d1..9b4ee5d 100644 --- a/public/llms.txt +++ b/public/llms.txt @@ -1,9 +1,12 @@ # Up -- Setup: https://up.ax.cloudflare.dev/tutorial +- Tutorial: https://up.ax.cloudflare.dev/tutorial - How-to: https://up.ax.cloudflare.dev/how-to -- Reference: https://up.ax.cloudflare.dev/reference -- Principles: https://up.ax.cloudflare.dev/explanation +- Binding reference: https://up.ax.cloudflare.dev/reference +- Architecture: https://up.ax.cloudflare.dev/explanation - Source: https://github.com/acoyfellow/up +- Dynamic proof: https://github.com/acoyfellow/up/tree/main/examples/binding-lab +- Capa catalog: https://capa.coey.dev +- Capa integration contract: https://github.com/acoyfellow/up/blob/main/docs/capa-integration.md -Up puts static folders online for your company behind Cloudflare Access. Sites stay private by default and run from private R2 in your Cloudflare account. +Up runs a dynamic Cloudflare application before signup: Worker code, Static Assets, KV, D1, and Durable Objects work together in one public Temporary Account. A proven Capa spike installs a generated third-party API Worker, provider secret, and JSRPC service binding into that same account; the click-to-connect installer is not shipped yet. The app and resources expire after about 60 minutes unless the human uses the sensitive account-wide ownership link. After ownership, `up handoff` reconnects the same Worker and bindings through normal Wrangler OAuth. Up is an independent experiment, not an official Cloudflare product. diff --git a/public/manifest.webmanifest b/public/manifest.webmanifest index d40eb7e..2562c0e 100644 --- a/public/manifest.webmanifest +++ b/public/manifest.webmanifest @@ -1,7 +1,7 @@ { - "name": "Up — your company’s private web", + "name": "Up — dynamic stacks before signup", "short_name": "Up", - "description": "Publish a folder to a company-private URL, private by default.", + "description": "Run a Cloudflare app with Worker code, assets, and platform bindings before signup, then keep it if it works.", "id": "/", "start_url": "/", "scope": "/", @@ -20,7 +20,10 @@ "purpose": "maskable" } ], - "shortcuts": [{ "name": "Publish", "url": "/app" }, { "name": "Tutorial", "url": "/tutorial" }], + "shortcuts": [ + { "name": "Dynamic app tutorial", "url": "/tutorial" }, + { "name": "Binding reference", "url": "/reference" } + ], "screenshots": [ { "src": "/screenshots/desktop.png", diff --git a/receipts/2026-06-19-quick-parity-review.md b/receipts/2026-06-19-quick-parity-review.md index 3d18ad2..01af8c9 100644 --- a/receipts/2026-06-19-quick-parity-review.md +++ b/receipts/2026-06-19-quick-parity-review.md @@ -26,7 +26,9 @@ A source-only disposable spike used Alchemy v2 `2.0.0-beta.25`. It could type-ch Current Cloudflare Temporary Accounts were reviewed from the June 19 changelog and official claim-deployments documentation. -Temporary Accounts support Workers, Static Assets, KV, D1, Hyperdrive, Queues, and certificates. They do not currently support Durable Objects, R2, Workers AI, Access, custom domains, or wildcard routes. +Temporary Accounts support Workers, Static Assets, KV, D1, Hyperdrive, Queues, and certificates. At review time this receipt treated Durable Objects, R2, Workers AI, Access, custom domains, and wildcard routes as unsupported. + +**2026-06-23 correction:** Cloudflare's current official matrix now explicitly includes Durable Objects for commands that use temporary credentials. R2, Workers AI, Access, custom domains, and wildcard routes remain outside the documented matrix. The temporary deployment below did not use or validate Durable Objects. A real unauthenticated temporary account was created with an isolated `HOME` and Wrangler `4.103.0`. No permanent Cloudflare credentials were used or changed. diff --git a/receipts/2026-06-23-anonymous-first-pivot.md b/receipts/2026-06-23-anonymous-first-pivot.md new file mode 100644 index 0000000..7cd1e0b --- /dev/null +++ b/receipts/2026-06-23-anonymous-first-pivot.md @@ -0,0 +1,71 @@ +# Anonymous-first pivot receipt — 2026-06-23 + +## Decision + +Up's default product is now: + +```text +Worker + Assets + bindings → public Temporary Account graph → claim or expire +``` + +The front-and-center utility is dynamic: `_worker.js` plus Static Assets, KV, D1, and Durable Objects deploy and transfer together. Static-only folders remain a subset. + +The company-private Access installation remains a secondary `up private` mode. No AX production Worker, route, Access policy, DNS record, or permanent Cloudflare account was changed by this pivot branch. + +## Sources reviewed + +- Cloudflare claim-deployments documentation +- June 19 Temporary Accounts changelog +- Temporary Accounts launch post +- installed Wrangler `4.103.0` source and tests +- current Workers SDK implementation of proof of work, temporary account storage, credential rejection, and claim output +- internal documentation MCP was attempted; the `cf-portal` gateway returned HTTP 500 and was unavailable during this review + +## Current official matrix + +Temporary Accounts currently document support for: + +- Workers and `workers.dev` deployments; +- Static Assets: 1,000 files, 5 MiB each; +- KV; +- one D1 database, 100 MB total; +- Durable Objects for commands accepting temporary credentials; +- two Hyperdrive configurations and 10 connections; +- 10 Queues; +- certificate commands using temporary credentials. + +R2, Workers AI, Access, custom domains, wildcard routes, Workflows, Browser Rendering, Containers, Sandboxes, Workers Builds, and Dispatch Namespaces are not listed for temporary use. + +## Implementation boundary + +Up invokes pinned Wrangler rather than copying the unpublished `/client/v4/provisioning/previews` protocol. Wrangler owns: + +- Terms acceptance; +- proof of work; +- temporary API-token creation; +- account caching; +- upload and deployment; +- claim URL generation. + +Up owns: + +- static-folder validation; +- local credential isolation; +- stable non-identifying Worker naming; +- public/expiry/claim warnings; +- secondary company-mode dispatch. + +## Live anonymous smoke + +Two real isolated Temporary Accounts were created without OAuth or permanent Cloudflare credentials. + +1. A one-file static site deployed and returned HTTP 200. +2. `examples/binding-lab` deployed a Worker, Static Assets, KV, D1, and a SQLite Durable Object through the generated anonymous graph. The page and `/api/state` both returned HTTP 200. The API returned live values from KV and the Durable Object plus a successful D1 query result. + +Up withheld Wrangler's raw claim URL and printed one labeled sensitive ownership link. Claim URLs and temporary credentials stayed out of git and this receipt, then were deleted from local disk. The unclaimed smoke accounts expire automatically after up to/about 60 minutes. + +## Security decision + +The former blanket prohibition on public `workers.dev` uploaded content applied to company-private Up. The user explicitly approved an anonymous-first pivot on 2026-06-23. Public `workers.dev` is now expected only for the default disposable mode and must be labeled public. + +The company-mode prohibition remains unchanged: never expose its R2 content or private sites through `workers.dev` or preview URLs. diff --git a/receipts/2026-06-23-anonymous-live-verify.md b/receipts/2026-06-23-anonymous-live-verify.md new file mode 100644 index 0000000..b5d62c0 --- /dev/null +++ b/receipts/2026-06-23-anonymous-live-verify.md @@ -0,0 +1,34 @@ +# Live receipt: anonymous deploy + claim-URL redaction + +Date: 2026-06-23 +Branch: feat/anonymous-first + +## Watched it run + +`UP_RUN_LIVE_ANONYMOUS=yes UP_ACCEPT_CLOUDFLARE_TERMS=yes bun run verify:anonymous` + +- Deployed `examples/binding-lab` to a real Temporary Account (`puzzling-hellebore.workers.dev`) + with no Cloudflare account and no inherited credentials. +- Wrangler auto-provisioned KV + D1 and bound the Durable Object and Static Assets. +- Public URL exercised live: + - page serves (Static Assets) — HTTP 200 + - KV read/write — pageViews 1 → 2 across requests + - Durable Object — roomVisits 1 + - D1 — notes table created, written row persisted +- Left to expire (~60 min). Never claimed. + +This is the "watched it run" receipt behind the homepage headline. + +## Security fix (regression) + +`deployAnonymous` and `claim` previously printed the account-wide claim URL to +stdout. Fixed: + +- deploy never prints the claim URL; it points to `up claim --open` / `--show`. +- `up claim` withholds the link by default; `--open` opens it without printing; + `--show` is the explicit interactive reveal. +- Wrangler's own claim line was already redacted by the output relay. +- The link is stored locally in `wrangler-temporary-account.toml` (mode 600). + +CLI tests updated to assert the link is withheld from deploy/`claim` and only +revealed via `--show`. 9/9 CLI tests pass. diff --git a/receipts/2026-06-23-capa-temporary-account-spike.md b/receipts/2026-06-23-capa-temporary-account-spike.md new file mode 100644 index 0000000..882f91f --- /dev/null +++ b/receipts/2026-06-23-capa-temporary-account-spike.md @@ -0,0 +1,177 @@ +# Up × Capa same-account spike — 2026-06-23 + +## Question + +Can Up install a generated Capa API capability into the same unauthenticated Cloudflare Temporary Account as the user app, connect them with a JSRPC service binding, keep provider credentials out of the app and evidence, and transfer the graph through one account claim? + +## Verdict + +**Yes, the Cloudflare account/runtime composition works. The product installer does not exist yet.** + +The spike proved: + +```text +Temporary Account +├── Up caller Worker +├── generated Capa capability Worker +├── capability Worker secret +└── JSRPC service binding +``` + +The caller executed a real read-only API operation through the generated capability, received `{ result, evidence }`, redeployed, and executed the same binding again. + +## Safe proof credential + +No permanent provider credential was copied. + +The spike generated a narrow Capa capability for one Cloudflare endpoint: + +```text +GET https://api.cloudflare.com/client/v4/accounts +``` + +It used the Temporary Account's own short-lived API token as the capability Worker secret. The capability exposed only one generated read-only method. The token and account expire with the unclaimed spike. + +## Procedure + +1. Used Capa's actual `tools/codegen/src/cli.ts`, parser, emitter, and runtime template to generate `CloudflareReadCapability` from a one-operation OpenAPI document. +2. Deployed the generated capability Worker first with pinned Wrangler `4.103.0 --temporary` and an isolated home/config directory. +3. Read the short-lived token from Wrangler's private mode-600 temporary cache without printing it. +4. Piped it to `wrangler secret put CLOUDFLARE_READ_API_KEY --temporary`. +5. Deployed an Up caller Worker in the same Temporary Account with: + + ```json + { + "binding": "CLOUDFLARE", + "service": "capa-cloudflare-read", + "entrypoint": "CloudflareReadCapability" + } + ``` + +6. Called `env.CLOUDFLARE.accounts.list()` through JSRPC. +7. Redeployed the caller through the reused Temporary Account and repeated the call. +8. Queried the temporary account's Worker settings to verify the two Workers, secret binding, and service binding. + +## Successful operation evidence + +The live caller returned HTTP `200` with: + +```json +{ + "capability": "cloudflare-read", + "operationId": "listAccounts", + "upstreamStatus": 200, + "verdict": "pass", + "assert": [ + { + "kind": "httpStatus", + "expected": "2xx", + "actual": 200, + "passed": true + } + ] +} +``` + +The result contained exactly one temporary account. The credential and `Authorization` header were absent from the response and evidence. The raw response SHA-256 was recorded during the run but is omitted because the result included a temporary account identifier. + +After caller revision 2 was deployed, Wrangler reported the account as reused, the updated page was visible, and the same Capa operation still returned `verdict: pass`. + +## Existing Capa catalog artifact test + +The spike also tested the checked-in Stripe capability with a deliberately fake value: + +```text +sk_test_up_capa_spike_deliberately_not_real +``` + +No real Stripe credential or side effect was possible. + +### Capa `main` + +Capa `main` at `382359f` deployed into the same Temporary Account and was bound as `STRIPE_MAIN → capa-stripe-main-spike.StripeCapability`. + +Calling `env.STRIPE_MAIN.customers.list({ limit: 1 })` reached Stripe and returned a bounded proof: + +```json +{ + "capability": "stripe", + "operationId": "GetCustomers", + "upstreamStatus": 401, + "verdict": "fail", + "result": null, + "assert": [ + { + "kind": "httpStatus", + "expected": "2xx", + "actual": 401, + "passed": false + } + ] +} +``` + +The fake credential and authorization header were absent from the response and evidence. This proves the current mainline catalog Worker, secret, service binding, generated method, upstream fetch, and evidence path all function inside a Temporary Account. + +### Local Capa rewrite branch + +The local `rewrite/distilled-runtime` branch at `cfe48ab` also deployed and bound successfully, but its lazy provider operation was not included in the runtime bundle: + +```text +No such module "../../node_modules/@distilled.cloud/stripe/lib/operations/GetCustomers.js" +``` + +Capa still returned a credential-free `fail` receipt, but it did not reach Stripe. Up must pin Capa `main` or another proven immutable revision until that branch fixes Worker bundling. + +## Final temporary account inventory + +The account contained four Workers: + +- `up-capa-caller` +- `capa-cloudflare-read` +- `capa-stripe-main-spike` +- `capa-stripe-spike` (negative branch-compatibility test) + +The caller had three exact service bindings. Each Capa Worker had only its expected `secret_text` binding. No Capa Worker had a public `workers.dev` route. + +## Claim semantics + +Cloudflare's Temporary Account documentation says claiming preserves the Worker and supported resources created in the account. The spike verified that every app Worker, capability Worker, secret, and service binding was in one account behind one claim URL. + +The spike did **not** open the claim URL because that would make test resources permanent. Account transfer is therefore supported by the documented account-level claim contract and resource inventory, not by a destructive claim/unclaim experiment. + +## Product conclusion + +A local Up composer can safely model: + +```text +Native bindings + KV / D1 / Durable Objects + +API capabilities by Capa + install capability Worker + set provider credential on that Worker + bind caller by entrypoint +``` + +Capa Workers must be installed into the same Temporary Account. Existing permanent `capa-*` Workers cannot be service-bound cross-account. + +The missing product layer is an immutable Capa install artifact/catalog contract. Up should not clone the entire Capa repository or generate large providers at deployment time. The first integration should pin one proven Capa revision and install one capability bundle with machine-readable: + +- capability name; +- source revision; +- Worker entrypoint; +- required secret names; +- auth shape; +- operation count; +- bundle integrity hash. + +## Security + +- Claim URLs were redacted from every retained spike log. +- Temporary and provider tokens were never printed or passed as command arguments. +- The only non-temporary provider value was deliberately fake. +- Evidence contained no credential or authorization header. +- No permanent Cloudflare account, Worker, route, Access policy, DNS record, or Capa deployment changed. +- Local temporary state and all raw API responses were deleted after evidence extraction. +- The unclaimed remote account expires automatically. diff --git a/receipts/2026-06-23-dogfood-architecture.md b/receipts/2026-06-23-dogfood-architecture.md new file mode 100644 index 0000000..6b9987d --- /dev/null +++ b/receipts/2026-06-23-dogfood-architecture.md @@ -0,0 +1,89 @@ +# Decision: Up is pure orchestration of public Cloudflare primitives + +Date: 2026-06-23 +Branch: feat/anonymous-first + +## Thesis (why Up exists next to cloudflare.com/drop) + +`/drop` is browser drag-and-drop, static sites, on cloudflare.com, for net-new +users. Up is the opposite corner on purpose: + +| | /drop | Up | +|---|---|---| +| Surface | browser | terminal / agent | +| Payload | static site | dynamic Worker + KV + D1 + DO | +| Home | cloudflare.com | independent (`up.coey.dev`) | +| Audience | new-user funnel | devs/agents who want the whole platform first | + +The implementation rule that makes Up a *demonstration*, not a competitor: + +> **Every Up capability is a public Cloudflare primitive invoked through Wrangler +> or a documented API. Up writes orchestration and configuration — never a +> reimplementation of a platform feature.** + +If a layer can be a Cloudflare primitive, it must be. Custom code is a bug. + +## Audit result (what violated the rule) + +The anonymous path already obeys it (wraps Wrangler + Temporary Accounts + Workers +Static Assets). The private/company path predates the pivot and hand-rolled four +platform features: + +1. Static hosting reimplemented on an R2 bucket (`core-backend.ts serveSite`). +2. A bespoke manifest → upload → activate deploy protocol (Wrangler asset upload). +3. CLI auth via custom PKCE + HMAC session cookies + a wildcard session broker + (`wrangler login` / Access). +4. A custom credential-free capability RPC over R2/DO (`capabilities.ts`) — the + platform bindings already *are* the capabilities. + +## Target architecture (both modes are pure orchestration) + +### Anonymous (default, already correct) +`up deploy ./app` → snapshot → generate `wrangler.jsonc` → +`wrangler deploy --temporary --experimental-provision`. +- compute: Worker · host: Workers Static Assets · state: KV / D1 / Durable Objects +- ownership: Wrangler's claim URL +- Up backend: none + +### Private (company) — re-platformed +`up private ./app name` → `wrangler deploy` of a per-app Worker (Static Assets + +declared bindings) into a **Workers for Platforms dispatch namespace** on a real +account. A dispatcher Worker routes `name.domain` → namespace script. **Access** +on the dispatcher enforces employee-only. +- registry: the dispatch namespace (no DO registry) +- host: Workers Static Assets per script (no R2 hand-roll) +- auth: Access JWT at the dispatcher (no PKCE, no session broker) +- CLI auth: `wrangler login` + +This is the same shape Cloudflare's internal drag-and-drop prototype uses, which +confirms the custom code is the seam to delete, not extend. + +### Bindings, not a capability RPC +"Bindings off the shelf" means the user's own Worker calls `env.KV`, `env.DB`, +`env.ROOMS` directly. Up ships example Workers, not a mediated RPC. `capabilities.ts`, +`site-database.ts`, `site-realtime.ts` become copyable examples, not an Up runtime. + +## What gets deleted + +`src/core-backend.ts` (R2 host + deploy API), `src/session.ts` (PKCE/HMAC), +`src/registry.ts` (DO registry), `src/capabilities.ts` (custom RPC), and the +`ASSETS: R2Bucket` convention. The four duplicated MIME maps collapse to zero — +Static Assets sets `content-type`. + +## Verification rule (Dane: "watched it run") + +Each mode has a real run, not a unit assertion: +- anonymous: a live deploy to a Temporary Account that fetches the URL + every + declared binding, then lets it expire. Gated behind explicit terms acceptance. +- private: a live deploy into a dispatch namespace that proves Access blocks + anonymous and serves authenticated. Gated behind a human Access token + approval. + +No claim ships on the homepage that lacks one of these green runs. + +## Build order (independently shippable slices) + +1. Anonymous deploy verified live (backs the headline; fixes the untested-promise bug). +2. Hero copy truth pass + public-exposure warning back on the hero. +3. Remove the capability RPC; docs switch to "use your bindings directly." +4. Re-platform private mode onto WFP + Access + Static Assets; delete the R2 backend. +5. Both-mode verification harness + receipts. diff --git a/receipts/2026-06-23-oauth-client-cleanup.md b/receipts/2026-06-23-oauth-client-cleanup.md new file mode 100644 index 0000000..c18c161 --- /dev/null +++ b/receipts/2026-06-23-oauth-client-cleanup.md @@ -0,0 +1,15 @@ +# OAuth client cleanup — 2026-06-23 + +The personal Cloudflare account contained two private clients named `Up CLI`, created 18 seconds apart with the same localhost callback. + +- Deleted unused client: `3ac22d18b8ede8f9a8ffd254bf58bd79` + - created `2026-06-23T09:36:36Z` + - stale eight-scope registration + - never selected by `.cloudflare-oauth.json` +- Retained company-mode client: `b4f7cde044f302c248e4564fb30cee6e` + - created `2026-06-23T09:36:54Z` + - the client used by the personal company-mode installation + +The Cloudflare API returned HTTP 200 for deletion. A fresh account-scoped list returned exactly one remaining OAuth client. + +The local broad OAuth credential was removed after the inventory. It had no refresh token. Direct token revocation from the shell was blocked by dashboard WAF error 1010; the short-lived token was not retained on disk. No Access application, policy, Worker, route, DNS record, R2 bucket, or production deployment was changed during OAuth-client cleanup. diff --git a/receipts/2026-06-23-onboarding-verify.md b/receipts/2026-06-23-onboarding-verify.md new file mode 100644 index 0000000..5d4bfad --- /dev/null +++ b/receipts/2026-06-23-onboarding-verify.md @@ -0,0 +1,66 @@ +# Access-first onboarding: end-to-end verification + +Date: 2026-06-23 +Branch: feat/anonymous-first + +## Onboarding flow under verification + +Employee → live private app, no pre-existing per-app setup: + +1. `up private ./dist ` +2. CLI opens `/app/cli-auth`; employee authenticates through Cloudflare Access. +3. PKCE code → `POST /cli/exchange` → short-lived deploy-only token. +4. `POST /cli/sites//deployments` (manifest). +5. `PUT /cli/deployments//assets` per file (sha256 + size enforced). +6. `POST /cli/deployments//activate` (atomic; rejects partial/mismatched). +7. `.` serves only to authenticated org members; anonymous → Access login. + +This dogfoods temporary accounts (the public deploy primitive) plus the OAuth/Access +login as the claim/identity layer. + +## Single command + +```sh +bun run company:verify:onboarding # local + gated production +bun run company:verify:onboarding:local # local only (fast inner loop) +``` + +Orchestrator: `scripts/onboarding-verify.mjs`. Exit codes: `0` ok · `1` failure · +`78` blocked (needs human-authorized input, not a failure). + +## Local — GREEN + +- `local: onboarding flow tests` — 17 passed, real Durable Objects + R2 via the + workers pool: create → upload (digest/size) → activate (atomic) → private serve → + identity → per-site capability isolation → atomic supersede. +- `local: build` — SvelteKit + Worker build. +- `local: live fail-closed boot` — boots `wrangler.local.jsonc` and asserts the + deployed Worker gates correctly with Access unconfigured and no bypass: + - `/api/health` → `accessConfigured:false`; + - `/api/sites` → 503/403; + - `/cli/sites//deployments` (no token) → 401/503; + - unknown `.localhost` private site → 404/302 (raw Host header). + +## Production — BLOCKED (by design, requires the human) + +The unattended path can never mutate AX production. The live authed deploy runs only +with BOTH a human-minted token and explicit approval: + +- `production: Access posture guard` — needs `UP_ACCESS_APPLICATION_FILE` (+ expected + app/IdP/domain/host env) → read-only employee-only-policy check. +- `production: live authed deploy` — needs `CF_ACCESS_TOKEN` + (`cloudflared access token -app https://up.ax.cloudflare.dev`) AND + `UP_ALLOW_PRODUCTION_DEPLOY=yes`. Runs `scripts/production-e2e.mjs`: + authed deploy → serve "Private by default." → `/__up/me` identity → + anonymous request blocked. + +## To finish production verification (human step) + +```sh +export CF_ACCESS_TOKEN="$(cloudflared access token -app https://up.ax.cloudflare.dev)" +export UP_ALLOW_PRODUCTION_DEPLOY=yes +# optional posture guard inputs: +# UP_ACCESS_APPLICATION_FILE, UP_EXPECTED_ACCESS_APP_ID, UP_EXPECTED_ACCESS_IDP, +# UP_EXPECTED_EMAIL_DOMAIN, UP_CONTROL_HOST +bun run company:verify:onboarding +``` diff --git a/scripts/anonymous-verify.mjs b/scripts/anonymous-verify.mjs new file mode 100644 index 0000000..81a8b80 --- /dev/null +++ b/scripts/anonymous-verify.mjs @@ -0,0 +1,145 @@ +#!/usr/bin/env node +// Live proof for the homepage headline: `up deploy` puts a real dynamic Worker +// (KV + D1 + Durable Object) on a public URL with no Cloudflare account. +// +// This is the "watched it run" receipt. It deploys examples/binding-lab to a real +// Temporary Account, exercises every binding over the public URL, then leaves the +// account to expire (~1h). It never claims and never prints the claim URL. +// +// Gated: requires UP_RUN_LIVE_ANONYMOUS=yes and UP_ACCEPT_CLOUDFLARE_TERMS=yes +// (terms acceptance is a human decision). Exit: 0 ok · 1 fail · 78 blocked. + +import { spawn } from 'node:child_process'; + +const BLOCKED = 78; +const APP = 'examples/binding-lab'; + +// Strip account-wide ownership links from anything we print. +const redact = (text) => + String(text) + .replace(/https?:\/\/[^\s]*claim[^\s]*/gi, '[claim-url redacted]') + .replace(/claimToken=[A-Za-z0-9._-]+/gi, 'claimToken=[redacted]'); + +function sh(command, args, opts = {}) { + return new Promise((resolve) => { + const child = spawn(command, args, { ...opts, stdio: ['ignore', 'pipe', 'pipe'] }); + let out = ''; + child.stdout.on('data', (d) => (out += d)); + child.stderr.on('data', (d) => (out += d)); + child.on('close', (code) => resolve({ code, out })); + child.on('error', (e) => resolve({ code: 1, out: String(e) })); + }); +} + +// curl -k dodges corporate TLS interception; this is a disposable public app. +async function get(url, init = {}) { + const args = ['-sk', '-m', '25', '-X', init.method || 'GET']; + for (const [k, v] of Object.entries(init.headers || {})) args.push('-H', `${k}: ${v}`); + if (init.body) args.push('--data', init.body); + args.push('-w', '\n__HTTP__%{http_code}', url); + const { out } = await sh('curl', args); + const marker = out.lastIndexOf('\n__HTTP__'); + const status = Number(out.slice(marker + 9).trim()); + const body = out.slice(0, marker); + return { status, body }; +} + +if ( + process.env.UP_RUN_LIVE_ANONYMOUS !== 'yes' || + process.env.UP_ACCEPT_CLOUDFLARE_TERMS !== 'yes' +) { + console.log( + '▷ blocked — set UP_RUN_LIVE_ANONYMOUS=yes and UP_ACCEPT_CLOUDFLARE_TERMS=yes to run a live\n' + + ' disposable deploy (no account, auto-expires in ~1h). This accepts Cloudflare\u2019s terms.', + ); + process.exit(BLOCKED); +} + +const name = `binding-lab-verify-${Date.now().toString(36)}`; +console.log(`Deploying ${APP} to a real Temporary Account as ${name}\u2026`); + +const deploy = await sh( + 'bun', + ['run', 'cli/up.ts', 'deploy', APP, name, '--accept-cloudflare-terms'], + { + env: process.env, + }, +); +console.log( + redact(deploy.out) + .split('\n') + .filter((l) => !/^Keep it|^This link claims|sensitive ownership/.test(l)) + .join('\n'), +); +if (deploy.code !== 0) { + console.log('\nRESULT: FAIL (deploy failed)'); + process.exit(1); +} + +const liveUrl = (redact(deploy.out).match(/https:\/\/[^\s]+workers\.dev[^\s]*/) || [])[0]; +if (!liveUrl) { + console.log('\nRESULT: FAIL (no workers.dev URL in output)'); + process.exit(1); +} +const origin = new URL(liveUrl).origin; + +const checks = []; +const check = (name, ok, detail) => { + checks.push(ok); + console.log(`${ok ? '✓' : '✗'} ${name}${detail ? ` — ${detail}` : ''}`); +}; + +const page = await get(`${origin}/`); +check('public page serves (Static Assets)', page.status === 200, `HTTP ${page.status}`); + +const before = await get(`${origin}/api/state`); +let s1 = {}; +try { + s1 = JSON.parse(before.body); +} catch {} +check( + 'KV read/write (pageViews)', + before.status === 200 && Number.isFinite(s1.pageViews) && s1.pageViews >= 1, + `pageViews=${s1.pageViews}`, +); +check( + 'Durable Object (roomVisits)', + Number.isFinite(s1.roomVisits) && s1.roomVisits >= 1, + `roomVisits=${s1.roomVisits}`, +); +check( + 'D1 (notes table)', + Array.isArray(s1.notes), + `notes=${Array.isArray(s1.notes) ? s1.notes.length : 'n/a'}`, +); + +const note = `verify ${new Date().toISOString()}`; +const post = await get(`${origin}/api/notes`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ body: note }), +}); +const after = await get(`${origin}/api/state`); +let s2 = {}; +try { + s2 = JSON.parse(after.body); +} catch {} +check( + 'D1 write persists', + post.status === 201 && (s2.notes || []).some((n) => n.body === note), + `wrote "${note.slice(0, 16)}…"`, +); +check( + 'KV increments across requests', + Number(s2.pageViews) > Number(s1.pageViews), + `${s1.pageViews} → ${s2.pageViews}`, +); + +console.log(`\nLeft to expire — not claimed. Public URL: ${origin}`); +console.log(`${'─'.repeat(60)}`); +if (checks.every(Boolean)) { + console.log('RESULT: ALL GREEN — real Worker + KV + D1 + Durable Object, no account.'); + process.exit(0); +} +console.log('RESULT: FAIL (a binding did not behave)'); +process.exit(1); diff --git a/scripts/browser-e2e.mjs b/scripts/browser-e2e.mjs index 424a8d9..3234388 100644 --- a/scripts/browser-e2e.mjs +++ b/scripts/browser-e2e.mjs @@ -8,24 +8,24 @@ try { page.on('pageerror', (error) => browserErrors.push(error.message)); await page.goto(origin, { waitUntil: 'networkidle' }); if (browserErrors.length) throw Error(`browser hydration failed: ${browserErrors.join('; ')}`); - if (!(await page.getByRole('heading', { name: 'Your company’s private web.' }).isVisible())) + if ( + !(await page.getByRole('heading', { name: 'Your app is live before you sign up.' }).isVisible()) + ) throw Error('product front door missing'); - if (!(await page.getByRole('link', { name: 'Open Up', exact: true }).isVisible())) - throw Error('publisher CTA missing'); - if (!(await page.getByRole('link', { name: 'Install your own', exact: true }).isVisible())) - throw Error('installation CTA missing'); + if (!(await page.getByRole('link', { name: 'Get the CLI', exact: true }).isVisible())) + throw Error('CLI CTA missing'); + if (!(await page.getByRole('link', { name: 'See it work', exact: true }).isVisible())) + throw Error('tutorial CTA missing'); if ( - !(await page - .getByText('Cloudflare’s hosted installation is currently available to Cloudflare employees.') - .isVisible()) + !(await page.getByText('Independent project. Not an official Cloudflare product.').isVisible()) ) - throw Error('hosted installation qualifier missing'); - for (const name of ['Tutorial', 'How-to guides', 'Reference', 'Explanation']) { + throw Error('independent-project qualifier missing'); + for (const name of ['Docs', 'How it works', 'Get the CLI']) { if (!(await page.getByRole('link', { name }).first().isVisible())) - throw Error(`Diátaxis entry missing: ${name}`); + throw Error(`primary navigation entry missing: ${name}`); } await page.goto(`${origin}/tutorial`); - if (!(await page.getByRole('heading', { name: 'Set up Up' }).isVisible())) + if (!(await page.getByRole('heading', { name: 'Deploy the stack before signup' }).isVisible())) throw Error('tutorial missing'); const protectedApi = await page.request.get(`${origin}/api/sites`); if (![403, 503].includes(protectedApi.status())) @@ -35,10 +35,12 @@ try { const noJs = await browser.newPage({ javaScriptEnabled: false }); await noJs.goto(origin, { waitUntil: 'load' }); - if (!(await noJs.getByRole('heading', { name: 'Your company’s private web.' }).isVisible())) + if ( + !(await noJs.getByRole('heading', { name: 'Your app is live before you sign up.' }).isVisible()) + ) throw Error('SSR front door missing without JavaScript'); const description = await noJs.locator('meta[name="description"]').getAttribute('content'); - if (!description?.includes('company-private URL')) + if (!description?.includes('KV, D1, and Durable Objects')) throw Error('SSR description metadata missing'); const canonical = await noJs.locator('link[rel="canonical"]').getAttribute('href'); if (canonical !== 'https://up.ax.cloudflare.dev/') throw Error('SSR canonical metadata missing'); diff --git a/scripts/lib/cf-credentials.ts b/scripts/lib/cf-credentials.ts index ff305c8..e86e5bd 100644 --- a/scripts/lib/cf-credentials.ts +++ b/scripts/lib/cf-credentials.ts @@ -22,6 +22,7 @@ export const CREDENTIALS_FILE = resolve(process.cwd(), '.cloudflare-oauth.json') */ export const DEFAULT_SCOPES = [ 'access.write', // Access: Apps and Policies Write + 'access-org.write', // create/read the account Access organization when absent 'workers-scripts.write', // deploy the control Worker (+ Durable Objects) 'workers-routes.write', // bind the wildcard + app routes 'workers-r2.write', // create/manage the private assets bucket @@ -58,7 +59,7 @@ function isExpired(stored: StoredCredentials): boolean { async function refresh(stored: StoredCredentials): Promise { if (!stored.refresh_token || !stored.client_id) { throw new Error( - 'Stored Cloudflare token expired and cannot be refreshed. Run bun run oauth:connect.', + 'Stored Cloudflare token expired and cannot be refreshed. Run bun run company:oauth:connect.', ); } const body = new URLSearchParams({ @@ -93,7 +94,7 @@ export async function resolveToken(): Promise { const stored = await readStored(); if (!stored?.access_token) { throw new Error( - 'No Cloudflare credentials. Run bun run oauth:connect, or set CLOUDFLARE_API_TOKEN.', + 'No Cloudflare credentials. Run bun run company:oauth:connect, or set CLOUDFLARE_API_TOKEN.', ); } if (isExpired(stored)) return (await refresh(stored)).access_token; diff --git a/scripts/oauth-bootstrap.ts b/scripts/oauth-bootstrap.ts index 5fb9e94..58aa670 100644 --- a/scripts/oauth-bootstrap.ts +++ b/scripts/oauth-bootstrap.ts @@ -155,4 +155,4 @@ await writeFile(CREDENTIALS_FILE, `${JSON.stringify(record, null, 2)}\n`, { mode console.log(`\nConnected. Token stored in ${CREDENTIALS_FILE} (gitignored, mode 600).`); console.log('Scripts read it automatically. For ad-hoc shells:'); console.log(' export CLOUDFLARE_API_TOKEN="$(bun run scripts/print-token.ts)"'); -console.log('\nNext: bun run access:provision → bun run deploy:app'); +console.log('\nNext: bun run company:access:provision → bun run company:deploy:app'); diff --git a/scripts/onboarding-verify.mjs b/scripts/onboarding-verify.mjs new file mode 100644 index 0000000..fdcb27d --- /dev/null +++ b/scripts/onboarding-verify.mjs @@ -0,0 +1,242 @@ +#!/usr/bin/env node +// Single orchestrator for the Access-first onboarding flow. +// +// Onboarding under verification (employee → live private app): +// 1. employee runs `up private ./dist ` +// 2. CLI opens /app/cli-auth, employee authenticates through Cloudflare Access +// 3. PKCE code → POST /cli/exchange → short-lived deploy token +// 4. POST /cli/sites//deployments (manifest) +// 5. PUT /cli/deployments//assets (per file, sha256 + size checked) +// 6. POST /cli/deployments//activate (atomic) +// 7. . serves only to authenticated org members; anonymous → Access login +// +// Phases: +// LOCAL always. Hermetic flow tests (real DO + R2 via the workers pool) +// plus a live boot that proves the deployed Worker fails closed. +// PRODUCTION gated. Read-only Access posture guard always runs when its inputs +// are present. The real authed deploy against AX production only runs +// with BOTH a human-minted CF_ACCESS_TOKEN and UP_ALLOW_PRODUCTION_DEPLOY=yes. +// +// Exit codes: 0 ok · 1 failure · 78 blocked (needs human input, not a failure). + +import { spawn } from 'node:child_process'; +import http from 'node:http'; +import { setTimeout as sleep } from 'node:timers/promises'; + +// Raw request so we can set the forbidden `Host` header (undici fetch drops it), +// which is the only way to exercise wildcard private-site routing in local dev. +function statusWithHost(port, path, host, method = 'GET') { + return new Promise((resolve, reject) => { + const request = http.request( + { host: '127.0.0.1', port, path, method, headers: { host } }, + (response) => { + response.resume(); + resolve(response.statusCode); + }, + ); + request.on('error', reject); + request.end(); + }); +} + +const BLOCKED = 78; +const arg = (name) => process.argv.includes(name); +const localOnly = arg('--local-only'); +const productionOnly = arg('--production-only'); +const skipLive = arg('--no-live'); + +const steps = []; +const record = (name, status, detail) => { + steps.push({ name, status, detail }); + const icon = status === 'ok' ? '✓' : status === 'blocked' ? '▷' : '✗'; + console.log(`${icon} ${name}${detail ? ` — ${detail}` : ''}`); +}; + +function run(command, args, { env } = {}) { + return new Promise((resolve) => { + const child = spawn(command, args, { + stdio: ['ignore', 'pipe', 'pipe'], + env: { ...process.env, ...env }, + }); + let out = ''; + child.stdout.on('data', (d) => { + out += d; + }); + child.stderr.on('data', (d) => { + out += d; + }); + child.on('close', (code) => resolve({ code, out })); + child.on('error', (error) => resolve({ code: 1, out: String(error) })); + }); +} + +async function expect(label, promise, predicate) { + try { + const value = await promise; + if (predicate(value)) return true; + record(label, 'fail', `unexpected ${JSON.stringify(value)}`); + return false; + } catch (error) { + record(label, 'fail', error instanceof Error ? error.message : String(error)); + return false; + } +} + +async function status(url, init) { + const response = await fetch(url, { redirect: 'manual', ...init }); + return response.status; +} + +// ── LOCAL ──────────────────────────────────────────────────────────────────── +async function local() { + const tests = await run('bun', ['run', 'test:workers']); + if (tests.code !== 0) { + record('local: onboarding flow tests', 'fail', 'see output below'); + console.log(tests.out.split('\n').slice(-25).join('\n')); + return false; + } + const passed = (tests.out.match(/Tests\s+(\d+)\s+passed/) || [])[1] || 'all'; + record('local: onboarding flow tests', 'ok', `${passed} passed (deploy→activate→serve→Access)`); + + if (skipLive) { + record('local: live fail-closed boot', 'blocked', '--no-live'); + return true; + } + + const build = await run('bun', ['run', 'build']); + if (build.code !== 0) { + record('local: build', 'fail', 'see output below'); + console.log(build.out.split('\n').slice(-25).join('\n')); + return false; + } + record('local: build', 'ok'); + + const port = 8799; + const server = spawn( + 'bunx', + ['wrangler', 'dev', '--local', '--port', String(port), '-c', 'wrangler.local.jsonc'], + { stdio: 'ignore', env: process.env }, + ); + const base = `http://127.0.0.1:${port}`; + try { + let ready = false; + for (let i = 0; i < 80; i++) { + try { + const r = await fetch(`${base}/api/health`); + if (r.ok) { + ready = true; + break; + } + } catch {} + await sleep(250); + } + if (!ready) { + record('local: live fail-closed boot', 'fail', 'worker did not become ready'); + return false; + } + + const ok = ( + await Promise.all([ + expect( + 'local: /api/health live', + fetch(`${base}/api/health`).then((r) => r.json()), + (b) => b.edge === 'ok' && b.accessConfigured === false, + ), + expect( + 'local: control API rejects unauthenticated', + status(`${base}/api/sites`), + (s) => s === 503 || s === 403, + ), + expect( + 'local: CLI deploy rejects without token', + status(`${base}/cli/sites/demo/deployments`, { method: 'POST' }), + (s) => s === 401 || s === 503, + ), + expect( + 'local: unknown private site never serves', + statusWithHost(port, '/', 'demo.localhost'), + (s) => s === 404 || s === 302, + ), + ]) + ).every(Boolean); + + if (ok) record('local: live fail-closed boot', 'ok', 'gate verified, no auth bypass'); + return ok; + } finally { + server.kill('SIGTERM'); + } +} + +// ── PRODUCTION ───────────────────────────────────────────────────────────────── +async function production() { + let ok = true; + + const snapshot = process.env.UP_ACCESS_APPLICATION_FILE; + if (snapshot) { + const guard = await run('bun', ['run', 'release:access:check']); + if (guard.code === 0) record('production: Access posture guard', 'ok', 'employee-only policy'); + else { + record( + 'production: Access posture guard', + 'fail', + guard.out.trim().split('\n').slice(-3).join(' '), + ); + ok = false; + } + } else { + record('production: Access posture guard', 'blocked', 'set UP_ACCESS_APPLICATION_FILE'); + } + + const token = process.env.CF_ACCESS_TOKEN; + const approved = process.env.UP_ALLOW_PRODUCTION_DEPLOY === 'yes'; + if (!token || !approved) { + record( + 'production: live authed deploy', + 'blocked', + !token + ? 'needs CF_ACCESS_TOKEN (cloudflared access token -app https://up.ax.cloudflare.dev)' + : 'needs UP_ALLOW_PRODUCTION_DEPLOY=yes (explicit approval)', + ); + return { ok, blocked: true }; + } + + const e2e = await run('node', ['scripts/production-e2e.mjs']); + if (e2e.code === 0) { + record('production: live authed deploy', 'ok', 'deploy→serve→identity→anonymous-block'); + console.log(e2e.out.trim().split('\n').slice(-12).join('\n')); + } else { + record( + 'production: live authed deploy', + 'fail', + e2e.out.trim().split('\n').slice(-5).join('\n'), + ); + ok = false; + } + return { ok, blocked: false }; +} + +// ── DRIVER ───────────────────────────────────────────────────────────────────── +const result = { local: null, production: null }; + +if (!productionOnly) result.local = await local(); +if (!localOnly) result.production = await production(); + +console.log(`\n${'─'.repeat(60)}`); +const localOk = productionOnly || result.local === true; +const prodOk = localOnly || result.production?.ok; +const prodBlocked = !localOnly && result.production?.blocked; + +if (!localOk) { + console.log('RESULT: FAIL (local onboarding broken)'); + process.exit(1); +} +if (!prodOk) { + console.log('RESULT: FAIL (production verification failed)'); + process.exit(1); +} +if (prodBlocked) { + console.log('RESULT: LOCAL GREEN · PRODUCTION BLOCKED (needs human-authorized inputs)'); + process.exit(BLOCKED); +} +console.log('RESULT: ALL GREEN (local + production)'); +process.exit(0); diff --git a/scripts/provision-access.ts b/scripts/provision-access.ts index b051a05..64c9b53 100644 --- a/scripts/provision-access.ts +++ b/scripts/provision-access.ts @@ -6,7 +6,7 @@ const allowEmail = process.env.UP_ALLOWED_EMAIL; const allowDomain = process.env.UP_ALLOWED_DOMAIN; if (!accountId || (!allowEmail && !allowDomain)) { throw new Error( - 'Set CLOUDFLARE_ACCOUNT_ID and UP_ALLOWED_EMAIL or UP_ALLOWED_DOMAIN (run bun run oauth:connect first)', + 'Set CLOUDFLARE_ACCOUNT_ID and UP_ALLOWED_EMAIL or UP_ALLOWED_DOMAIN (run bun run company:oauth:connect first)', ); } const controlHost = process.env.UP_CONTROL_HOST || 'up.example.com'; diff --git a/scripts/setup.ts b/scripts/setup.ts index e6b1c74..06cf76d 100644 --- a/scripts/setup.ts +++ b/scripts/setup.ts @@ -141,7 +141,7 @@ const config = { }; await writeFile( configOut, - `// Generated by bun run setup. Gitignored: contains account Access config.\n${JSON.stringify(config, null, 2)}\n`, + `// Generated by bun run company:setup. Gitignored: contains account Access config.\n${JSON.stringify(config, null, 2)}\n`, ); console.log(`[6/6] Deploying "${appName}" into zone "${deploymentZone}"…`); diff --git a/skills/up/HANDOFF.md b/skills/up/HANDOFF.md new file mode 100644 index 0000000..2e542f0 --- /dev/null +++ b/skills/up/HANDOFF.md @@ -0,0 +1,18 @@ +# Continue this Up app after ownership + +Fill in the app path, exact Worker name from the `workers.dev` URL, and the account ID selected by the human. + +```text +Continue this existing Cloudflare Worker from . + +1. Ask me to complete `wrangler login` in the browser. Do not ask for or copy an API key. +2. Run `wrangler whoami` and ask me which account was created by the ownership flow if several are listed. +3. Use the exact Worker name from its workers.dev URL. +4. Run `up handoff --account-id `. +5. Do not create replacement KV or D1 resources. Preserve the existing bindings by name. +6. Test the deployed URL and every binding, including existing data. +7. Remind me that ownership did not make the URL private. Ask before adding Cloudflare Access or another login. +8. Create a scoped API token only if I explicitly ask for CI/CD. +``` + +`up handoff` refuses to deploy unless the Worker already exists in the selected account. Wrangler OAuth is the normal local credential; no Up API key is required. diff --git a/skills/up/SKILL.md b/skills/up/SKILL.md index 1c1c128..a768014 100644 --- a/skills/up/SKILL.md +++ b/skills/up/SKILL.md @@ -1,39 +1,87 @@ # Up -Use Up when the user asks for a small company-private website, prototype, dashboard, poll, presentation, or internal tool. +Use Up when the user wants an agent to publish and exercise a small dynamic Cloudflare app immediately, before creating or authenticating an account. -## Build contract +## Dynamic app 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: +Build framework-free output unless the project already has a build system: -```js -import { up } from '/_up/client.js'; +```text +dist/ +├── index.html +├── app.js +├── _worker.js +└── up.json ``` -Available capabilities: +- `index.html` is required. +- `_worker.js` exports the Worker and any Durable Object classes. +- Browser assets are available to Worker code as `env.ASSETS`. +- `up.json` may request KV, D1, and Durable Object bindings: + +```json +{ + "bindings": { + "kv": ["CACHE"], + "d1": ["DB"], + "durableObjects": [{ "binding": "ROOMS", "className": "Room" }] + } +} +``` + +Use the normal APIs in Worker code: ```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 }); +await env.CACHE.get('key'); +await env.DB.prepare('SELECT 1').first(); +const room = env.ROOMS.get(env.ROOMS.idFromName('main')); +return env.ASSETS.fetch(request); +``` + +Do not claim R2, Workers AI, Access, Workflows, Browser Rendering, Containers, Sandboxes, or Dispatch bindings in anonymous mode. They are not in the current Temporary Account matrix. + +## Publish contract + +1. Keep the browser folder under 1,000 files and every file under 5 MiB. +2. Do not include credentials, private data, source maps with secrets, `.env` files, or internal-only content. The resulting URL and API are public. +3. Do not include symbolic links. +4. Test locally. +5. Ask the user to approve: + - making the app public for up to/about 60 minutes; + - Cloudflare’s Terms of Service and Privacy Policy; + - the declared platform bindings. +6. After approval, deploy: + +```sh +up deploy ./dist [name] --accept-cloudflare-terms ``` -6. Test the local static files before publishing. -7. Publish only after the user approves the folder and site name: +7. Fetch both the page and dynamic API routes. Exercise each declared binding. +8. Return the public deployment URL. +9. Return the claim URL only through the active user conversation and label it sensitive. +10. Explain that every app and binding in the temporary session disappears unless the account is claimed. + +## Claim contract ```sh -up deploy ./dist +up claim --open ``` -8. Return the company-private URL printed by Up. +The claim URL grants ownership of the entire Temporary Account. Never commit it, put it in a screenshot, send it to another agent, or include it in a public log. + +## Important behavior + +- No Cloudflare login or permanent API token is required. +- Up deliberately removes inherited Cloudflare credentials. +- Wrangler provisions the supported graph and may apply proof of work or rate limits. +- Use this for prototypes and review, not production or CI/CD. + +## Secondary company mode + +Only use company mode when the user explicitly asks for an existing private Up installation: + +```sh +up private ./dist --origin https://up.example.com +``` -Do not ask the user about R2, Durable Objects, Access, bindings, databases, secrets, schedules, or Workers. Those belong to the company installation. +Company mode has a different Access-protected capability contract. diff --git a/src/lib/seo.ts b/src/lib/seo.ts index 2968f30..05124da 100644 --- a/src/lib/seo.ts +++ b/src/lib/seo.ts @@ -9,40 +9,39 @@ export type PageMetadata = { export const metadata = { '/': { - title: 'Up — Your Company’s Private Web', + title: 'Up — Your Cloudflare App Before Signup', description: - 'Publish a folder to a company-private URL. Up runs in your Cloudflare account behind the identity system your organization already trusts.', - eyebrow: 'Private sites · Cloudflare Access · your account', + 'Run Worker code, Static Assets, KV, D1, and Durable Objects together on Cloudflare before creating an account. Keep the whole app if it works.', + eyebrow: 'A live app · platform bindings · no account yet', }, '/tutorial': { - title: 'Set Up Up on Cloudflare — Tutorial', + title: 'Deploy a Dynamic Stack Before Signup — Up Tutorial', description: - 'Connect Up to your Cloudflare account, establish the Access boundary, and publish a first company-private static site.', - eyebrow: 'Tutorial · publish a private site', + 'Publish Worker code, browser assets, and platform bindings through a Temporary Account, then keep the whole app or let it disappear.', + eyebrow: 'Tutorial · deploy before signup', }, '/how-to': { - title: 'Operate Company-Private Sites — Up How-to Guides', + title: 'Iterate, Keep, and Continue — Up How-to Guides', description: - 'Update and verify sites, configure company identity, inspect deployment receipts, and operate Up safely on Cloudflare.', - eyebrow: 'How-to · operate a deployment', + 'Iterate on a temporary app, protect its ownership link, keep the account, and hand the same Worker back to your agent.', + eyebrow: 'How-to · iterate and continue', }, '/examples': { title: 'Apps Built with Up — Examples', - description: - 'Explore small company-private apps built with Up and inspect their framework-free source.', + description: 'Explore small apps built with Up and inspect their framework-free source.', eyebrow: 'Examples · built with Up', }, '/reference': { - title: 'Up Reference — API, Limits, and Cloudflare Resources', + title: 'Up Reference — Dynamic Apps, Bindings, and Ownership', description: - 'Exact Up contracts for authenticated routes, manifests, limits, R2 keys, Durable Object state, Access identity, and headers.', - eyebrow: 'Reference · exact contracts', + 'Exact contracts for Worker code, Static Assets, KV, D1, Durable Objects, public URLs, expiry, and account ownership.', + eyebrow: 'Reference · anonymous deployment contract', }, '/explanation': { - title: 'Why Up Is Private by Default — Architecture', + title: 'Why Up Deploys the Dynamic Graph First — Architecture', description: - 'Understand why Up uses an organization-wide Access boundary, private R2, immutable deployments, and a trusted control plane.', - eyebrow: 'Explanation · the company trust boundary', + 'Understand how Temporary Accounts let agents exercise a real Worker and platform bindings before ownership.', + eyebrow: 'Explanation · deploy before ownership', }, '/app': { title: 'Publish a Private Site — Up', diff --git a/src/site.svelte b/src/site.svelte index 84cfa50..0b6955d 100644 --- a/src/site.svelte +++ b/src/site.svelte @@ -62,6 +62,32 @@ const deployUrl = 'https://deploy.workers.cloudflare.com/?url=https://github.com/acoyfellow/up'; + const nativeCatalog = [ + { name: 'Worker', slug: 'workers', action: 'Run code', status: 'Included in Up', detail: 'Dynamic request handling at a public workers.dev URL.' }, + { name: 'Static Assets', slug: 'assets', action: 'Serve the page', status: 'Included in Up', detail: 'HTML, CSS, JavaScript, images, and other browser files.' }, + { name: 'KV', slug: 'kv', action: 'Store keys', status: 'Included in Up', detail: 'Fast key-value reads and writes, provisioned from up.json.' }, + { name: 'D1', slug: 'd1', action: 'Query SQL', status: 'Included in Up', detail: 'A SQLite database that stays with the app if you keep it.' }, + { name: 'Durable Objects', slug: 'durableObjects', action: 'Coordinate state', status: 'Included in Up', detail: 'Storage, coordination, and WebSockets with SQLite migrations.' }, + { name: 'Queues', slug: 'queues', action: 'Process work later', status: 'Temporary Account ready', detail: 'Supported upstream; Up’s up.json wiring is next.' }, + { name: 'Hyperdrive', slug: 'hyperdrive', action: 'Reach an existing database', status: 'Temporary Account ready', detail: 'Supported upstream; requires a database connection policy.' }, + { name: 'Certificates', slug: 'certificates', action: 'Use client certificates', status: 'Account operation', detail: 'Supported by Temporary Accounts; configured outside the app manifest.' }, + ]; + const capaCatalog = [ + { name: 'GitHub', slug: 'github' }, + { name: 'GitLab', slug: 'gitlab' }, + { name: 'Jira', slug: 'jira' }, + { name: 'Slack', slug: 'slack' }, + { name: 'Stripe', slug: 'stripe' }, + { name: 'Discord', slug: 'discord' }, + { name: 'Box', slug: 'box' }, + { name: 'Kubernetes', slug: 'kubernetes' }, + { name: 'Sentry', slug: 'sentry' }, + { name: 'Twilio', slug: 'twilio' }, + { name: 'Twilio Messaging', slug: 'twilio' }, + { name: 'Twilio Verify', slug: 'twilio' }, + { name: 'Twitch', slug: 'twitch' }, + { name: 'Zoom', slug: 'zoom' }, + ]; const isProduct = $derived(section === 'app'); onMount(() => { @@ -258,7 +284,7 @@ {#if !isProduct} Docs How it works - Open Up ↗ + Get the CLI ↗ {:else} {identity} {/if} @@ -270,14 +296,15 @@
-
Cloudflare-nativePrivate by default
-

Your company’s
private web.

-

Publish a folder to a company-private URL behind the identity system your organization already trusts.

+
Deploy without an account
+

Your app is live
before you sign up.

+

Worker code, browser assets, KV, D1, and Durable Objects—running together on Cloudflare for about an hour. Keep the whole thing, or let it disappear.

-

Cloudflare’s hosted installation is currently available to Cloudflare employees.

+

The URL is public. Anyone with it can load your app — don’t deploy secrets.

+

Independent project. Not an official Cloudflare product.

-
-

01 / PRODUCT

-
-

One installation.
Every small site.

-

Up gives employees and agents one dependable place to publish prototypes, reports, demos, and internal tools. Files stay in private R2. Every request meets Cloudflare Access first.

+
+
+

01 / DYNAMIC

+

Bindings off the shelf.

+

Up wires Worker, Static Assets, KV, D1, and Durable Objects now. Temporary Accounts also support Queues, Hyperdrive, and certificates; the catalog below shows what works in Up today and what the platform is ready for next.

-
- -
-
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.

-
- -
-

02 / SYSTEM

-
-

Folder to private URL.

-
- static folderverified uploadprivate R2Access URL +

In Temporary Accounts today · 8 primitives

+
+ {#each nativeCatalog as product} +
+
+ + {product.name} +
+ {product.status} +

{product.action}

+

{product.detail}

+
+ {/each} +
+
+
+

CONNECTED SERVICES / CAPA

+

Connect GitHub, Stripe, and the tools your app needs.

+

Capa keeps API keys out of your app code. You choose what the app is allowed to do; each request comes back with the answer and a clear record of what happened.

+

In Capa today · 14 bindings

+
    + {#each capaCatalog as service} +
  • + + {service.name} +
  • + {/each} +
+
+
+ Tested end to end + The connection worked in a real temporary account. The simple installer is not shipped yet. + See Capa
-
    -
  1. ConnectApprove Up once in Cloudflare.
  2. -
  3. PublishChoose a folder and name the site.
  4. -
  5. ProveVerify authenticated content and anonymous denial.
  6. -
-
- -
-

03 / DOCUMENTATION

Read for the job at hand.

Up follows Diátaxis: learning, goals, information, and understanding stay distinct.

-
- -
{:else if section === 'app'} @@ -450,23 +476,30 @@ 03Reference 04Explanation - +

{eyebrow}

{#if section === 'tutorial'} -

Set up Up

Connect your Cloudflare account once. Up provisions itself and the Access boundary on your behalf — no API tokens to mint.

-

1. Connect with Cloudflare

Connect with Cloudflare

You review the requested scopes on Cloudflare’s consent screen and approve. Up creates the Worker, Durable Object, private R2 bucket, and Access application — all in your account.

-

2. Choose who gets in

The Access policy defaults to your company email domain. Point it at your existing SSO (Okta, Entra, Google) or Cloudflare authentication. Everyone in the company; nobody outside.

-

3. Verify before use

Publish a folder while authenticated. Open the resulting URL in a clean browser. It must reach Access before any uploaded bytes. workers.dev and Preview URLs stay disabled.

-

Prefer to fork and self-host the source? Deploy to Cloudflare ↗

+

Deploy the stack before signup

Give an agent Worker code, browser assets, and binding names. Create an account only after the dynamic graph proves itself.

+

1. Build one app folder

app/
+  index.html
+  _worker.js
+  up.json

_worker.js handles requests and calls env.ASSETS. up.json declares KV, D1, and Durable Object bindings.

+

2. Deploy anonymously

bunx github:acoyfellow/up deploy ./app

Up snapshots the app and runs pinned Wrangler with isolated credentials. Wrangler creates the Temporary Account and provisions the supported resources.

+

3. Exercise or keep

Fetch the page and API, mutate every binding, revise, and redeploy. Run up claim --open to keep the whole stack, or let every resource disappear.

+

4. Hand it back to your agent

After the browser ownership flow, connect normal Wrangler with wrangler login and find the new account with wrangler whoami. Then continue the same Worker and bindings:

up handoff ./app exact-worker-name \
+  --account-id <claimed-account-id>

No Up API key is needed. The URL stays public until you add Cloudflare Access or another login.

Copy the agent handoff prompt →

{:else if section === 'how-to'} -

Operate Up

Publish the same folder from the browser, CLI, or an agent.

Update a site

Publish the same name. Up activates the replacement only after every new asset passes verification.

Initialize an agent

up init
+        

Iterate, keep, continue

Temporary deployment is the first loop. Handoff reconnects the same source and resources to normal Wrangler.

Redeploy before ownership

Run up deploy ./dist again. A stable path fingerprint selects the same Worker name and Wrangler reuses the active temporary account.

Initialize an agent

up init
 # Ask the agent to read .up/SKILL.md
 # Build into ./dist
-up deploy ./dist team-tool

Use fixed capabilities

Import /_up/client.js. Identity, document collections, files, AI, and realtime are available without credentials or per-site infrastructure.

Browse apps built with Up →

Respond to exposure

Disable the wildcard route or Access application first. Never enable workers.dev as a workaround.

+up deploy ./dist

Keep the session

up claim --open

The ownership link controls every deployment in the current anonymous Up session. Up stores it locally and does not print it unless you run up claim --show.

Continue after ownership

bunx wrangler@4.103.0 login
+bunx wrangler@4.103.0 whoami
+up handoff ./dist exact-worker-name \
+  --account-id <claimed-account-id>

Wrangler OAuth is enough for local work. The handoff preflight refuses the wrong account or Worker name and preserves attached KV and D1 resources by binding name.

The URL is still public. Add Access or another login before adding sensitive data, then verify an anonymous request is denied.

Copy the agent handoff prompt →

Browse apps built with Up →

{:else if section === 'examples'} -

Apps built with Up

Small company-private apps and the folders that power them. Live AX examples require a Cloudflare employee identity.

+

Apps built with Up

Small apps and the folders that power them. Lunch Vote demonstrates the retained company mode; anonymous examples are next.

Lunch Vote interface @@ -474,9 +507,9 @@ up deploy ./dist team-tool

Use fixed capabilities

Import

{:else if section === 'reference'} -

Reference

Exact contracts for version 0.0.1.

GET /appAuthenticated publisher
GET /api/sitesList company sites
POST /api/sites/:name/deploymentsCreate a pending company deployment
PUT /api/deployments/:id/assetsVerify and store one asset
POST /api/deployments/:id/activateAtomically activate a deployment
GET /_up/identityCurrent verified employee
/_up/db/*Site document collections
/_up/files/*Site file storage
POST /_up/ai/chatBounded Workers AI request
GET /_up/realtime/:channelAuthenticated WebSocket channel

Limits

  • 500 deployment files; 10 MiB each; 50 MiB total
  • index.html required
  • 64 KiB per database document; 100 documents per page
  • 10 MiB per site file; 1,000 listed files
  • 24 AI messages; 20,000 input characters; 512 output tokens
  • 16 KiB per realtime message
+

Reference

Exact anonymous dynamic-app contracts for version 0.0.1.

index.htmlRequired browser entry point served as a Static Asset
_worker.jsOptional dynamic Worker and Durable Object exports
up.jsonOptional KV, D1, and Durable Object binding manifest
up deploy <folder> [name]Provision and deploy one Temporary Account graph
up claim --openOpen the ownership flow without printing the sensitive link
up claim --showExplicitly reveal the ownership link
up handoff <folder> <name> --account-id <id>Continue the existing Worker after ownership using normal Wrangler OAuth

Current bindings

  • Static Assets via env.ASSETS
  • KV namespace bindings
  • One D1 database, up to 100 MB total
  • Durable Object class bindings with SQLite migration
  • Queues and Hyperdrive are supported by Temporary Accounts; Up wiring is not shipped yet
  • Certificates are a supported account operation outside up.json
  • R2, Workers AI, Access, Workflows, and Containers are unavailable anonymously

Connected services with Capa

Capa keeps provider API keys out of app code and gives the Worker a smaller set of allowed actions for services such as GitHub and Stripe. A live same-account test passed; the simple installer is not shipped yet. Read the integration contract →

{:else if section === 'explanation'} -

A URL does not grant access.

Up verifies a company session before returning a site or any of its capabilities.

One company boundary

Cloudflare Access authenticates employees. Up converts that identity into a scoped sibling-domain session. Missing identity returns no uploaded content.

The hostname selects one site

Database collections, uploaded files, AI requests, and realtime channels are resolved from the current site hostname. Browser code cannot supply another site’s resource ID.

Credentials stay on the server

The browser receives a fixed same-origin API. R2, Durable Object, and Workers AI credentials never enter site code.

A deployment appears all at once

Files remain pending in private R2 until every manifest digest passes verification. Activation changes one pointer, so visitors receive either the previous complete version or the next one.

+

The dynamic graph comes first.

Worker code and platform bindings exist before the deployer has a Cloudflare identity. That inversion is the product.

Agents need real behavior

A screenshot of static output cannot validate data, coordination, or API logic. Temporary Accounts let an agent exercise Worker, KV, D1, and Durable Object semantics in the real runtime.

Bindings travel together

The claim URL transfers the whole account, including the supported resources and data produced during the experiment. The app is not reconstructed after signup.

Public is explicit

The generated Worker URL has no Access boundary. Anyone with it can call the app. Up labels that fact instead of pretending a hard-to-guess hostname is private.

Credentials stay isolated

Up snapshots the folder, launches Wrangler in a separate home, and removes inherited Cloudflare credentials so the anonymous graph cannot mutate a permanent account.

{:else if section === 'offline'}

You are offline.

The documentation shell is cached. Publishing still requires the network and Access.

{:else} @@ -487,7 +520,7 @@ up deploy ./dist team-tool

Use fixed capabilities

Import {/if} -

+