Add verified email API key signup
This commit is contained in:
+7
-1
@@ -5,9 +5,15 @@ PORT=8787
|
|||||||
# CLONE_TTL_MS=1800000 # in-memory mode: how long results are retained
|
# CLONE_TTL_MS=1800000 # in-memory mode: how long results are retained
|
||||||
# API_KEYS= # comma-separated keys that can call /v1/clones and /mcp
|
# 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
|
# 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
|
# SIGNUP_RATE_LIMIT_PER_HOUR=3
|
||||||
# DEFAULT_SIGNUP_KEY_RATE_LIMIT=30 # stored on minted keys; current limiter is service-wide
|
# 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 + queue (Postgres; pg-boss uses the same DB) ----
|
||||||
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/ditto_site
|
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/ditto_site
|
||||||
|
|||||||
@@ -17,10 +17,20 @@ frozen capture in, byte-stable app out.
|
|||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
The hosted URLs below are placeholders until the public service is live:
|
|
||||||
|
|
||||||
- REST API: `https://api.ditto.site`
|
- 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
|
### REST API
|
||||||
|
|
||||||
@@ -92,7 +102,7 @@ Connect an MCP client to the hosted Streamable HTTP endpoint:
|
|||||||
{
|
{
|
||||||
"mcpServers": {
|
"mcpServers": {
|
||||||
"ditto": {
|
"ditto": {
|
||||||
"url": "https://mcp.ditto.site/mcp",
|
"url": "https://api.ditto.site/mcp",
|
||||||
"headers": {
|
"headers": {
|
||||||
"Authorization": "Bearer ${DITTO_API_KEY}"
|
"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).
|
deployment, see [docs/DEPLOY.md](docs/DEPLOY.md).
|
||||||
|
|
||||||
Hosted deployments should keep `/v1/clones*` and `/mcp` behind API-key auth.
|
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
|
When `SIGNUP_ENABLED=true` in DB mode, the Resend-backed
|
||||||
`dtto_live_...` keys from an email address while storing only key hashes.
|
`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
|
## Repository Map
|
||||||
|
|
||||||
|
|||||||
@@ -53,6 +53,12 @@ RATE_LIMIT_PER_MINUTE=60
|
|||||||
SIGNUP_ENABLED=true # optional: public API-key minting
|
SIGNUP_ENABLED=true # optional: public API-key minting
|
||||||
SIGNUP_RATE_LIMIT_PER_HOUR=3
|
SIGNUP_RATE_LIMIT_PER_HOUR=3
|
||||||
DEFAULT_SIGNUP_KEY_RATE_LIMIT=30
|
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.
|
# SSRF is on by default; do NOT set SSRF_ALLOW_LOOPBACK in prod.
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
+17
-6
@@ -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/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 → list (metadata)
|
||||||
GET /v1/clones/:id → status + metadata (fileCount, totalBytes, capture, timings)
|
GET /v1/clones/:id → status + metadata (fileCount, totalBytes, capture, timings)
|
||||||
GET /v1/clones/:id/result → the eager CloneResult (text files inline; binaries by URL)
|
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
|
`/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>`.
|
keys exist. Use `Authorization: Bearer <key>` or `x-api-key: <key>`.
|
||||||
`/v1/signup` is intentionally public only when `SIGNUP_ENABLED=true` **and**
|
Signup routes are intentionally public only when `SIGNUP_ENABLED=true` **and**
|
||||||
`DATABASE_URL` is set. It mints a `dtto_live_...` key, stores only its SHA-256
|
`DATABASE_URL` is set. Direct `POST /v1/signup` mints a `dtto_live_...` key
|
||||||
hash in Postgres, stores the submitted email in the key label for attribution,
|
immediately when `SIGNUP_DIRECT_ENABLED=true`. For public production signup,
|
||||||
and returns the raw key once.
|
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" }`.
|
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
|
`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 |
|
| `PUBLIC_BASE_URL` | api | — | absolute base for MCP-returned URLs |
|
||||||
| `API_KEYS` | api | — | comma-separated keys; empty = open |
|
| `API_KEYS` | api | — | comma-separated keys; empty = open |
|
||||||
| `RATE_LIMIT_PER_MINUTE` | api | `0` | per key/IP cap (0 = unlimited) |
|
| `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 |
|
| `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` |
|
| `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_DISABLE` | api | `false` | turn off the SSRF guard (not recommended) |
|
||||||
| `SSRF_ALLOW_LOOPBACK` | api | `false` | allow cloning localhost (local dev) |
|
| `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 |
|
| `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
@@ -1,5 +1,6 @@
|
|||||||
import { randomBytes } from "node:crypto";
|
import { randomBytes } from "node:crypto";
|
||||||
import { Hono, type Context, type MiddlewareHandler } from "hono";
|
import { Hono, type Context, type MiddlewareHandler } from "hono";
|
||||||
|
import { cors } from "hono/cors";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { RESPONSE_ALREADY_SENT } from "@hono/node-server/utils/response";
|
import { RESPONSE_ALREADY_SENT } from "@hono/node-server/utils/response";
|
||||||
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
||||||
@@ -45,10 +46,24 @@ const SignupRequest = z
|
|||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
|
|
||||||
|
const SignupVerifyRequest = z
|
||||||
|
.object({
|
||||||
|
token: z.string().min(24).max(256),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
export type SignupDeps = {
|
export type SignupDeps = {
|
||||||
createApiKey: (input: { keyHash: string; label: string; rateLimit?: number }) => Promise<void>;
|
createApiKey: (input: { keyHash: string; label: string; rateLimit?: number }) => Promise<void>;
|
||||||
defaultRateLimit?: number;
|
defaultRateLimit?: number;
|
||||||
rateLimitPerHour?: 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 = {
|
export type AppDeps = {
|
||||||
@@ -63,6 +78,8 @@ export type AppDeps = {
|
|||||||
rateLimitPerMinute?: number;
|
rateLimitPerMinute?: number;
|
||||||
/** public key minting endpoint at POST /v1/signup (omit = disabled). */
|
/** public key minting endpoint at POST /v1/signup (omit = disabled). */
|
||||||
signup?: SignupDeps;
|
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. */
|
/** SSRF guard run on submit (omit = no check — set in production). Throws to reject. */
|
||||||
assertUrl?: (url: string) => Promise<void>;
|
assertUrl?: (url: string) => Promise<void>;
|
||||||
};
|
};
|
||||||
@@ -73,24 +90,44 @@ export type AppDeps = {
|
|||||||
export function createApp(deps: AppDeps): Hono {
|
export function createApp(deps: AppDeps): Hono {
|
||||||
const { backend } = deps;
|
const { backend } = deps;
|
||||||
const app = new Hono();
|
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 }));
|
app.get("/healthz", (c) => c.json({ ok: true }));
|
||||||
|
|
||||||
if (deps.signup) {
|
if (deps.signup) {
|
||||||
const signupRateLimit = deps.signup.rateLimitPerHour ?? 3;
|
const signup = deps.signup;
|
||||||
const signupHandler = async (c: Context) => {
|
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 body = await c.req.json().catch(() => null);
|
||||||
const parsed = SignupRequest.safeParse(body);
|
const parsed = SignupRequest.safeParse(body);
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
return c.json({ error: "invalid request", details: parsed.error.flatten() }, 400);
|
return c.json({ error: "invalid request", details: parsed.error.flatten() }, 400);
|
||||||
}
|
}
|
||||||
const apiKey = `dtto_live_${randomBytes(32).toString("base64url")}`;
|
const apiKey = await mintKey(parsed.data.email, parsed.data.label);
|
||||||
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,
|
|
||||||
});
|
|
||||||
return c.json(
|
return c.json(
|
||||||
{
|
{
|
||||||
apiKey,
|
apiKey,
|
||||||
@@ -99,16 +136,66 @@ export function createApp(deps: AppDeps): Hono {
|
|||||||
201,
|
201,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
if (signupRateLimit > 0) {
|
|
||||||
app.post("/v1/signup", rateLimit({ perMinute: signupRateLimit, windowMs: 60 * 60 * 1000 }), signupHandler);
|
if (signup.directEnabled !== false) {
|
||||||
} else {
|
if (signupRateLimit > 0) app.post("/v1/signup", signupLimiter, directSignupHandler);
|
||||||
app.post("/v1/signup", signupHandler);
|
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 => {
|
const skipSignup = (mw: MiddlewareHandler): MiddlewareHandler => {
|
||||||
return async (c, next) => {
|
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);
|
return mw(c, next);
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -24,6 +24,18 @@ export type ApiEnv = {
|
|||||||
signupRateLimitPerHour: number;
|
signupRateLimitPerHour: number;
|
||||||
/** per-key requests/minute stored on keys minted through signup. */
|
/** per-key requests/minute stored on keys minted through signup. */
|
||||||
defaultSignupKeyRateLimit: number;
|
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). */
|
/** SSRF guard (default on). */
|
||||||
ssrfEnabled: boolean;
|
ssrfEnabled: boolean;
|
||||||
/** allow loopback targets through SSRF (local dev cloning of localhost). */
|
/** allow loopback targets through SSRF (local dev cloning of localhost). */
|
||||||
@@ -43,6 +55,12 @@ export function loadEnv(): ApiEnv {
|
|||||||
signupEnabled: process.env.SIGNUP_ENABLED === "true",
|
signupEnabled: process.env.SIGNUP_ENABLED === "true",
|
||||||
signupRateLimitPerHour: parseInt(process.env.SIGNUP_RATE_LIMIT_PER_HOUR ?? "3", 10),
|
signupRateLimitPerHour: parseInt(process.env.SIGNUP_RATE_LIMIT_PER_HOUR ?? "3", 10),
|
||||||
defaultSignupKeyRateLimit: parseInt(process.env.DEFAULT_SIGNUP_KEY_RATE_LIMIT ?? "30", 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",
|
ssrfEnabled: process.env.SSRF_DISABLE !== "true",
|
||||||
ssrfAllowLoopback: process.env.SSRF_ALLOW_LOOPBACK === "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 { hashApiKey, type AuthConfig } from "./auth.js";
|
||||||
import { assertPublicUrl } from "./ssrf.js";
|
import { assertPublicUrl } from "./ssrf.js";
|
||||||
import { loadEnv, type ApiEnv } from "./env.js";
|
import { loadEnv, type ApiEnv } from "./env.js";
|
||||||
|
import { sendSignupEmail } from "./resend.js";
|
||||||
|
|
||||||
function buildAuth(env: ApiEnv, db?: Db): AuthConfig | undefined {
|
function buildAuth(env: ApiEnv, db?: Db): AuthConfig | undefined {
|
||||||
const keyHashes = new Set(env.apiKeys.map(hashApiKey));
|
const keyHashes = new Set(env.apiKeys.map(hashApiKey));
|
||||||
@@ -44,11 +45,32 @@ async function main(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const auth = buildAuth(env, db);
|
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({
|
const app = createApp({
|
||||||
backend,
|
backend,
|
||||||
baseUrl: env.publicBaseUrl,
|
baseUrl: env.publicBaseUrl,
|
||||||
auth,
|
auth,
|
||||||
rateLimitPerMinute: env.rateLimitPerMinute,
|
rateLimitPerMinute: env.rateLimitPerMinute,
|
||||||
|
signupCorsOrigins: env.signupCorsOrigins,
|
||||||
signup:
|
signup:
|
||||||
env.signupEnabled && db
|
env.signupEnabled && db
|
||||||
? {
|
? {
|
||||||
@@ -57,6 +79,8 @@ async function main(): Promise<void> {
|
|||||||
},
|
},
|
||||||
defaultRateLimit: env.defaultSignupKeyRateLimit,
|
defaultRateLimit: env.defaultSignupKeyRateLimit,
|
||||||
rateLimitPerHour: env.signupRateLimitPerHour,
|
rateLimitPerHour: env.signupRateLimitPerHour,
|
||||||
|
directEnabled: env.signupDirectEnabled,
|
||||||
|
email: emailSignup,
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
assertUrl: env.ssrfEnabled ? async (url) => void (await assertPublicUrl(url, { allowLoopback: env.ssrfAllowLoopback })) : 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,
|
port: info.port,
|
||||||
auth: !!auth,
|
auth: !!auth,
|
||||||
signup: env.signupEnabled && !!db,
|
signup: env.signupEnabled && !!db,
|
||||||
|
emailSignup: !!emailSignup,
|
||||||
rateLimitPerMinute: env.rateLimitPerMinute || null,
|
rateLimitPerMinute: env.rateLimitPerMinute || null,
|
||||||
ssrf: env.ssrfEnabled,
|
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);
|
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 () => {
|
test("rate limit: 429 once the per-minute cap is exceeded", async () => {
|
||||||
const app = appWith({ rateLimitPerMinute: 2 });
|
const app = appWith({ rateLimitPerMinute: 2 });
|
||||||
const headers = { "x-forwarded-for": "9.9.9.9" };
|
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,
|
"when": 1782170309013,
|
||||||
"tag": "0000_plain_wither",
|
"tag": "0000_plain_wither",
|
||||||
"breakpoints": true
|
"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 * as schema from "./schema.js";
|
||||||
export {
|
export {
|
||||||
jobs, clones, cache, apiKeys,
|
jobs, clones, cache, apiKeys, signupTokens,
|
||||||
type Job, type NewJob, type Clone, type NewClone, type CacheRow, type ApiKey,
|
type Job, type NewJob, type Clone, type NewClone, type CacheRow, type ApiKey, type SignupToken, type NewSignupToken,
|
||||||
} from "./schema.js";
|
} from "./schema.js";
|
||||||
export { createDb, type Db, type DbHandle } from "./client.js";
|
export { createDb, type Db, type DbHandle } from "./client.js";
|
||||||
export * as repo from "./repo.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 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 ----
|
// ---- 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();
|
const [row] = await db.insert(apiKeys).values(input).returning();
|
||||||
return row!;
|
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 }),
|
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 Job = typeof jobs.$inferSelect;
|
||||||
export type NewJob = typeof jobs.$inferInsert;
|
export type NewJob = typeof jobs.$inferInsert;
|
||||||
export type Clone = typeof clones.$inferSelect;
|
export type Clone = typeof clones.$inferSelect;
|
||||||
export type NewClone = typeof clones.$inferInsert;
|
export type NewClone = typeof clones.$inferInsert;
|
||||||
export type CacheRow = typeof cache.$inferSelect;
|
export type CacheRow = typeof cache.$inferSelect;
|
||||||
export type ApiKey = typeof apiKeys.$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