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 ( +-); + ); +}; 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+- {blob.isBinary ? ( -+ {formatBytes(blob.size)} + {blob.isBinary ? " · binary" : ""} + {highlighted ? ` · ${highlighted.lang}` : ""} ++ {blob.isBinary ? ( +Binary file — preview not shown.+ ) : highlighted ? ( +++ ) : ( +{raw(highlighted.html)}+++ )}{text}+Binary file — preview not shown.- ) : ( -- )} -{blob.text ?? ""}= { + 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) => ( + + +); 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++++ ++ +++ GitFlare + +
v{p.version}+Deployments
++ {p.githubFullName} · browse code +
+ + {!p.cdEnabled ? ( +++ ) : p.deploys.length === 0 ? ( +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. ++ No deploys yet. Push to a branch matched by+ ) : ( + + )} +.gitflare/deploy.ymlto trigger one. +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`; //  — 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. SetREPO_MAPon this Worker (the CLI does this for you) and re-deploy. ++) : ( repos.map((r) =>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_MAPhere.{" "} + Full walkthrough → +) @@ -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 }) => ( ++ +++ GitFlare + +
+ + +); + +/** A styled 500 page — surfaces the underlying message instead of bare text. */ +export const ErrorView: FC<{ detail: string; backHref?: string }> = ({ + detail, + backHref, +}) => ( ++ + +); 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 = '