Add public API key signup
This commit is contained in:
+59
-6
@@ -1,4 +1,5 @@
|
||||
import { Hono } from "hono";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { Hono, type Context, type MiddlewareHandler } from "hono";
|
||||
import { z } from "zod";
|
||||
import { RESPONSE_ALREADY_SENT } from "@hono/node-server/utils/response";
|
||||
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
||||
@@ -6,7 +7,7 @@ import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import { normalizeCloneRequestOptions } from "@cloner/core";
|
||||
import type { Backend } from "./backend.js";
|
||||
import { createMcpServer } from "./mcp.js";
|
||||
import { apiKeyAuth, rateLimit, type AuthConfig } from "./auth.js";
|
||||
import { apiKeyAuth, hashApiKey, rateLimit, type AuthConfig } from "./auth.js";
|
||||
|
||||
const OptionsSchema = z
|
||||
.object({
|
||||
@@ -37,6 +38,19 @@ const CloneRequest = z.object({
|
||||
options: OptionsSchema.optional(),
|
||||
});
|
||||
|
||||
const SignupRequest = z
|
||||
.object({
|
||||
email: z.string().email().max(320).transform((s) => s.trim().toLowerCase()),
|
||||
label: z.string().trim().min(1).max(120).optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export type SignupDeps = {
|
||||
createApiKey: (input: { keyHash: string; label: string; rateLimit?: number }) => Promise<void>;
|
||||
defaultRateLimit?: number;
|
||||
rateLimitPerHour?: number;
|
||||
};
|
||||
|
||||
export type AppDeps = {
|
||||
backend: Backend;
|
||||
/** absolute base URL used in MCP-returned references (binary/bundle URLs). */
|
||||
@@ -47,6 +61,8 @@ export type AppDeps = {
|
||||
auth?: AuthConfig;
|
||||
/** per-window request cap on /v1/* and /mcp (omit = unlimited). */
|
||||
rateLimitPerMinute?: number;
|
||||
/** public key minting endpoint at POST /v1/signup (omit = disabled). */
|
||||
signup?: SignupDeps;
|
||||
/** SSRF guard run on submit (omit = no check — set in production). Throws to reject. */
|
||||
assertUrl?: (url: string) => Promise<void>;
|
||||
};
|
||||
@@ -60,16 +76,53 @@ export function createApp(deps: AppDeps): Hono {
|
||||
|
||||
app.get("/healthz", (c) => c.json({ ok: true }));
|
||||
|
||||
// Protect the API + MCP surfaces (not /healthz). Auth before rate-limit so the
|
||||
// limiter can key by API key.
|
||||
if (deps.signup) {
|
||||
const signupRateLimit = deps.signup.rateLimitPerHour ?? 3;
|
||||
const signupHandler = 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,
|
||||
});
|
||||
return c.json(
|
||||
{
|
||||
apiKey,
|
||||
message: "Save this key now; it will not be shown again.",
|
||||
},
|
||||
201,
|
||||
);
|
||||
};
|
||||
if (signupRateLimit > 0) {
|
||||
app.post("/v1/signup", rateLimit({ perMinute: signupRateLimit, windowMs: 60 * 60 * 1000 }), signupHandler);
|
||||
} else {
|
||||
app.post("/v1/signup", signupHandler);
|
||||
}
|
||||
}
|
||||
|
||||
const skipSignup = (mw: MiddlewareHandler): MiddlewareHandler => {
|
||||
return async (c, next) => {
|
||||
if (c.req.path === "/v1/signup") return next();
|
||||
return mw(c, next);
|
||||
};
|
||||
};
|
||||
|
||||
// Protect the clone API + MCP surfaces (not /healthz or /v1/signup). Auth
|
||||
// before rate-limit so the limiter can key by API key.
|
||||
if (deps.auth) {
|
||||
const mw = apiKeyAuth(deps.auth);
|
||||
app.use("/v1/*", mw);
|
||||
app.use("/v1/*", skipSignup(mw));
|
||||
app.use("/mcp", mw);
|
||||
}
|
||||
if (deps.rateLimitPerMinute && deps.rateLimitPerMinute > 0) {
|
||||
const mw = rateLimit({ perMinute: deps.rateLimitPerMinute });
|
||||
app.use("/v1/*", mw);
|
||||
app.use("/v1/*", skipSignup(mw));
|
||||
app.use("/mcp", mw);
|
||||
}
|
||||
|
||||
|
||||
@@ -35,6 +35,8 @@ export function apiKeyAuth(cfg: AuthConfig): MiddlewareHandler {
|
||||
|
||||
export type RateLimitConfig = {
|
||||
perMinute: number;
|
||||
/** window length in milliseconds (default: one minute). */
|
||||
windowMs?: number;
|
||||
/** key the limit by (default: API-key hash, else client IP). */
|
||||
keyFn?: (c: Parameters<MiddlewareHandler>[0]) => string;
|
||||
};
|
||||
@@ -43,7 +45,7 @@ export type RateLimitConfig = {
|
||||
* shared store (Redis/PG) would be needed across replicas. Keyed by API key when
|
||||
* present, else client IP. */
|
||||
export function rateLimit(cfg: RateLimitConfig): MiddlewareHandler {
|
||||
const windowMs = 60_000;
|
||||
const windowMs = cfg.windowMs ?? 60_000;
|
||||
const buckets = new Map<string, { count: number; reset: number }>();
|
||||
const keyFn =
|
||||
cfg.keyFn ??
|
||||
|
||||
@@ -18,6 +18,12 @@ export type ApiEnv = {
|
||||
apiKeys: string[];
|
||||
/** per-minute request cap on /v1/* and /mcp (0 = unlimited). */
|
||||
rateLimitPerMinute: number;
|
||||
/** allow public API-key minting at POST /v1/signup. Requires DATABASE_URL. */
|
||||
signupEnabled: boolean;
|
||||
/** requests per hour per IP for POST /v1/signup (0 = unlimited). */
|
||||
signupRateLimitPerHour: number;
|
||||
/** per-key requests/minute stored on keys minted through signup. */
|
||||
defaultSignupKeyRateLimit: number;
|
||||
/** SSRF guard (default on). */
|
||||
ssrfEnabled: boolean;
|
||||
/** allow loopback targets through SSRF (local dev cloning of localhost). */
|
||||
@@ -34,6 +40,9 @@ export function loadEnv(): ApiEnv {
|
||||
publicBaseUrl: process.env.PUBLIC_BASE_URL,
|
||||
apiKeys: (process.env.API_KEYS ?? "").split(",").map((s) => s.trim()).filter(Boolean),
|
||||
rateLimitPerMinute: parseInt(process.env.RATE_LIMIT_PER_MINUTE ?? "0", 10),
|
||||
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),
|
||||
ssrfEnabled: process.env.SSRF_DISABLE !== "true",
|
||||
ssrfAllowLoopback: process.env.SSRF_ALLOW_LOOPBACK === "true",
|
||||
};
|
||||
|
||||
@@ -49,6 +49,16 @@ async function main(): Promise<void> {
|
||||
baseUrl: env.publicBaseUrl,
|
||||
auth,
|
||||
rateLimitPerMinute: env.rateLimitPerMinute,
|
||||
signup:
|
||||
env.signupEnabled && db
|
||||
? {
|
||||
createApiKey: async (input) => {
|
||||
await repo.createApiKey(db!, input);
|
||||
},
|
||||
defaultRateLimit: env.defaultSignupKeyRateLimit,
|
||||
rateLimitPerHour: env.signupRateLimitPerHour,
|
||||
}
|
||||
: undefined,
|
||||
assertUrl: env.ssrfEnabled ? async (url) => void (await assertPublicUrl(url, { allowLoopback: env.ssrfAllowLoopback })) : undefined,
|
||||
});
|
||||
|
||||
@@ -58,6 +68,7 @@ async function main(): Promise<void> {
|
||||
event: "api_listening",
|
||||
port: info.port,
|
||||
auth: !!auth,
|
||||
signup: env.signupEnabled && !!db,
|
||||
rateLimitPerMinute: env.rateLimitPerMinute || null,
|
||||
ssrf: env.ssrfEnabled,
|
||||
}),
|
||||
|
||||
Reference in New Issue
Block a user