diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..1a47040 --- /dev/null +++ b/.env.example @@ -0,0 +1,3 @@ +# Artifact Worker Base URL +# Used by the debugger to load large programs from the artifact worker +VITE_ARTIFACTS_BASE_URL=https://pvm-artifacts.tomusdrw-cloudflare.workers.dev diff --git a/.gitignore b/.gitignore index 218faca..154ac74 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,7 @@ dist-ssr /playwright-report/ /blob-report/ /playwright/.cache/ + +# Environment files +.env +!.env.example diff --git a/README.md b/README.md index 013d3f5..aeef33a 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,43 @@ There are few ways how you can add your own PVM to execute the code. Details about the API requirements can be found in [#81](https://github.com/FluffyLabs/pvm-debugger/issues/81) +## Large Program Links (Artifacts) + +For large binaries (too large for `?program=0x...` URL payloads), use the Cloudflare artifact worker in +[`workers/artifacts-worker`](./workers/artifacts-worker). + +The debugger can load `?artifact=` from `/load`. Set up your environment: + +```bash +cp .env.example .env +``` + +This configures `VITE_ARTIFACTS_BASE_URL=https://pvm-artifacts.tomusdrw-cloudflare.workers.dev`. + +Programmatic upload helper: + +```bash +npm run artifact:upload -- \ + --file ./path/to/program.bin \ + --worker https://pvm-artifacts.tomusdrw-cloudflare.workers.dev \ + --debugger http://localhost:5173 +``` + +This prints the returned `artifactId` and a ready-to-open debugger link: + +```text +http://localhost:5173/#/load?artifact= +``` + +Upload and open the debugger automatically: + +```bash +npm run artifact:upload:open -- \ + --file ./path/to/program.bin \ + --worker https://pvm-artifacts.tomusdrw-cloudflare.workers.dev \ + --debugger http://localhost:5173 +``` + ## Development ### Requirements diff --git a/package.json b/package.json index f8a8cf3..3fbbbad 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,10 @@ "test:e2e:ui": "playwright test --ui", "test:e2e:headed": "playwright test --headed", "test:e2e:install": "playwright install --with-deps", + "artifact:upload": "node scripts/upload-artifact.mjs", + "artifact:upload:open": "node scripts/upload-artifact.mjs --open", + "artifact:worker:dev": "npx wrangler dev --config workers/artifacts-worker/wrangler.toml", + "artifact:worker:deploy": "npx wrangler deploy --config workers/artifacts-worker/wrangler.toml", "prepare": "husky" }, "dependencies": { diff --git a/scripts/upload-artifact.mjs b/scripts/upload-artifact.mjs new file mode 100755 index 0000000..b108c2e --- /dev/null +++ b/scripts/upload-artifact.mjs @@ -0,0 +1,127 @@ +#!/usr/bin/env node +import { spawnSync } from "node:child_process"; +import { readFile } from "node:fs/promises"; +import { basename } from "node:path"; + +function printUsage() { + console.error( + "Usage: node scripts/upload-artifact.mjs --file --worker [--ttl ] [--debugger ] [--open]", + ); +} + +function parseArgs(argv) { + const args = {}; + + for (let index = 0; index < argv.length; index += 1) { + const key = argv[index]; + const value = argv[index + 1]; + + if (!key.startsWith("--")) { + continue; + } + + if (!value || value.startsWith("--")) { + args[key.slice(2)] = "true"; + continue; + } + + args[key.slice(2)] = value; + index += 1; + } + + return args; +} + +function normalizeBaseUrl(url) { + return url.replace(/\/+$/, ""); +} + +function shouldOpenBrowser(value) { + return value === "true" || value === "1" || value === "yes"; +} + +function openInBrowser(url) { + const launchCommands = + process.platform === "darwin" + ? [["open", [url]]] + : process.platform === "win32" + ? [["cmd", ["/c", "start", "", url]]] + : [["xdg-open", [url]]]; + + for (const [command, args] of launchCommands) { + const result = spawnSync(command, args, { stdio: "ignore" }); + if (!result.error && result.status === 0) { + return; + } + } + + throw new Error("Could not open browser automatically on this platform."); +} + +async function main() { + const args = parseArgs(process.argv.slice(2)); + const filePath = args.file; + const workerBase = args.worker; + const ttl = args.ttl; + const debuggerBase = args.debugger; + const open = shouldOpenBrowser(args.open); + + if (!filePath || !workerBase) { + printUsage(); + process.exit(1); + } + + if (open && !debuggerBase) { + console.error("--open requires --debugger ."); + process.exit(1); + } + + const file = await readFile(filePath); + const endpoint = new URL(`${normalizeBaseUrl(workerBase)}/artifacts`); + + if (ttl) { + endpoint.searchParams.set("ttl", ttl); + } + + const response = await fetch(endpoint, { + method: "POST", + headers: { + "content-type": "application/octet-stream", + "x-upload-file-name": basename(filePath), + }, + body: file, + }); + + const textPayload = await response.text(); + let parsed; + try { + parsed = JSON.parse(textPayload); + } catch { + parsed = { raw: textPayload }; + } + + if (!response.ok) { + console.error("Upload failed", { + status: response.status, + body: parsed, + }); + process.exit(1); + } + + console.log(JSON.stringify(parsed, null, 2)); + + if (debuggerBase && parsed.artifactId) { + const debuggerUrl = `${normalizeBaseUrl(debuggerBase)}/#/load?artifact=${encodeURIComponent(parsed.artifactId)}`; + console.log(`Debugger link: ${debuggerUrl}`); + + if (open) { + openInBrowser(debuggerUrl); + console.log("Opened debugger in your default browser."); + } + } +} + +main().catch((error) => { + console.error("Unexpected error", error); + process.exit(1); +}); diff --git a/src/components/ProgramLoader/Loader.tsx b/src/components/ProgramLoader/Loader.tsx index 1a6d3d4..a8d5b25 100644 --- a/src/components/ProgramLoader/Loader.tsx +++ b/src/components/ProgramLoader/Loader.tsx @@ -26,14 +26,19 @@ import { getTraceSummary, parseTrace } from "@/lib/host-call-trace"; type LoaderStep = "upload" | "entrypoint"; -export const Loader = ({ setIsDialogOpen }: { setIsDialogOpen?: (val: boolean) => void }) => { +interface LoaderProps { + setIsDialogOpen?: (val: boolean) => void; + initialProgram?: ProgramUploadFileOutput; + initialTrace?: string; +} + +export const Loader = ({ setIsDialogOpen, initialProgram, initialTrace }: LoaderProps) => { const dispatch = useAppDispatch(); - const [programLoad, setProgramLoad] = useState(); + const [programLoad, setProgramLoad] = useState(initialProgram); const [error, setError] = useState(); - const [currentStep, setCurrentStep] = useState("upload"); - const [traceContent, setTraceContent] = useState(null); + const [traceContent, setTraceContent] = useState(initialTrace ?? null); const [traceSummary, setTraceSummary] = useState | null>(null); - + const [currentStep, setCurrentStep] = useState("upload"); // Load saved config once on mount const savedConfig = loadSpiConfig(); @@ -56,10 +61,24 @@ export const Loader = ({ setIsDialogOpen }: { setIsDialogOpen?: (val: boolean) = setError(""); }, [isLoading]); - // Reset step when program changes + // Handle initial trace parsing useEffect(() => { - setCurrentStep("upload"); - }, [programLoad]); + if (initialTrace) { + try { + const parsed = parseTrace(initialTrace); + setTraceSummary(getTraceSummary(parsed)); + } catch (e) { + console.error("Failed to parse initial trace:", e); + } + } + }, [initialTrace]); + + // Sync initialProgram to programLoad when it changes (e.g., from URL artifact loading) + useEffect(() => { + if (initialProgram) { + setProgramLoad(initialProgram); + } + }, [initialProgram]); // Auto-switch to RAW mode when a trace is loaded useEffect(() => { @@ -221,6 +240,22 @@ export const Loader = ({ setIsDialogOpen }: { setIsDialogOpen?: (val: boolean) = setCurrentStep("upload"); }; + // Handle initialProgram changes (from URL artifact loading) + useEffect(() => { + if (!initialProgram) return; + + const isSpi = initialProgram.spiProgram !== null && initialProgram.spiProgram !== undefined; + const hasTrace = initialTrace !== undefined; + + if (!isSpi || hasTrace) { + // Non-SPI or has trace: auto-load immediately + handleLoad(initialProgram); + } else { + // SPI without trace: switch to entrypoint step for user to select + setCurrentStep("entrypoint"); + } + }, [initialProgram, initialTrace]); // eslint-disable-line react-hooks/exhaustive-deps + return (

