diff --git a/.github/workflows/deploy-worker.yml b/.github/workflows/deploy-worker.yml index d2df272..2af2014 100644 --- a/.github/workflows/deploy-worker.yml +++ b/.github/workflows/deploy-worker.yml @@ -53,6 +53,16 @@ jobs: - name: Build run: pnpm build + - name: Deploy product router + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + run: | + for attempt in 1 2 3; do + pnpm deploy:product && exit 0 + sleep $((attempt * 5)) + done + pnpm deploy:product + - name: Apply D1 migrations env: CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 28d5c31..742f93a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ - Fix GitHub OAuth login after the canonical host move by honoring the registered `crabfleet.openclaw.ai` callback URL. - Add the Crabfleet v2 fleet-control spec, redacted fleet registry API, and dashboard summary for visible Codex crabboxes. - Make `crabfleet.openclaw.ai` the OpenClaw app/API canonical URL, redirect old OpenClaw aliases there, and keep `crabfleet.ai` independent as the public product site. +- Deploy the source-controlled `crabfleet.ai` product router before the app Worker so product traffic cannot drift back to an app redirect. - Route the built-in interactive provision hook in-process and default new sessions to Cloudflare Sandbox so production creates usable Codex terminals without a crabbox adapter. - Keep Cloudflare Sandbox model and GitHub credentials in the Worker path, add DO-backed sandbox credential/checkpoint state, and add CLI lifecycle commands for doctor/status/stop/checkpoint/restore. - Show failed and expired Codex sessions as stable log replays instead of remounting Ghostty terminals. diff --git a/README.md b/README.md index 5798b77..a59f366 100644 --- a/README.md +++ b/README.md @@ -132,13 +132,16 @@ merge: ### Deploy Pushes to `main` run `.github/workflows/deploy-worker.yml`, which checks, tests, builds, -applies remote D1 migrations, and deploys the Worker. Configure the repository secret -`CLOUDFLARE_API_TOKEN` with permissions for Workers deploys and D1 migrations. +deploys the generic product router, applies remote D1 migrations, and deploys the app +Worker. Configure the repository secret `CLOUDFLARE_API_TOKEN` with permissions for +Workers deploys and D1 migrations. `crabfleet.ai` product routing, `crabfleet.openclaw.ai`, and `crabd.sh` DNS/route convergence is handled by `scripts/ensure-cloudflare-domains.mjs`; set `CLOUDFLARE_DNS_API_TOKEN` when CI should manage those records. Without that DNS-scoped token, CI skips domain convergence. The app Worker still proxies the generic product site for `crabfleet.ai` as a defensive fallback, never the authenticated app. +The product router source and deploy configuration live in `src/product-router.ts` and +`wrangler.product.jsonc`. Manual deploy is still available: @@ -146,6 +149,9 @@ Manual deploy is still available: # Build assets pnpm build +# Deploy the generic product router +pnpm deploy:product + # Apply migrations wrangler d1 migrations apply DB --remote diff --git a/package.json b/package.json index 688aae8..b65ec2b 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,8 @@ "scripts": { "build": "node scripts/generate-assets.mjs && tsgo --noEmit", "build:static": "node scripts/generate-assets.mjs --static", - "deploy": "pnpm build && wrangler d1 migrations apply DB --remote && wrangler deploy", + "deploy": "pnpm build && pnpm deploy:product && wrangler d1 migrations apply DB --remote && wrangler deploy", + "deploy:product": "wrangler deploy --config wrangler.product.jsonc", "test": "node --test --experimental-strip-types tests/*.test.ts", "lint": "oxlint --ignore-pattern node_modules --ignore-pattern src/generated.ts .", "format": "oxfmt --check .", diff --git a/src/canonical-host.ts b/src/canonical-host.ts index 540d865..cd4bd27 100644 --- a/src/canonical-host.ts +++ b/src/canonical-host.ts @@ -1,6 +1,7 @@ export const appCanonicalHost = "crabfleet.openclaw.ai"; export const appCanonicalOrigin = `https://${appCanonicalHost}`; const productCanonicalHost = "crabfleet.ai"; +const productCanonicalOrigin = `https://${productCanonicalHost}`; const productOriginHost = "crabbox.sh"; const productHosts = new Set([productCanonicalHost, `www.${productCanonicalHost}`]); export const appRedirectHosts = new Set([ @@ -62,6 +63,20 @@ export async function productHostResponse( }); } +export async function routeProductRequest( + request: Request, + fetcher: typeof fetch = fetch, +): Promise { + const productResponse = await productHostResponse(request, fetcher); + if (productResponse) return productResponse; + + const source = new URL(request.url); + const target = new URL(productCanonicalOrigin); + target.pathname = source.pathname; + target.search = source.search; + return Response.redirect(target.toString(), 308); +} + export function canonicalAppRedirect(url: URL): Response | null { if (!appRedirectHosts.has(url.hostname)) return null; // Existing CLI, agent, and terminal clients may attach Authorization headers. diff --git a/src/product-router.ts b/src/product-router.ts new file mode 100644 index 0000000..ecd8c8a --- /dev/null +++ b/src/product-router.ts @@ -0,0 +1,7 @@ +import { routeProductRequest } from "./canonical-host"; + +export default { + async fetch(request: Request): Promise { + return routeProductRequest(request); + }, +}; diff --git a/tests/canonical-host.test.ts b/tests/canonical-host.test.ts index 1309235..aef6ddf 100644 --- a/tests/canonical-host.test.ts +++ b/tests/canonical-host.test.ts @@ -1,6 +1,10 @@ import assert from "node:assert/strict"; import { test } from "node:test"; -import { canonicalAppRedirect, productHostResponse } from "../src/canonical-host.ts"; +import { + canonicalAppRedirect, + productHostResponse, + routeProductRequest, +} from "../src/canonical-host.ts"; test("product hosts never fall through to the app worker", async () => { let upstreamRequest: Request | undefined; @@ -32,6 +36,13 @@ test("product www host redirects to the product apex", async () => { assert.equal(response?.headers.get("location"), "https://crabfleet.ai/docs?mode=full"); }); +test("product aliases redirect to the product apex", async () => { + const response = await routeProductRequest(new Request("https://crabfleet.app/docs?mode=full")); + + assert.equal(response.status, 308); + assert.equal(response.headers.get("location"), "https://crabfleet.ai/docs?mode=full"); +}); + test("legacy app pages redirect to the canonical host", () => { const response = canonicalAppRedirect( new URL("https://clawfleet.openclaw.ai/app/sessions/IS-1?view=grid"), diff --git a/wrangler.jsonc b/wrangler.jsonc index 39a5d6e..3934e26 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -74,7 +74,7 @@ ], "workers_dev": true, // The canonical app/API host and legacy OpenClaw aliases are Worker Custom - // Domains. The public crabfleet.ai product site is managed separately. + // Domains. The public crabfleet.ai product site uses wrangler.product.jsonc. "routes": [ { "pattern": "crabfleet.openclaw.ai", "custom_domain": true }, { "pattern": "clawfleet.openclaw.ai", "custom_domain": true }, diff --git a/wrangler.product.jsonc b/wrangler.product.jsonc new file mode 100644 index 0000000..69ac49c --- /dev/null +++ b/wrangler.product.jsonc @@ -0,0 +1,11 @@ +{ + "$schema": "node_modules/wrangler/config-schema.json", + "name": "crabfleet-canonical-router", + "main": "src/product-router.ts", + "compatibility_date": "2026-06-11", + "account_id": "91b59577e757131d68d55a471fe32aca", + "workers_dev": false, + "observability": { + "enabled": true, + }, +}