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
+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" };