diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..175505b --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,34 @@ +name: CI + +on: + pull_request: + push: + branches: [main] + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: pnpm + + - run: pnpm install --frozen-lockfile + + - name: Typecheck + run: pnpm -r typecheck + + - name: Test + run: pnpm -r test + + - name: Build CLI (bundles the Worker) + run: pnpm --filter gitflare build diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml new file mode 100644 index 0000000..bfbfab2 --- /dev/null +++ b/.github/workflows/release-please.yml @@ -0,0 +1,52 @@ +name: Release + +on: + push: + branches: [main] + +permissions: + contents: write + pull-requests: write + +jobs: + # On every push to main, Release Please opens/updates a release PR that bumps + # the version + changelog from Conventional Commits. When that release PR is + # merged, this same run reports release_created=true and the publish job runs. + release-please: + runs-on: ubuntu-latest + outputs: + release_created: ${{ steps.release.outputs.release_created }} + tag_name: ${{ steps.release.outputs.tag_name }} + steps: + - uses: googleapis/release-please-action@v4 + id: release + with: + token: ${{ secrets.GITHUB_TOKEN }} + + publish: + needs: release-please + if: ${{ needs.release-please.outputs.release_created == 'true' }} + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: pnpm + registry-url: https://registry.npmjs.org + + - run: pnpm install --frozen-lockfile + + # Build the published artifact (tsc + bundles the Worker into dist/). + - run: pnpm --filter gitflare build + + - name: Publish to npm + working-directory: packages/cli + run: npm publish --access public + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.release-please-manifest.json b/.release-please-manifest.json new file mode 100644 index 0000000..c05df9b --- /dev/null +++ b/.release-please-manifest.json @@ -0,0 +1,3 @@ +{ + ".": "0.1.3" +} diff --git a/PLAN.md b/PLAN.md index 97f921a..49ddb9b 100644 --- a/PLAN.md +++ b/PLAN.md @@ -550,7 +550,9 @@ The roadmap in §4 is what we're shipping. This section is *where we are right n | M4 | v0.1 cut: end-to-end working read replica | ✅ done | All three components compose cleanly: CLI provisions → Worker deploys → webhook fires → sync runs → UI shows synced state. [QUICKSTART.md](./QUICKSTART.md) walks the full flow. Live-validated against `sinameraji/kimiflare`. | | M4.5 | Browseable dashboard | ✅ done | Top-level entries on the home page link to per-path tree/blob routes. Inside a directory: breadcrumb + parent link + clickable entries (links recurse). Inside a file: plain `
` rendering with binary detection. README images rewritten to GitHub raw URLs for public repos. |
 | M4.7 | Publish to npm | ✅ done | esbuild bundles the worker (~678 KB) into the CLI's `dist/`. CLI invokes wrangler directly via `require.resolve` (no `pnpm exec` runtime dep). Published as [`gitflare`](https://www.npmjs.com/package/gitflare). Anyone can now `npx gitflare init `. |
-| M5 | Privacy via Cloudflare Access | ⏳ next | The dashboard URL is currently public. Put Access in front of the Worker (free up to 50 seats on Cloudflare One), gate `/` + `/r/*` + `/api/*` with SSO, leave `/webhooks/github` unauth (HMAC already gates it). Then private GitHub repos actually stay private. |
+| M5 | Privacy via Cloudflare Access | 🧪 implemented, not yet live-validated | Worker: `accessGuard` middleware verifies the `Cf-Access-Jwt-Assertion` JWT (RS256 via WebCrypto + JWKS cache, no `jose`) and gates `/` + `/r/*` + `/api/*`; enforces only when `ACCESS_AUD` var is set, so public mirrors stay open. `/webhooks/github` + `/health` left unauth. 8 unit tests. CLI: opt-in `gitflare access enable/disable` creates/deletes a self-hosted Access app + allow-list policy and redeploys with `ACCESS_AUD`/`ACCESS_TEAM_DOMAIN` vars. **Caveat:** this gates the dashboard/API only — `git clone` hits Artifacts directly (`*.artifacts.cloudflare.net`), so it is NOT Access-gated; private-clone is a later (v0.4+) item per §6/§11. **TODO before ✅:** verify the live Access apps/policies API shapes + `aud` field, and that `*.workers.dev` hosts are accepted, against a real account. |
+| M5.5 | v0.1 polish | 🧪 implemented | Syntax highlighting in the blob viewer (highlight.js core + ~20 curated grammars, server-side; 512 KB cap with `
` fallback; bundle 681→901 KB). README image proxy: new `GET /r/:name/raw/*` serves blob bytes from the Artifacts mirror so images render for private repos and survive GitHub outages — README rewriting now points there instead of `raw.githubusercontent.com`. Styled empty/error states (`ui/states.tsx`): home shows the `npx gitflare init` command, browse 404/500 render through the layout with a way back. |
+| M6 | v0.2 CD (MVP slice) | 🧪 implemented, not yet live-validated | **Self-deploy model (user chose Worker-Secret path).** On push, after sync, the webhook fires `DeployDO` (`waitUntil`), which clones the repo, reads + parses `.gitflare/deploy.yml` (minimal fixed-schema parser, `deploy/workflow.ts`), and for each `cloudflare/deploy { kind: worker }` step uploads the **pre-built** `entry` file to the user's account via the Workers Scripts multipart API (`deploy/cf-deploy.ts`). History persisted in `DeployDO`; Deployments UI at `/r/:name/deployments` (Access-gated, polls DO `/state`). CLI `gitflare deploy enable/disable` stores a `CF_DEPLOY_TOKEN` Worker Secret + sets `ACCOUNT_ID`/`CD_ENABLED` vars (CD gated on `CD_ENABLED` so disable is a clean redeploy). New toml emits a `DEPLOY` DO binding + migration `v2`. 13 new unit tests (parser + upload-form/API). **Out of MVP:** build steps (need Sandbox → v0.3), Pages, bindings/D1 migrations, rollback, Artifacts-push trigger. **TODO before ✅:** verify the live Workers Scripts multipart shape end-to-end against a real account. |
 
 ### What's in the repo right now (as of M0)
 
diff --git a/packages/cli/src/cloudflare.ts b/packages/cli/src/cloudflare.ts
index 7d1e3b0..e9f9d4e 100644
--- a/packages/cli/src/cloudflare.ts
+++ b/packages/cli/src/cloudflare.ts
@@ -82,4 +82,64 @@ export class CloudflareClient {
     );
     return r.subdomain;
   }
+
+  // --- Cloudflare Access (Zero Trust) ---
+  // NOTE: exact request/response shapes vary by API version — verify live
+  // before relying on these. `aud` is the tag the Worker validates.
+
+  /**
+   * Returns the account's Zero Trust org auth domain (e.g.
+   * "myteam.cloudflareaccess.com"). Throws if the account has no org yet —
+   * the user must enable Zero Trust once in the dashboard.
+   */
+  async getZeroTrustOrg(
+    accountId: string,
+  ): Promise<{ authDomain: string; name: string }> {
+    const r = await this.req<{ auth_domain: string; name: string }>(
+      "GET",
+      `/accounts/${accountId}/access/organizations`,
+    );
+    if (!r?.auth_domain) {
+      throw new Error("no Zero Trust organization on this account");
+    }
+    return { authDomain: r.auth_domain, name: r.name };
+  }
+
+  async listAccessApps(
+    accountId: string,
+  ): Promise> {
+    return this.req("GET", `/accounts/${accountId}/access/apps`);
+  }
+
+  async createAccessApp(
+    accountId: string,
+    params: { name: string; domain: string },
+  ): Promise<{ id: string; aud: string }> {
+    return this.req("POST", `/accounts/${accountId}/access/apps`, {
+      type: "self_hosted",
+      name: params.name,
+      domain: params.domain,
+      session_duration: "24h",
+    });
+  }
+
+  async createAccessPolicy(
+    accountId: string,
+    appId: string,
+    params: { name: string; emails: string[] },
+  ): Promise<{ id: string }> {
+    return this.req(
+      "POST",
+      `/accounts/${accountId}/access/apps/${appId}/policies`,
+      {
+        name: params.name,
+        decision: "allow",
+        include: params.emails.map((email) => ({ email: { email } })),
+      },
+    );
+  }
+
+  async deleteAccessApp(accountId: string, appId: string): Promise {
+    await this.req("DELETE", `/accounts/${accountId}/access/apps/${appId}`);
+  }
 }
