diff --git a/.changeset/unbounded-rate-limit-token.md b/.changeset/unbounded-rate-limit-token.md new file mode 100644 index 000000000..d1963e715 --- /dev/null +++ b/.changeset/unbounded-rate-limit-token.md @@ -0,0 +1,6 @@ +--- +"@anticapture/gateful": minor +"@anticapture/authful": minor +--- + +Support unbounded (rate-limit-exempt) tokens. A token with `rateLimitPerMin` set to `0` (the sentinel for any non-positive value) is now skipped entirely by Gateful's rate-limit middleware — it never touches Redis and is never throttled. Authful's mint endpoint accepts `0` accordingly (`rateLimitPerMin` validation relaxed from positive to non-negative). diff --git a/apps/authful/README.md b/apps/authful/README.md index ce4e84709..d4bd54a86 100644 --- a/apps/authful/README.md +++ b/apps/authful/README.md @@ -42,7 +42,8 @@ curl -sX POST http://localhost:4002/tokens \ -d '{"tenant": "acme", "name": "acme mcp prod", "rateLimitPerMin": 600}' ``` -`rateLimitPerMin` is optional (defaults to 600). +`rateLimitPerMin` is optional (defaults to 600). Set it to `0` to make the +token unbounded — Gateful exempts it from rate limiting entirely. ## Migrations diff --git a/apps/authful/src/controllers/authful.integration.test.ts b/apps/authful/src/controllers/authful.integration.test.ts index cbd441a0b..61c50c70c 100644 --- a/apps/authful/src/controllers/authful.integration.test.ts +++ b/apps/authful/src/controllers/authful.integration.test.ts @@ -130,6 +130,14 @@ describe("authful app", () => { expect(JSON.stringify(row)).not.toContain(body.token); }); + it("accepts rateLimitPerMin 0 to mint an unbounded token", async () => { + const body = await mint({ rateLimitPerMin: 0 }); + expect(body.rateLimitPerMin).toBe(0); + + const [row] = await db.select().from(tokens); + expect(row!.rateLimitPerMin).toBe(0); + }); + it("rejects a body sent without a JSON content-type (no silent {})", async () => { // Regression: without `required: true`, @hono/zod-openapi skips // validation for a non-JSON content-type and substitutes {}, so mint diff --git a/apps/authful/src/mappers/tokens/index.ts b/apps/authful/src/mappers/tokens/index.ts index 90c0ddf32..02e91ae34 100644 --- a/apps/authful/src/mappers/tokens/index.ts +++ b/apps/authful/src/mappers/tokens/index.ts @@ -1,5 +1,8 @@ import { z } from "@hono/zod-openapi"; +/** Mirrors the `rate_limit_per_min` column default in the DB schema. */ +export const DEFAULT_RATE_LIMIT_PER_MIN = 600; + export const TokenMetadataSchema = z .object({ id: z.uuid(), @@ -20,7 +23,15 @@ export const MintTokenBodySchema = z .object({ tenant: z.string().min(1), name: z.string().min(1), - rateLimitPerMin: z.number().int().positive().optional(), + rateLimitPerMin: z + .number() + .int() + .nonnegative() + .default(DEFAULT_RATE_LIMIT_PER_MIN) + .openapi({ + description: + "Requests per minute. 0 means unbounded (no rate limiting). Defaults to 600 when omitted.", + }), }) .openapi("MintTokenBody"); diff --git a/apps/gateful/src/auth/rate-limit.test.ts b/apps/gateful/src/auth/rate-limit.test.ts index 2e4354cce..b64d01a4b 100644 --- a/apps/gateful/src/auth/rate-limit.test.ts +++ b/apps/gateful/src/auth/rate-limit.test.ts @@ -63,6 +63,17 @@ describe("rateLimitMiddleware", () => { expect(retryAfter).toBeLessThanOrEqual(60); }); + it("never limits an unbounded token (rateLimitPerMin 0) and skips the store", async () => { + const store = new FakeRateLimitStore(); + const app = buildApp(store, { ...AUTH, rateLimitPerMin: 0 }); + + for (let i = 0; i < 10; i++) { + const res = await app.request("/test"); + expect(res.status).toBe(200); + } + expect(store.counters.size).toBe(0); + }); + it("skips limiting when there is no auth context (public/legacy)", async () => { const store = new FakeRateLimitStore(); const app = buildApp(store, null); diff --git a/apps/gateful/src/auth/rate-limit.ts b/apps/gateful/src/auth/rate-limit.ts index 4ac07b0b1..5b4f7a70b 100644 --- a/apps/gateful/src/auth/rate-limit.ts +++ b/apps/gateful/src/auth/rate-limit.ts @@ -15,11 +15,14 @@ export interface RateLimitStore { * Redis (or on Redis errors) requests pass — availability over enforcement. * Runs after tokenAuthMiddleware; requests without auth context (public * paths, auth disabled) are not limited. + * + * A non-positive limit (0 is the sentinel) means "unbounded": the token is + * exempt from rate limiting and never touches Redis. */ export function rateLimitMiddleware(store?: RateLimitStore) { return async (c: Context, next: Next) => { const auth = c.get("auth"); - if (!auth || !store) return next(); + if (!auth || !store || auth.rateLimitPerMin <= 0) return next(); const epochMinute = Math.floor(Date.now() / 60_000); const key = `rl:${auth.tokenId}:${epochMinute}`;