diff --git a/src/env.d.ts b/src/env.d.ts new file mode 100644 index 0000000..4d4d530 --- /dev/null +++ b/src/env.d.ts @@ -0,0 +1,7 @@ +interface ImportMetaEnv { + readonly VITE_ARTIFACTS_BASE_URL?: string; +} + +interface ImportMeta { + readonly env: ImportMetaEnv; +} diff --git a/src/lib/artifact-url.test.ts b/src/lib/artifact-url.test.ts new file mode 100644 index 0000000..b3e4d9c --- /dev/null +++ b/src/lib/artifact-url.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; +import { buildArtifactDownloadUrl } from "./artifact-url"; + +describe("buildArtifactDownloadUrl", () => { + it("returns absolute URLs as-is", () => { + const url = buildArtifactDownloadUrl("https://artifacts.example.com/artifacts/abc123"); + + expect(url).toBe("https://artifacts.example.com/artifacts/abc123"); + }); + + it("builds URL from base and artifact ID", () => { + const url = buildArtifactDownloadUrl("abc123", "https://artifacts.example.com/"); + + expect(url).toBe("https://artifacts.example.com/artifacts/abc123"); + }); + + it("falls back to origin when base URL is missing", () => { + const url = buildArtifactDownloadUrl("abc123", undefined, "http://localhost:5173"); + + expect(url).toBe("http://localhost:5173/artifacts/abc123"); + }); + + it("throws for empty artifact reference", () => { + expect(() => buildArtifactDownloadUrl(" ", "https://artifacts.example.com")).toThrow( + "Artifact reference is empty", + ); + }); + + it("throws when it cannot resolve base URL", () => { + expect(() => buildArtifactDownloadUrl("abc123")).toThrow("Missing artifacts base URL"); + }); +}); diff --git a/src/lib/artifact-url.ts b/src/lib/artifact-url.ts new file mode 100644 index 0000000..23990a4 --- /dev/null +++ b/src/lib/artifact-url.ts @@ -0,0 +1,19 @@ +const HTTP_URL_PATTERN = /^https?:\/\//i; + +export function buildArtifactDownloadUrl(artifactRef: string, baseUrl?: string, fallbackOrigin?: string): string { + const normalizedArtifactRef = artifactRef.trim(); + if (!normalizedArtifactRef) { + throw new Error("Artifact reference is empty"); + } + + if (HTTP_URL_PATTERN.test(normalizedArtifactRef)) { + return normalizedArtifactRef; + } + + const normalizedBaseUrl = (baseUrl?.trim() || fallbackOrigin?.trim() || "").replace(/\/+$/, ""); + if (!normalizedBaseUrl) { + throw new Error("Missing artifacts base URL"); + } + + return `${normalizedBaseUrl}/artifacts/${encodeURIComponent(normalizedArtifactRef)}`; +} diff --git a/src/pages/ProgramLoader.tsx b/src/pages/ProgramLoader.tsx index 0703bd2..f7377dc 100644 --- a/src/pages/ProgramLoader.tsx +++ b/src/pages/ProgramLoader.tsx @@ -1,21 +1,23 @@ import { Loader } from "@/components/ProgramLoader/Loader.tsx"; -import { useEffect, useRef } from "react"; +import { useEffect, useState } from "react"; import * as bytes from "@typeberry/lib/bytes"; -import { DEFAULT_GAS, MemoryChunkItem, PageMapItem, RegistersArray } from "@/types/pvm.ts"; +import { MemoryChunkItem, PageMapItem, RegistersArray } from "@/types/pvm.ts"; import { useNavigate } from "react-router"; import { useAppSelector } from "@/store/hooks.ts"; import { selectInitialState } from "@/store/debugger/debuggerSlice.ts"; -import { useDebuggerActions } from "@/hooks/useDebuggerActions.ts"; import { bigUint64ArrayToRegistersArray, getAsChunks, getAsPageMap } from "@/lib/utils.ts"; import { programs } from "@/components/ProgramLoader/examplePrograms"; import { decodeSpiWithMetadata } from "@/utils/spi"; +import { buildArtifactDownloadUrl } from "@/lib/artifact-url"; +import { ProgramUploadFileOutput } from "@/components/ProgramLoader/types"; const ProgramLoader = () => { const initialState = useAppSelector(selectInitialState); - const debuggerActions = useDebuggerActions(); const navigate = useNavigate(); const pvmLoaded = useAppSelector((state) => state.debugger.pvmLoaded); - const isLoadedFromUrl = useRef(false); + const isLoadedFromUrl = useState(false); + + const [pendingProgram, setPendingProgram] = useState(null); useEffect(() => { const loadProgramFromUrl = async () => { @@ -24,14 +26,92 @@ const ProgramLoader = () => { return; } // but we never load from url twice - if (isLoadedFromUrl.current) { + if (isLoadedFromUrl[0]) { return; } - isLoadedFromUrl.current = true; + isLoadedFromUrl[1](true); + + // Parse query params - they may be in window.location.search OR inside the hash + // Hash-based routing: /#/load?artifact=... means params are in the hash + const hashParams = new URLSearchParams(window.location.hash.split("?")[1] ?? ""); const searchParams = new URLSearchParams(window.location.search); - const example = searchParams.get("example"); + // Helper to get param from either location (hash takes precedence) + const getParam = (key: string): string | null => hashParams.get(key) ?? searchParams.get(key); + + // Helper to create ProgramUploadFileOutput from raw bytes + const createProgramOutput = ( + rawProgram: Uint8Array, + sourceName: string, + flavour?: string | null, + ): ProgramUploadFileOutput | null => { + // If flavour is explicitly "jam", force SPI decoding + if ((flavour ?? "").toLowerCase() === "jam") { + const { code, memory, registers, metadata } = decodeSpiWithMetadata(rawProgram, new Uint8Array()); + const pageMap: PageMapItem[] = getAsPageMap(memory); + const chunks: MemoryChunkItem[] = getAsChunks(memory); + + return { + program: Array.from(code), + name: `${sourceName} [SPI]`, + spiProgram: { + program: rawProgram, + hasMetadata: metadata !== undefined, + }, + kind: "JAM SPI", + initial: { + regs: bigUint64ArrayToRegistersArray(registers), + pc: 0, + pageMap, + memory: chunks, + gas: 100_000_000_000n, // 100 billion gas for SPI programs + }, + }; + } + + // Auto-detect: try SPI first, then fall back to generic + let spi = null; + try { + spi = decodeSpiWithMetadata(rawProgram, new Uint8Array()); + } catch { + // Not an SPI blob, will try generic + } + + if (spi !== null) { + const { code, memory, registers, metadata } = spi; + const pageMap: PageMapItem[] = getAsPageMap(memory); + const chunks: MemoryChunkItem[] = getAsChunks(memory); + + return { + program: Array.from(code), + name: `${sourceName} [SPI]`, + spiProgram: { + program: rawProgram, + hasMetadata: metadata !== undefined, + }, + kind: "JAM SPI", + initial: { + regs: bigUint64ArrayToRegistersArray(registers), + pc: 0, + pageMap, + memory: chunks, + gas: 100_000_000_000n, // 100 billion gas for SPI programs + }, + }; + } + + // Fall back to generic PVM + return { + program: Array.from(rawProgram), + name: `${sourceName} [generic]`, + initial: initialState, + kind: "Generic PVM", + spiProgram: null, + }; + }; + + const example = getParam("example"); if (example) { const program = programs[example]; if (!program) { @@ -40,7 +120,8 @@ const ProgramLoader = () => { return; } - await debuggerActions.handleProgramLoad({ + // Examples are loaded directly without entrypoint selection + const output: ProgramUploadFileOutput = { program: program.program, name: program.name, spiProgram: null, @@ -53,58 +134,53 @@ const ProgramLoader = () => { gas: program.gas, }, exampleName: example, - }); - - navigate("/", { replace: true }); + }; + setPendingProgram(output); return; } - const program = searchParams.get("program"); + const artifact = getParam("artifact"); + if (artifact) { + try { + const artifactUrl = buildArtifactDownloadUrl( + artifact, + import.meta.env.VITE_ARTIFACTS_BASE_URL, + window.location.origin, + ); + + const response = await fetch(artifactUrl); + if (!response.ok) { + throw new Error(`Failed to download artifact (${response.status})`); + } + + const rawBytes = new Uint8Array(await response.arrayBuffer()); + const output = createProgramOutput(rawBytes, "loaded-from-artifact", getParam("flavour")); + if (output) { + setPendingProgram(output); + } else { + console.warn("Could not create program output from artifact"); + navigate("/load", { replace: true }); + } + return; + } catch (e) { + console.warn("Could not load the artifact from URL", e); + navigate("/load", { replace: true }); + return; + } + } + + const program = getParam("program"); if (program) { try { // Add 0x prefix if it's not there - we're assuming it's the hex program either way const hexProgram = program?.startsWith("0x") ? program : `0x${program}`; - const parsedBlob = bytes.BytesBlob.parseBlob(hexProgram ?? ""); - const parsedBlobArray = Array.prototype.slice.call(parsedBlob.raw); - - if (searchParams.get("flavour") === "jam") { - try { - const { code, memory, registers, metadata } = decodeSpiWithMetadata(parsedBlob.raw, new Uint8Array()); - - const pageMap: PageMapItem[] = getAsPageMap(memory); - const chunks: MemoryChunkItem[] = getAsChunks(memory); - - await debuggerActions.handleProgramLoad({ - program: Array.from(code), - name: "loaded-from-url [SPI]", - spiProgram: { - program: parsedBlob.raw, - hasMetadata: metadata !== undefined, - }, - kind: "JAM SPI", - initial: { - regs: bigUint64ArrayToRegistersArray(registers), - pc: 0, - pageMap, - memory: chunks, - gas: DEFAULT_GAS, - }, - }); - - navigate("/", { replace: true }); - } catch (e) { - console.warn("Could not load the program from URL", e); - } + const parsedBlob = bytes.BytesBlob.parseBlob(hexProgram ?? "").raw; + const output = createProgramOutput(parsedBlob, "loaded-from-url", getParam("flavour")); + if (output) { + setPendingProgram(output); } else { - await debuggerActions.handleProgramLoad({ - program: parsedBlobArray, - name: "loaded-from-url [generic]", - initial: initialState, - kind: "Generic PVM", - spiProgram: null, - }); - - navigate("/", { replace: true }); + console.warn("Could not create program output from URL"); + navigate("/load", { replace: true }); } } catch (e) { console.warn("Could not parse the program from URL", e); @@ -114,12 +190,12 @@ const ProgramLoader = () => { }; loadProgramFromUrl(); - }, [pvmLoaded, isLoadedFromUrl, debuggerActions, navigate, initialState]); + }, [pvmLoaded, isLoadedFromUrl, navigate, initialState]); return (

