diff --git a/.env.example b/.env.example index c74dc3e..09280e3 100644 --- a/.env.example +++ b/.env.example @@ -5,9 +5,15 @@ PORT=8787 # 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_ENABLED=false # DB mode only: expose public API-key signup routes # SIGNUP_RATE_LIMIT_PER_HOUR=3 # DEFAULT_SIGNUP_KEY_RATE_LIMIT=30 # stored on minted keys; current limiter is service-wide +# SIGNUP_DIRECT_ENABLED=true # set false when using verified-email signup only +# RESEND_API_KEY= # enables POST /v1/signup/request + /v1/signup/verify +# SIGNUP_FROM_EMAIL=Ditto +# SIGNUP_VERIFY_URL=https://ditto.site/api-key +# SIGNUP_TOKEN_TTL_MINUTES=30 +# SIGNUP_CORS_ORIGINS=https://ditto.site # ---- 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 6347e58..c0b55f2 100644 --- a/README.md +++ b/README.md @@ -17,10 +17,20 @@ frozen capture in, byte-stable app out. ## Usage -The hosted URLs below are placeholders until the public service is live: - - REST API: `https://api.ditto.site` -- MCP server: `https://mcp.ditto.site/mcp` +- MCP server: `https://api.ditto.site/mcp` + +Get a hosted key at `https://ditto.site/api-key`, or call the verified-email +signup flow directly: + +```bash +curl -sS -X POST "https://api.ditto.site/v1/signup/request" \ + -H "content-type: application/json" \ + -d '{"email":"you@example.com"}' +``` + +The emailed verification link lands on `/api-key?token=...`, which calls +`POST /v1/signup/verify` and displays the new `dtto_live_...` key once. ### REST API @@ -92,7 +102,7 @@ Connect an MCP client to the hosted Streamable HTTP endpoint: { "mcpServers": { "ditto": { - "url": "https://mcp.ditto.site/mcp", + "url": "https://api.ditto.site/mcp", "headers": { "Authorization": "Bearer ${DITTO_API_KEY}" } @@ -215,8 +225,11 @@ 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. +When `SIGNUP_ENABLED=true` in DB mode, the Resend-backed +`POST /v1/signup/request` and `POST /v1/signup/verify` flow can publicly mint +`dtto_live_...` keys from verified email links while storing only key hashes. +Keep `SIGNUP_DIRECT_ENABLED=false` in production unless direct unauthenticated +minting is intentional. ## Repository Map diff --git a/docs/DEPLOY.md b/docs/DEPLOY.md index 65da63a..36feee3 100644 --- a/docs/DEPLOY.md +++ b/docs/DEPLOY.md @@ -53,6 +53,12 @@ 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 +SIGNUP_DIRECT_ENABLED=false # recommended once verified email is configured +RESEND_API_KEY=re_... +SIGNUP_FROM_EMAIL=Ditto # must be a verified Resend sender/domain +SIGNUP_VERIFY_URL=https://ditto.site/api-key +SIGNUP_TOKEN_TTL_MINUTES=30 +SIGNUP_CORS_ORIGINS=https://ditto.site # browser origins allowed to call signup routes # SSRF is on by default; do NOT set SSRF_ALLOW_LOOPBACK in prod. ``` diff --git a/docs/SERVICE.md b/docs/SERVICE.md index 3948299..0c5a004 100644 --- a/docs/SERVICE.md +++ b/docs/SERVICE.md @@ -64,7 +64,9 @@ 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) +POST /v1/signup { email, label? } → 201 {apiKey,message} (direct public signup when enabled) +POST /v1/signup/request { email } → 202 {message} (send verification email) +POST /v1/signup/verify { token } → 201 {apiKey,message} (consume email token) 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) @@ -76,10 +78,13 @@ 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. +Signup routes are intentionally public only when `SIGNUP_ENABLED=true` **and** +`DATABASE_URL` is set. Direct `POST /v1/signup` mints a `dtto_live_...` key +immediately when `SIGNUP_DIRECT_ENABLED=true`. For public production signup, +prefer the Resend-backed verified flow: `POST /v1/signup/request` sends a +one-time email link, and `POST /v1/signup/verify` consumes the token, stores +only the API key's SHA-256 hash in Postgres, stores the verified 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 @@ -132,9 +137,15 @@ 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_ENABLED` | api | `false` | DB mode only: expose public API-key signup routes | | `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` | +| `SIGNUP_DIRECT_ENABLED` | api | `true` | keep direct `POST /v1/signup` enabled; set `false` when Resend verification is configured | +| `RESEND_API_KEY` | api | — | enables verified-email signup request/verify endpoints | +| `SIGNUP_FROM_EMAIL` | api | — | verified sender, e.g. `Ditto ` | +| `SIGNUP_VERIFY_URL` | api | — | landing-page URL that receives `?token=...`, e.g. `https://ditto.site/api-key` | +| `SIGNUP_TOKEN_TTL_MINUTES` | api | `30` | one-time email verification token lifetime | +| `SIGNUP_CORS_ORIGINS` | api | `https://ditto.site` | comma-separated browser origins allowed to call public signup routes | | `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 e73d3d8..8efda70 100644 --- a/packages/api/src/app.ts +++ b/packages/api/src/app.ts @@ -1,5 +1,6 @@ import { randomBytes } from "node:crypto"; import { Hono, type Context, type MiddlewareHandler } from "hono"; +import { cors } from "hono/cors"; import { z } from "zod"; import { RESPONSE_ALREADY_SENT } from "@hono/node-server/utils/response"; import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; @@ -45,10 +46,24 @@ const SignupRequest = z }) .strict(); +const SignupVerifyRequest = z + .object({ + token: z.string().min(24).max(256), + }) + .strict(); + export type SignupDeps = { createApiKey: (input: { keyHash: string; label: string; rateLimit?: number }) => Promise; defaultRateLimit?: number; rateLimitPerHour?: number; + directEnabled?: boolean; + email?: { + createToken: (input: { email: string; tokenHash: string; expiresAt: Date }) => Promise; + consumeToken: (tokenHash: string) => Promise<{ email: string } | undefined>; + sendVerificationEmail: (input: { email: string; verifyUrl: string; expiresAt: Date }) => Promise; + verifyUrl: string; + tokenTtlMs: number; + }; }; export type AppDeps = { @@ -63,6 +78,8 @@ export type AppDeps = { rateLimitPerMinute?: number; /** public key minting endpoint at POST /v1/signup (omit = disabled). */ signup?: SignupDeps; + /** browser origins allowed to call public signup routes. */ + signupCorsOrigins?: string[]; /** SSRF guard run on submit (omit = no check — set in production). Throws to reject. */ assertUrl?: (url: string) => Promise; }; @@ -73,24 +90,44 @@ export type AppDeps = { export function createApp(deps: AppDeps): Hono { const { backend } = deps; const app = new Hono(); + const signupCorsOrigins = deps.signupCorsOrigins ?? []; + + if (signupCorsOrigins.length > 0) { + const allowedOrigins = new Set(signupCorsOrigins); + const signupCors = cors({ + origin: (origin) => (allowedOrigins.has(origin) ? origin : null), + allowMethods: ["POST", "OPTIONS"], + allowHeaders: ["content-type"], + maxAge: 86400, + }); + app.use("/v1/signup", signupCors); + app.use("/v1/signup/*", signupCors); + } app.get("/healthz", (c) => c.json({ ok: true })); if (deps.signup) { - const signupRateLimit = deps.signup.rateLimitPerHour ?? 3; - const signupHandler = async (c: Context) => { + const signup = deps.signup; + const signupRateLimit = signup.rateLimitPerHour ?? 3; + const signupLimiter = rateLimit({ perMinute: signupRateLimit, windowMs: 60 * 60 * 1000 }); + const mintKey = async (email: string, label?: string) => { + const apiKey = `dtto_live_${randomBytes(32).toString("base64url")}`; + const storedLabel = label ? `${email} (${label})` : email; + await signup.createApiKey({ + keyHash: hashApiKey(apiKey), + label: storedLabel, + rateLimit: signup.defaultRateLimit, + }); + return apiKey; + }; + + const directSignupHandler = 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, - }); + const apiKey = await mintKey(parsed.data.email, parsed.data.label); return c.json( { apiKey, @@ -99,16 +136,66 @@ export function createApp(deps: AppDeps): Hono { 201, ); }; - if (signupRateLimit > 0) { - app.post("/v1/signup", rateLimit({ perMinute: signupRateLimit, windowMs: 60 * 60 * 1000 }), signupHandler); - } else { - app.post("/v1/signup", signupHandler); + + if (signup.directEnabled !== false) { + if (signupRateLimit > 0) app.post("/v1/signup", signupLimiter, directSignupHandler); + else app.post("/v1/signup", directSignupHandler); + } + + const emailSignup = signup.email; + if (emailSignup) { + const requestSignupHandler = 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 rawToken = `dtto_signup_${randomBytes(32).toString("base64url")}`; + const expiresAt = new Date(Date.now() + emailSignup.tokenTtlMs); + const url = new URL(emailSignup.verifyUrl); + url.searchParams.set("token", rawToken); + await emailSignup.createToken({ + email: parsed.data.email, + tokenHash: hashApiKey(rawToken), + expiresAt, + }); + await emailSignup.sendVerificationEmail({ + email: parsed.data.email, + verifyUrl: url.toString(), + expiresAt, + }); + return c.json({ message: "Check your email for a verification link." }, 202); + }; + + const verifySignupHandler = async (c: Context) => { + const body = await c.req.json().catch(() => null); + const parsed = SignupVerifyRequest.safeParse(body); + if (!parsed.success) { + return c.json({ error: "invalid request", details: parsed.error.flatten() }, 400); + } + const token = await emailSignup.consumeToken(hashApiKey(parsed.data.token)); + if (!token) { + return c.json({ error: "invalid or expired signup token" }, 400); + } + const apiKey = await mintKey(token.email); + return c.json( + { + apiKey, + message: "Save this key now; it will not be shown again.", + }, + 201, + ); + }; + + if (signupRateLimit > 0) app.post("/v1/signup/request", signupLimiter, requestSignupHandler); + else app.post("/v1/signup/request", requestSignupHandler); + app.post("/v1/signup/verify", verifySignupHandler); } } const skipSignup = (mw: MiddlewareHandler): MiddlewareHandler => { return async (c, next) => { - if (c.req.path === "/v1/signup") return next(); + if (c.req.path === "/v1/signup" || c.req.path === "/v1/signup/request" || c.req.path === "/v1/signup/verify") return next(); return mw(c, next); }; }; diff --git a/packages/api/src/env.ts b/packages/api/src/env.ts index 0c0c8d1..1197a39 100644 --- a/packages/api/src/env.ts +++ b/packages/api/src/env.ts @@ -24,6 +24,18 @@ export type ApiEnv = { signupRateLimitPerHour: number; /** per-key requests/minute stored on keys minted through signup. */ defaultSignupKeyRateLimit: number; + /** keep the legacy direct POST /v1/signup key minting route enabled. */ + signupDirectEnabled: boolean; + /** Resend API key for email verification signup. */ + resendApiKey?: string; + /** verified sender address, e.g. "Ditto ". */ + signupFromEmail?: string; + /** landing-page URL that receives ?token=... for verification. */ + signupVerifyUrl?: string; + /** verification token lifetime in minutes. */ + signupTokenTtlMinutes: number; + /** browser origins allowed to call public signup routes. */ + signupCorsOrigins: string[]; /** SSRF guard (default on). */ ssrfEnabled: boolean; /** allow loopback targets through SSRF (local dev cloning of localhost). */ @@ -43,6 +55,12 @@ export function loadEnv(): ApiEnv { 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), + signupDirectEnabled: process.env.SIGNUP_DIRECT_ENABLED !== "false", + resendApiKey: process.env.RESEND_API_KEY, + signupFromEmail: process.env.SIGNUP_FROM_EMAIL, + signupVerifyUrl: process.env.SIGNUP_VERIFY_URL, + signupTokenTtlMinutes: parseInt(process.env.SIGNUP_TOKEN_TTL_MINUTES ?? "30", 10), + signupCorsOrigins: (process.env.SIGNUP_CORS_ORIGINS ?? "https://ditto.site").split(",").map((s) => s.trim()).filter(Boolean), ssrfEnabled: process.env.SSRF_DISABLE !== "true", ssrfAllowLoopback: process.env.SSRF_ALLOW_LOOPBACK === "true", }; diff --git a/packages/api/src/resend.ts b/packages/api/src/resend.ts new file mode 100644 index 0000000..21d6ef6 --- /dev/null +++ b/packages/api/src/resend.ts @@ -0,0 +1,53 @@ +export type SendSignupEmailInput = { + apiKey: string; + from: string; + to: string; + verifyUrl: string; + expiresAt: Date; +}; + +export async function sendSignupEmail(input: SendSignupEmailInput): Promise { + const expires = input.expiresAt.toLocaleString("en-US", { + timeZone: "UTC", + dateStyle: "medium", + timeStyle: "short", + }); + const text = [ + "Your ditto.site API key is almost ready.", + "", + `Open this link to verify your email and reveal your key: ${input.verifyUrl}`, + "", + `This link expires at ${expires} UTC.`, + "If you did not request this, you can ignore this email.", + ].join("\n"); + + const html = ` +
+

