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
# 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 <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_URL=postgresql://postgres:postgres@localhost:5432/ditto_site
+19 -6
View File
@@ -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
+6
View File
@@ -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 <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.
```
+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/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 <key>` or `x-api-key: <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 <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_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 |
+101 -14
View File
@@ -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<void>;
defaultRateLimit?: 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 = {
@@ -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<void>;
};
@@ -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);
};
};
+18
View File
@@ -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 <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). */
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",
};
+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 { 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<void> {
}
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<void> {
},
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<void> {
port: info.port,
auth: !!auth,
signup: env.signupEnabled && !!db,
emailSignup: !!emailSignup,
rateLimitPerMinute: env.rateLimitPerMinute || null,
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);
});
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 () => {
const app = appWith({ rateLimitPerMinute: 2 });
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,
"tag": "0000_plain_wither",
"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 {
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";
+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 { 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<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 }),
});
/** 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;