Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
5 changes: 4 additions & 1 deletion apps/authful/src/mappers/tokens/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,10 @@ 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().optional().openapi({
Comment thread
pikonha marked this conversation as resolved.
Outdated
description:
"Requests per minute. 0 means unbounded (no rate limiting). Omit to default to 600.",
}),
})
.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