Add verified email API key signup
This commit is contained in:
+101
-14
@@ -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);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
|
||||
@@ -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)}`);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}),
|
||||
|
||||
Reference in New Issue
Block a user