Add public API key signup
This commit is contained in:
@@ -3,6 +3,11 @@ PORT=8787
|
|||||||
# When DATABASE_URL is set the API runs in async (queue) mode; otherwise it runs
|
# When DATABASE_URL is set the API runs in async (queue) mode; otherwise it runs
|
||||||
# clones inline in-memory (handy for a quick local single-page demo).
|
# clones inline in-memory (handy for a quick local single-page demo).
|
||||||
# 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
|
||||||
|
# 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_RATE_LIMIT_PER_HOUR=3
|
||||||
|
# DEFAULT_SIGNUP_KEY_RATE_LIMIT=30 # stored on minted keys; current limiter is service-wide
|
||||||
|
|
||||||
# ---- 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
|
||||||
|
|||||||
@@ -214,6 +214,10 @@ arbitrary third-party JavaScript are not replayed.
|
|||||||
For the detailed service API, see [docs/SERVICE.md](docs/SERVICE.md). For
|
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.
|
||||||
|
When `SIGNUP_ENABLED=true` in DB mode, `POST /v1/signup` can publicly mint
|
||||||
|
`dtto_live_...` keys from an email address while storing only key hashes.
|
||||||
|
|
||||||
## Repository Map
|
## Repository Map
|
||||||
|
|
||||||
| Path | Purpose |
|
| Path | Purpose |
|
||||||
|
|||||||
@@ -50,6 +50,9 @@ PORT=8787
|
|||||||
PUBLIC_BASE_URL=https://api.yourdomain.com # so MCP-returned URLs are absolute
|
PUBLIC_BASE_URL=https://api.yourdomain.com # so MCP-returned URLs are absolute
|
||||||
API_KEYS=key_live_xxx,key_live_yyy # require auth
|
API_KEYS=key_live_xxx,key_live_yyy # require auth
|
||||||
RATE_LIMIT_PER_MINUTE=60
|
RATE_LIMIT_PER_MINUTE=60
|
||||||
|
SIGNUP_ENABLED=true # optional: public API-key minting
|
||||||
|
SIGNUP_RATE_LIMIT_PER_HOUR=3
|
||||||
|
DEFAULT_SIGNUP_KEY_RATE_LIMIT=30
|
||||||
# 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.
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ 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)
|
||||||
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)
|
||||||
@@ -73,6 +74,13 @@ DELETE /v1/clones/:id → purge artifacts
|
|||||||
GET /healthz → { ok: true } (unauthenticated)
|
GET /healthz → { ok: true } (unauthenticated)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`/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>`.
|
||||||
|
`/v1/signup` is intentionally public only when `SIGNUP_ENABLED=true` **and**
|
||||||
|
`DATABASE_URL` is set. It mints a `dtto_live_...` key, stores only its SHA-256
|
||||||
|
hash in Postgres, stores the submitted 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
|
||||||
remain `{ verify?, asyncVerify?, maxRoutes?, maxCollection?, captureConcurrency?, validationConcurrency?, viewportConcurrency?, noCache? }`; `noCache` is service-level and
|
remain `{ verify?, asyncVerify?, maxRoutes?, maxCollection?, captureConcurrency?, validationConcurrency?, viewportConcurrency?, noCache? }`; `noCache` is service-level and
|
||||||
@@ -124,6 +132,9 @@ 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_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` |
|
||||||
| `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 |
|
||||||
|
|||||||
+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 { 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";
|
||||||
@@ -6,7 +7,7 @@ import type { IncomingMessage, ServerResponse } from "node:http";
|
|||||||
import { normalizeCloneRequestOptions } from "@cloner/core";
|
import { normalizeCloneRequestOptions } from "@cloner/core";
|
||||||
import type { Backend } from "./backend.js";
|
import type { Backend } from "./backend.js";
|
||||||
import { createMcpServer } from "./mcp.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
|
const OptionsSchema = z
|
||||||
.object({
|
.object({
|
||||||
@@ -37,6 +38,19 @@ const CloneRequest = z.object({
|
|||||||
options: OptionsSchema.optional(),
|
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 = {
|
export type AppDeps = {
|
||||||
backend: Backend;
|
backend: Backend;
|
||||||
/** absolute base URL used in MCP-returned references (binary/bundle URLs). */
|
/** absolute base URL used in MCP-returned references (binary/bundle URLs). */
|
||||||
@@ -47,6 +61,8 @@ export type AppDeps = {
|
|||||||
auth?: AuthConfig;
|
auth?: AuthConfig;
|
||||||
/** per-window request cap on /v1/* and /mcp (omit = unlimited). */
|
/** per-window request cap on /v1/* and /mcp (omit = unlimited). */
|
||||||
rateLimitPerMinute?: number;
|
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. */
|
/** SSRF guard run on submit (omit = no check — set in production). Throws to reject. */
|
||||||
assertUrl?: (url: string) => Promise<void>;
|
assertUrl?: (url: string) => Promise<void>;
|
||||||
};
|
};
|
||||||
@@ -60,16 +76,53 @@ export function createApp(deps: AppDeps): Hono {
|
|||||||
|
|
||||||
app.get("/healthz", (c) => c.json({ ok: true }));
|
app.get("/healthz", (c) => c.json({ ok: true }));
|
||||||
|
|
||||||
// Protect the API + MCP surfaces (not /healthz). Auth before rate-limit so the
|
if (deps.signup) {
|
||||||
// limiter can key by API key.
|
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) {
|
if (deps.auth) {
|
||||||
const mw = apiKeyAuth(deps.auth);
|
const mw = apiKeyAuth(deps.auth);
|
||||||
app.use("/v1/*", mw);
|
app.use("/v1/*", skipSignup(mw));
|
||||||
app.use("/mcp", mw);
|
app.use("/mcp", mw);
|
||||||
}
|
}
|
||||||
if (deps.rateLimitPerMinute && deps.rateLimitPerMinute > 0) {
|
if (deps.rateLimitPerMinute && deps.rateLimitPerMinute > 0) {
|
||||||
const mw = rateLimit({ perMinute: deps.rateLimitPerMinute });
|
const mw = rateLimit({ perMinute: deps.rateLimitPerMinute });
|
||||||
app.use("/v1/*", mw);
|
app.use("/v1/*", skipSignup(mw));
|
||||||
app.use("/mcp", mw);
|
app.use("/mcp", mw);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ export function apiKeyAuth(cfg: AuthConfig): MiddlewareHandler {
|
|||||||
|
|
||||||
export type RateLimitConfig = {
|
export type RateLimitConfig = {
|
||||||
perMinute: number;
|
perMinute: number;
|
||||||
|
/** window length in milliseconds (default: one minute). */
|
||||||
|
windowMs?: number;
|
||||||
/** key the limit by (default: API-key hash, else client IP). */
|
/** key the limit by (default: API-key hash, else client IP). */
|
||||||
keyFn?: (c: Parameters<MiddlewareHandler>[0]) => string;
|
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
|
* shared store (Redis/PG) would be needed across replicas. Keyed by API key when
|
||||||
* present, else client IP. */
|
* present, else client IP. */
|
||||||
export function rateLimit(cfg: RateLimitConfig): MiddlewareHandler {
|
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 buckets = new Map<string, { count: number; reset: number }>();
|
||||||
const keyFn =
|
const keyFn =
|
||||||
cfg.keyFn ??
|
cfg.keyFn ??
|
||||||
|
|||||||
@@ -18,6 +18,12 @@ export type ApiEnv = {
|
|||||||
apiKeys: string[];
|
apiKeys: string[];
|
||||||
/** per-minute request cap on /v1/* and /mcp (0 = unlimited). */
|
/** per-minute request cap on /v1/* and /mcp (0 = unlimited). */
|
||||||
rateLimitPerMinute: number;
|
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). */
|
/** 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). */
|
||||||
@@ -34,6 +40,9 @@ export function loadEnv(): ApiEnv {
|
|||||||
publicBaseUrl: process.env.PUBLIC_BASE_URL,
|
publicBaseUrl: process.env.PUBLIC_BASE_URL,
|
||||||
apiKeys: (process.env.API_KEYS ?? "").split(",").map((s) => s.trim()).filter(Boolean),
|
apiKeys: (process.env.API_KEYS ?? "").split(",").map((s) => s.trim()).filter(Boolean),
|
||||||
rateLimitPerMinute: parseInt(process.env.RATE_LIMIT_PER_MINUTE ?? "0", 10),
|
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",
|
ssrfEnabled: process.env.SSRF_DISABLE !== "true",
|
||||||
ssrfAllowLoopback: process.env.SSRF_ALLOW_LOOPBACK === "true",
|
ssrfAllowLoopback: process.env.SSRF_ALLOW_LOOPBACK === "true",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -49,6 +49,16 @@ async function main(): Promise<void> {
|
|||||||
baseUrl: env.publicBaseUrl,
|
baseUrl: env.publicBaseUrl,
|
||||||
auth,
|
auth,
|
||||||
rateLimitPerMinute: env.rateLimitPerMinute,
|
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,
|
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",
|
event: "api_listening",
|
||||||
port: info.port,
|
port: info.port,
|
||||||
auth: !!auth,
|
auth: !!auth,
|
||||||
|
signup: env.signupEnabled && !!db,
|
||||||
rateLimitPerMinute: env.rateLimitPerMinute || null,
|
rateLimitPerMinute: env.rateLimitPerMinute || null,
|
||||||
ssrf: env.ssrfEnabled,
|
ssrf: env.ssrfEnabled,
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -22,6 +22,56 @@ test("auth: 401 without key / with wrong key, 200 with a valid key; /healthz sta
|
|||||||
assert.equal((await app.request("/healthz")).status, 200, "health check is unauthenticated");
|
assert.equal((await app.request("/healthz")).status, 200, "health check is unauthenticated");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("signup: public endpoint mints a stored-hash API key that can access protected routes", async () => {
|
||||||
|
const created: { keyHash: string; label: string; rateLimit?: number }[] = [];
|
||||||
|
const app = appWith({
|
||||||
|
auth: {
|
||||||
|
keyHashes: new Set(),
|
||||||
|
lookup: async (h) => created.some((k) => k.keyHash === h),
|
||||||
|
},
|
||||||
|
signup: {
|
||||||
|
createApiKey: async (input) => {
|
||||||
|
created.push(input);
|
||||||
|
},
|
||||||
|
defaultRateLimit: 30,
|
||||||
|
rateLimitPerHour: 3,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await app.request("/v1/signup", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json", "x-forwarded-for": "2.2.2.2" },
|
||||||
|
body: JSON.stringify({ email: "USER@Example.com", label: "Beta" }),
|
||||||
|
});
|
||||||
|
assert.equal(res.status, 201);
|
||||||
|
const body = await res.json();
|
||||||
|
assert.match(body.apiKey, /^dtto_live_/);
|
||||||
|
assert.equal(body.message, "Save this key now; it will not be shown again.");
|
||||||
|
assert.equal(created.length, 1);
|
||||||
|
assert.equal(created[0]!.keyHash, hashApiKey(body.apiKey));
|
||||||
|
assert.equal(created[0]!.label, "user@example.com (Beta)");
|
||||||
|
assert.equal(created[0]!.rateLimit, 30);
|
||||||
|
|
||||||
|
assert.equal((await app.request("/v1/clones")).status, 401, "clone routes still require auth");
|
||||||
|
assert.equal((await app.request("/v1/clones", { headers: { authorization: `Bearer ${body.apiKey}` } })).status, 200);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("signup: validates email and rate-limits per IP", async () => {
|
||||||
|
const app = appWith({
|
||||||
|
signup: {
|
||||||
|
createApiKey: async () => {},
|
||||||
|
rateLimitPerHour: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.equal(
|
||||||
|
(await app.request("/v1/signup", { method: "POST", headers: { "content-type": "application/json", "x-forwarded-for": "3.3.3.3" }, body: JSON.stringify({ email: "not-an-email" }) })).status,
|
||||||
|
400,
|
||||||
|
);
|
||||||
|
const headers = { "content-type": "application/json", "x-forwarded-for": "4.4.4.4" };
|
||||||
|
assert.equal((await app.request("/v1/signup", { method: "POST", headers, body: JSON.stringify({ email: "a@example.com" }) })).status, 201);
|
||||||
|
assert.equal((await app.request("/v1/signup", { method: "POST", headers, body: JSON.stringify({ email: "b@example.com" }) })).status, 429);
|
||||||
|
});
|
||||||
|
|
||||||
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" };
|
||||||
|
|||||||
Reference in New Issue
Block a user