Add verified email API key signup

This commit is contained in:
Samraaj Bath
2026-06-29 20:35:16 -07:00
parent 49a6541b24
commit cf8af6d936
15 changed files with 783 additions and 32 deletions
+7 -1
View File
@@ -5,9 +5,15 @@ PORT=8787
# CLONE_TTL_MS=1800000 # in-memory mode: how long results are retained # CLONE_TTL_MS=1800000 # in-memory mode: how long results are retained
# API_KEYS= # comma-separated keys that can call /v1/clones and /mcp # 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 # 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 # SIGNUP_RATE_LIMIT_PER_HOUR=3
# DEFAULT_SIGNUP_KEY_RATE_LIMIT=30 # stored on minted keys; current limiter is service-wide # 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 <hello@ditto.site>
# 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 + queue (Postgres; pg-boss uses the same DB) ----
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/ditto_site DATABASE_URL=postgresql://postgres:postgres@localhost:5432/ditto_site
+19 -6
View File
@@ -17,10 +17,20 @@ frozen capture in, byte-stable app out.
## Usage ## Usage
The hosted URLs below are placeholders until the public service is live:
- REST API: `https://api.ditto.site` - 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 ### REST API
@@ -92,7 +102,7 @@ Connect an MCP client to the hosted Streamable HTTP endpoint:
{ {
"mcpServers": { "mcpServers": {
"ditto": { "ditto": {
"url": "https://mcp.ditto.site/mcp", "url": "https://api.ditto.site/mcp",
"headers": { "headers": {
"Authorization": "Bearer ${DITTO_API_KEY}" "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). deployment, see [docs/DEPLOY.md](docs/DEPLOY.md).
Hosted deployments should keep `/v1/clones*` and `/mcp` behind API-key auth. 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 When `SIGNUP_ENABLED=true` in DB mode, the Resend-backed
`dtto_live_...` keys from an email address while storing only key hashes. `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 ## Repository Map
+6
View File
@@ -53,6 +53,12 @@ RATE_LIMIT_PER_MINUTE=60
SIGNUP_ENABLED=true # optional: public API-key minting SIGNUP_ENABLED=true # optional: public API-key minting
SIGNUP_RATE_LIMIT_PER_HOUR=3 SIGNUP_RATE_LIMIT_PER_HOUR=3
DEFAULT_SIGNUP_KEY_RATE_LIMIT=30 DEFAULT_SIGNUP_KEY_RATE_LIMIT=30
SIGNUP_DIRECT_ENABLED=false # recommended once verified email is configured
RESEND_API_KEY=re_...
SIGNUP_FROM_EMAIL=Ditto <hello@ditto.site> # 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. # SSRF is on by default; do NOT set SSRF_ALLOW_LOOPBACK in prod.
``` ```
+17 -6
View File
@@ -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/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 → list (metadata)
GET /v1/clones/:id → status + metadata (fileCount, totalBytes, capture, timings) GET /v1/clones/:id → status + metadata (fileCount, totalBytes, capture, timings)
GET /v1/clones/:id/result → the eager CloneResult (text files inline; binaries by URL) 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 `/v1/clones*` and `/mcp` are authenticated when `API_KEYS` is set or DB-backed
keys exist. Use `Authorization: Bearer <key>` or `x-api-key: <key>`. keys exist. Use `Authorization: Bearer <key>` or `x-api-key: <key>`.
`/v1/signup` is intentionally public only when `SIGNUP_ENABLED=true` **and** Signup routes are intentionally public only when `SIGNUP_ENABLED=true` **and**
`DATABASE_URL` is set. It mints a `dtto_live_...` key, stores only its SHA-256 `DATABASE_URL` is set. Direct `POST /v1/signup` mints a `dtto_live_...` key
hash in Postgres, stores the submitted email in the key label for attribution, immediately when `SIGNUP_DIRECT_ENABLED=true`. For public production signup,
and returns the raw key once. 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" }`. 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 `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 | | `PUBLIC_BASE_URL` | api | — | absolute base for MCP-returned URLs |
| `API_KEYS` | api | — | comma-separated keys; empty = open | | `API_KEYS` | api | — | comma-separated keys; empty = open |
| `RATE_LIMIT_PER_MINUTE` | api | `0` | per key/IP cap (0 = unlimited) | | `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 | | `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` | | `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 <hello@ditto.site>` |
| `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_DISABLE` | api | `false` | turn off the SSRF guard (not recommended) |
| `SSRF_ALLOW_LOOPBACK` | api | `false` | allow cloning localhost (local dev) | | `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 | | `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 |
+101 -14
View File
@@ -1,5 +1,6 @@
import { randomBytes } from "node:crypto"; import { randomBytes } from "node:crypto";
import { Hono, type Context, type MiddlewareHandler } from "hono"; import { Hono, type Context, type MiddlewareHandler } from "hono";
import { cors } from "hono/cors";
import { z } from "zod"; import { z } from "zod";
import { RESPONSE_ALREADY_SENT } from "@hono/node-server/utils/response"; import { RESPONSE_ALREADY_SENT } from "@hono/node-server/utils/response";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
@@ -45,10 +46,24 @@ const SignupRequest = z
}) })
.strict(); .strict();
const SignupVerifyRequest = z
.object({
token: z.string().min(24).max(256),
})
.strict();
export type SignupDeps = { export type SignupDeps = {
createApiKey: (input: { keyHash: string; label: string; rateLimit?: number }) => Promise<void>; createApiKey: (input: { keyHash: string; label: string; rateLimit?: number }) => Promise<void>;
defaultRateLimit?: number; defaultRateLimit?: number;
rateLimitPerHour?: number; rateLimitPerHour?: number;
directEnabled?: boolean;
email?: {
createToken: (input: { email: string; tokenHash: string; expiresAt: Date }) => Promise<void>;
consumeToken: (tokenHash: string) => Promise<{ email: string } | undefined>;
sendVerificationEmail: (input: { email: string; verifyUrl: string; expiresAt: Date }) => Promise<void>;
verifyUrl: string;
tokenTtlMs: number;
};
}; };
export type AppDeps = { export type AppDeps = {
@@ -63,6 +78,8 @@ export type AppDeps = {
rateLimitPerMinute?: number; rateLimitPerMinute?: number;
/** public key minting endpoint at POST /v1/signup (omit = disabled). */ /** public key minting endpoint at POST /v1/signup (omit = disabled). */
signup?: SignupDeps; 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. */ /** SSRF guard run on submit (omit = no check — set in production). Throws to reject. */
assertUrl?: (url: string) => Promise<void>; assertUrl?: (url: string) => Promise<void>;
}; };
@@ -73,24 +90,44 @@ export type AppDeps = {
export function createApp(deps: AppDeps): Hono { export function createApp(deps: AppDeps): Hono {
const { backend } = deps; const { backend } = deps;
const app = new Hono(); 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 })); app.get("/healthz", (c) => c.json({ ok: true }));
if (deps.signup) { if (deps.signup) {
const signupRateLimit = deps.signup.rateLimitPerHour ?? 3; const signup = deps.signup;
const signupHandler = async (c: Context) => { 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 body = await c.req.json().catch(() => null);
const parsed = SignupRequest.safeParse(body); const parsed = SignupRequest.safeParse(body);
if (!parsed.success) { if (!parsed.success) {
return c.json({ error: "invalid request", details: parsed.error.flatten() }, 400); return c.json({ error: "invalid request", details: parsed.error.flatten() }, 400);
} }
const apiKey = `dtto_live_${randomBytes(32).toString("base64url")}`; const apiKey = await mintKey(parsed.data.email, parsed.data.label);
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( return c.json(
{ {
apiKey, apiKey,
@@ -99,16 +136,66 @@ export function createApp(deps: AppDeps): Hono {
201, 201,
); );
}; };
if (signupRateLimit > 0) {
app.post("/v1/signup", rateLimit({ perMinute: signupRateLimit, windowMs: 60 * 60 * 1000 }), signupHandler); if (signup.directEnabled !== false) {
} else { if (signupRateLimit > 0) app.post("/v1/signup", signupLimiter, directSignupHandler);
app.post("/v1/signup", signupHandler); 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 => { const skipSignup = (mw: MiddlewareHandler): MiddlewareHandler => {
return async (c, next) => { 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); return mw(c, next);
}; };
}; };
+18
View File
@@ -24,6 +24,18 @@ export type ApiEnv = {
signupRateLimitPerHour: number; signupRateLimitPerHour: number;
/** per-key requests/minute stored on keys minted through signup. */ /** per-key requests/minute stored on keys minted through signup. */
defaultSignupKeyRateLimit: number; 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 <hello@ditto.site>". */
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). */ /** SSRF guard (default on). */
ssrfEnabled: boolean; ssrfEnabled: boolean;
/** allow loopback targets through SSRF (local dev cloning of localhost). */ /** allow loopback targets through SSRF (local dev cloning of localhost). */
@@ -43,6 +55,12 @@ export function loadEnv(): ApiEnv {
signupEnabled: process.env.SIGNUP_ENABLED === "true", signupEnabled: process.env.SIGNUP_ENABLED === "true",
signupRateLimitPerHour: parseInt(process.env.SIGNUP_RATE_LIMIT_PER_HOUR ?? "3", 10), signupRateLimitPerHour: parseInt(process.env.SIGNUP_RATE_LIMIT_PER_HOUR ?? "3", 10),
defaultSignupKeyRateLimit: parseInt(process.env.DEFAULT_SIGNUP_KEY_RATE_LIMIT ?? "30", 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", ssrfEnabled: process.env.SSRF_DISABLE !== "true",
ssrfAllowLoopback: process.env.SSRF_ALLOW_LOOPBACK === "true", ssrfAllowLoopback: process.env.SSRF_ALLOW_LOOPBACK === "true",
}; };
+53
View File
@@ -0,0 +1,53 @@
export type SendSignupEmailInput = {
apiKey: string;
from: string;
to: string;
verifyUrl: string;
expiresAt: Date;
};
export async function sendSignupEmail(input: SendSignupEmailInput): Promise<void> {
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 = `
<div style="font-family:ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;line-height:1.55;color:#0e2a3d">
<h1 style="font-size:22px;margin:0 0 12px">Your ditto.site API key is almost ready.</h1>
<p>Open the link below to verify your email and reveal your key.</p>
<p><a href="${input.verifyUrl}" style="display:inline-block;background:#14b3ad;color:#06251f;padding:12px 16px;text-decoration:none;font-weight:700;border:2px solid #0f2c40">Verify and get key</a></p>
<p style="color:#3c5b6a;font-size:14px">This link expires at ${expires} UTC.</p>
<p style="color:#3c5b6a;font-size:14px">If you did not request this, you can ignore this email.</p>
</div>
`;
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)}`);
}
}
+25
View File
@@ -10,6 +10,7 @@ import type { Backend } from "./backend.js";
import { hashApiKey, type AuthConfig } from "./auth.js"; import { hashApiKey, type AuthConfig } from "./auth.js";
import { assertPublicUrl } from "./ssrf.js"; import { assertPublicUrl } from "./ssrf.js";
import { loadEnv, type ApiEnv } from "./env.js"; import { loadEnv, type ApiEnv } from "./env.js";
import { sendSignupEmail } from "./resend.js";
function buildAuth(env: ApiEnv, db?: Db): AuthConfig | undefined { function buildAuth(env: ApiEnv, db?: Db): AuthConfig | undefined {
const keyHashes = new Set(env.apiKeys.map(hashApiKey)); const keyHashes = new Set(env.apiKeys.map(hashApiKey));
@@ -44,11 +45,32 @@ async function main(): Promise<void> {
} }
const auth = buildAuth(env, db); 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({ const app = createApp({
backend, backend,
baseUrl: env.publicBaseUrl, baseUrl: env.publicBaseUrl,
auth, auth,
rateLimitPerMinute: env.rateLimitPerMinute, rateLimitPerMinute: env.rateLimitPerMinute,
signupCorsOrigins: env.signupCorsOrigins,
signup: signup:
env.signupEnabled && db env.signupEnabled && db
? { ? {
@@ -57,6 +79,8 @@ async function main(): Promise<void> {
}, },
defaultRateLimit: env.defaultSignupKeyRateLimit, defaultRateLimit: env.defaultSignupKeyRateLimit,
rateLimitPerHour: env.signupRateLimitPerHour, rateLimitPerHour: env.signupRateLimitPerHour,
directEnabled: env.signupDirectEnabled,
email: emailSignup,
} }
: undefined, : undefined,
assertUrl: env.ssrfEnabled ? async (url) => void (await assertPublicUrl(url, { allowLoopback: env.ssrfAllowLoopback })) : undefined, assertUrl: env.ssrfEnabled ? async (url) => void (await assertPublicUrl(url, { allowLoopback: env.ssrfAllowLoopback })) : undefined,
@@ -69,6 +93,7 @@ async function main(): Promise<void> {
port: info.port, port: info.port,
auth: !!auth, auth: !!auth,
signup: env.signupEnabled && !!db, signup: env.signupEnabled && !!db,
emailSignup: !!emailSignup,
rateLimitPerMinute: env.rateLimitPerMinute || null, rateLimitPerMinute: env.rateLimitPerMinute || null,
ssrf: env.ssrfEnabled, ssrf: env.ssrfEnabled,
}), }),
+106
View File
@@ -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); 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<string, string>();
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 () => { test("rate limit: 429 once the per-minute cap is exceeded", async () => {
const app = appWith({ rateLimitPerMinute: 2 }); const app = appWith({ rateLimitPerMinute: 2 });
const headers = { "x-forwarded-for": "9.9.9.9" }; const headers = { "x-forwarded-for": "9.9.9.9" };
@@ -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")
);
@@ -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": {}
}
}
@@ -8,6 +8,13 @@
"when": 1782170309013, "when": 1782170309013,
"tag": "0000_plain_wither", "tag": "0000_plain_wither",
"breakpoints": true "breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1782790200000,
"tag": "0001_signup_tokens",
"breakpoints": true
} }
] ]
} }
+2 -2
View File
@@ -1,7 +1,7 @@
export * as schema from "./schema.js"; export * as schema from "./schema.js";
export { export {
jobs, clones, cache, apiKeys, jobs, clones, cache, apiKeys, signupTokens,
type Job, type NewJob, type Clone, type NewClone, type CacheRow, type ApiKey, type Job, type NewJob, type Clone, type NewClone, type CacheRow, type ApiKey, type SignupToken, type NewSignupToken,
} from "./schema.js"; } from "./schema.js";
export { createDb, type Db, type DbHandle } from "./client.js"; export { createDb, type Db, type DbHandle } from "./client.js";
export * as repo from "./repo.js"; export * as repo from "./repo.js";
+20 -2
View File
@@ -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 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 ---- // ---- 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(); const [row] = await db.insert(apiKeys).values(input).returning();
return row!; return row!;
} }
// ---- signup tokens ----
export async function createSignupToken(db: Db, input: { email: string; tokenHash: string; expiresAt: Date }): Promise<SignupToken> {
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<SignupToken | undefined> {
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;
}
+12
View File
@@ -56,9 +56,21 @@ export const apiKeys = pgTable("api_keys", {
revokedAt: timestamp("revoked_at", { withTimezone: true }), 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 Job = typeof jobs.$inferSelect;
export type NewJob = typeof jobs.$inferInsert; export type NewJob = typeof jobs.$inferInsert;
export type Clone = typeof clones.$inferSelect; export type Clone = typeof clones.$inferSelect;
export type NewClone = typeof clones.$inferInsert; export type NewClone = typeof clones.$inferInsert;
export type CacheRow = typeof cache.$inferSelect; export type CacheRow = typeof cache.$inferSelect;
export type ApiKey = typeof apiKeys.$inferSelect; export type ApiKey = typeof apiKeys.$inferSelect;
export type SignupToken = typeof signupTokens.$inferSelect;
export type NewSignupToken = typeof signupTokens.$inferInsert;