diff --git a/packages/cli/src/commands/access.ts b/packages/cli/src/commands/access.ts
new file mode 100644
index 0000000..cd29b24
--- /dev/null
+++ b/packages/cli/src/commands/access.ts
@@ -0,0 +1,207 @@
+import * as p from "@clack/prompts";
+import kleur from "kleur";
+import { CloudflareClient } from "../cloudflare.js";
+import { loadConfig, saveConfig } from "../config.js";
+import { orange } from "../util.js";
+import { redeployWorker } from "../redeploy.js";
+import { pickRepo, getCfToken } from "../repo-select.js";
+
+// "Cloudflare Access: Apps and Policies: Edit" is needed in addition to the
+// 3 scopes `init` requests. The token-creation page:
+const ACCESS_TOKEN_URL = "https://dash.cloudflare.com/profile/api-tokens";
+const ZERO_TRUST_URL = "https://one.dash.cloudflare.com/";
+
+function hostOf(workerUrl: string): string {
+  return workerUrl.replace(/^https?:\/\//, "").replace(/\/+$/, "");
+}
+
+function scopeHint(): void {
+  p.log.warn(
+    [
+      "This looks like a missing scope or no Zero Trust org.",
+      `  • Enable Zero Trust once (free up to 50 seats): ${kleur.gray(ZERO_TRUST_URL)}`,
+      `  • Re-issue your Cloudflare token at ${kleur.gray(ACCESS_TOKEN_URL)} adding:`,
+      `      ${kleur.cyan("Cloudflare Access: Apps and Policies")} — Edit`,
+      "  Then re-run `gitflare access enable` and choose a fresh token.",
+    ].join("\n"),
+  );
+}
+
+export async function runAccessEnable(
+  repoArg: string | undefined,
+): Promise {
+  p.intro(kleur.bold(orange("GitFlare access enable")));
+  const cfg = await loadConfig();
+  const entry = await pickRepo(cfg, repoArg);
+  if (!entry) return;
+
+  const cfToken = await getCfToken(cfg);
+  if (!cfToken) return p.cancel("Cancelled."), undefined;
+  const cf = new CloudflareClient(cfToken);
+
+  // 1. Resolve the Zero Trust team auth domain.
+  const sp = p.spinner();
+  sp.start("Resolving Zero Trust organization");
+  let teamDomain: string;
+  try {
+    const org = await cf.getZeroTrustOrg(entry.cloudflareAccountId);
+    teamDomain = org.authDomain;
+    sp.stop(`Zero Trust org: ${kleur.cyan(teamDomain)}`);
+  } catch (e) {
+    sp.stop("Could not resolve Zero Trust org");
+    p.log.error((e as Error).message);
+    scopeHint();
+    return;
+  }
+
+  // 2. Who's allowed in.
+  const emailsRaw = await p.text({
+    message: "Allowed email(s), comma-separated",
+    placeholder: "you@example.com",
+    validate: (s) => (!s ? "at least one email required" : undefined),
+  });
+  if (p.isCancel(emailsRaw)) return p.cancel("Cancelled."), undefined;
+  const emails = (emailsRaw as string)
+    .split(",")
+    .map((s) => s.trim())
+    .filter(Boolean);
+
+  // 3. Reconstruct the Artifacts remote (needed to redeploy).
+  sp.start("Fetching Artifacts remote");
+  let remote: string;
+  try {
+    const r = await cf.getRepo(
+      entry.cloudflareAccountId,
+      entry.artifactsNamespace,
+      entry.artifactsRepoName,
+    );
+    remote = r.remote;
+    sp.stop("Got Artifacts remote");
+  } catch (e) {
+    sp.stop("Artifacts remote lookup failed");
+    p.log.error((e as Error).message);
+    return;
+  }
+
+  // 4. Create (or reuse) the Access app + policy.
+  const host = hostOf(entry.workerUrl);
+  sp.start("Creating Cloudflare Access application");
+  let appId: string;
+  let aud: string;
+  try {
+    const existing = (await cf.listAccessApps(entry.cloudflareAccountId)).find(
+      (a) => a.domain === host || a.domain === host + "/",
+    );
+    if (existing) {
+      appId = existing.id;
+      aud = existing.aud;
+      sp.message("Reusing existing Access app");
+    } else {
+      const app = await cf.createAccessApp(entry.cloudflareAccountId, {
+        name: `GitFlare — ${entry.githubFullName}`,
+        domain: host,
+      });
+      appId = app.id;
+      aud = app.aud;
+    }
+    await cf.createAccessPolicy(entry.cloudflareAccountId, appId, {
+      name: "GitFlare allow-list",
+      emails,
+    });
+    sp.stop(`Access app ready (aud ${kleur.gray(aud.slice(0, 8) + "…")})`);
+  } catch (e) {
+    sp.stop("Access app setup failed");
+    p.log.error((e as Error).message);
+    scopeHint();
+    return;
+  }
+
+  // 5. Record the Access config, then redeploy so the vars take effect.
+  entry.access = { appId, aud, teamDomain, allowedEmails: emails };
+  sp.start("Redeploying Worker with Access enabled");
+  try {
+    await redeployWorker(entry, cfToken, remote);
+    sp.stop("Worker redeployed");
+  } catch (e) {
+    sp.stop("Redeploy failed");
+    p.log.error((e as Error).message);
+    return;
+  }
+
+  // 6. Persist.
+  cfg.cloudflare = { token: cfToken };
+  await saveConfig(cfg);
+
+  p.outro(
+    [
+      kleur.bold(orange("Access enabled.")),
+      "",
+      `  ${entry.workerUrl} now requires SSO login.`,
+      `  Allowed: ${kleur.cyan(emails.join(", "))}`,
+      "",
+      kleur.yellow("  Note: this gates the web dashboard + API only."),
+      kleur.gray("  `git clone` of the Artifacts remote is NOT gated by Access —"),
+      kleur.gray("  it uses Artifacts' own repo tokens. Private-clone lands in a later version."),
+    ].join("\n"),
+  );
+}
+
+export async function runAccessDisable(
+  repoArg: string | undefined,
+): Promise {
+  p.intro(kleur.bold(orange("GitFlare access disable")));
+  const cfg = await loadConfig();
+  const entry = await pickRepo(cfg, repoArg);
+  if (!entry) return;
+
+  if (!entry.access) {
+    p.log.warn(`Access is not enabled for ${kleur.cyan(entry.githubFullName)}.`);
+    p.outro("");
+    return;
+  }
+
+  const cfToken = await getCfToken(cfg);
+  if (!cfToken) return p.cancel("Cancelled."), undefined;
+  const cf = new CloudflareClient(cfToken);
+
+  const sp = p.spinner();
+  sp.start("Fetching Artifacts remote");
+  let remote: string;
+  try {
+    const r = await cf.getRepo(
+      entry.cloudflareAccountId,
+      entry.artifactsNamespace,
+      entry.artifactsRepoName,
+    );
+    remote = r.remote;
+    sp.stop("Got Artifacts remote");
+  } catch (e) {
+    sp.stop("Artifacts remote lookup failed");
+    p.log.error((e as Error).message);
+    return;
+  }
+
+  sp.start("Deleting Access application");
+  try {
+    await cf.deleteAccessApp(entry.cloudflareAccountId, entry.access.appId);
+    sp.stop("Access app deleted");
+  } catch (e) {
+    // Non-fatal: the app may already be gone. Continue to redeploy open.
+    sp.stop("Access app delete failed (continuing)");
+    p.log.warn((e as Error).message);
+  }
+
+  delete entry.access;
+  sp.start("Redeploying Worker as public");
+  try {
+    await redeployWorker(entry, cfToken, remote);
+    sp.stop("Worker redeployed");
+  } catch (e) {
+    sp.stop("Redeploy failed");
+    p.log.error((e as Error).message);
+    return;
+  }
+
+  await saveConfig(cfg);
+  p.outro(kleur.bold(orange("Access disabled — repo is public again.")));
+}
diff --git a/packages/cli/src/commands/deploy.ts b/packages/cli/src/commands/deploy.ts
new file mode 100644
index 0000000..16ce3c6
--- /dev/null
+++ b/packages/cli/src/commands/deploy.ts
@@ -0,0 +1,159 @@
+import * as p from "@clack/prompts";
+import kleur from "kleur";
+import { CloudflareClient } from "../cloudflare.js";
+import { loadConfig, saveConfig } from "../config.js";
+import { orange } from "../util.js";
+import { redeployWorker } from "../redeploy.js";
+import { wranglerSecret } from "../wrangler.js";
+import { pickRepo, getCfToken } from "../repo-select.js";
+
+const TOKEN_URL = "https://dash.cloudflare.com/profile/api-tokens";
+
+async function fetchRemote(
+  cf: CloudflareClient,
+  entry: Awaited>,
+): Promise {
+  if (!entry) return undefined;
+  try {
+    const r = await cf.getRepo(
+      entry.cloudflareAccountId,
+      entry.artifactsNamespace,
+      entry.artifactsRepoName,
+    );
+    return r.remote;
+  } catch (e) {
+    p.log.error(`Artifacts remote lookup failed: ${(e as Error).message}`);
+    return undefined;
+  }
+}
+
+export async function runDeployEnable(repoArg: string | undefined): Promise {
+  p.intro(kleur.bold(orange("GitFlare deploy enable")));
+  const cfg = await loadConfig();
+  const entry = await pickRepo(cfg, repoArg);
+  if (!entry) return;
+
+  p.log.message(
+    [
+      kleur.bold("Continuous deploy stores a Cloudflare token as a Worker Secret"),
+      "on your own account so the Worker can ship your project on push — even",
+      "when GitHub Actions is down. GitFlare never sees it; it lives only in your",
+      "Worker's secrets.",
+      "",
+      `The token needs ${kleur.cyan("Workers Scripts: Edit")} (the same scope init already used).`,
+      `Create a fresh, narrow one at ${kleur.gray(TOKEN_URL)} or reuse your saved token.`,
+    ].join("\n"),
+  );
+
+  let deployToken: string | undefined;
+  if (cfg.cloudflare?.token) {
+    const reuse = await p.confirm({
+      message: "Reuse your saved Cloudflare token as the deploy token?",
+      initialValue: false,
+    });
+    if (p.isCancel(reuse)) return p.cancel("Cancelled."), undefined;
+    if (reuse) deployToken = cfg.cloudflare.token;
+  }
+  if (!deployToken) {
+    const v = await p.password({
+      message: "Deploy token (Workers Scripts: Edit)",
+      validate: (s) => (!s ? "required" : undefined),
+    });
+    if (p.isCancel(v)) return p.cancel("Cancelled."), undefined;
+    deployToken = v as string;
+  }
+
+  // The provisioning token (for the deploy itself).
+  const cfToken = await getCfToken(cfg);
+  if (!cfToken) return p.cancel("Cancelled."), undefined;
+  const cf = new CloudflareClient(cfToken);
+
+  const sp = p.spinner();
+  sp.start("Verifying deploy token");
+  try {
+    await new CloudflareClient(deployToken).verifyToken();
+    sp.stop("Deploy token OK");
+  } catch (e) {
+    sp.stop("Deploy token verification failed");
+    p.log.error((e as Error).message);
+    return;
+  }
+
+  const remote = await fetchRemote(cf, entry);
+  if (!remote) return;
+
+  // Mark CD on so redeploy emits CD_ENABLED + the DeployDO binding/migration,
+  // then set the deploy-token secret.
+  entry.deploy = { enabledAt: new Date().toISOString() };
+  sp.start("Redeploying Worker with CD enabled");
+  try {
+    const res = await redeployWorker(entry, cfToken, remote);
+    sp.message("Setting deploy-token secret");
+    await wranglerSecret(res.workDir, cfToken, "CF_DEPLOY_TOKEN", deployToken);
+    sp.stop("Worker redeployed with CD enabled");
+  } catch (e) {
+    sp.stop("Redeploy failed");
+    p.log.error((e as Error).message);
+    return;
+  }
+
+  cfg.cloudflare = { token: cfToken };
+  await saveConfig(cfg);
+
+  p.outro(
+    [
+      kleur.bold(orange("CD enabled.")),
+      "",
+      "  Commit a .gitflare/deploy.yml like:",
+      kleur.gray("    on: push"),
+      kleur.gray("    branches: [main]"),
+      kleur.gray("    steps:"),
+      kleur.gray("      - cloudflare/deploy:"),
+      kleur.gray("          project: my-worker"),
+      kleur.gray("          kind: worker"),
+      kleur.gray("          entry: dist/worker.js   # a pre-built, single-file ES module"),
+      "",
+      `  Deploys appear at ${kleur.cyan(`${entry.workerUrl}/r/${entry.artifactsRepoName}/deployments`)}`,
+      kleur.gray("  v0.2 MVP deploys pre-built artifacts only — build steps land in v0.3 (CI)."),
+    ].join("\n"),
+  );
+}
+
+export async function runDeployDisable(repoArg: string | undefined): Promise {
+  p.intro(kleur.bold(orange("GitFlare deploy disable")));
+  const cfg = await loadConfig();
+  const entry = await pickRepo(cfg, repoArg);
+  if (!entry) return;
+  if (!entry.deploy) {
+    p.log.warn(`CD is not enabled for ${kleur.cyan(entry.githubFullName)}.`);
+    p.outro("");
+    return;
+  }
+
+  const cfToken = await getCfToken(cfg);
+  if (!cfToken) return p.cancel("Cancelled."), undefined;
+  const cf = new CloudflareClient(cfToken);
+
+  const remote = await fetchRemote(cf, entry);
+  if (!remote) return;
+
+  // Clear CD so redeploy drops CD_ENABLED — the Worker then ignores pushes even
+  // though the CF_DEPLOY_TOKEN secret remains (harmless; gated on CD_ENABLED).
+  delete entry.deploy;
+  const sp = p.spinner();
+  sp.start("Redeploying Worker with CD disabled");
+  try {
+    await redeployWorker(entry, cfToken, remote);
+    sp.stop("CD disabled");
+  } catch (e) {
+    sp.stop("Disable failed");
+    p.log.error((e as Error).message);
+    return;
+  }
+
+  await saveConfig(cfg);
+  p.log.info(
+    "The CF_DEPLOY_TOKEN secret is left on the Worker (inert without CD_ENABLED); delete it in the dashboard to fully revoke.",
+  );
+  p.outro(kleur.bold(orange("CD disabled.")));
+}
diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts
index 010051f..40515c9 100644
--- a/packages/cli/src/commands/init.ts
+++ b/packages/cli/src/commands/init.ts
@@ -282,6 +282,9 @@ export async function runInit(
       `  Web UI:  ${kleur.cyan(deployedUrl)}`,
       `  Clone:   ${kleur.gray(`git clone `)}`,
       `  Push to GitHub → mirror lands in Artifacts in seconds.`,
+      "",
+      kleur.yellow("  The dashboard is publicly readable by anyone with the URL."),
+      `  Run ${kleur.cyan("gitflare access enable")} to gate it behind SSO.`,
     ].join("\n"),
   );
 }
