Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/unbounded-rate-limit-token.md
Original file line number Diff line number Diff line change
@@ -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).
3 changes: 2 additions & 1 deletion apps/authful/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
8 changes: 8 additions & 0 deletions apps/authful/src/controllers/authful.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 12 additions & 1 deletion apps/authful/src/mappers/tokens/index.ts
Original file line number Diff line number Diff line change
@@ -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(),
Expand All @@ -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");

Expand Down
11 changes: 11 additions & 0 deletions apps/gateful/src/auth/rate-limit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
5 changes: 4 additions & 1 deletion apps/gateful/src/auth/rate-limit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`;
Expand Down
Loading