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" };
@@ -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 -1
View File
@@ -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;