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,
|
||||
}),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user