diff --git a/.env.example b/.env.example index e588ad7..c74dc3e 100644 --- a/.env.example +++ b/.env.example @@ -3,6 +3,11 @@ PORT=8787 # When DATABASE_URL is set the API runs in async (queue) mode; otherwise it runs # clones inline in-memory (handy for a quick local single-page demo). # CLONE_TTL_MS=1800000 # in-memory mode: how long results are retained +# API_KEYS= # comma-separated keys that can call /v1/clones and /mcp +# RATE_LIMIT_PER_MINUTE=60 # service-wide cap for authenticated clone/MCP calls +# SIGNUP_ENABLED=false # DB mode only: expose POST /v1/signup to mint API keys +# SIGNUP_RATE_LIMIT_PER_HOUR=3 +# DEFAULT_SIGNUP_KEY_RATE_LIMIT=30 # stored on minted keys; current limiter is service-wide # ---- Database + queue (Postgres; pg-boss uses the same DB) ---- DATABASE_URL=postgresql://postgres:postgres@localhost:5432/ditto_site diff --git a/README.md b/README.md index 881c8ab..6347e58 100644 --- a/README.md +++ b/README.md @@ -214,6 +214,10 @@ arbitrary third-party JavaScript are not replayed. For the detailed service API, see [docs/SERVICE.md](docs/SERVICE.md). For deployment, see [docs/DEPLOY.md](docs/DEPLOY.md). +Hosted deployments should keep `/v1/clones*` and `/mcp` behind API-key auth. +When `SIGNUP_ENABLED=true` in DB mode, `POST /v1/signup` can publicly mint +`dtto_live_...` keys from an email address while storing only key hashes. + ## Repository Map | Path | Purpose | diff --git a/docs/DEPLOY.md b/docs/DEPLOY.md index 7987c3e..65da63a 100644 --- a/docs/DEPLOY.md +++ b/docs/DEPLOY.md @@ -50,6 +50,9 @@ PORT=8787 PUBLIC_BASE_URL=https://api.yourdomain.com # so MCP-returned URLs are absolute API_KEYS=key_live_xxx,key_live_yyy # require auth RATE_LIMIT_PER_MINUTE=60 +SIGNUP_ENABLED=true # optional: public API-key minting +SIGNUP_RATE_LIMIT_PER_HOUR=3 +DEFAULT_SIGNUP_KEY_RATE_LIMIT=30 # SSRF is on by default; do NOT set SSRF_ALLOW_LOOPBACK in prod. ``` diff --git a/docs/SERVICE.md b/docs/SERVICE.md index f838964..3948299 100644 --- a/docs/SERVICE.md +++ b/docs/SERVICE.md @@ -64,6 +64,7 @@ curl -s -X POST localhost:8787/v1/clones -H 'content-type: application/json' \ ``` POST /v1/clones { url, options? } → 202 {jobId,status} | 200 {cached result | inline result} +POST /v1/signup { email, label? } → 201 {apiKey,message} (public when enabled) GET /v1/clones → list (metadata) GET /v1/clones/:id → status + metadata (fileCount, totalBytes, capture, timings) GET /v1/clones/:id/result → the eager CloneResult (text files inline; binaries by URL) @@ -73,6 +74,13 @@ DELETE /v1/clones/:id → purge artifacts GET /healthz → { ok: true } (unauthenticated) ``` +`/v1/clones*` and `/mcp` are authenticated when `API_KEYS` is set or DB-backed +keys exist. Use `Authorization: Bearer ` or `x-api-key: `. +`/v1/signup` is intentionally public only when `SIGNUP_ENABLED=true` **and** +`DATABASE_URL` is set. It mints a `dtto_live_...` key, stores only its SHA-256 +hash in Postgres, stores the submitted email in the key label for attribution, +and returns the raw key once. + Normal product `options` are `{ mode?: "single" | "multi", styling?: "tailwind" | "css", framework?: "next" | "vite" }`. `mode` defaults to `"single"`, `styling` defaults to `"tailwind"`, and `framework` defaults to `"next"`. Operational options remain `{ verify?, asyncVerify?, maxRoutes?, maxCollection?, captureConcurrency?, validationConcurrency?, viewportConcurrency?, noCache? }`; `noCache` is service-level and @@ -124,6 +132,9 @@ List-then-read so a clone never floods the agent's context: | `PUBLIC_BASE_URL` | api | — | absolute base for MCP-returned URLs | | `API_KEYS` | api | — | comma-separated keys; empty = open | | `RATE_LIMIT_PER_MINUTE` | api | `0` | per key/IP cap (0 = unlimited) | +| `SIGNUP_ENABLED` | api | `false` | DB mode only: expose public `POST /v1/signup` for API-key minting | +| `SIGNUP_RATE_LIMIT_PER_HOUR` | api | `3` | per-IP signup cap; `0` disables signup throttling | +| `DEFAULT_SIGNUP_KEY_RATE_LIMIT` | api | `30` | stored on keys minted by signup; service-wide enforcement still uses `RATE_LIMIT_PER_MINUTE` | | `SSRF_DISABLE` | api | `false` | turn off the SSRF guard (not recommended) | | `SSRF_ALLOW_LOOPBACK` | api | `false` | allow cloning localhost (local dev) | | `S3_BUCKET` / `S3_ENDPOINT` / `S3_REGION` / `S3_ACCESS_KEY_ID` / `S3_SECRET_ACCESS_KEY` / `S3_FORCE_PATH_STYLE` / `S3_PUBLIC_URL` | api, worker | — | set `S3_BUCKET` ⇒ object storage | diff --git a/packages/api/src/app.ts b/packages/api/src/app.ts index 082513a..e73d3d8 100644 --- a/packages/api/src/app.ts +++ b/packages/api/src/app.ts @@ -1,4 +1,5 @@ -import { Hono } from "hono"; +import { randomBytes } from "node:crypto"; +import { Hono, type Context, type MiddlewareHandler } from "hono"; import { z } from "zod"; import { RESPONSE_ALREADY_SENT } from "@hono/node-server/utils/response"; import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; @@ -6,7 +7,7 @@ import type { IncomingMessage, ServerResponse } from "node:http"; import { normalizeCloneRequestOptions } from "@cloner/core"; import type { Backend } from "./backend.js"; import { createMcpServer } from "./mcp.js"; -import { apiKeyAuth, rateLimit, type AuthConfig } from "./auth.js"; +import { apiKeyAuth, hashApiKey, rateLimit, type AuthConfig } from "./auth.js"; const OptionsSchema = z .object({ @@ -37,6 +38,19 @@ const CloneRequest = z.object({ options: OptionsSchema.optional(), }); +const SignupRequest = z + .object({ + email: z.string().email().max(320).transform((s) => s.trim().toLowerCase()), + label: z.string().trim().min(1).max(120).optional(), + }) + .strict(); + +export type SignupDeps = { + createApiKey: (input: { keyHash: string; label: string; rateLimit?: number }) => Promise; + defaultRateLimit?: number; + rateLimitPerHour?: number; +}; + export type AppDeps = { backend: Backend; /** absolute base URL used in MCP-returned references (binary/bundle URLs). */ @@ -47,6 +61,8 @@ export type AppDeps = { auth?: AuthConfig; /** per-window request cap on /v1/* and /mcp (omit = unlimited). */ rateLimitPerMinute?: number; + /** public key minting endpoint at POST /v1/signup (omit = disabled). */ + signup?: SignupDeps; /** SSRF guard run on submit (omit = no check — set in production). Throws to reject. */ assertUrl?: (url: string) => Promise; }; @@ -60,16 +76,53 @@ export function createApp(deps: AppDeps): Hono { app.get("/healthz", (c) => c.json({ ok: true })); - // Protect the API + MCP surfaces (not /healthz). Auth before rate-limit so the - // limiter can key by API key. + if (deps.signup) { + const signupRateLimit = deps.signup.rateLimitPerHour ?? 3; + const signupHandler = async (c: Context) => { + const body = await c.req.json().catch(() => null); + const parsed = SignupRequest.safeParse(body); + if (!parsed.success) { + return c.json({ error: "invalid request", details: parsed.error.flatten() }, 400); + } + const apiKey = `dtto_live_${randomBytes(32).toString("base64url")}`; + const label = parsed.data.label ? `${parsed.data.email} (${parsed.data.label})` : parsed.data.email; + await deps.signup!.createApiKey({ + keyHash: hashApiKey(apiKey), + label, + rateLimit: deps.signup!.defaultRateLimit, + }); + return c.json( + { + apiKey, + message: "Save this key now; it will not be shown again.", + }, + 201, + ); + }; + if (signupRateLimit > 0) { + app.post("/v1/signup", rateLimit({ perMinute: signupRateLimit, windowMs: 60 * 60 * 1000 }), signupHandler); + } else { + app.post("/v1/signup", signupHandler); + } + } + + const skipSignup = (mw: MiddlewareHandler): MiddlewareHandler => { + return async (c, next) => { + if (c.req.path === "/v1/signup") return next(); + return mw(c, next); + }; + }; + + // Protect the clone API + MCP surfaces (not /healthz or /v1/signup). Auth + // before rate-limit so the limiter can key by API key. if (deps.auth) { const mw = apiKeyAuth(deps.auth); - app.use("/v1/*", mw); + app.use("/v1/*", skipSignup(mw)); app.use("/mcp", mw); } if (deps.rateLimitPerMinute && deps.rateLimitPerMinute > 0) { const mw = rateLimit({ perMinute: deps.rateLimitPerMinute }); - app.use("/v1/*", mw); + app.use("/v1/*", skipSignup(mw)); app.use("/mcp", mw); } diff --git a/packages/api/src/auth.ts b/packages/api/src/auth.ts index fdaa2bf..225ee87 100644 --- a/packages/api/src/auth.ts +++ b/packages/api/src/auth.ts @@ -35,6 +35,8 @@ export function apiKeyAuth(cfg: AuthConfig): MiddlewareHandler { export type RateLimitConfig = { perMinute: number; + /** window length in milliseconds (default: one minute). */ + windowMs?: number; /** key the limit by (default: API-key hash, else client IP). */ keyFn?: (c: Parameters[0]) => string; }; @@ -43,7 +45,7 @@ export type RateLimitConfig = { * shared store (Redis/PG) would be needed across replicas. Keyed by API key when * present, else client IP. */ export function rateLimit(cfg: RateLimitConfig): MiddlewareHandler { - const windowMs = 60_000; + const windowMs = cfg.windowMs ?? 60_000; const buckets = new Map(); const keyFn = cfg.keyFn ?? diff --git a/packages/api/src/env.ts b/packages/api/src/env.ts index 89b5d4d..0c0c8d1 100644 --- a/packages/api/src/env.ts +++ b/packages/api/src/env.ts @@ -18,6 +18,12 @@ export type ApiEnv = { apiKeys: string[]; /** per-minute request cap on /v1/* and /mcp (0 = unlimited). */ rateLimitPerMinute: number; + /** allow public API-key minting at POST /v1/signup. Requires DATABASE_URL. */ + signupEnabled: boolean; + /** requests per hour per IP for POST /v1/signup (0 = unlimited). */ + signupRateLimitPerHour: number; + /** per-key requests/minute stored on keys minted through signup. */ + defaultSignupKeyRateLimit: number; /** SSRF guard (default on). */ ssrfEnabled: boolean; /** allow loopback targets through SSRF (local dev cloning of localhost). */ @@ -34,6 +40,9 @@ export function loadEnv(): ApiEnv { publicBaseUrl: process.env.PUBLIC_BASE_URL, apiKeys: (process.env.API_KEYS ?? "").split(",").map((s) => s.trim()).filter(Boolean), rateLimitPerMinute: parseInt(process.env.RATE_LIMIT_PER_MINUTE ?? "0", 10), + signupEnabled: process.env.SIGNUP_ENABLED === "true", + signupRateLimitPerHour: parseInt(process.env.SIGNUP_RATE_LIMIT_PER_HOUR ?? "3", 10), + defaultSignupKeyRateLimit: parseInt(process.env.DEFAULT_SIGNUP_KEY_RATE_LIMIT ?? "30", 10), ssrfEnabled: process.env.SSRF_DISABLE !== "true", ssrfAllowLoopback: process.env.SSRF_ALLOW_LOOPBACK === "true", }; diff --git a/packages/api/src/server.ts b/packages/api/src/server.ts index 9cf5c25..b98a9c7 100644 --- a/packages/api/src/server.ts +++ b/packages/api/src/server.ts @@ -49,6 +49,16 @@ async function main(): Promise { baseUrl: env.publicBaseUrl, auth, rateLimitPerMinute: env.rateLimitPerMinute, + signup: + env.signupEnabled && db + ? { + createApiKey: async (input) => { + await repo.createApiKey(db!, input); + }, + defaultRateLimit: env.defaultSignupKeyRateLimit, + rateLimitPerHour: env.signupRateLimitPerHour, + } + : undefined, assertUrl: env.ssrfEnabled ? async (url) => void (await assertPublicUrl(url, { allowLoopback: env.ssrfAllowLoopback })) : undefined, }); @@ -58,6 +68,7 @@ async function main(): Promise { event: "api_listening", port: info.port, auth: !!auth, + signup: env.signupEnabled && !!db, rateLimitPerMinute: env.rateLimitPerMinute || null, ssrf: env.ssrfEnabled, }), diff --git a/packages/api/test/auth.test.ts b/packages/api/test/auth.test.ts index f370012..9f5654b 100644 --- a/packages/api/test/auth.test.ts +++ b/packages/api/test/auth.test.ts @@ -22,6 +22,56 @@ test("auth: 401 without key / with wrong key, 200 with a valid key; /healthz sta assert.equal((await app.request("/healthz")).status, 200, "health check is unauthenticated"); }); +test("signup: public endpoint mints a stored-hash API key that can access protected routes", async () => { + const created: { keyHash: string; label: string; rateLimit?: number }[] = []; + const app = appWith({ + auth: { + keyHashes: new Set(), + lookup: async (h) => created.some((k) => k.keyHash === h), + }, + signup: { + createApiKey: async (input) => { + created.push(input); + }, + defaultRateLimit: 30, + rateLimitPerHour: 3, + }, + }); + + const res = await app.request("/v1/signup", { + method: "POST", + headers: { "content-type": "application/json", "x-forwarded-for": "2.2.2.2" }, + body: JSON.stringify({ email: "USER@Example.com", label: "Beta" }), + }); + assert.equal(res.status, 201); + const body = await res.json(); + assert.match(body.apiKey, /^dtto_live_/); + assert.equal(body.message, "Save this key now; it will not be shown again."); + assert.equal(created.length, 1); + assert.equal(created[0]!.keyHash, hashApiKey(body.apiKey)); + assert.equal(created[0]!.label, "user@example.com (Beta)"); + assert.equal(created[0]!.rateLimit, 30); + + assert.equal((await app.request("/v1/clones")).status, 401, "clone routes still require auth"); + assert.equal((await app.request("/v1/clones", { headers: { authorization: `Bearer ${body.apiKey}` } })).status, 200); +}); + +test("signup: validates email and rate-limits per IP", async () => { + const app = appWith({ + signup: { + createApiKey: async () => {}, + rateLimitPerHour: 1, + }, + }); + assert.equal( + (await app.request("/v1/signup", { method: "POST", headers: { "content-type": "application/json", "x-forwarded-for": "3.3.3.3" }, body: JSON.stringify({ email: "not-an-email" }) })).status, + 400, + ); + const headers = { "content-type": "application/json", "x-forwarded-for": "4.4.4.4" }; + assert.equal((await app.request("/v1/signup", { method: "POST", headers, body: JSON.stringify({ email: "a@example.com" }) })).status, 201); + assert.equal((await app.request("/v1/signup", { method: "POST", headers, body: JSON.stringify({ email: "b@example.com" }) })).status, 429); +}); + test("rate limit: 429 once the per-minute cap is exceeded", async () => { const app = appWith({ rateLimitPerMinute: 2 }); const headers = { "x-forwarded-for": "9.9.9.9" };