- +
); diff --git a/workers/artifacts-worker/README.md b/workers/artifacts-worker/README.md new file mode 100644 index 0000000..9054f18 --- /dev/null +++ b/workers/artifacts-worker/README.md @@ -0,0 +1,122 @@ +# Artifacts Worker (Cloudflare) + +Temporary artifact storage for PVM debugger links. + +## Endpoints + +- `POST /artifacts` + + - Body: raw bytes (`application/octet-stream` recommended) + - Optional query params: + - `ttl=` (max 48 hours) + - Response: `{ artifactId, downloadUrl, expiresAt, sizeBytes }` + +- `GET /artifacts/:artifactId` + + - Returns raw bytes. + - Response headers include `x-artifact-expires-at`. + +- `GET /healthz` + - Health check endpoint. + +## Security Features + +### Rate Limiting (Native Cloudflare) + +The worker uses Cloudflare's native rate limiting API: + +- **Upload**: 10 requests per minute per IP +- **Download**: 60 requests per minute per IP + +These limits are enforced at the edge before reaching your worker code. + +### Kill Switch + +Set `USAGE_KILL_SWITCH=true` to immediately disable all non-health endpoints: + +```bash +# Via wrangler secret +wrangler secret put USAGE_KILL_SWITCH +# Enter: true + +# Or via environment variable (for testing) +wrangler dev --var USAGE_KILL_SWITCH:true +``` + +### TTL Limits + +- **Minimum**: 60 seconds +- **Maximum**: 48 hours (hard-coded, cannot be exceeded) +- **Default**: 48 hours + +Artifacts are automatically cleaned up by a scheduled worker running every hour. + +## Configuration + +Configure via `wrangler.toml` vars: + +| Variable | Default | Description | +| -------------------------- | --------- | -------------------------------- | +| `ARTIFACT_TTL_SECONDS` | 172800 | Default TTL (48 hours) | +| `MAX_ARTIFACT_TTL_SECONDS` | 172800 | Max TTL allowed (48 hours) | +| `MAX_ARTIFACT_SIZE_BYTES` | 1048576 | Max upload size (1 MB) | +| `PUBLIC_BASE_URL` | (auto) | Base URL for download links | +| `ALLOWED_ORIGINS` | `*` | CORS origins (comma-separated) | +| `USAGE_KILL_SWITCH` | false | Set to "true" to disable service | + +## Deploy + +1. Create an R2 bucket: + + ```bash + wrangler r2 bucket create pvm-artifacts + ``` + +2. Update `wrangler.toml` with your settings: + + - Set `PUBLIC_BASE_URL` to your worker URL + - Set `ALLOWED_ORIGINS` to restrict CORS + +3. Deploy: + + ```bash + npm run artifact:worker:deploy + ``` + +## Local Dev + +```bash +npm run artifact:worker:dev +``` + +## Cost Protection + +To protect against unexpected costs: + +1. **Set up billing alerts** in Cloudflare Dashboard: + + - Manage Account → Billing → Notifications + - Set alerts at $10, $25, $50 thresholds + +2. **Enable kill switch** if abuse detected: + + ```bash + wrangler secret put USAGE_KILL_SWITCH + # Enter: true + ``` + +3. **Monitor usage** via Cloudflare Analytics dashboard + +## Estimated Costs (Free Tier) + +| Resource | Free Tier | Notes | +| ---------------------- | --------- | ----------------------------- | +| Worker requests | 100K/day | ~70/minute sustained | +| R2 Class A (uploads) | 1M/month | | +| R2 Class B (downloads) | 10M/month | | +| R2 Storage | 10GB | With 48h TTL, unlikely to hit | + +With rate limiting, worst-case free tier usage: + +- Uploads: 10/min × 60 × 24 = 14,400/day (within 100K) +- Downloads: 60/min per IP, distributed across users diff --git a/workers/artifacts-worker/src/index.ts b/workers/artifacts-worker/src/index.ts new file mode 100644 index 0000000..c0aa8de --- /dev/null +++ b/workers/artifacts-worker/src/index.ts @@ -0,0 +1,422 @@ +// ============================================================================ +// CONFIGURATION +// ============================================================================ + +const DEFAULT_ARTIFACT_TTL_SECONDS = 48 * 60 * 60; // 48 hours default +const MAX_ARTIFACT_TTL_SECONDS = 48 * 60 * 60; // 48 hours max +const DEFAULT_MAX_ARTIFACT_SIZE_BYTES = 1024 * 1024; // 1 MB +const MIN_TTL_SECONDS = 60; // 1 minute min +const ARTIFACT_ID_NUM_BYTES = 12; // 96 bits = 24 hex chars + +// Cleanup batch size +const CLEANUP_BATCH_SIZE = 100; + +// ============================================================================ +// TYPE DEFINITIONS +// ============================================================================ + +interface R2PutOptions { + httpMetadata?: { + contentType?: string; + }; + customMetadata?: Record; +} + +interface R2ObjectBody { + key: string; + body: ReadableStream | null; + size: number; + customMetadata?: Record; + writeHttpMetadata: (headers: Headers) => void; +} + +interface R2Bucket { + put(key: string, value: ArrayBuffer | ArrayBufferView | string, options?: R2PutOptions): Promise; + get(key: string): Promise; + delete(keys: string | string[]): Promise; + list(options?: { prefix?: string; limit?: number; cursor?: string }): Promise; +} + +interface R2Objects { + objects: R2ObjectBody[]; + truncated: boolean; + cursor?: string; +} + +interface RateLimiter { + limit(options: { key: string }): Promise<{ success: boolean }>; +} + +interface ExecutionContext { + waitUntil(promise: Promise): void; +} + +interface ScheduledEvent { + cron: string; + scheduledTime: Date; +} + +interface Env { + ARTIFACTS_BUCKET: R2Bucket; + UPLOAD_RATE_LIMITER: RateLimiter; + DOWNLOAD_RATE_LIMITER: RateLimiter; + ARTIFACT_TTL_SECONDS?: string; + MAX_ARTIFACT_TTL_SECONDS?: string; + MAX_ARTIFACT_SIZE_BYTES?: string; + PUBLIC_BASE_URL?: string; + ALLOWED_ORIGINS?: string; + USAGE_KILL_SWITCH?: string; // Set to "true" to disable the service +} + +class HttpError extends Error { + status: number; + + constructor(status: number, message: string) { + super(message); + this.status = status; + } +} + +// ============================================================================ +// MAIN EXPORT +// ============================================================================ + +export default { + async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise { + // Kill switch check + if (env.USAGE_KILL_SWITCH === "true") { + return new Response(JSON.stringify({ error: "Service temporarily disabled" }), { + status: 503, + headers: { "content-type": "application/json; charset=utf-8" }, + }); + } + + // Handle CORS preflight + if (request.method === "OPTIONS") { + return new Response(null, { + status: 204, + headers: corsHeaders(request, env), + }); + } + + try { + const url = new URL(request.url); + const path = normalizePath(url.pathname); + + // Health check (always available, even with kill switch) + if (request.method === "GET" && path === "/healthz") { + return jsonResponse( + { + ok: true, + now: new Date().toISOString(), + killSwitchActive: env.USAGE_KILL_SWITCH === "true", + }, + 200, + request, + env, + ); + } + + if (request.method === "POST" && path === "/artifacts") { + return await createArtifact(request, env); + } + + const artifactMatch = path.match(/^\/artifacts\/([a-f0-9]{24})$/); + if (request.method === "GET" && artifactMatch) { + return await downloadArtifact(request, env, ctx, artifactMatch[1]); + } + + return jsonResponse({ error: "Not found" }, 404, request, env); + } catch (error) { + if (error instanceof HttpError) { + return jsonResponse({ error: error.message }, error.status, request, env); + } + + console.error("Unexpected worker error", error); + return jsonResponse({ error: "Internal server error" }, 500, request, env); + } + }, + + // Scheduled handler for cleanup (configure via cron trigger in wrangler.toml) + async scheduled(event: ScheduledEvent, env: Env, ctx: ExecutionContext): Promise { + ctx.waitUntil(cleanupExpiredArtifacts(env)); + }, +}; + +// ============================================================================ +// HANDLERS +// ============================================================================ + +async function createArtifact(request: Request, env: Env): Promise { + if (!request.body) { + throw new HttpError(400, "Request body is required"); + } + + const url = new URL(request.url); + const maxArtifactSize = toBoundedInt( + env.MAX_ARTIFACT_SIZE_BYTES, + DEFAULT_MAX_ARTIFACT_SIZE_BYTES, + 1, + Number.MAX_SAFE_INTEGER, + ); + + // Early size check via Content-Length header + const declaredLength = parsePositiveInt(request.headers.get("content-length")); + if (declaredLength !== null && declaredLength > maxArtifactSize) { + throw new HttpError(413, `Artifact exceeds maximum size of ${maxArtifactSize} bytes`); + } + + // Read body + const payload = new Uint8Array(await request.arrayBuffer()); + if (payload.byteLength === 0) { + throw new HttpError(400, "Artifact payload cannot be empty"); + } + + if (payload.byteLength > maxArtifactSize) { + throw new HttpError(413, `Artifact exceeds maximum size of ${maxArtifactSize} bytes`); + } + + // Rate limit by IP + const clientIp = request.headers.get("cf-connecting-ip") ?? "unknown"; + const rateLimit = await env.UPLOAD_RATE_LIMITER.limit({ key: clientIp }); + if (!rateLimit.success) { + throw new HttpError(429, "Rate limit exceeded. Please try again later."); + } + + // Resolve TTL (max 48h) + const ttlSeconds = resolveArtifactTtlSeconds(url, env); + const createdAt = new Date(); + const expiresAt = new Date(createdAt.getTime() + ttlSeconds * 1000); + const artifactId = generateArtifactId(); + + await env.ARTIFACTS_BUCKET.put(artifactId, payload, { + httpMetadata: { + contentType: request.headers.get("content-type") ?? "application/octet-stream", + }, + customMetadata: { + createdAt: createdAt.toISOString(), + expiresAt: expiresAt.toISOString(), + sizeBytes: String(payload.byteLength), + }, + }); + + const publicBaseUrl = resolvePublicBaseUrl(request, env); + + return jsonResponse( + { + artifactId, + downloadUrl: `${publicBaseUrl}/artifacts/${artifactId}`, + expiresAt: expiresAt.toISOString(), + sizeBytes: payload.byteLength, + }, + 201, + request, + env, + ); +} + +async function downloadArtifact( + request: Request, + env: Env, + ctx: ExecutionContext, + artifactId: string, +): Promise { + if (!/^[a-f0-9]{24}$/.test(artifactId)) { + throw new HttpError(400, "Invalid artifact ID"); + } + + const object = await env.ARTIFACTS_BUCKET.get(artifactId); + if (!object) { + throw new HttpError(404, "Artifact not found"); + } + + // Check expiration + const expiresAt = object.customMetadata?.expiresAt; + if (expiresAt) { + const expiresAtMs = Date.parse(expiresAt); + if (!Number.isNaN(expiresAtMs) && expiresAtMs <= Date.now()) { + ctx.waitUntil(env.ARTIFACTS_BUCKET.delete(artifactId)); + throw new HttpError(404, "Artifact expired"); + } + } + + // Rate limit by IP + const clientIp = request.headers.get("cf-connecting-ip") ?? "unknown"; + const rateLimit = await env.DOWNLOAD_RATE_LIMITER.limit({ key: clientIp }); + if (!rateLimit.success) { + throw new HttpError(429, "Rate limit exceeded. Please try again later."); + } + + // Build response headers + const headers = corsHeaders(request, env); + object.writeHttpMetadata(headers); + if (!headers.has("content-type")) { + headers.set("content-type", "application/octet-stream"); + } + headers.set("cache-control", "private, no-store"); + headers.set("x-content-type-options", "nosniff"); + headers.set("x-artifact-id", artifactId); + headers.set("x-artifact-size-bytes", String(object.size)); + + if (expiresAt) { + headers.set("x-artifact-expires-at", expiresAt); + } + + return new Response(object.body, { + status: 200, + headers, + }); +} + +// ============================================================================ +// CLEANUP (Scheduled Handler) +// ============================================================================ + +async function cleanupExpiredArtifacts(env: Env): Promise { + const now = Date.now(); + let cursor: string | undefined; + + do { + const listed = await env.ARTIFACTS_BUCKET.list({ + limit: CLEANUP_BATCH_SIZE, + cursor, + }); + + const toDelete: string[] = []; + + for (const obj of listed.objects) { + const expiresAt = obj.customMetadata?.expiresAt; + if (expiresAt) { + const expiresAtMs = Date.parse(expiresAt); + if (!Number.isNaN(expiresAtMs) && expiresAtMs <= now) { + toDelete.push(obj.key); + } + } + } + + if (toDelete.length > 0) { + await env.ARTIFACTS_BUCKET.delete(toDelete); + } + + cursor = listed.truncated ? listed.cursor : undefined; + } while (cursor); +} + +// ============================================================================ +// UTILITIES +// ============================================================================ + +function resolveArtifactTtlSeconds(url: URL, env: Env): number { + const maxTtl = toBoundedInt( + env.MAX_ARTIFACT_TTL_SECONDS, + MAX_ARTIFACT_TTL_SECONDS, + MIN_TTL_SECONDS, + MAX_ARTIFACT_TTL_SECONDS, // Hard cap at 48h + ); + const defaultTtl = toBoundedInt(env.ARTIFACT_TTL_SECONDS, DEFAULT_ARTIFACT_TTL_SECONDS, MIN_TTL_SECONDS, maxTtl); + const requestedTtl = toBoundedInt(url.searchParams.get("ttl"), defaultTtl, MIN_TTL_SECONDS, maxTtl); + return requestedTtl; +} + +function resolvePublicBaseUrl(request: Request, env: Env): string { + const fallback = new URL(request.url).origin; + const candidate = (env.PUBLIC_BASE_URL ?? fallback).trim(); + return candidate.replace(/\/+$/, ""); +} + +function normalizePath(pathname: string): string { + if (pathname === "/") { + return pathname; + } + return pathname.replace(/\/+$/, ""); +} + +// ============================================================================ +// CORS & RESPONSE HELPERS +// ============================================================================ + +function jsonResponse(payload: unknown, status: number, request: Request, env: Env): Response { + const headers = corsHeaders(request, env); + headers.set("content-type", "application/json; charset=utf-8"); + headers.set("x-content-type-options", "nosniff"); + + return new Response(JSON.stringify(payload), { + status, + headers, + }); +} + +function corsHeaders(request: Request, env: Env): Headers { + const headers = new Headers(); + const allowedOrigins = parseAllowedOrigins(env.ALLOWED_ORIGINS); + + if (allowedOrigins.length === 0 || allowedOrigins.includes("*")) { + headers.set("access-control-allow-origin", "*"); + } else { + const origin = request.headers.get("origin"); + if (origin && allowedOrigins.includes(origin)) { + headers.set("access-control-allow-origin", origin); + headers.set("vary", "origin"); + } + } + + headers.set("access-control-allow-methods", "GET,POST,OPTIONS"); + headers.set("access-control-allow-headers", "content-type,x-requested-with"); + headers.set("access-control-max-age", "86400"); + + return headers; +} + +function parseAllowedOrigins(value?: string): string[] { + if (!value) { + return []; + } + + return value + .split(",") + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0); +} + +// ============================================================================ +// ID GENERATION +// ============================================================================ + +function generateArtifactId(): string { + const bytes = crypto.getRandomValues(new Uint8Array(ARTIFACT_ID_NUM_BYTES)); + return bytesToHex(bytes); +} + +function bytesToHex(bytes: Uint8Array): string { + return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(""); +} + +// ============================================================================ +// PARSING UTILITIES +// ============================================================================ + +function parsePositiveInt(value: string | null): number | null { + if (!value) { + return null; + } + + const parsed = Number.parseInt(value, 10); + if (Number.isNaN(parsed) || parsed < 0) { + return null; + } + + return parsed; +} + +function toBoundedInt(value: string | null | undefined, fallback: number, min: number, max: number): number { + if (value === undefined || value === null || value === "") { + return fallback; + } + + const parsed = Number.parseInt(value, 10); + if (Number.isNaN(parsed)) { + return fallback; + } + + return Math.min(Math.max(parsed, min), max); +} diff --git a/workers/artifacts-worker/wrangler.toml b/workers/artifacts-worker/wrangler.toml new file mode 100644 index 0000000..bd1aba7 --- /dev/null +++ b/workers/artifacts-worker/wrangler.toml @@ -0,0 +1,64 @@ +name = "pvm-artifacts" +main = "src/index.ts" +compatibility_date = "2026-03-06" + +# ============================================================================ +# R2 Storage +# ============================================================================ + +[[r2_buckets]] +binding = "ARTIFACTS_BUCKET" +bucket_name = "pvm-artifacts" + +# ============================================================================ +# Native Rate Limiting (Cloudflare-managed) +# ============================================================================ + +# Upload rate limiter: 10 requests per minute per IP +[[unsafe.bindings]] +name = "UPLOAD_RATE_LIMITER" +type = "ratelimit" +namespace_id = "1001" +simple = { limit = 10, period = 60 } + +# Download rate limiter: 60 requests per minute per IP +[[unsafe.bindings]] +name = "DOWNLOAD_RATE_LIMITER" +type = "ratelimit" +namespace_id = "1002" +simple = { limit = 60, period = 60 } + +# ============================================================================ +# Migrations (remove old Durable Object from previous version) +# ============================================================================ + +[[migrations]] +tag = "v2" +deleted_classes = ["RateLimiter"] + +# ============================================================================ +# Scheduled Cleanup (runs every hour) +# ============================================================================ + +[triggers] +crons = ["0 * * * *"] + +# ============================================================================ +# Environment Variables +# ============================================================================ + +[vars] +# TTL Configuration (max 48 hours enforced in code) +ARTIFACT_TTL_SECONDS = "172800" # Default: 48 hours +MAX_ARTIFACT_TTL_SECONDS = "172800" # Max: 48 hours (hard cap) +MAX_ARTIFACT_SIZE_BYTES = "1048576" # 1 MB + +# Public URL for artifact downloads +PUBLIC_BASE_URL = "https://pvm-artifacts.tomusdrw-cloudflare.workers.dev" + +# CORS: Restrict to allowed origins (comma-separated) +# ALLOWED_ORIGINS = "https://pvm.fluffylabs.dev,http://localhost:5173" + +# Kill switch: Set to "true" to immediately disable the service +# Can also be set via: wrangler secret put USAGE_KILL_SWITCH +# USAGE_KILL_SWITCH = "false"