diff --git a/packages/cli/src/config.ts b/packages/cli/src/config.ts
index 6cadf17..9defb6d 100644
--- a/packages/cli/src/config.ts
+++ b/packages/cli/src/config.ts
@@ -12,6 +12,17 @@ export interface LocalConfig {
     workerName: string;
     workerUrl: string;
     createdAt: string;
+    // Set by `gitflare access enable`; cleared by `disable`.
+    access?: {
+      appId: string;
+      aud: string;
+      teamDomain: string;
+      allowedEmails: string[];
+    };
+    // Set by `gitflare deploy enable`; cleared by `disable`.
+    deploy?: {
+      enabledAt: string;
+    };
   }>;
   // Tokens — kept local, never sent to gitflare servers.
   github?: { token: string };
diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts
index 84244b5..33b3126 100644
--- a/packages/cli/src/index.ts
+++ b/packages/cli/src/index.ts
@@ -3,6 +3,8 @@ import { Command } from "commander";
 import { createRequire } from "node:module";
 import { runInit } from "./commands/init.js";
 import { runStatus } from "./commands/status.js";
+import { runAccessEnable, runAccessDisable } from "./commands/access.js";
+import { runDeployEnable, runDeployDisable } from "./commands/deploy.js";
 
 const require = createRequire(import.meta.url);
 const pkg = require("../package.json") as { version?: string };
@@ -26,4 +28,32 @@ program
   .description("Show sync status for the current repo")
   .action(runStatus);
 
+const access = program
+  .command("access")
+  .description("Gate a repo's dashboard behind Cloudflare Access SSO");
+access
+  .command("enable")
+  .description("Put Cloudflare Access in front of the Worker (web UI + API)")
+  .argument("[repo]", "github full name or artifacts repo name; prompts if omitted")
+  .action(runAccessEnable);
+access
+  .command("disable")
+  .description("Remove Cloudflare Access — make the repo public again")
+  .argument("[repo]", "github full name or artifacts repo name; prompts if omitted")
+  .action(runAccessDisable);
+
+const deploy = program
+  .command("deploy")
+  .description("Continuous deploy: ship your project on push, GitHub-down-proof");
+deploy
+  .command("enable")
+  .description("Enable CD — store a deploy token and deploy on push via .gitflare/deploy.yml")
+  .argument("[repo]", "github full name or artifacts repo name; prompts if omitted")
+  .action(runDeployEnable);
+deploy
+  .command("disable")
+  .description("Disable CD for a repo")
+  .argument("[repo]", "github full name or artifacts repo name; prompts if omitted")
+  .action(runDeployDisable);
+
 program.parseAsync(process.argv);
diff --git a/packages/cli/src/redeploy.ts b/packages/cli/src/redeploy.ts
new file mode 100644
index 0000000..e7da429
--- /dev/null
+++ b/packages/cli/src/redeploy.ts
@@ -0,0 +1,30 @@
+import type { LocalConfig } from "./config.js";
+import { wranglerDeploy, type DeployResult } from "./wrangler.js";
+
+type RepoEntry = LocalConfig["repos"][number];
+
+/**
+ * Redeploy a repo's Worker, preserving any already-enabled Cloudflare Access
+ * config stored on the entry. Used by `access` and `deploy` commands so that
+ * enabling one feature never silently drops the other (Worker vars are
+ * replaced wholesale on each deploy).
+ */
+export async function redeployWorker(
+  entry: RepoEntry,
+  cfToken: string,
+  remote: string,
+): Promise {
+  return wranglerDeploy({
+    cloudflareApiToken: cfToken,
+    accountId: entry.cloudflareAccountId,
+    workerName: entry.workerName,
+    artifactsNamespace: entry.artifactsNamespace,
+    repoMap: {
+      [entry.githubFullName]: { name: entry.artifactsRepoName, remote },
+    },
+    ...(entry.access
+      ? { accessAud: entry.access.aud, accessTeamDomain: entry.access.teamDomain }
+      : {}),
+    ...(entry.deploy ? { cdEnabled: true } : {}),
+  });
+}
diff --git a/packages/cli/src/repo-select.ts b/packages/cli/src/repo-select.ts
new file mode 100644
index 0000000..5df7a29
--- /dev/null
+++ b/packages/cli/src/repo-select.ts
@@ -0,0 +1,47 @@
+import * as p from "@clack/prompts";
+import kleur from "kleur";
+import type { LocalConfig } from "./config.js";
+
+export type RepoEntry = LocalConfig["repos"][number];
+
+/** Resolve a repo entry from an arg, or prompt if there's more than one. */
+export async function pickRepo(
+  cfg: LocalConfig,
+  repoArg: string | undefined,
+): Promise {
+  if (cfg.repos.length === 0) {
+    p.log.warn("No repos provisioned. Run `gitflare init ` first.");
+    return undefined;
+  }
+  if (repoArg) {
+    const match = cfg.repos.find(
+      (r) => r.githubFullName === repoArg || r.artifactsRepoName === repoArg,
+    );
+    if (!match) {
+      p.log.error(`No provisioned repo matches ${kleur.cyan(repoArg)}.`);
+      return undefined;
+    }
+    return match;
+  }
+  if (cfg.repos.length === 1) return cfg.repos[0];
+  const choice = await p.select({
+    message: "Which repo?",
+    options: cfg.repos.map((r) => ({
+      value: r.githubFullName,
+      label: r.githubFullName,
+    })),
+  });
+  if (p.isCancel(choice)) return undefined;
+  return cfg.repos.find((r) => r.githubFullName === choice);
+}
+
+/** Reuse the saved Cloudflare token, or prompt for one. */
+export async function getCfToken(cfg: LocalConfig): Promise {
+  if (cfg.cloudflare?.token) return cfg.cloudflare.token;
+  const v = await p.password({
+    message: "Cloudflare API token",
+    validate: (s) => (!s ? "required" : undefined),
+  });
+  if (p.isCancel(v)) return undefined;
+  return v as string;
+}
diff --git a/packages/cli/src/wrangler.ts b/packages/cli/src/wrangler.ts
index ff72c37..d689f79 100644
--- a/packages/cli/src/wrangler.ts
+++ b/packages/cli/src/wrangler.ts
@@ -19,6 +19,12 @@ export interface DeployParams {
   workerName: string;
   artifactsNamespace: string;
   repoMap: Record;
+  // Cloudflare Access (set by `gitflare access enable`). Both present → the
+  // Worker gates its dashboard/API behind Access; both absent → public mirror.
+  accessAud?: string;
+  accessTeamDomain?: string;
+  // Continuous deploy (set by `gitflare deploy enable`). Emits CD_ENABLED="1".
+  cdEnabled?: boolean;
 }
 
 export interface DeployResult {
@@ -69,34 +75,27 @@ async function locateWorker(): Promise<
   );
 }
 