Your ditto.site API key is almost ready.

+

Open the link below to verify your email and reveal your key.

+

Verify and get key

+

This link expires at ${expires} UTC.

+

If you did not request this, you can ignore this email.

+
+ `; + + const res = await fetch("https://api.resend.com/emails", { + method: "POST", + headers: { + authorization: `Bearer ${input.apiKey}`, + "content-type": "application/json", + }, + body: JSON.stringify({ + from: input.from, + to: input.to, + subject: "Verify your ditto.site API key", + text, + html, + }), + }); + + if (!res.ok) { + const detail = await res.text().catch(() => ""); + throw new Error(`Resend send failed (${res.status}): ${detail.slice(0, 500)}`); + } +} diff --git a/packages/api/src/server.ts b/packages/api/src/server.ts index b98a9c7..57663c1 100644 --- a/packages/api/src/server.ts +++ b/packages/api/src/server.ts @@ -10,6 +10,7 @@ import type { Backend } from "./backend.js"; import { hashApiKey, type AuthConfig } from "./auth.js"; import { assertPublicUrl } from "./ssrf.js"; import { loadEnv, type ApiEnv } from "./env.js"; +import { sendSignupEmail } from "./resend.js"; function buildAuth(env: ApiEnv, db?: Db): AuthConfig | undefined { const keyHashes = new Set(env.apiKeys.map(hashApiKey)); @@ -44,11 +45,32 @@ async function main(): Promise { } const auth = buildAuth(env, db); + const emailSignup = + db && env.resendApiKey && env.signupFromEmail && env.signupVerifyUrl + ? { + createToken: async (input: { email: string; tokenHash: string; expiresAt: Date }) => { + await repo.createSignupToken(db!, input); + }, + consumeToken: async (tokenHash: string) => repo.consumeSignupToken(db!, tokenHash), + sendVerificationEmail: async (input: { email: string; verifyUrl: string; expiresAt: Date }) => { + await sendSignupEmail({ + apiKey: env.resendApiKey!, + from: env.signupFromEmail!, + to: input.email, + verifyUrl: input.verifyUrl, + expiresAt: input.expiresAt, + }); + }, + verifyUrl: env.signupVerifyUrl, + tokenTtlMs: Math.max(1, env.signupTokenTtlMinutes) * 60 * 1000, + } + : undefined; const app = createApp({ backend, baseUrl: env.publicBaseUrl, auth, rateLimitPerMinute: env.rateLimitPerMinute, + signupCorsOrigins: env.signupCorsOrigins, signup: env.signupEnabled && db ? { @@ -57,6 +79,8 @@ async function main(): Promise { }, defaultRateLimit: env.defaultSignupKeyRateLimit, rateLimitPerHour: env.signupRateLimitPerHour, + directEnabled: env.signupDirectEnabled, + email: emailSignup, } : undefined, assertUrl: env.ssrfEnabled ? async (url) => void (await assertPublicUrl(url, { allowLoopback: env.ssrfAllowLoopback })) : undefined, @@ -69,6 +93,7 @@ async function main(): Promise { port: info.port, auth: !!auth, signup: env.signupEnabled && !!db, + emailSignup: !!emailSignup, rateLimitPerMinute: env.rateLimitPerMinute || null, ssrf: env.ssrfEnabled, }), diff --git a/packages/api/test/auth.test.ts b/packages/api/test/auth.test.ts index 9f5654b..867bf4d 100644 --- a/packages/api/test/auth.test.ts +++ b/packages/api/test/auth.test.ts @@ -72,6 +72,112 @@ test("signup: validates email and rate-limits per IP", async () => { assert.equal((await app.request("/v1/signup", { method: "POST", headers, body: JSON.stringify({ email: "b@example.com" }) })).status, 429); }); +test("signup email verification: request sends a one-time link; verify mints a working key", async () => { + const createdKeys: { keyHash: string; label: string; rateLimit?: number }[] = []; + const tokens = new Map(); + let sent: { email: string; verifyUrl: string; expiresAt: Date } | undefined; + const app = appWith({ + auth: { + keyHashes: new Set(), + lookup: async (h) => createdKeys.some((k) => k.keyHash === h), + }, + signup: { + createApiKey: async (input) => { + createdKeys.push(input); + }, + defaultRateLimit: 30, + directEnabled: false, + email: { + createToken: async (input) => { + tokens.set(input.tokenHash, input.email); + }, + consumeToken: async (tokenHash) => { + const email = tokens.get(tokenHash); + if (!email) return undefined; + tokens.delete(tokenHash); + return { email }; + }, + sendVerificationEmail: async (input) => { + sent = input; + }, + verifyUrl: "https://ditto.site/api-key", + tokenTtlMs: 30 * 60 * 1000, + }, + }, + }); + + assert.equal((await app.request("/v1/signup", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ email: "a@example.com" }) })).status, 404); + const request = await app.request("/v1/signup/request", { + method: "POST", + headers: { "content-type": "application/json", "x-forwarded-for": "5.5.5.5" }, + body: JSON.stringify({ email: "USER@Example.com" }), + }); + assert.equal(request.status, 202); + assert.equal((await request.json()).message, "Check your email for a verification link."); + assert.equal(sent?.email, "user@example.com"); + assert.ok(sent?.verifyUrl.startsWith("https://ditto.site/api-key?token=dtto_signup_")); + + const token = new URL(sent!.verifyUrl).searchParams.get("token")!; + const verify = await app.request("/v1/signup/verify", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ token }), + }); + assert.equal(verify.status, 201); + const body = await verify.json(); + assert.match(body.apiKey, /^dtto_live_/); + assert.equal(createdKeys.length, 1); + assert.equal(createdKeys[0]!.label, "user@example.com"); + assert.equal((await app.request("/v1/clones", { headers: { authorization: `Bearer ${body.apiKey}` } })).status, 200); + + const replay = await app.request("/v1/signup/verify", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ token }), + }); + assert.equal(replay.status, 400); +}); + +test("signup: browser CORS is allowed only for configured origins", async () => { + const app = appWith({ + signupCorsOrigins: ["https://ditto.site"], + signup: { + createApiKey: async () => {}, + rateLimitPerHour: 0, + directEnabled: false, + email: { + createToken: async () => {}, + consumeToken: async () => undefined, + sendVerificationEmail: async () => {}, + verifyUrl: "https://ditto.site/api-key", + tokenTtlMs: 30 * 60 * 1000, + }, + }, + }); + + const preflight = await app.request("/v1/signup/request", { + method: "OPTIONS", + headers: { + origin: "https://ditto.site", + "access-control-request-method": "POST", + "access-control-request-headers": "content-type", + }, + }); + assert.equal(preflight.status, 204); + assert.equal(preflight.headers.get("access-control-allow-origin"), "https://ditto.site"); + assert.match(preflight.headers.get("access-control-allow-methods") ?? "", /POST/); + assert.match(preflight.headers.get("access-control-allow-headers") ?? "", /content-type/i); + + const denied = await app.request("/v1/signup/request", { + method: "OPTIONS", + headers: { + origin: "https://evil.example", + "access-control-request-method": "POST", + }, + }); + assert.equal(denied.headers.get("access-control-allow-origin"), null); +}); + 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" }; diff --git a/packages/db/migrations/0001_signup_tokens.sql b/packages/db/migrations/0001_signup_tokens.sql new file mode 100644 index 0000000..b53f905 --- /dev/null +++ b/packages/db/migrations/0001_signup_tokens.sql @@ -0,0 +1,9 @@ +CREATE TABLE "signup_tokens" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "email" text NOT NULL, + "token_hash" text NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "expires_at" timestamp with time zone NOT NULL, + "consumed_at" timestamp with time zone, + CONSTRAINT "signup_tokens_token_hash_unique" UNIQUE("token_hash") +); diff --git a/packages/db/migrations/meta/0001_snapshot.json b/packages/db/migrations/meta/0001_snapshot.json new file mode 100644 index 0000000..05d33d3 --- /dev/null +++ b/packages/db/migrations/meta/0001_snapshot.json @@ -0,0 +1,380 @@ +{ + "id": "e076bd1e-a7ce-40ab-abd1-9eb11d00ddb9", + "prevId": "9000d72e-0529-4eaf-b53d-43d3b93b5620", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.api_keys": { + "name": "api_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rate_limit": { + "name": "rate_limit", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "api_keys_key_hash_unique": { + "name": "api_keys_key_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "key_hash" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.cache": { + "name": "cache", + "schema": "", + "columns": { + "cache_key": { + "name": "cache_key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "job_id": { + "name": "job_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "options_hash": { + "name": "options_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "compiler_version": { + "name": "compiler_version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "cache_job_id_jobs_id_fk": { + "name": "cache_job_id_jobs_id_fk", + "tableFrom": "cache", + "tableTo": "jobs", + "columnsFrom": [ + "job_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.clones": { + "name": "clones", + "schema": "", + "columns": { + "job_id": { + "name": "job_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "route_count": { + "name": "route_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "file_manifest": { + "name": "file_manifest", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "bundle_s3_key": { + "name": "bundle_s3_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "verify": { + "name": "verify", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "capture_meta": { + "name": "capture_meta", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "clones_job_id_jobs_id_fk": { + "name": "clones_job_id_jobs_id_fk", + "tableFrom": "clones", + "tableTo": "jobs", + "columnsFrom": [ + "job_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.jobs": { + "name": "jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "options": { + "name": "options", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "cache_key": { + "name": "cache_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "compiler_version": { + "name": "compiler_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "timings": { + "name": "timings", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.signup_tokens": { + "name": "signup_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "consumed_at": { + "name": "consumed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "signup_tokens_token_hash_unique": { + "name": "signup_tokens_token_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "token_hash" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/db/migrations/meta/_journal.json b/packages/db/migrations/meta/_journal.json index 8bab0ad..06f02ca 100644 --- a/packages/db/migrations/meta/_journal.json +++ b/packages/db/migrations/meta/_journal.json @@ -8,6 +8,13 @@ "when": 1782170309013, "tag": "0000_plain_wither", "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1782790200000, + "tag": "0001_signup_tokens", + "breakpoints": true } ] -} \ No newline at end of file +} diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index ec9d402..ec70103 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -1,7 +1,7 @@ export * as schema from "./schema.js"; export { - jobs, clones, cache, apiKeys, - type Job, type NewJob, type Clone, type NewClone, type CacheRow, type ApiKey, + jobs, clones, cache, apiKeys, signupTokens, + type Job, type NewJob, type Clone, type NewClone, type CacheRow, type ApiKey, type SignupToken, type NewSignupToken, } from "./schema.js"; export { createDb, type Db, type DbHandle } from "./client.js"; export * as repo from "./repo.js"; diff --git a/packages/db/src/repo.ts b/packages/db/src/repo.ts index 8beb0f3..75e392a 100644 --- a/packages/db/src/repo.ts +++ b/packages/db/src/repo.ts @@ -1,6 +1,6 @@ -import { and, desc, eq, gt, sql } from "drizzle-orm"; +import { and, desc, eq, gt, isNull, sql } from "drizzle-orm"; import type { Db } from "./client.js"; -import { jobs, clones, cache, apiKeys, type Job, type NewJob, type Clone, type NewClone, type CacheRow, type ApiKey } from "./schema.js"; +import { jobs, clones, cache, apiKeys, signupTokens, type Job, type NewJob, type Clone, type NewClone, type CacheRow, type ApiKey, type SignupToken } from "./schema.js"; // ---- jobs ---- @@ -93,3 +93,21 @@ export async function createApiKey(db: Db, input: { keyHash: string; label?: str const [row] = await db.insert(apiKeys).values(input).returning(); return row!; } + +// ---- signup tokens ---- + +export async function createSignupToken(db: Db, input: { email: string; tokenHash: string; expiresAt: Date }): Promise { + const [row] = await db.insert(signupTokens).values(input).returning(); + return row!; +} + +/** Atomically consume a still-fresh signup token. Returns undefined for missing, + * expired, or already-used tokens. */ +export async function consumeSignupToken(db: Db, tokenHash: string): Promise { + const [row] = await db + .update(signupTokens) + .set({ consumedAt: new Date() }) + .where(and(eq(signupTokens.tokenHash, tokenHash), gt(signupTokens.expiresAt, new Date()), isNull(signupTokens.consumedAt))) + .returning(); + return row; +} diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index 1c580b8..30f28a5 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -56,9 +56,21 @@ export const apiKeys = pgTable("api_keys", { revokedAt: timestamp("revoked_at", { withTimezone: true }), }); +/** One-time email verification tokens for public API-key signup. */ +export const signupTokens = pgTable("signup_tokens", { + id: uuid("id").defaultRandom().primaryKey(), + email: text("email").notNull(), + tokenHash: text("token_hash").notNull().unique(), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(), + consumedAt: timestamp("consumed_at", { withTimezone: true }), +}); + export type Job = typeof jobs.$inferSelect; export type NewJob = typeof jobs.$inferInsert; export type Clone = typeof clones.$inferSelect; export type NewClone = typeof clones.$inferInsert; export type CacheRow = typeof cache.$inferSelect; export type ApiKey = typeof apiKeys.$inferSelect; +export type SignupToken = typeof signupTokens.$inferSelect; +export type NewSignupToken = typeof signupTokens.$inferInsert;