-function bundledToml(p: DeployParams, version: string): string {
-  return `name = "${p.workerName}"
-main = "worker.js"
-compatibility_date = "2026-05-01"
-compatibility_flags = ["nodejs_compat"]
-account_id = "${p.accountId}"
-
-[[artifacts]]
-binding = "ARTIFACTS"
-namespace = "${p.artifactsNamespace}"
-
-[[durable_objects.bindings]]
-name = "REPO"
-class_name = "RepoDO"
-
-[[migrations]]
-tag = "v1"
-new_sqlite_classes = ["RepoDO"]
-
-[vars]
+function varsBlock(p: DeployParams, version: string): string {
+  let out = `[vars]
 GITFLARE_VERSION = "${version}"
+ACCOUNT_ID = "${p.accountId}"
 REPO_MAP = ${JSON.stringify(JSON.stringify(p.repoMap))}
 `;
+  if (p.accessAud && p.accessTeamDomain) {
+    out += `ACCESS_AUD = ${JSON.stringify(p.accessAud)}
+ACCESS_TEAM_DOMAIN = ${JSON.stringify(p.accessTeamDomain)}
+`;
+  }
+  if (p.cdEnabled) {
+    out += `CD_ENABLED = "1"
+`;
+  }
+  return out;
 }
 
-function sourceToml(p: DeployParams, version: string): string {
+function tomlFor(main: string, p: DeployParams, version: string): string {
   return `name = "${p.workerName}"
-main = "src/index.tsx"
+main = "${main}"
 compatibility_date = "2026-05-01"
 compatibility_flags = ["nodejs_compat"]
 account_id = "${p.accountId}"
@@ -109,14 +108,27 @@ namespace = "${p.artifactsNamespace}"
 name = "REPO"
 class_name = "RepoDO"
 
+[[durable_objects.bindings]]
+name = "DEPLOY"
+class_name = "DeployDO"
+
 [[migrations]]
 tag = "v1"
 new_sqlite_classes = ["RepoDO"]
 
-[vars]
-GITFLARE_VERSION = "${version}"
-REPO_MAP = ${JSON.stringify(JSON.stringify(p.repoMap))}
-`;
+[[migrations]]
+tag = "v2"
+new_sqlite_classes = ["DeployDO"]
+
+${varsBlock(p, version)}`;
+}
+
+function bundledToml(p: DeployParams, version: string): string {
+  return tomlFor("worker.js", p, version);
+}
+
+function sourceToml(p: DeployParams, version: string): string {
+  return tomlFor("src/index.tsx", p, version);
 }
 
 async function getCliVersion(): Promise {
diff --git a/packages/worker/package.json b/packages/worker/package.json
index 9d69aed..709ffd5 100644
--- a/packages/worker/package.json
+++ b/packages/worker/package.json
@@ -12,6 +12,7 @@
   },
   "dependencies": {
     "@gitflare/shared": "workspace:*",
+    "highlight.js": "^11.11.1",
     "hono": "^4.6.0",
     "isomorphic-git": "^1.27.1",
     "marked": "^14.1.0"
diff --git a/packages/worker/src/access/jwt.ts b/packages/worker/src/access/jwt.ts
new file mode 100644
index 0000000..590702e
--- /dev/null
+++ b/packages/worker/src/access/jwt.ts
@@ -0,0 +1,174 @@
+// Verifies a Cloudflare Access application token (the `Cf-Access-Jwt-Assertion`
+// header Access injects after a successful login). Access tokens are RS256-signed
+// JWTs; we verify them with WebCrypto only — no `jose`, to keep the bundle small
+// (this worker is esbuild-bundled into the CLI's dist/). Mirrors the crypto.subtle
+// idiom in ../github/webhook.ts.
+
+export interface AccessClaims {
+  /** Subject — the Access user id. */
+  sub: string;
+  /** Email of the authenticated identity, when present. */
+  email?: string;
+}
+
+export interface VerifyOptions {
+  /** The Access application AUD tag(s) this worker accepts. */
+  aud: string;
+  /** Team auth domain, e.g. "myteam.cloudflareaccess.com" (no scheme). */
+  teamDomain: string;
+  /** Current time in seconds; injectable for tests. Defaults to now. */
+  nowSeconds?: number;
+  /** Override the JWKS fetch; injectable for tests. */
+  fetchJwks?: (certsUrl: string) => Promise;
+}
+
+interface JwtHeader {
+  alg: string;
+  kid?: string;
+}
+
+interface JwtPayload {
+  aud?: string | string[];
+  iss?: string;
+  exp?: number;
+  nbf?: number;
+  sub?: string;
+  email?: string;
+}
+
+export interface Jwk {
+  kid: string;
+  kty: string;
+  alg?: string;
+  n: string;
+  e: string;
+  use?: string;
+}
+
+export interface Jwks {
+  keys: Jwk[];
+}
+
+// Cache JWKS per team domain in module scope. Access rotates keys rarely; we
+// re-fetch on a kid miss so rotation self-heals.
+const jwksCache = new Map();
+const JWKS_TTL_MS = 60 * 60 * 1000; // 1h
+
+function certsUrlFor(teamDomain: string): string {
+  return `https://${teamDomain}/cdn-cgi/access/certs`;
+}
+
+async function defaultFetchJwks(certsUrl: string): Promise {
+  const res = await fetch(certsUrl);
+  if (!res.ok) throw new Error(`JWKS fetch ${certsUrl} → ${res.status}`);
+  return (await res.json()) as Jwks;
+}
+
+async function resolveKey(
+  teamDomain: string,
+  kid: string,
+  fetchJwks: (certsUrl: string) => Promise,
+): Promise {
+  const cached = jwksCache.get(teamDomain);
+  const fresh = cached && Date.now() - cached.fetchedAt < JWKS_TTL_MS;
+  const hit = fresh ? cached!.keys.find((k) => k.kid === kid) : undefined;
+  if (hit) return hit;
+
+  // Miss (cold, stale, or key rotation) — fetch fresh.
+  const jwks = await fetchJwks(certsUrlFor(teamDomain));
+  jwksCache.set(teamDomain, { keys: jwks.keys ?? [], fetchedAt: Date.now() });
+  return jwks.keys?.find((k) => k.kid === kid) ?? null;
+}
+
+/**
+ * Verify a Cloudflare Access JWT. Returns the claims on success, or null on any
+ * failure (bad signature, expired, wrong aud/iss, unknown key, malformed).
+ * Never throws on an untrusted token — only the caller decides what to do.
+ */
+export async function verifyAccessJwt(
+  token: string,
+  opts: VerifyOptions,
+): Promise {
+  const fetchJwks = opts.fetchJwks ?? defaultFetchJwks;
+  const now = opts.nowSeconds ?? Math.floor(Date.now() / 1000);
+
+  const parts = token.split(".");
+  if (parts.length !== 3) return null;
+  const [headerB64, payloadB64, sigB64] = parts as [string, string, string];
+
+  let header: JwtHeader;
+  let payload: JwtPayload;
+  try {
+    header = JSON.parse(decodeUtf8(base64urlToBytes(headerB64))) as JwtHeader;
+    payload = JSON.parse(decodeUtf8(base64urlToBytes(payloadB64))) as JwtPayload;
+  } catch {
+    return null;
+  }
+
+  if (header.alg !== "RS256" || !header.kid) return null;
+
+  // Claim checks before the expensive crypto.
+  const expectedIss = `https://${opts.teamDomain}`;
+  if (payload.iss !== expectedIss) return null;
+  const auds = Array.isArray(payload.aud)
+    ? payload.aud
+    : payload.aud
+      ? [payload.aud]
+      : [];
+  if (!auds.includes(opts.aud)) return null;
+  if (typeof payload.exp === "number" && now >= payload.exp) return null;
+  if (typeof payload.nbf === "number" && now < payload.nbf) return null;
+  if (!payload.sub) return null;
+
+  const jwk = await resolveKey(opts.teamDomain, header.kid, fetchJwks);
+  if (!jwk || jwk.kty !== "RSA") return null;
+
+  let key: CryptoKey;
+  try {
+    key = await crypto.subtle.importKey(
+      "jwk",
+      { kty: jwk.kty, n: jwk.n, e: jwk.e, alg: "RS256", ext: true },
+      { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
+      false,
+      ["verify"],
+    );
+  } catch {
+    return null;
+  }
+
+  const signed = new TextEncoder().encode(`${headerB64}.${payloadB64}`);
+  let valid = false;
+  try {
+    valid = await crypto.subtle.verify(
+      "RSASSA-PKCS1-v1_5",
+      key,
+      base64urlToBytes(sigB64),
+      signed,
+    );
+  } catch {
+    return null;
+  }
+  if (!valid) return null;
+
+  return payload.email !== undefined
+    ? { sub: payload.sub, email: payload.email }
+    : { sub: payload.sub };
+}
+
+function base64urlToBytes(input: string): Uint8Array {
+  const b64 = input.replace(/-/g, "+").replace(/_/g, "/");
+  const pad = b64.length % 4 === 0 ? "" : "=".repeat(4 - (b64.length % 4));
+  const bin = atob(b64 + pad);
+  const out = new Uint8Array(bin.length);
+  for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
+  return out;
+}
+
+function decodeUtf8(bytes: Uint8Array): string {
+  return new TextDecoder().decode(bytes);
+}
+
+// Exposed for tests that want a clean cache between cases.
+export function _clearJwksCache(): void {
+  jwksCache.clear();
+}
diff --git a/packages/worker/src/access/middleware.ts b/packages/worker/src/access/middleware.ts
new file mode 100644
index 0000000..e572bc7
--- /dev/null
+++ b/packages/worker/src/access/middleware.ts
@@ -0,0 +1,44 @@
+import type { MiddlewareHandler } from "hono";
+import type { Env } from "../env";
+import { verifyAccessJwt } from "./jwt";
+
+export interface AccessVariables {
+  accessEmail?: string;
+}
+
+const COOKIE_NAME = "CF_Authorization";
+
+function tokenFromCookie(cookieHeader: string | undefined): string | undefined {
+  if (!cookieHeader) return undefined;
+  for (const part of cookieHeader.split(";")) {
+    const [k, ...v] = part.trim().split("=");
+    if (k === COOKIE_NAME) return v.join("=");
+  }
+  return undefined;
+}
+
+/**
+ * Gates a route group behind a verified Cloudflare Access token. No-ops when
+ * ACCESS_AUD is unset, so public-repo mirrors stay open until the user opts in
+ * via `gitflare access enable`. Defense-in-depth: Access also blocks at the
+ * edge, but this middleware is the real boundary inside the Worker.
+ */
+export const accessGuard: MiddlewareHandler<{
+  Bindings: Env;
+  Variables: AccessVariables;
+}> = async (c, next) => {
+  const aud = c.env.ACCESS_AUD;
+  const teamDomain = c.env.ACCESS_TEAM_DOMAIN;
+  if (!aud || !teamDomain) return next(); // public mirror
+
+  const token =
+    c.req.header("Cf-Access-Jwt-Assertion") ??
+    tokenFromCookie(c.req.header("Cookie"));
+  if (!token) return c.text("Forbidden — Cloudflare Access required", 403);
+
+  const claims = await verifyAccessJwt(token, { aud, teamDomain });
+  if (!claims) return c.text("Forbidden — invalid Access token", 403);
+
+  if (claims.email) c.set("accessEmail", claims.email);
+  return next();
+};
diff --git a/packages/worker/src/deploy/cf-deploy.ts b/packages/worker/src/deploy/cf-deploy.ts
new file mode 100644
index 0000000..f4d3101
--- /dev/null
+++ b/packages/worker/src/deploy/cf-deploy.ts
@@ -0,0 +1,78 @@
+// Uploads a pre-built single-file Worker to the user's own Cloudflare account
+// via the Workers Scripts API — the same multipart PUT `wrangler deploy` makes
+// under the hood. v0.2 MVP: no build step (Workers can't spawn processes), so
+// the repo must commit a built ES-module entry; arbitrary build lands in v0.3
+// (generic CI on Sandboxes).
+//
+// NOTE: the exact multipart shape is verified against wrangler's behaviour but
+// should be re-checked live before relying on it in production.
+
+export interface ScriptUpload {
+  scriptName: string;
+  /** The module file name referenced by metadata.main_module. */
+  moduleFileName: string;
+  /** ES-module source. */
+  code: string;
+  compatibilityDate?: string;
+}
+
+/**
+ * Build the multipart body for `PUT /accounts/{id}/workers/scripts/{name}`.
+ * Pure + synchronous so it can be unit-tested without a network.
+ */
+export function buildScriptUploadForm(u: ScriptUpload): FormData {
+  const form = new FormData();
+  const metadata = {
+    main_module: u.moduleFileName,
+    compatibility_date: u.compatibilityDate ?? "2026-05-01",
+  };
+  form.append(
+    "metadata",
+    new Blob([JSON.stringify(metadata)], { type: "application/json" }),
+  );
+  form.append(
+    u.moduleFileName,
+    new Blob([u.code], { type: "application/javascript+module" }),
+    u.moduleFileName,
+  );
+  return form;
+}
+
+export interface DeployApiParams {
+  accountId: string;
+  apiToken: string;
+  upload: ScriptUpload;
+  /** Injectable for tests. */
+  fetchImpl?: typeof fetch;
+}
+
+export interface DeployApiResult {
+  ok: boolean;
+  status: number;
+  detail?: string;
+}
+
+export async function uploadWorkerScript(
+  p: DeployApiParams,
+): Promise {
+  const doFetch = p.fetchImpl ?? fetch;
+  const url = `https://api.cloudflare.com/client/v4/accounts/${p.accountId}/workers/scripts/${p.upload.scriptName}`;
+  const res = await doFetch(url, {
+    method: "PUT",
+    headers: { Authorization: `Bearer ${p.apiToken}` },
+    body: buildScriptUploadForm(p.upload),
+  });
+  let detail: string | undefined;
+  try {
+    const json = (await res.json()) as {
+      success?: boolean;
+      errors?: Array<{ code: number; message: string }>;
+    };
+    if (json.errors?.length) {
+      detail = json.errors.map((e) => `[${e.code}] ${e.message}`).join("; ");
+    }
+    return { ok: res.ok && json.success !== false, status: res.status, ...(detail ? { detail } : {}) };
+  } catch {
+    return { ok: res.ok, status: res.status };
+  }
+}
diff --git a/packages/worker/src/deploy/workflow.ts b/packages/worker/src/deploy/workflow.ts
new file mode 100644
index 0000000..76c0b39
--- /dev/null
+++ b/packages/worker/src/deploy/workflow.ts
@@ -0,0 +1,152 @@
+// Minimal parser for `.gitflare/deploy.yml`. We intentionally do NOT pull a
+// full YAML library — the schema is fixed and small, and the worker bundle
+// ships inside the CLI. This understands exactly the v0.2 shape:
+//
+//   on: push
+//   branches: [main]
+//   steps:
+//     - cloudflare/deploy:
+//         project: my-worker
+//         kind: worker
+//         entry: dist/worker.js
+//
+// Anything outside this shape is reported as an error rather than guessed at.
+
+export interface DeployStep {
+  type: "cloudflare/deploy";
+  project: string;
+  kind: "worker" | "pages";
+  entry: string;
+}
+
+export interface DeployWorkflow {
+  on: string[]; // e.g. ["push"]
+  branches: string[]; // empty = every branch
+  steps: DeployStep[];
+}
+
+export interface ParseResult {
+  workflow?: DeployWorkflow;
+  error?: string;
+}
+
+function stripComment(line: string): string {
+  // Naive: drop a trailing " # ..." comment. Good enough for this schema —
+  // values here are identifiers/paths, not strings containing '#'.
+  const i = line.indexOf(" #");
+  return i === -1 ? line : line.slice(0, i);
+}
+
+function parseInlineList(value: string): string[] | null {
+  const m = value.match(/^\[(.*)\]$/);
+  if (!m) return null;
+  return m[1]!
+    .split(",")
+    .map((s) => s.trim().replace(/^["']|["']$/g, ""))
+    .filter(Boolean);
+}
+
+function unquote(s: string): string {
+  return s.trim().replace(/^["']|["']$/g, "");
+}
+
+export function parseDeployWorkflow(src: string): ParseResult {
+  const rawLines = src.split(/\r?\n/);
+  const on: string[] = [];
+  let branches: string[] = [];
+  const steps: DeployStep[] = [];
+
+  // Index lines with their indentation, skipping blanks/comment-only lines.
+  const lines: Array<{ indent: number; text: string }> = [];
+  for (const raw of rawLines) {
+    if (raw.trim().startsWith("#")) continue; // full-line comment
+    const noComment = stripComment(raw);
+    if (!noComment.trim()) continue;
+    const indent = noComment.length - noComment.trimStart().length;
+    lines.push({ indent, text: noComment.trim() });
+  }
+
+  let i = 0;
+  while (i < lines.length) {
+    const { indent, text } = lines[i]!;
+    if (indent !== 0) {
+      return { error: `unexpected indentation at: "${text}"` };
+    }
+
+    if (text.startsWith("on:")) {
+      const v = text.slice(3).trim();
+      const inline = parseInlineList(v);
+      if (inline) on.push(...inline);
+      else if (v) on.push(unquote(v));
+      i++;
+    } else if (text.startsWith("branches:")) {
+      const v = text.slice("branches:".length).trim();
+      const inline = parseInlineList(v);
+      if (inline) branches = inline;
+      else if (v) branches = [unquote(v)];
+      else {
+        // Block list form.
+        i++;
+        while (i < lines.length && lines[i]!.indent > 0 && lines[i]!.text.startsWith("- ")) {
+          branches.push(unquote(lines[i]!.text.slice(2)));
+          i++;
+        }
+        continue;
+      }
+      i++;
+    } else if (text === "steps:") {
+      i++;
+      const r = parseSteps(lines, i, steps);
+      if (r.error) return { error: r.error };
+      i = r.next;
+    } else {
+      return { error: `unknown top-level key: "${text}"` };
+    }
+  }
+
+  if (on.length === 0) return { error: "missing `on:`" };
+  if (steps.length === 0) return { error: "no steps defined" };
+
+  return { workflow: { on, branches, steps } };
+}
+
+function parseSteps(
+  lines: Array<{ indent: number; text: string }>,
+  start: number,
+  out: DeployStep[],
+): { next: number; error?: string } {
+  let i = start;
+  while (i < lines.length && lines[i]!.indent > 0) {
+    const line = lines[i]!;
+    const m = line.text.match(/^-\s*cloudflare\/deploy:\s*$/);
+    if (!m) {
+      return { next: i, error: `unsupported step: "${line.text}" (only cloudflare/deploy)` };
+    }
+    const stepIndent = line.indent;
+    i++;
+    const fields: Record = {};
+    while (i < lines.length && lines[i]!.indent > stepIndent) {
+      const kv = lines[i]!.text.match(/^([a-zA-Z_]+):\s*(.*)$/);
+      if (kv) fields[kv[1]!] = unquote(kv[2]!);
+      i++;
+    }
+    const project = fields.project;
+    const kind = (fields.kind ?? "worker") as DeployStep["kind"];
+    const entry = fields.entry;
+    if (!project) return { next: i, error: "step missing `project`" };
+    if (!entry) return { next: i, error: "step missing `entry`" };
+    if (kind !== "worker" && kind !== "pages") {
+      return { next: i, error: `unsupported kind: "${kind}" (worker only in v0.2 MVP)` };
+    }
+    out.push({ type: "cloudflare/deploy", project, kind, entry });
+  }
+  return { next: i };
+}
+
+/** Does this workflow run for a push to `ref` (e.g. "refs/heads/main")? */
+export function matchesPush(wf: DeployWorkflow, ref: string): boolean {
+  if (!wf.on.includes("push")) return false;
+  if (wf.branches.length === 0) return true;
+  const branch = ref.replace(/^refs\/heads\//, "");
+  return wf.branches.includes(branch);
+}
diff --git a/packages/worker/src/durable-objects/deploy.ts b/packages/worker/src/durable-objects/deploy.ts
new file mode 100644
index 0000000..b0b0474
--- /dev/null
+++ b/packages/worker/src/durable-objects/deploy.ts
@@ -0,0 +1,164 @@
+import type { Env } from "../env";
+import { cloneRepoShallow, readBlobAt } from "../artifacts/content";
+import { parseDeployWorkflow, matchesPush } from "../deploy/workflow";
+import { uploadWorkerScript } from "../deploy/cf-deploy";
+
+export interface DeployRecord {
+  id: number;
+  ref: string;
+  sha: string;
+  startedAt: number;
+  finishedAt?: number;
+  status: "running" | "success" | "failed" | "skipped";
+  steps: Array<{ project: string; kind: string; ok: boolean; detail?: string }>;
+  message?: string;
+}
+
+interface DeployRequest {
+  artifactsRepoName: string;
+  remote: string;
+  ref: string;
+  sha: string;
+}
+
+const WORKFLOW_PATH = ".gitflare/deploy.yml";
+
+/**
+ * Per-repo deploy stream. Serializes deploys, records history, and runs the
+ * Workers Scripts upload. Mirrors RepoDO's shape (idFromName per repo).
+ */
+export class DeployDO {
+  private state: DurableObjectState;
+  private env: Env;
+  private inFlight: Promise | null = null;
+
+  constructor(state: DurableObjectState, env: Env) {
+    this.state = state;
+    this.env = env;
+  }
+
+  async fetch(request: Request): Promise {
+    const url = new URL(request.url);
+    if (request.method === "POST" && url.pathname === "/deploy") {
+      const body = (await request.json()) as DeployRequest;
+      const prior = this.inFlight ?? Promise.resolve();
+      const run = prior.then(() => this.runDeploy(body));
+      this.inFlight = run.catch(() => undefined);
+      try {
+        return Response.json(await run);
+      } catch (err) {
+        return Response.json({ ok: false, error: (err as Error).message }, { status: 500 });
+      }
+    }
+    if (request.method === "GET" && url.pathname === "/state") {
+      return Response.json({ deploys: await this.history() });
+    }
+    return new Response("not found", { status: 404 });
+  }
+
+  private async nextId(): Promise {
+    const last = (await this.state.storage.get("lastId")) ?? 0;
+    const id = last + 1;
+    await this.state.storage.put("lastId", id);
+    return id;
+  }
+
+  private async record(r: DeployRecord): Promise {
+    await this.state.storage.put(`deploy:${String(r.id).padStart(10, "0")}`, r);
+  }
+
+  private async runDeploy(req: DeployRequest): Promise {
+    const id = await this.nextId();
+    const rec: DeployRecord = {
+      id,
+      ref: req.ref,
+      sha: req.sha,
+      startedAt: Date.now(),
+      status: "running",
+      steps: [],
+    };
+    await this.record(rec);
+
+    const finish = async (
+      status: DeployRecord["status"],
+      message?: string,
+    ): Promise => {
+      rec.status = status;
+      rec.finishedAt = Date.now();
+      if (message) rec.message = message;
+      await this.record(rec);
+      return rec;
+    };
+
+    const token = this.env.CF_DEPLOY_TOKEN;
+    const accountId = this.env.ACCOUNT_ID;
+    if (this.env.CD_ENABLED !== "1" || !token || !accountId) {
+      return finish("skipped", "CD not enabled. Run `gitflare deploy enable`.");
+    }
+
+    let shallow;
+    try {
+      const handle = await this.env.ARTIFACTS.get(req.artifactsRepoName);
+      shallow = await cloneRepoShallow(handle, req.remote);
+    } catch (e) {
+      return finish("failed", `clone failed: ${(e as Error).message}`);
+    }
+
+    const wfBlob = await readBlobAt(shallow, WORKFLOW_PATH).catch(() => null);
+    if (!wfBlob || wfBlob.isBinary || !wfBlob.text) {
+      return finish("skipped", `no ${WORKFLOW_PATH}`);
+    }
+
+    const parsed = parseDeployWorkflow(wfBlob.text);
+    if (parsed.error || !parsed.workflow) {
+      return finish("failed", `invalid ${WORKFLOW_PATH}: ${parsed.error}`);
+    }
+    if (!matchesPush(parsed.workflow, req.ref)) {
+      return finish("skipped", `${req.ref} doesn't match workflow branches`);
+    }
+
+    let anyFailed = false;
+    for (const step of parsed.workflow.steps) {
+      const entryBlob = await readBlobAt(shallow, step.entry).catch(() => null);
+      if (!entryBlob || entryBlob.isBinary || !entryBlob.text) {
+        rec.steps.push({ project: step.project, kind: step.kind, ok: false, detail: `entry not found: ${step.entry}` });
+        anyFailed = true;
+        continue;
+      }
+      if (step.kind !== "worker") {
+        rec.steps.push({ project: step.project, kind: step.kind, ok: false, detail: "only kind: worker in v0.2 MVP" });
+        anyFailed = true;
+        continue;
+      }
+      const result = await uploadWorkerScript({
+        accountId,
+        apiToken: token,
+        upload: {
+          scriptName: step.project,
+          moduleFileName: "worker.js",
+          code: entryBlob.text,
+        },
+      });
+      rec.steps.push({
+        project: step.project,
+        kind: step.kind,
+        ok: result.ok,
+        ...(result.detail ? { detail: result.detail } : {}),
+      });
+      if (!result.ok) anyFailed = true;
+      await this.record(rec); // checkpoint after each step
+    }
+
+    return finish(anyFailed ? "failed" : "success");
+  }
+
+  private async history(): Promise {
+    const map = await this.state.storage.list({ prefix: "deploy:", reverse: true, limit: 50 });
+    return [...map.values()];
+  }
+}
+
+export function deployStubFor(env: Env, artifactsRepoName: string): DurableObjectStub {
+  const id = env.DEPLOY.idFromName(artifactsRepoName);
+  return env.DEPLOY.get(id);
+}
diff --git a/packages/worker/src/env.ts b/packages/worker/src/env.ts
index 83a221a..6dbafb4 100644
--- a/packages/worker/src/env.ts
+++ b/packages/worker/src/env.ts
@@ -11,15 +11,31 @@ export interface Env {
   // Secrets
   GITHUB_WEBHOOK_SECRET: string;
   GITHUB_TOKEN: string;
+  // CD deploy token (optional — set by `gitflare deploy enable`). Scoped to
+  // Workers Scripts: Edit on the user's own account. Absent = CD disabled.
+  CF_DEPLOY_TOKEN?: string;
 
   // Vars
   GITFLARE_VERSION: string;
   // JSON-encoded { "owner/repo": { name, remote } }
   REPO_MAP: string;
+  // The Cloudflare account id, exposed so the Worker can call the Scripts API.
+  ACCOUNT_ID?: string;
+  // "1" when `gitflare deploy enable` is active. Gates CD independently of the
+  // secret's presence so `disable` (a redeploy without this var) cleanly stops
+  // deploys without needing to delete the Worker Secret.
+  CD_ENABLED?: string;
+
+  // Cloudflare Access (optional — set by `gitflare access enable`). When
+  // ACCESS_AUD is present, the dashboard + API routes are gated behind a
+  // verified Access token; absent means the mirror is public-readable.
+  ACCESS_AUD?: string;
+  ACCESS_TEAM_DOMAIN?: string; // e.g. "myteam.cloudflareaccess.com"
 
   // Bindings
   ARTIFACTS: ArtifactsNamespace;
   REPO: DurableObjectNamespace;
+  DEPLOY: DurableObjectNamespace;
 }
 
 export function parseRepoMap(env: Env): RepoMap {
diff --git a/packages/worker/src/index.tsx b/packages/worker/src/index.tsx
index a1bfcdc..c9ab7c5 100644
--- a/packages/worker/src/index.tsx
+++ b/packages/worker/src/index.tsx
@@ -6,10 +6,21 @@ import { listArtifactsRefs } from "./artifacts/refs";
 import { cloneRepoShallow, getRepoContent, listTreeAt, readBlobAt } from "./artifacts/content";
 import { Browse } from "./ui/browse";
 import { Home, type HomeRepo } from "./ui/home";
+import { Deployments } from "./ui/deployments";
+import { NotFound, ErrorView } from "./ui/states";
+import { accessGuard, type AccessVariables } from "./access/middleware";
+import { deployStubFor, type DeployRecord } from "./durable-objects/deploy";
 
 export { RepoDO } from "./durable-objects/repo";
+export { DeployDO } from "./durable-objects/deploy";
 
-const app = new Hono<{ Bindings: Env }>();
+const app = new Hono<{ Bindings: Env; Variables: AccessVariables }>();
+
+// /health and /webhooks/github stay open (the latter is HMAC-gated). Everything
+// human- or API-facing is gated behind Cloudflare Access when ACCESS_AUD is set.
+app.use("/", accessGuard);
+app.use("/r/*", accessGuard);
+app.use("/api/*", accessGuard);
 
 app.get("/health", (c) =>
   c.json({ ok: true, version: c.env.GITFLARE_VERSION ?? "0.0.0" }),
@@ -66,10 +77,32 @@ function findRepoByArtifactsName(env: Env, artifactsName: string):
   return undefined;
 }
 
+const CONTENT_TYPES: Record = {
+  png: "image/png",
+  jpg: "image/jpeg",
+  jpeg: "image/jpeg",
+  gif: "image/gif",
+  webp: "image/webp",
+  svg: "image/svg+xml",
+  ico: "image/x-icon",
+  bmp: "image/bmp",
+  avif: "image/avif",
+  pdf: "application/pdf",
+};
+
+function contentTypeFor(path: string): string {
+  const ext = path.slice(path.lastIndexOf(".") + 1).toLowerCase();
+  return CONTENT_TYPES[ext] ?? "application/octet-stream";
+}
+
 app.get("/r/:name/tree/*", async (c) => {
   const name = c.req.param("name");
   const repo = findRepoByArtifactsName(c.env, name);
-  if (!repo) return c.text(`Unknown repo: ${name}`, 404);
+  if (!repo)
+    return c.html(
+      ,
+      404,
+    );
 
   const prefix = `/r/${name}/tree/`;
   const path = decodeURIComponent(c.req.path.slice(prefix.length)).replace(/^\/+|\/+$/g, "");
@@ -78,7 +111,16 @@ app.get("/r/:name/tree/*", async (c) => {
     const handle = await c.env.ARTIFACTS.get(name);
     const shallow = await cloneRepoShallow(handle, repo.remote);
     const entries = await listTreeAt(shallow, path);
-    if (!entries) return c.text(`Path not found: ${path}`, 404);
+    if (!entries)
+      return c.html(
+        ,
+        404,
+      );
     return c.html(
        {
       />,
     );
   } catch (err) {
-    return c.text(`Error: ${(err as Error).message}`, 500);
+    return c.html(, 500);
   }
 });
 
 app.get("/r/:name/blob/*", async (c) => {
   const name = c.req.param("name");
   const repo = findRepoByArtifactsName(c.env, name);
-  if (!repo) return c.text(`Unknown repo: ${name}`, 404);
+  if (!repo)
+    return c.html(
+      ,
+      404,
+    );
 
   const prefix = `/r/${name}/blob/`;
   const path = decodeURIComponent(c.req.path.slice(prefix.length)).replace(/^\/+|\/+$/g, "");
@@ -107,7 +153,16 @@ app.get("/r/:name/blob/*", async (c) => {
     const handle = await c.env.ARTIFACTS.get(name);
     const shallow = await cloneRepoShallow(handle, repo.remote);
     const blob = await readBlobAt(shallow, path);
-    if (!blob) return c.text(`File not found: ${path}`, 404);
+    if (!blob)
+      return c.html(
+        ,
+        404,
+      );
     return c.html(
        {
         version={c.env.GITFLARE_VERSION ?? "0.0.0"}
       />,
     );
+  } catch (err) {
+    return c.html(, 500);
+  }
+});
+
+// Raw blob proxy — serves file bytes straight from the Artifacts mirror. Used
+// for README images so they render for private repos and survive GitHub
+// outages. Under /r/* so the Access guard already covers it.
+app.get("/r/:name/raw/*", async (c) => {
+  const name = c.req.param("name");
+  const repo = findRepoByArtifactsName(c.env, name);
+  if (!repo) return c.text(`Unknown repo: ${name}`, 404);
+
+  const prefix = `/r/${name}/raw/`;
+  const path = decodeURIComponent(c.req.path.slice(prefix.length)).replace(/^\/+|\/+$/g, "");
+
+  try {
+    const handle = await c.env.ARTIFACTS.get(name);
+    const shallow = await cloneRepoShallow(handle, repo.remote);
+    const blob = await readBlobAt(shallow, path);
+    if (!blob) return c.text(`File not found: ${path}`, 404);
+    // bytes is a plain Uint8Array; the cast sidesteps the ArrayBufferLike vs
+    // ArrayBuffer generic mismatch in the typed-array lib types.
+    return new Response(blob.bytes as unknown as BodyInit, {
+      headers: {
+        "Content-Type": contentTypeFor(path),
+        "Cache-Control": "public, max-age=300",
+      },
+    });
   } catch (err) {
     return c.text(`Error: ${(err as Error).message}`, 500);
   }
@@ -139,6 +223,33 @@ app.get("/api/refs", async (c) => {
   return c.json(out);
 });
 
+app.get("/r/:name/deployments", async (c) => {
+  const name = c.req.param("name");
+  const repo = findRepoByArtifactsName(c.env, name);
+  if (!repo)
+    return c.html(
+      ,
+      404,
+    );
+  let deploys: DeployRecord[] = [];
+  try {
+    const stub = deployStubFor(c.env, name);
+    const resp = await stub.fetch("https://deploy-do/state");
+    if (resp.ok) ({ deploys } = (await resp.json()) as { deploys: DeployRecord[] });
+  } catch {
+    // Soft fail — show an empty list.
+  }
+  return c.html(
+    ,
+  );
+});
+
 app.post("/webhooks/github", async (c) => {
   const signature = c.req.header("x-hub-signature-256");
   const event = c.req.header("x-github-event");
@@ -193,6 +304,24 @@ app.post("/webhooks/github", async (c) => {
       }),
     });
     const json = await resp.json();
+
+    // CD (v0.2): once the sync landed, kick off a deploy. DeployDO no-ops if
+    // there's no .gitflare/deploy.yml or CD isn't enabled. Runs after the
+    // response so the webhook returns fast.
+    if (resp.ok) {
+      const deploy = deployStubFor(c.env, entry.name);
+      c.executionCtx.waitUntil(
+        deploy.fetch("https://deploy-do/deploy", {
+          method: "POST",
+          body: JSON.stringify({
+            artifactsRepoName: entry.name,
+            remote: entry.remote,
+            ref: payload.ref,
+            sha: payload.after,
+          }),
+        }),
+      );
+    }
     return c.json({ accepted: true, result: json }, resp.ok ? 202 : 500);
   }
 
diff --git a/packages/worker/src/ui/browse.tsx b/packages/worker/src/ui/browse.tsx
index b31aeaa..e7dde1e 100644
--- a/packages/worker/src/ui/browse.tsx
+++ b/packages/worker/src/ui/browse.tsx
@@ -1,6 +1,8 @@
 import type { FC } from "hono/jsx";
+import { raw } from "hono/html";
 import { Layout } from "./layout";
 import { LOGO_PNG_DATA_URL } from "./logo-data";
+import { highlightCode } from "./highlight";
 import type { TreeEntry, BlobAtPath } from "../artifacts/content";
 
 interface BrowseProps {
@@ -117,18 +119,33 @@ const TreeView: FC<{ repoName: string; basePath: string; entries: TreeEntry[] }>
   
 );
 
-const BlobView: FC<{ blob: BlobAtPath }> = ({ blob }) => (
-  
-
- {formatBytes(blob.size)}{blob.isBinary ? " · binary" : ""} +const PRE_STYLE = + "margin: 0; padding: 12px; background: var(--bg); border-radius: 6px; border: 1px solid var(--border); overflow-x: auto; max-height: 70vh;"; + +const BlobView: FC<{ blob: BlobAtPath }> = ({ blob }) => { + const text = blob.text ?? ""; + const highlighted = blob.isBinary ? null : highlightCode(text, blob.path); + return ( +
+
+ {formatBytes(blob.size)} + {blob.isBinary ? " · binary" : ""} + {highlighted ? ` · ${highlighted.lang}` : ""} +
+ {blob.isBinary ? ( +
Binary file — preview not shown.
+ ) : highlighted ? ( +
+          {raw(highlighted.html)}
+        
+ ) : ( +
+          {text}
+        
+ )}
- {blob.isBinary ? ( -
Binary file — preview not shown.
- ) : ( -
{blob.text ?? ""}
- )} -
-); + ); +}; function parentPath(p: string): string { const i = p.lastIndexOf("/"); diff --git a/packages/worker/src/ui/deployments.tsx b/packages/worker/src/ui/deployments.tsx new file mode 100644 index 0000000..feeb2e8 --- /dev/null +++ b/packages/worker/src/ui/deployments.tsx @@ -0,0 +1,99 @@ +import type { FC } from "hono/jsx"; +import { Layout } from "./layout"; +import { LOGO_PNG_DATA_URL } from "./logo-data"; +import type { DeployRecord } from "../durable-objects/deploy"; + +interface Props { + githubFullName: string; + artifactsRepoName: string; + deploys: DeployRecord[]; + cdEnabled: boolean; + version: string; +} + +const PILL: Record = { + success: "ok", + failed: "err", + running: "warn", + skipped: "warn", +}; + +function rel(ts: number): string { + const s = Math.max(0, Math.round((Date.now() - ts) / 1000)); + if (s < 60) return `${s}s ago`; + if (s < 3600) return `${Math.round(s / 60)}m ago`; + if (s < 86400) return `${Math.round(s / 3600)}h ago`; + return `${Math.round(s / 86400)}d ago`; +} + +export const Deployments: FC = (p) => ( + +
+
+ +
v{p.version}
+
+ +

Deployments

+

+ {p.githubFullName} · browse code +

+ + {!p.cdEnabled ? ( +
+
Continuous deploy isn't enabled for this repo.
+
gitflare deploy enable
+
+ Then commit a .gitflare/deploy.yml. On push, GitFlare deploys your built + Worker to your own account — even when GitHub Actions is down. +
+
+ ) : p.deploys.length === 0 ? ( +
+ No deploys yet. Push to a branch matched by .gitflare/deploy.yml to trigger one. +
+ ) : ( +
+ + + + + + + + + + + + + {p.deploys.map((d) => ( + + + + + + + + + ))} + +
#RefCommitStepsStatusWhen
{d.id}{d.ref.replace(/^refs\/heads\//, "")}{d.sha.slice(0, 8)} + {d.steps.length === 0 + ? "—" + : d.steps.map((s) => `${s.project}${s.ok ? "✓" : "✗"}`).join(" ")} + + {d.status} + {d.message ? ( + {d.message} + ) : null} + {rel(d.startedAt)}
+
+ )} +
+
+); diff --git a/packages/worker/src/ui/highlight.ts b/packages/worker/src/ui/highlight.ts new file mode 100644 index 0000000..90f8a67 --- /dev/null +++ b/packages/worker/src/ui/highlight.ts @@ -0,0 +1,141 @@ +// Server-side syntax highlighting for the blob viewer. We use highlight.js +// *core* and register only a curated language set — the full library pulls +// ~190 grammars and would balloon the worker bundle (which ships inside the +// CLI). Core (~25KB) + ~20 small grammars keeps the cost modest. highlight.js +// runs without a DOM, so it works inside a Worker. + +import hljs from "highlight.js/lib/core"; +import javascript from "highlight.js/lib/languages/javascript"; +import typescript from "highlight.js/lib/languages/typescript"; +import xml from "highlight.js/lib/languages/xml"; // HTML/XML/SVG +import css from "highlight.js/lib/languages/css"; +import json from "highlight.js/lib/languages/json"; +import markdown from "highlight.js/lib/languages/markdown"; +import bash from "highlight.js/lib/languages/bash"; +import python from "highlight.js/lib/languages/python"; +import go from "highlight.js/lib/languages/go"; +import rust from "highlight.js/lib/languages/rust"; +import yaml from "highlight.js/lib/languages/yaml"; +import toml from "highlight.js/lib/languages/ini"; // ini grammar covers toml +import sql from "highlight.js/lib/languages/sql"; +import java from "highlight.js/lib/languages/java"; +import c from "highlight.js/lib/languages/c"; +import cpp from "highlight.js/lib/languages/cpp"; +import csharp from "highlight.js/lib/languages/csharp"; +import ruby from "highlight.js/lib/languages/ruby"; +import php from "highlight.js/lib/languages/php"; +import shell from "highlight.js/lib/languages/shell"; +import dockerfile from "highlight.js/lib/languages/dockerfile"; +import diff from "highlight.js/lib/languages/diff"; + +hljs.registerLanguage("javascript", javascript); +hljs.registerLanguage("typescript", typescript); +hljs.registerLanguage("xml", xml); +hljs.registerLanguage("css", css); +hljs.registerLanguage("json", json); +hljs.registerLanguage("markdown", markdown); +hljs.registerLanguage("bash", bash); +hljs.registerLanguage("python", python); +hljs.registerLanguage("go", go); +hljs.registerLanguage("rust", rust); +hljs.registerLanguage("yaml", yaml); +hljs.registerLanguage("ini", toml); +hljs.registerLanguage("sql", sql); +hljs.registerLanguage("java", java); +hljs.registerLanguage("c", c); +hljs.registerLanguage("cpp", cpp); +hljs.registerLanguage("csharp", csharp); +hljs.registerLanguage("ruby", ruby); +hljs.registerLanguage("php", php); +hljs.registerLanguage("shell", shell); +hljs.registerLanguage("dockerfile", dockerfile); +hljs.registerLanguage("diff", diff); + +// Skip highlighting very large files — tokenizing megabytes blows the Worker's +// CPU budget. The viewer falls back to a plain
 above this size.
+const MAX_HIGHLIGHT_BYTES = 512 * 1024;
+
+const EXT_TO_LANG: Record = {
+  js: "javascript",
+  mjs: "javascript",
+  cjs: "javascript",
+  jsx: "javascript",
+  ts: "typescript",
+  tsx: "typescript",
+  mts: "typescript",
+  cts: "typescript",
+  html: "xml",
+  htm: "xml",
+  xml: "xml",
+  svg: "xml",
+  vue: "xml",
+  css: "css",
+  scss: "css",
+  json: "json",
+  jsonc: "json",
+  md: "markdown",
+  markdown: "markdown",
+  sh: "bash",
+  bash: "bash",
+  zsh: "bash",
+  py: "python",
+  go: "go",
+  rs: "rust",
+  yml: "yaml",
+  yaml: "yaml",
+  toml: "ini",
+  ini: "ini",
+  sql: "sql",
+  java: "java",
+  c: "c",
+  h: "c",
+  cpp: "cpp",
+  cc: "cpp",
+  cxx: "cpp",
+  hpp: "cpp",
+  cs: "csharp",
+  rb: "ruby",
+  php: "php",
+  dockerfile: "dockerfile",
+  diff: "diff",
+  patch: "diff",
+};
+
+const FILENAME_TO_LANG: Record = {
+  dockerfile: "dockerfile",
+  makefile: "bash",
+  ".bashrc": "bash",
+  ".zshrc": "bash",
+  "package.json": "json",
+  "tsconfig.json": "json",
+};
+
+function langFor(path: string): string | undefined {
+  const base = path.split("/").pop()?.toLowerCase() ?? "";
+  if (FILENAME_TO_LANG[base]) return FILENAME_TO_LANG[base];
+  const dot = base.lastIndexOf(".");
+  if (dot === -1) return undefined;
+  return EXT_TO_LANG[base.slice(dot + 1)];
+}
+
+export interface Highlighted {
+  html: string; // highlight.js markup; render with dangerouslySetInnerHTML
+  lang: string;
+}
+
+/**
+ * Highlight `text` based on the file path's extension. Returns null when the
+ * language is unknown, the file is too large, or highlighting throws — callers
+ * fall back to a plain 
.
+ */
+export function highlightCode(text: string, path: string): Highlighted | null {
+  if (text.length > MAX_HIGHLIGHT_BYTES) return null;
+  const lang = langFor(path);
+  if (!lang) return null;
+  try {
+    const { value } = hljs.highlight(text, { language: lang, ignoreIllegals: true });
+    return { html: value, lang };
+  } catch {
+    return null;
+  }
+}
diff --git a/packages/worker/src/ui/home.tsx b/packages/worker/src/ui/home.tsx
index 8d6a5ca..d0b0663 100644
--- a/packages/worker/src/ui/home.tsx
+++ b/packages/worker/src/ui/home.tsx
@@ -22,8 +22,13 @@ export interface HomeRepo {
   error?: string;
 }
 
-function rewriteReadmeImages(md: string, githubFullName: string, branch: string): string {
-  const base = `https://raw.githubusercontent.com/${githubFullName}/${branch}`;
+// Rewrite relative README image paths to the Worker's own raw-blob proxy
+// (/r//raw/). Serving from the Artifacts mirror — rather than
+// raw.githubusercontent.com — means images render for private repos too and
+// keep working during a GitHub outage. The README lives at the repo root, so
+// relative paths resolve from there.
+function rewriteReadmeImages(md: string, artifactsRepoName: string): string {
+  const base = `/r/${artifactsRepoName}/raw`;
   // ![alt](url) — handle markdown image syntax
   let out = md.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (m, alt, url) => {
     const u = String(url).trim();
@@ -60,8 +65,14 @@ export const Home: FC<{ repos: HomeRepo[]; version: string }> = ({
       

{repos.length === 0 ? ( -
- No repos configured yet. Set REPO_MAP on this Worker (the CLI does this for you) and re-deploy. +
+
No repos mirrored on this Worker yet.
+
npx gitflare init github.com/<owner>/<repo>
+
+ Runs on your machine, provisions into your own Cloudflare account, and sets{" "} + REPO_MAP here.{" "} + Full walkthrough → +
) : ( repos.map((r) => ) @@ -199,7 +210,7 @@ const RepoContentSection: FC<{ repo: HomeRepo; content: NonNullable diff --git a/packages/worker/src/ui/layout.tsx b/packages/worker/src/ui/layout.tsx index 6f9f00c..d02d9b9 100644 --- a/packages/worker/src/ui/layout.tsx +++ b/packages/worker/src/ui/layout.tsx @@ -88,4 +88,22 @@ footer { color: var(--muted); font-size: 12px; padding: 32px 0; border-top: 1px .readme th { background: var(--bg); color: var(--muted); font-weight: 500; } .readme img { max-width: 100%; } .readme hr { border: 0; border-top: 1px solid var(--border); margin: 24px 0; } + +/* highlight.js — compact dark theme tuned to the GitFlare palette. */ +.hljs { color: var(--fg); background: transparent; } +.hljs-comment, .hljs-quote { color: #6b6b73; font-style: italic; } +.hljs-keyword, .hljs-selector-tag, .hljs-built_in, .hljs-meta { color: #c792ea; } +.hljs-string, .hljs-regexp, .hljs-symbol, .hljs-char { color: #8bd49c; } +.hljs-number, .hljs-literal { color: #f78c6c; } +.hljs-title, .hljs-title.function_, .hljs-section { color: #82aaff; } +.hljs-attr, .hljs-attribute, .hljs-variable, .hljs-template-variable { color: #ffcb6b; } +.hljs-type, .hljs-class .hljs-title, .hljs-title.class_ { color: #ffcb6b; } +.hljs-tag { color: #8b8b91; } +.hljs-name { color: #f07178; } +.hljs-params { color: var(--fg); } +.hljs-deletion { color: var(--err); } +.hljs-addition { color: var(--ok); } +.hljs-emphasis { font-style: italic; } +.hljs-strong { font-weight: 600; } +.hljs-link { color: var(--accent); } `; diff --git a/packages/worker/src/ui/states.tsx b/packages/worker/src/ui/states.tsx new file mode 100644 index 0000000..8f9f4fa --- /dev/null +++ b/packages/worker/src/ui/states.tsx @@ -0,0 +1,60 @@ +import type { FC } from "hono/jsx"; +import { Layout } from "./layout"; +import { LOGO_PNG_DATA_URL } from "./logo-data"; + +const Header: FC = () => ( + +); + +/** A styled 404 page rendered through the normal layout, with a way back. */ +export const NotFound: FC<{ + title: string; + detail: string; + backHref?: string; + backLabel?: string; +}> = ({ title, detail, backHref, backLabel }) => ( + +
+
+

{title}

+ +
+
+); + +/** A styled 500 page — surfaces the underlying message instead of bare text. */ +export const ErrorView: FC<{ detail: string; backHref?: string }> = ({ + detail, + backHref, +}) => ( + +
+
+

Something went wrong

+
+ error +
+          {detail}
+        
+ +
+
+
+); diff --git a/packages/worker/test/access-jwt.test.ts b/packages/worker/test/access-jwt.test.ts new file mode 100644 index 0000000..4c77980 --- /dev/null +++ b/packages/worker/test/access-jwt.test.ts @@ -0,0 +1,191 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { verifyAccessJwt, _clearJwksCache, type Jwks } from "../src/access/jwt"; + +const TEAM = "myteam.cloudflareaccess.com"; +const AUD = "test-aud-tag"; +const ISS = `https://${TEAM}`; +const KID = "key-1"; + +function b64url(bytes: Uint8Array): string { + let bin = ""; + for (const b of bytes) bin += String.fromCharCode(b); + return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); +} + +function b64urlJson(obj: unknown): string { + return b64url(new TextEncoder().encode(JSON.stringify(obj))); +} + +interface Keypair { + privateKey: CryptoKey; + jwks: Jwks; +} + +async function makeKeypair(kid = KID): Promise { + const pair = await crypto.subtle.generateKey( + { + name: "RSASSA-PKCS1-v1_5", + modulusLength: 2048, + publicExponent: new Uint8Array([1, 0, 1]), + hash: "SHA-256", + }, + true, + ["sign", "verify"], + ); + const jwk = (await crypto.subtle.exportKey("jwk", pair.publicKey)) as { + n: string; + e: string; + }; + return { + privateKey: pair.privateKey, + jwks: { keys: [{ kid, kty: "RSA", alg: "RS256", n: jwk.n, e: jwk.e }] }, + }; +} + +async function signJwt( + privateKey: CryptoKey, + payload: Record, + header: Record = { alg: "RS256", kid: KID, typ: "JWT" }, +): Promise { + const headerB64 = b64urlJson(header); + const payloadB64 = b64urlJson(payload); + const signed = new TextEncoder().encode(`${headerB64}.${payloadB64}`); + const sig = await crypto.subtle.sign( + "RSASSA-PKCS1-v1_5", + privateKey, + signed, + ); + return `${headerB64}.${payloadB64}.${b64url(new Uint8Array(sig))}`; +} + +const NOW = 1_700_000_000; +function validPayload(over: Record = {}) { + return { + aud: [AUD], + iss: ISS, + sub: "user-123", + email: "sina@example.com", + exp: NOW + 3600, + nbf: NOW - 60, + ...over, + }; +} + +describe("verifyAccessJwt", () => { + beforeEach(() => _clearJwksCache()); + + it("accepts a valid token", async () => { + const { privateKey, jwks } = await makeKeypair(); + const token = await signJwt(privateKey, validPayload()); + const claims = await verifyAccessJwt(token, { + aud: AUD, + teamDomain: TEAM, + nowSeconds: NOW, + fetchJwks: async () => jwks, + }); + expect(claims).toEqual({ sub: "user-123", email: "sina@example.com" }); + }); + + it("rejects an expired token", async () => { + const { privateKey, jwks } = await makeKeypair(); + const token = await signJwt(privateKey, validPayload({ exp: NOW - 1 })); + expect( + await verifyAccessJwt(token, { + aud: AUD, + teamDomain: TEAM, + nowSeconds: NOW, + fetchJwks: async () => jwks, + }), + ).toBeNull(); + }); + + it("rejects the wrong aud", async () => { + const { privateKey, jwks } = await makeKeypair(); + const token = await signJwt(privateKey, validPayload({ aud: ["other"] })); + expect( + await verifyAccessJwt(token, { + aud: AUD, + teamDomain: TEAM, + nowSeconds: NOW, + fetchJwks: async () => jwks, + }), + ).toBeNull(); + }); + + it("rejects the wrong issuer", async () => { + const { privateKey, jwks } = await makeKeypair(); + const token = await signJwt( + privateKey, + validPayload({ iss: "https://evil.cloudflareaccess.com" }), + ); + expect( + await verifyAccessJwt(token, { + aud: AUD, + teamDomain: TEAM, + nowSeconds: NOW, + fetchJwks: async () => jwks, + }), + ).toBeNull(); + }); + + it("rejects a token signed by a different key", async () => { + const signer = await makeKeypair(); + // Verifier is handed an unrelated public key under the same kid. + const other = await makeKeypair(); + const token = await signJwt(signer.privateKey, validPayload()); + expect( + await verifyAccessJwt(token, { + aud: AUD, + teamDomain: TEAM, + nowSeconds: NOW, + fetchJwks: async () => other.jwks, + }), + ).toBeNull(); + }); + + it("rejects an unknown kid", async () => { + const { privateKey, jwks } = await makeKeypair(); + const token = await signJwt(privateKey, validPayload(), { + alg: "RS256", + kid: "unknown-kid", + typ: "JWT", + }); + expect( + await verifyAccessJwt(token, { + aud: AUD, + teamDomain: TEAM, + nowSeconds: NOW, + fetchJwks: async () => jwks, + }), + ).toBeNull(); + }); + + it("rejects a non-RS256 alg", async () => { + const { privateKey, jwks } = await makeKeypair(); + const token = await signJwt(privateKey, validPayload(), { + alg: "none", + kid: KID, + typ: "JWT", + }); + expect( + await verifyAccessJwt(token, { + aud: AUD, + teamDomain: TEAM, + nowSeconds: NOW, + fetchJwks: async () => jwks, + }), + ).toBeNull(); + }); + + it("rejects a malformed token", async () => { + const { jwks } = await makeKeypair(); + expect( + await verifyAccessJwt("not.a.jwt.really", { + aud: AUD, + teamDomain: TEAM, + nowSeconds: NOW, + fetchJwks: async () => jwks, + }), + ).toBeNull(); + }); +}); diff --git a/packages/worker/test/cf-deploy.test.ts b/packages/worker/test/cf-deploy.test.ts new file mode 100644 index 0000000..8e99879 --- /dev/null +++ b/packages/worker/test/cf-deploy.test.ts @@ -0,0 +1,59 @@ +import { describe, it, expect } from "vitest"; +import { buildScriptUploadForm, uploadWorkerScript } from "../src/deploy/cf-deploy"; + +describe("buildScriptUploadForm", () => { + it("includes metadata referencing the module and the module file", async () => { + const form = buildScriptUploadForm({ + scriptName: "my-worker", + moduleFileName: "worker.js", + code: "export default { fetch(){return new Response('hi')} }", + }); + const metaBlob = form.get("metadata") as Blob; + const meta = JSON.parse(await metaBlob.text()); + expect(meta.main_module).toBe("worker.js"); + expect(meta.compatibility_date).toBeTruthy(); + + const mod = form.get("worker.js") as File; + expect(await mod.text()).toContain("export default"); + }); +}); + +describe("uploadWorkerScript", () => { + it("PUTs to the scripts API with bearer auth and reports success", async () => { + let seenUrl = ""; + let seenAuth = ""; + let seenMethod = ""; + const fakeFetch = (async (url: string, init: RequestInit) => { + seenUrl = url; + seenMethod = init.method ?? ""; + seenAuth = (init.headers as Record).Authorization ?? ""; + return new Response(JSON.stringify({ success: true, errors: [] }), { status: 200 }); + }) as unknown as typeof fetch; + + const r = await uploadWorkerScript({ + accountId: "acc123", + apiToken: "tok456", + upload: { scriptName: "w", moduleFileName: "worker.js", code: "x" }, + fetchImpl: fakeFetch, + }); + expect(r.ok).toBe(true); + expect(seenMethod).toBe("PUT"); + expect(seenUrl).toContain("/accounts/acc123/workers/scripts/w"); + expect(seenAuth).toBe("Bearer tok456"); + }); + + it("surfaces API errors", async () => { + const fakeFetch = (async () => + new Response(JSON.stringify({ success: false, errors: [{ code: 10001, message: "bad" }] }), { + status: 400, + })) as unknown as typeof fetch; + const r = await uploadWorkerScript({ + accountId: "a", + apiToken: "t", + upload: { scriptName: "w", moduleFileName: "worker.js", code: "x" }, + fetchImpl: fakeFetch, + }); + expect(r.ok).toBe(false); + expect(r.detail).toContain("bad"); + }); +}); diff --git a/packages/worker/test/highlight.test.ts b/packages/worker/test/highlight.test.ts new file mode 100644 index 0000000..01081de --- /dev/null +++ b/packages/worker/test/highlight.test.ts @@ -0,0 +1,36 @@ +import { describe, it, expect } from "vitest"; +import { highlightCode } from "../src/ui/highlight"; + +describe("highlightCode", () => { + it("highlights a known extension", () => { + const r = highlightCode("const x: number = 1;", "src/index.ts"); + expect(r).not.toBeNull(); + expect(r!.lang).toBe("typescript"); + expect(r!.html).toContain("hljs-"); + }); + + it("maps by full filename (Dockerfile)", () => { + const r = highlightCode("FROM node:20\nRUN npm ci", "Dockerfile"); + expect(r?.lang).toBe("dockerfile"); + }); + + it("returns null for an unknown extension", () => { + expect(highlightCode("whatever", "notes.xyz")).toBeNull(); + }); + + it("returns null for a file with no extension", () => { + expect(highlightCode("plain text", "LICENSE")).toBeNull(); + }); + + it("returns null for very large input", () => { + const big = "a".repeat(512 * 1024 + 1); + expect(highlightCode(big, "big.js")).toBeNull(); + }); + + it("escapes HTML so output is injection-safe", () => { + const r = highlightCode("const s = '