From fb3853f95b670325e0dac146ec8e1c09294e1413 Mon Sep 17 00:00:00 2001 From: Tiankai Ma Date: Thu, 16 Jul 2026 09:13:53 +0800 Subject: [PATCH 1/2] feat(api): add application mutation rate limits --- docs/contracts/mcp.json | 5 +- docs/contracts/openapi.json | 3 + docs/contracts/subscription.json | 3 +- public/openapi.generated.json | 1457 ++++++++++++++++- scripts/openapi/route-collector.ts | 25 +- src/lib/adapters/cloudflare-runtime.ts | 13 + src/lib/api/responses.ts | 18 + src/lib/api/routes/admin-route-auth.ts | 38 + src/lib/api/routes/calendar-subscriptions.ts | 5 + src/lib/api/routes/comment-batch-route.ts | 1 + .../api/routes/dashboard-link-pin-route.ts | 13 +- .../api/routes/dashboard-link-visit-routes.ts | 11 +- src/lib/api/routes/homework-completion.ts | 1 + src/lib/api/routes/mcp-cors.ts | 3 +- src/lib/api/routes/mcp-request-handler.ts | 52 +- src/lib/api/routes/mcp-request-logging.ts | 6 +- .../api/routes/mcp-response-bookkeeping.ts | 6 +- src/lib/api/routes/todo-batch-route.ts | 2 + .../schemas/request-user-mutation-schemas.ts | 9 +- src/lib/auth/api-auth.ts | 55 +- src/lib/mcp/auth.ts | 19 +- src/lib/mcp/tool-scopes.ts | 48 +- src/lib/metrics/analytics-engine.ts | 6 +- src/lib/security/user-mutation-rate-limit.ts | 52 + src/routes/api/admin/comments/[id]/+server.ts | 2 + .../api/admin/descriptions/[id]/+server.ts | 2 + .../api/admin/homeworks/[id]/+server.ts | 2 + src/routes/api/admin/suspensions/+server.ts | 2 + .../api/admin/suspensions/[id]/+server.ts | 2 + src/routes/api/admin/users/[id]/+server.ts | 2 + src/routes/api/bus/preferences/+server.ts | 2 + .../api/calendar-subscriptions/+server.ts | 6 + .../calendar-subscriptions/batch/+server.ts | 2 + .../import-codes/+server.ts | 2 + src/routes/api/comments/+server.ts | 2 + src/routes/api/comments/[id]/+server.ts | 4 + .../api/comments/[id]/reactions/+server.ts | 4 + src/routes/api/comments/batch/+server.ts | 2 + src/routes/api/dashboard-links/pin/+server.ts | 2 + .../api/dashboard-links/pin/batch/+server.ts | 2 + src/routes/api/descriptions/+server.ts | 2 + src/routes/api/homeworks/+server.ts | 2 + src/routes/api/homeworks/[id]/+server.ts | 4 + .../api/homeworks/[id]/completion/+server.ts | 2 + .../api/homeworks/completions/+server.ts | 2 + src/routes/api/mcp/+server.ts | 2 + src/routes/api/todos/+server.ts | 2 + src/routes/api/todos/[id]/+server.ts | 4 + src/routes/api/todos/batch/+server.ts | 4 + src/routes/api/uploads/+server.ts | 2 + src/routes/api/uploads/[id]/+server.ts | 4 + src/routes/api/uploads/complete/+server.ts | 2 + src/routes/api/uploads/object/+server.ts | 2 + tests/unit/admin-route-auth.test.ts | 91 +- tests/unit/api-helpers.test.ts | 12 + tests/unit/api-schemas.test.ts | 16 +- tests/unit/auth-helpers.test.ts | 122 +- tests/unit/comment-batch-delete-route.test.ts | 34 +- tests/unit/dashboard-link-pin-route.test.ts | 55 +- tests/unit/dashboard-link-visit-route.test.ts | 76 + tests/unit/mcp-auth.test.ts | 50 + tests/unit/mcp-request-rate-limit.test.ts | 188 +++ tests/unit/mcp-tool-scopes.test.ts | 46 + tests/unit/openapi-collectors.test.ts | 29 + tests/unit/openapi-generator.test.ts | 17 +- .../unit/openapi-rate-limit-contract.test.ts | 84 + tests/unit/protected-write-auth-order.test.ts | 11 +- tests/unit/user-mutation-rate-limit.test.ts | 93 ++ wrangler.e2e.jsonc | 18 + wrangler.jsonc | 18 + 70 files changed, 2772 insertions(+), 113 deletions(-) create mode 100644 src/lib/security/user-mutation-rate-limit.ts create mode 100644 tests/unit/dashboard-link-visit-route.test.ts create mode 100644 tests/unit/mcp-request-rate-limit.test.ts create mode 100644 tests/unit/openapi-rate-limit-contract.test.ts create mode 100644 tests/unit/user-mutation-rate-limit.test.ts diff --git a/docs/contracts/mcp.json b/docs/contracts/mcp.json index 4c52dc4d7..a6d469c42 100644 --- a/docs/contracts/mcp.json +++ b/docs/contracts/mcp.json @@ -16,7 +16,10 @@ "property-priority-parity": "MCP default output follows the shared UI Model Property Priority contract: keep primary identity/task fields and useful disambiguators, compact repeated nested objects, and omit low-priority raw/audit fields unless full mode is explicitly requested.", "actionable-errors": "Validation and common not-found payloads prefer plain-language messages and may include a hint that points to the next useful tool or query to recover.", "resource-bound-access-token": "MCP transport requests must present a resource-bound Bearer token for /api/mcp. JWT access tokens minted with the canonical MCP resource are accepted; opaque tokens without a resource indicator are rejected. Omitted-resource refresh grants are normalized to MCP only when the stored grant includes MCP-compatible feature scopes and the canonical MCP resource approval.", + "complete-batch-scope-enforcement": "Every feature scope declared by each requested tool is required across the complete JSON-RPC batch. A feature:write grant also satisfies feature:read for the same feature, while the legacy mcp:tools scope retains compatibility access.", "transport-observability": "MCP transport handling emits production-safe structured request/response logs and Cloudflare Analytics Engine datapoints with JSON-RPC method summaries, tool names, argument keys, auth/origin phase, status, duration, and registered tool count; it never logs bearer tokens, cookies, or tool argument values.", + "mutation-rate-limits": "After MCP bearer authentication and before SDK tool handling, every actual mutation tools/call entry is checked against the same per-host, per-user, canonical feature-action Cloudflare budgets as REST: 60 standard writes per minute or 10 batch writes per minute. A JSON-RPC batch is rejected as a whole before any tool side effect when any check exceeds the budget (429) or the Cloudflare binding is unavailable (503); both responses include Retry-After: 60.", + "rate-limit-accuracy-boundary": "MCP mutation throttles are per-Cloudflare-location, permissive, eventually consistent abuse controls rather than global exact quotas. The protocol does not promise remaining/reset counters, and read-only tools do not consume mutation budgets.", "flexible-date-inputs": "Date and datetime parameters on MCP tools accept ISO 8601 with timezone offset (2026-05-01T08:00:00+08:00), bare date strings (2026-05-01, treated as UTC midnight for @db.Date columns), or timezone-less datetimes (2026-05-01T08:00:00, interpreted as Asia/Shanghai). Invalid strings produce a descriptive error response rather than a validation rejection.", "time-override": "Time-sensitive tools (get_my_7days_timeline, get_upcoming_deadlines, get_my_overview, get_next_buses) accept an optional atTime parameter to anchor their internal clock to a caller-supplied moment instead of the server clock, enabling reproducible queries and future-scenario planning.", "openai-compatible-descriptors": "Every registered tool advertises a title, a closed top-level object outputSchema with a required success boolean and typed known fields, MCP annotations for read-only/destructive/open-world hints, top-level securitySchemes, and _meta.securitySchemes with the OAuth feature scopes required for that tool. Default and full results keep the same top-level key and collection types.", @@ -36,7 +39,7 @@ "returns": "MCP JSON-RPC response", "notes": [ "All tool calls are handled through this endpoint and require a Bearer token bound to the MCP protected resource /api/mcp; transport handling writes Cloudflare Analytics Engine datapoints for HTTP phase/status/duration, JSON-RPC method counts, and tool-call counts by tool name.", - "Trusted browser-origin Bearer-token clients receive CORS headers, including exposed MCP-Session-Id and WWW-Authenticate response headers for browser transports.", + "Trusted browser-origin Bearer-token clients receive CORS headers, including exposed MCP-Session-Id, Retry-After, and WWW-Authenticate response headers for browser transports.", "Answers browser CORS preflight requests for the MCP transport headers used by POST/DELETE browser clients.", "Requests with a foreign Origin header are rejected before MCP auth/tool handling." ] diff --git a/docs/contracts/openapi.json b/docs/contracts/openapi.json index 5ea7e7994..ca6f2a95e 100644 --- a/docs/contracts/openapi.json +++ b/docs/contracts/openapi.json @@ -15,6 +15,9 @@ "generated-client-required": "CLI and Bot business calls must use generated OpenAPI clients and generated request/response types whenever the server OpenAPI covers the endpoint. Allowed exceptions are explicit raw request escape hatches, presigned upload object PUTs, OAuth protocol plumbing, external school/QQ/NapCat APIs, and documented non-OpenAPI endpoints such as OAuth userinfo fallback.", "openapi-drift-gate": "After server OpenAPI changes, regenerate public/openapi.generated.json and sync api/openapi.json plus generated Go clients in CLI and Bot before publishing. Cross-repo CI should fail if copied OpenAPI files drift from the server-generated contract.", "rest-observability": "REST API handling records production-safe structured request-start/request-finish logs with request ID, method, status, auth mode, duration, and normalized route template; when the Cloudflare Analytics Engine binding is present it also writes sanitized request finish/error datapoints with normalized route, method, status class, auth mode, and duration; it does not log request bodies, credentials, raw query strings, or high-cardinality resource IDs.", + "authenticated-mutation-rate-limits": "Authenticated end-user and admin REST mutations use Cloudflare rate-limit bindings keyed by deployment host, user, and canonical feature action. Standard writes allow 60 checks per minute; batch writes allow 10. REST and MCP share the same feature-action keys. The gate runs after authentication and before mutation. Exceeded budgets return 429; missing or failing Cloudflare bindings fail closed with 503. Both responses include Retry-After: 60. Host-native development bypasses the gate explicitly.", + "dashboard-rate-limit-fallbacks": "For the dashboard pin HTML form, rate-limit rejection skips the write and redirects with an error marker; JSON/API callers receive 429 or 503 directly. Dashboard link visits are best-effort analytics writes: rejection skips click recording but preserves the target redirect.", + "rate-limit-accuracy-boundary": "Mutation throttles are abuse controls, not exact accounting: Cloudflare counters are per-location, permissive, and eventually consistent. Contracts do not promise a global exact quota, remaining count, or reset timestamp. OAuth/provider endpoints retain Better Auth's separate limiter, and anonymous/read-only POST utilities are outside the authenticated mutation budget.", "unknown-api-json-errors": "Unknown paths under /api return the standard JSON { error } envelope with status 404 instead of falling through to the HTML application error page.", "pagination-query-parameter": "Paginated REST endpoints use pageSize as the canonical request parameter. The deprecated limit alias remains accepted during migration, and pageSize takes precedence when both are supplied.", "paginated-list-envelope": "Paginated REST list endpoints return { data, pagination } with page, pageSize, total, and totalPages. Resource-specific list metadata is nested under meta instead of introducing alternate collection keys; REST and equivalent MCP list tools share this core envelope.", diff --git a/docs/contracts/subscription.json b/docs/contracts/subscription.json index d47762916..9cccaa11d 100644 --- a/docs/contracts/subscription.json +++ b/docs/contracts/subscription.json @@ -8,7 +8,8 @@ }, "rules": { "subscription-language": "Copy may use 'subscribe to section' or 'follow section'; must not write 'enroll in course'.", - "user-scoped": "Subscription relationships belong only to the current user and determine the display scope for home page, calendar, homework, and exam." + "user-scoped": "Subscription relationships belong only to the current user and determine the display scope for home page, calendar, homework, and exam.", + "bounded-batch-input": "Calendar subscription query and mutation requests accept at most 500 section IDs and at most 500 lookup codes per request, in addition to the per-user batch request budget." }, "capabilities": { "subscribe-from-section": { diff --git a/public/openapi.generated.json b/public/openapi.generated.json index 855127de2..168e728f8 100644 --- a/public/openapi.generated.json +++ b/public/openapi.generated.json @@ -365,6 +365,44 @@ } } } + }, + "429": { + "description": "Error response", + "headers": { + "Retry-After": { + "description": "Seconds before retrying the mutation", + "schema": { + "type": "integer", + "minimum": 0 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/openApiErrorSchema" + } + } + } + }, + "503": { + "description": "Error response", + "headers": { + "Retry-After": { + "description": "Seconds before retrying the mutation", + "schema": { + "type": "integer", + "minimum": 0 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/openApiErrorSchema" + } + } + } } } }, @@ -432,6 +470,44 @@ } } } + }, + "429": { + "description": "Error response", + "headers": { + "Retry-After": { + "description": "Seconds before retrying the mutation", + "schema": { + "type": "integer", + "minimum": 0 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/openApiErrorSchema" + } + } + } + }, + "503": { + "description": "Error response", + "headers": { + "Retry-After": { + "description": "Seconds before retrying the mutation", + "schema": { + "type": "integer", + "minimum": 0 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/openApiErrorSchema" + } + } + } } } }, @@ -499,6 +575,44 @@ } } } + }, + "429": { + "description": "Error response", + "headers": { + "Retry-After": { + "description": "Seconds before retrying the mutation", + "schema": { + "type": "integer", + "minimum": 0 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/openApiErrorSchema" + } + } + } + }, + "503": { + "description": "Error response", + "headers": { + "Retry-After": { + "description": "Seconds before retrying the mutation", + "schema": { + "type": "integer", + "minimum": 0 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/openApiErrorSchema" + } + } + } } } } @@ -728,6 +842,44 @@ } } } + }, + "429": { + "description": "Error response", + "headers": { + "Retry-After": { + "description": "Seconds before retrying the mutation", + "schema": { + "type": "integer", + "minimum": 0 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/openApiErrorSchema" + } + } + } + }, + "503": { + "description": "Error response", + "headers": { + "Retry-After": { + "description": "Seconds before retrying the mutation", + "schema": { + "type": "integer", + "minimum": 0 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/openApiErrorSchema" + } + } + } } } } @@ -997,6 +1149,44 @@ } } } + }, + "429": { + "description": "Error response", + "headers": { + "Retry-After": { + "description": "Seconds before retrying the mutation", + "schema": { + "type": "integer", + "minimum": 0 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/openApiErrorSchema" + } + } + } + }, + "503": { + "description": "Error response", + "headers": { + "Retry-After": { + "description": "Seconds before retrying the mutation", + "schema": { + "type": "integer", + "minimum": 0 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/openApiErrorSchema" + } + } + } } } } @@ -1181,6 +1371,44 @@ } } } + }, + "429": { + "description": "Error response", + "headers": { + "Retry-After": { + "description": "Seconds before retrying the mutation", + "schema": { + "type": "integer", + "minimum": 0 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/openApiErrorSchema" + } + } + } + }, + "503": { + "description": "Error response", + "headers": { + "Retry-After": { + "description": "Seconds before retrying the mutation", + "schema": { + "type": "integer", + "minimum": 0 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/openApiErrorSchema" + } + } + } } } } @@ -1290,6 +1518,44 @@ } } } + }, + "429": { + "description": "Error response", + "headers": { + "Retry-After": { + "description": "Seconds before retrying the mutation", + "schema": { + "type": "integer", + "minimum": 0 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/openApiErrorSchema" + } + } + } + }, + "503": { + "description": "Error response", + "headers": { + "Retry-After": { + "description": "Seconds before retrying the mutation", + "schema": { + "type": "integer", + "minimum": 0 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/openApiErrorSchema" + } + } + } } } }, @@ -2037,6 +2303,44 @@ } } } + }, + "429": { + "description": "Error response", + "headers": { + "Retry-After": { + "description": "Seconds before retrying the mutation", + "schema": { + "type": "integer", + "minimum": 0 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/openApiErrorSchema" + } + } + } + }, + "503": { + "description": "Error response", + "headers": { + "Retry-After": { + "description": "Seconds before retrying the mutation", + "schema": { + "type": "integer", + "minimum": 0 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/openApiErrorSchema" + } + } + } } } } @@ -2196,22 +2500,60 @@ } } } - } - } - } - }, - "/api/admin/comments": { - "get": { - "operationId": "listAdminComments", - "summary": "List moderation comments", - "tags": [ - "Admin" - ], - "security": [ - { - "sessionCookie": [] - } - ], + }, + "429": { + "description": "Error response", + "headers": { + "Retry-After": { + "description": "Seconds before retrying the mutation", + "schema": { + "type": "integer", + "minimum": 0 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/openApiErrorSchema" + } + } + } + }, + "503": { + "description": "Error response", + "headers": { + "Retry-After": { + "description": "Seconds before retrying the mutation", + "schema": { + "type": "integer", + "minimum": 0 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/openApiErrorSchema" + } + } + } + } + } + } + }, + "/api/admin/comments": { + "get": { + "operationId": "listAdminComments", + "summary": "List moderation comments", + "tags": [ + "Admin" + ], + "security": [ + { + "sessionCookie": [] + } + ], "x-auth-role": "admin", "parameters": [ { @@ -2622,6 +2964,44 @@ } } } + }, + "429": { + "description": "Error response", + "headers": { + "Retry-After": { + "description": "Seconds before retrying the mutation", + "schema": { + "type": "integer", + "minimum": 0 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/openApiErrorSchema" + } + } + } + }, + "503": { + "description": "Error response", + "headers": { + "Retry-After": { + "description": "Seconds before retrying the mutation", + "schema": { + "type": "integer", + "minimum": 0 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/openApiErrorSchema" + } + } + } } } } @@ -2929,6 +3309,44 @@ } } } + }, + "429": { + "description": "Error response", + "headers": { + "Retry-After": { + "description": "Seconds before retrying the mutation", + "schema": { + "type": "integer", + "minimum": 0 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/openApiErrorSchema" + } + } + } + }, + "503": { + "description": "Error response", + "headers": { + "Retry-After": { + "description": "Seconds before retrying the mutation", + "schema": { + "type": "integer", + "minimum": 0 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/openApiErrorSchema" + } + } + } } } } @@ -3078,6 +3496,44 @@ } } } + }, + "429": { + "description": "Error response", + "headers": { + "Retry-After": { + "description": "Seconds before retrying the mutation", + "schema": { + "type": "integer", + "minimum": 0 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/openApiErrorSchema" + } + } + } + }, + "503": { + "description": "Error response", + "headers": { + "Retry-After": { + "description": "Seconds before retrying the mutation", + "schema": { + "type": "integer", + "minimum": 0 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/openApiErrorSchema" + } + } + } } } } @@ -3186,6 +3642,44 @@ } } } + }, + "429": { + "description": "Error response", + "headers": { + "Retry-After": { + "description": "Seconds before retrying the mutation", + "schema": { + "type": "integer", + "minimum": 0 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/openApiErrorSchema" + } + } + } + }, + "503": { + "description": "Error response", + "headers": { + "Retry-After": { + "description": "Seconds before retrying the mutation", + "schema": { + "type": "integer", + "minimum": 0 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/openApiErrorSchema" + } + } + } } } } @@ -3395,6 +3889,44 @@ } } } + }, + "429": { + "description": "Error response", + "headers": { + "Retry-After": { + "description": "Seconds before retrying the mutation", + "schema": { + "type": "integer", + "minimum": 0 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/openApiErrorSchema" + } + } + } + }, + "503": { + "description": "Error response", + "headers": { + "Retry-After": { + "description": "Seconds before retrying the mutation", + "schema": { + "type": "integer", + "minimum": 0 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/openApiErrorSchema" + } + } + } } } }, @@ -3463,6 +3995,44 @@ } } } + }, + "429": { + "description": "Error response", + "headers": { + "Retry-After": { + "description": "Seconds before retrying the mutation", + "schema": { + "type": "integer", + "minimum": 0 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/openApiErrorSchema" + } + } + } + }, + "503": { + "description": "Error response", + "headers": { + "Retry-After": { + "description": "Seconds before retrying the mutation", + "schema": { + "type": "integer", + "minimum": 0 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/openApiErrorSchema" + } + } + } } } } @@ -3532,6 +4102,44 @@ } } } + }, + "429": { + "description": "Error response", + "headers": { + "Retry-After": { + "description": "Seconds before retrying the mutation", + "schema": { + "type": "integer", + "minimum": 0 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/openApiErrorSchema" + } + } + } + }, + "503": { + "description": "Error response", + "headers": { + "Retry-After": { + "description": "Seconds before retrying the mutation", + "schema": { + "type": "integer", + "minimum": 0 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/openApiErrorSchema" + } + } + } } } } @@ -3655,6 +4263,25 @@ } } }, + "429": { + "description": "Error response", + "headers": { + "Retry-After": { + "description": "Seconds before retrying the mutation", + "schema": { + "type": "integer", + "minimum": 0 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/openApiErrorSchema" + } + } + } + }, "500": { "description": "Error response", "content": { @@ -3664,6 +4291,25 @@ } } } + }, + "503": { + "description": "Error response", + "headers": { + "Retry-After": { + "description": "Seconds before retrying the mutation", + "schema": { + "type": "integer", + "minimum": 0 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/openApiErrorSchema" + } + } + } } } } @@ -3817,6 +4463,44 @@ } } } + }, + "429": { + "description": "Error response", + "headers": { + "Retry-After": { + "description": "Seconds before retrying the mutation", + "schema": { + "type": "integer", + "minimum": 0 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/openApiErrorSchema" + } + } + } + }, + "503": { + "description": "Error response", + "headers": { + "Retry-After": { + "description": "Seconds before retrying the mutation", + "schema": { + "type": "integer", + "minimum": 0 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/openApiErrorSchema" + } + } + } } } }, @@ -3885,6 +4569,44 @@ } } } + }, + "429": { + "description": "Error response", + "headers": { + "Retry-After": { + "description": "Seconds before retrying the mutation", + "schema": { + "type": "integer", + "minimum": 0 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/openApiErrorSchema" + } + } + } + }, + "503": { + "description": "Error response", + "headers": { + "Retry-After": { + "description": "Seconds before retrying the mutation", + "schema": { + "type": "integer", + "minimum": 0 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/openApiErrorSchema" + } + } + } } } } @@ -3944,6 +4666,44 @@ } } } + }, + "429": { + "description": "Error response", + "headers": { + "Retry-After": { + "description": "Seconds before retrying the mutation", + "schema": { + "type": "integer", + "minimum": 0 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/openApiErrorSchema" + } + } + } + }, + "503": { + "description": "Error response", + "headers": { + "Retry-After": { + "description": "Seconds before retrying the mutation", + "schema": { + "type": "integer", + "minimum": 0 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/openApiErrorSchema" + } + } + } } } } @@ -4353,6 +5113,44 @@ } } } + }, + "429": { + "description": "Error response", + "headers": { + "Retry-After": { + "description": "Seconds before retrying the mutation", + "schema": { + "type": "integer", + "minimum": 0 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/openApiErrorSchema" + } + } + } + }, + "503": { + "description": "Error response", + "headers": { + "Retry-After": { + "description": "Seconds before retrying the mutation", + "schema": { + "type": "integer", + "minimum": 0 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/openApiErrorSchema" + } + } + } } } }, @@ -4421,6 +5219,44 @@ } } } + }, + "429": { + "description": "Error response", + "headers": { + "Retry-After": { + "description": "Seconds before retrying the mutation", + "schema": { + "type": "integer", + "minimum": 0 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/openApiErrorSchema" + } + } + } + }, + "503": { + "description": "Error response", + "headers": { + "Retry-After": { + "description": "Seconds before retrying the mutation", + "schema": { + "type": "integer", + "minimum": 0 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/openApiErrorSchema" + } + } + } } } } @@ -4480,6 +5316,44 @@ } } } + }, + "429": { + "description": "Error response", + "headers": { + "Retry-After": { + "description": "Seconds before retrying the mutation", + "schema": { + "type": "integer", + "minimum": 0 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/openApiErrorSchema" + } + } + } + }, + "503": { + "description": "Error response", + "headers": { + "Retry-After": { + "description": "Seconds before retrying the mutation", + "schema": { + "type": "integer", + "minimum": 0 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/openApiErrorSchema" + } + } + } } } }, @@ -4537,6 +5411,44 @@ } } } + }, + "429": { + "description": "Error response", + "headers": { + "Retry-After": { + "description": "Seconds before retrying the mutation", + "schema": { + "type": "integer", + "minimum": 0 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/openApiErrorSchema" + } + } + } + }, + "503": { + "description": "Error response", + "headers": { + "Retry-After": { + "description": "Seconds before retrying the mutation", + "schema": { + "type": "integer", + "minimum": 0 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/openApiErrorSchema" + } + } + } } } } @@ -4627,6 +5539,44 @@ } } } + }, + "429": { + "description": "Error response", + "headers": { + "Retry-After": { + "description": "Seconds before retrying the mutation", + "schema": { + "type": "integer", + "minimum": 0 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/openApiErrorSchema" + } + } + } + }, + "503": { + "description": "Error response", + "headers": { + "Retry-After": { + "description": "Seconds before retrying the mutation", + "schema": { + "type": "integer", + "minimum": 0 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/openApiErrorSchema" + } + } + } } } }, @@ -4696,6 +5646,25 @@ } } }, + "429": { + "description": "Error response", + "headers": { + "Retry-After": { + "description": "Seconds before retrying the mutation", + "schema": { + "type": "integer", + "minimum": 0 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/openApiErrorSchema" + } + } + } + }, "502": { "description": "Error response", "content": { @@ -4705,6 +5674,25 @@ } } } + }, + "503": { + "description": "Error response", + "headers": { + "Retry-After": { + "description": "Seconds before retrying the mutation", + "schema": { + "type": "integer", + "minimum": 0 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/openApiErrorSchema" + } + } + } } } } @@ -4774,6 +5762,44 @@ } } } + }, + "429": { + "description": "Error response", + "headers": { + "Retry-After": { + "description": "Seconds before retrying the mutation", + "schema": { + "type": "integer", + "minimum": 0 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/openApiErrorSchema" + } + } + } + }, + "503": { + "description": "Error response", + "headers": { + "Retry-After": { + "description": "Seconds before retrying the mutation", + "schema": { + "type": "integer", + "minimum": 0 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/openApiErrorSchema" + } + } + } } } } @@ -4865,6 +5891,44 @@ } } } + }, + "429": { + "description": "Error response", + "headers": { + "Retry-After": { + "description": "Seconds before retrying the mutation", + "schema": { + "type": "integer", + "minimum": 0 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/openApiErrorSchema" + } + } + } + }, + "503": { + "description": "Error response", + "headers": { + "Retry-After": { + "description": "Seconds before retrying the mutation", + "schema": { + "type": "integer", + "minimum": 0 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/openApiErrorSchema" + } + } + } } } } @@ -5155,18 +6219,56 @@ } } }, - "403": { + "403": { + "description": "Error response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/openApiErrorSchema" + } + } + } + }, + "404": { + "description": "Error response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/openApiErrorSchema" + } + } + } + }, + "429": { + "description": "Error response", + "headers": { + "Retry-After": { + "description": "Seconds before retrying the mutation", + "schema": { + "type": "integer", + "minimum": 0 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/openApiErrorSchema" + } + } + } + }, + "503": { "description": "Error response", - "content": { - "application/json": { + "headers": { + "Retry-After": { + "description": "Seconds before retrying the mutation", "schema": { - "$ref": "#/components/schemas/openApiErrorSchema" + "type": "integer", + "minimum": 0 } } - } - }, - "404": { - "description": "Error response", + }, "content": { "application/json": { "schema": { @@ -5262,6 +6364,44 @@ } } } + }, + "429": { + "description": "Error response", + "headers": { + "Retry-After": { + "description": "Seconds before retrying the mutation", + "schema": { + "type": "integer", + "minimum": 0 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/openApiErrorSchema" + } + } + } + }, + "503": { + "description": "Error response", + "headers": { + "Retry-After": { + "description": "Seconds before retrying the mutation", + "schema": { + "type": "integer", + "minimum": 0 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/openApiErrorSchema" + } + } + } } } } @@ -5330,6 +6470,44 @@ } } } + }, + "429": { + "description": "Error response", + "headers": { + "Retry-After": { + "description": "Seconds before retrying the mutation", + "schema": { + "type": "integer", + "minimum": 0 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/openApiErrorSchema" + } + } + } + }, + "503": { + "description": "Error response", + "headers": { + "Retry-After": { + "description": "Seconds before retrying the mutation", + "schema": { + "type": "integer", + "minimum": 0 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/openApiErrorSchema" + } + } + } } } } @@ -5398,6 +6576,44 @@ } } } + }, + "429": { + "description": "Error response", + "headers": { + "Retry-After": { + "description": "Seconds before retrying the mutation", + "schema": { + "type": "integer", + "minimum": 0 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/openApiErrorSchema" + } + } + } + }, + "503": { + "description": "Error response", + "headers": { + "Retry-After": { + "description": "Seconds before retrying the mutation", + "schema": { + "type": "integer", + "minimum": 0 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/openApiErrorSchema" + } + } + } } } } @@ -5486,6 +6702,44 @@ } } } + }, + "429": { + "description": "Error response", + "headers": { + "Retry-After": { + "description": "Seconds before retrying the mutation", + "schema": { + "type": "integer", + "minimum": 0 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/openApiErrorSchema" + } + } + } + }, + "503": { + "description": "Error response", + "headers": { + "Retry-After": { + "description": "Seconds before retrying the mutation", + "schema": { + "type": "integer", + "minimum": 0 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/openApiErrorSchema" + } + } + } } } } @@ -5725,6 +6979,44 @@ } } } + }, + "429": { + "description": "Error response", + "headers": { + "Retry-After": { + "description": "Seconds before retrying the mutation", + "schema": { + "type": "integer", + "minimum": 0 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/openApiErrorSchema" + } + } + } + }, + "503": { + "description": "Error response", + "headers": { + "Retry-After": { + "description": "Seconds before retrying the mutation", + "schema": { + "type": "integer", + "minimum": 0 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/openApiErrorSchema" + } + } + } } } }, @@ -5811,6 +7103,44 @@ } } } + }, + "429": { + "description": "Error response", + "headers": { + "Retry-After": { + "description": "Seconds before retrying the mutation", + "schema": { + "type": "integer", + "minimum": 0 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/openApiErrorSchema" + } + } + } + }, + "503": { + "description": "Error response", + "headers": { + "Retry-After": { + "description": "Seconds before retrying the mutation", + "schema": { + "type": "integer", + "minimum": 0 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/openApiErrorSchema" + } + } + } } } } @@ -5871,6 +7201,25 @@ } } }, + "429": { + "description": "Error response", + "headers": { + "Retry-After": { + "description": "Seconds before retrying the mutation", + "schema": { + "type": "integer", + "minimum": 0 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/openApiErrorSchema" + } + } + } + }, "500": { "description": "Error response", "content": { @@ -5880,6 +7229,25 @@ } } } + }, + "503": { + "description": "Error response", + "headers": { + "Retry-After": { + "description": "Seconds before retrying the mutation", + "schema": { + "type": "integer", + "minimum": 0 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/openApiErrorSchema" + } + } + } } } } @@ -5960,6 +7328,44 @@ } } } + }, + "429": { + "description": "Error response", + "headers": { + "Retry-After": { + "description": "Seconds before retrying the mutation", + "schema": { + "type": "integer", + "minimum": 0 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/openApiErrorSchema" + } + } + } + }, + "503": { + "description": "Error response", + "headers": { + "Retry-After": { + "description": "Seconds before retrying the mutation", + "schema": { + "type": "integer", + "minimum": 0 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/openApiErrorSchema" + } + } + } } } } @@ -7372,6 +8778,7 @@ "type": "object", "properties": { "sectionIds": { + "maxItems": 500, "type": "array", "items": { "type": "integer", @@ -8073,6 +9480,7 @@ "type": "object", "properties": { "sectionIds": { + "maxItems": 500, "type": "array", "items": { "type": "integer", @@ -8789,6 +10197,7 @@ "type": "object", "properties": { "sectionIds": { + "maxItems": 500, "type": "array", "items": { "type": "integer", diff --git a/scripts/openapi/route-collector.ts b/scripts/openapi/route-collector.ts index 9607c6cfc..ff9b77c5d 100644 --- a/scripts/openapi/route-collector.ts +++ b/scripts/openapi/route-collector.ts @@ -451,18 +451,25 @@ function buildResponse( } const statusNum = Number.parseInt(status, 10); - return { - description: statusNum >= 400 ? "Error response" : "Successful response", - ...(status === "201" + const headers = + status === "201" ? { - headers: { - Location: { - description: "Relative URL of the created resource", - schema: { type: "string" }, - }, + Location: { + description: "Relative URL of the created resource", + schema: { type: "string" }, }, } - : {}), + : status === "429" || status === "503" + ? { + "Retry-After": { + description: "Seconds before retrying the mutation", + schema: { type: "integer", minimum: 0 }, + }, + } + : undefined; + return { + description: statusNum >= 400 ? "Error response" : "Successful response", + ...(headers ? { headers } : {}), content: { "application/json": { schema: schemaRef(target) }, }, diff --git a/src/lib/adapters/cloudflare-runtime.ts b/src/lib/adapters/cloudflare-runtime.ts index 09347e8d7..5bfa1c3c4 100644 --- a/src/lib/adapters/cloudflare-runtime.ts +++ b/src/lib/adapters/cloudflare-runtime.ts @@ -36,12 +36,18 @@ export type CloudflareAnalyticsEngineDataset = { writeDataPoint(event?: CloudflareAnalyticsEngineDataPoint): void; }; +export type CloudflareRateLimiter = { + limit(options: { key: string }): Promise<{ success: boolean }>; +}; + type CloudflareRuntimeEnv = Record & { ANALYTICS?: CloudflareAnalyticsEngineDataset; HYPERDRIVE?: { connectionString?: unknown; }; R2_UPLOADS?: CloudflareR2Bucket; + USER_BATCH_WRITE_RATE_LIMITER?: CloudflareRateLimiter; + USER_WRITE_RATE_LIMITER?: CloudflareRateLimiter; }; type CloudflareRuntimeContext = { @@ -127,3 +133,10 @@ export function getCloudflareR2UploadsBucket() { export function getCloudflareAnalyticsEngineDataset() { return getCurrentCloudflareRuntimeEnv()?.ANALYTICS; } + +export function getCloudflareUserMutationRateLimiter(tier: "batch" | "write") { + const env = getCurrentCloudflareRuntimeEnv(); + return tier === "batch" + ? env?.USER_BATCH_WRITE_RATE_LIMITER + : env?.USER_WRITE_RATE_LIMITER; +} diff --git a/src/lib/api/responses.ts b/src/lib/api/responses.ts index 30a4e4c7c..e5b5f2b44 100644 --- a/src/lib/api/responses.ts +++ b/src/lib/api/responses.ts @@ -68,6 +68,24 @@ export function payloadTooLarge(message = "Payload too large") { return errorResponse(message, 413); } +export function rateLimitResponse( + reason: "limited" | "unavailable", + retryAfterSeconds = 60, +) { + return jsonResponse( + { + error: + reason === "limited" + ? "Rate limit exceeded" + : "Rate limiting unavailable", + }, + { + status: reason === "limited" ? 429 : 503, + headers: { "Retry-After": String(retryAfterSeconds) }, + }, + ); +} + export function invalidParamResponse(paramName: string) { return badRequest(`Invalid ${paramName}`); } diff --git a/src/lib/api/routes/admin-route-auth.ts b/src/lib/api/routes/admin-route-auth.ts index 7a1cd9df4..5af6533e2 100644 --- a/src/lib/api/routes/admin-route-auth.ts +++ b/src/lib/api/routes/admin-route-auth.ts @@ -4,16 +4,39 @@ import { } from "@/features/admin/server/admin-api"; import { handleRouteError, + rateLimitResponse, suspensionForbidden, unauthorized, } from "@/lib/api/helpers"; import { resolveApiUserId } from "@/lib/auth/api-auth"; import { findActiveSuspension } from "@/lib/auth/viewer-context"; +import { + checkUserMutationRateLimit, + USER_MUTATION_RATE_LIMIT_PERIOD_SECONDS, +} from "@/lib/security/user-mutation-rate-limit"; type AdminGuardOptions = { requireActive?: boolean; }; +const ADMIN_MUTATION_RESOURCES = new Set([ + "comments", + "descriptions", + "homeworks", + "suspensions", + "users", +]); + +function adminMutationRateLimitAction(pathname: string) { + const encodedResource = pathname.split("/").filter(Boolean)[2] ?? ""; + try { + const resource = decodeURIComponent(encodedResource).toLowerCase(); + return `admin:${ADMIN_MUTATION_RESOURCES.has(resource) ? resource : "unknown"}:write`; + } catch { + return "admin:unknown:write"; + } +} + export async function requireAdminRequest( request: Request, options: AdminGuardOptions = {}, @@ -29,6 +52,21 @@ export async function requireAdminRequest( if (suspension) return suspensionForbidden(suspension.reason); } + if (!["GET", "HEAD", "OPTIONS"].includes(request.method.toUpperCase())) { + const url = new URL(request.url); + const outcome = await checkUserMutationRateLimit({ + action: adminMutationRateLimitAction(url.pathname), + host: url.host, + userId: admin.userId, + }); + if (!outcome.allowed) { + return rateLimitResponse( + outcome.reason, + USER_MUTATION_RATE_LIMIT_PERIOD_SECONDS, + ); + } + } + return admin; } diff --git a/src/lib/api/routes/calendar-subscriptions.ts b/src/lib/api/routes/calendar-subscriptions.ts index 570e5d758..13c14de00 100644 --- a/src/lib/api/routes/calendar-subscriptions.ts +++ b/src/lib/api/routes/calendar-subscriptions.ts @@ -59,6 +59,7 @@ export async function postCalendarSubscriptionsRoute(request: Request) { try { const auth = await requireAuth(request, { bearerScope: { feature: "subscription", action: "write" }, + rateLimit: { action: "subscription:batch-write", tier: "batch" }, }); if (auth instanceof Response) return auth; const { userId } = auth; @@ -134,6 +135,7 @@ export async function postCalendarSubscriptionBatchRoute(request: Request) { try { const auth = await requireAuth(request, { bearerScope: { feature: "subscription", action: "write" }, + rateLimit: { action: "subscription:batch-write", tier: "batch" }, }); if (auth instanceof Response) return auth; const { userId } = auth; @@ -181,6 +183,7 @@ export async function postCalendarSubscriptionImportCodesRoute( try { const auth = await requireAuth(request, { bearerScope: { feature: "subscription", action: "write" }, + rateLimit: { action: "subscription:batch-write", tier: "batch" }, }); if (auth instanceof Response) return auth; const { userId } = auth; @@ -228,6 +231,7 @@ export async function patchCalendarSubscriptionsRoute(request: Request) { try { const auth = await requireAuth(request, { bearerScope: { feature: "subscription", action: "write" }, + rateLimit: { action: "subscription:batch-write", tier: "batch" }, }); if (auth instanceof Response) return auth; const { userId } = auth; @@ -263,6 +267,7 @@ export async function deleteCalendarSubscriptionsRoute(request: Request) { try { const auth = await requireAuth(request, { bearerScope: { feature: "subscription", action: "write" }, + rateLimit: { action: "subscription:batch-write", tier: "batch" }, }); if (auth instanceof Response) return auth; const { userId } = auth; diff --git a/src/lib/api/routes/comment-batch-route.ts b/src/lib/api/routes/comment-batch-route.ts index 9bcb4a24c..2a60fe425 100644 --- a/src/lib/api/routes/comment-batch-route.ts +++ b/src/lib/api/routes/comment-batch-route.ts @@ -25,6 +25,7 @@ function batchDeleteErrorMessage( export async function deleteCommentBatchRoute(request: Request) { const auth = await requireAuth(request, { bearerScope: { feature: "comment", action: "write" }, + rateLimit: { action: "comment:batch-write", tier: "batch" }, }); if (auth instanceof Response) return auth; diff --git a/src/lib/api/routes/dashboard-link-pin-route.ts b/src/lib/api/routes/dashboard-link-pin-route.ts index ecfacdd9f..c927e462c 100644 --- a/src/lib/api/routes/dashboard-link-pin-route.ts +++ b/src/lib/api/routes/dashboard-link-pin-route.ts @@ -11,25 +11,29 @@ import { dashboardLinkPinRequestSchema, } from "@/lib/api/schemas/request-schemas"; import { dashboardLinkPinResponseSchema } from "@/lib/api/schemas/response-schemas"; -import { requireAuth, resolveApiUserId } from "@/lib/auth/api-auth"; +import { requireAuth } from "@/lib/auth/api-auth"; import { jsonOrRedirectForPinnedLinks } from "./dashboard-link-pin-response"; export async function postDashboardLinkPinRoute(request: Request) { const wantsJson = request.headers.get("accept")?.includes("application/json") ?? false; - const userId = await resolveApiUserId(request, { + const auth = await requireAuth(request, { bearerScope: { feature: "dashboard", action: "write" }, + rateLimit: { action: "dashboard:write" }, }); - if (!userId) { + if (auth instanceof Response) { + if (auth.status !== 401 && wantsJson) return auth; return jsonOrRedirectForPinnedLinks({ request, wantsJson, pinnedSlugs: [], returnTo: "/", - status: 401, + status: auth.status, + error: auth.status === 429 ? "Rate limit exceeded" : null, }); } + const { userId } = auth; const formData = await request.formData(); const parsedBody = dashboardLinkPinRequestSchema.safeParse({ @@ -90,6 +94,7 @@ export async function postDashboardLinkPinRoute(request: Request) { export async function postDashboardLinkPinBatchRoute(request: Request) { const auth = await requireAuth(request, { bearerScope: { feature: "dashboard", action: "write" }, + rateLimit: { action: "dashboard:batch-write", tier: "batch" }, }); if (auth instanceof Response) return auth; diff --git a/src/lib/api/routes/dashboard-link-visit-routes.ts b/src/lib/api/routes/dashboard-link-visit-routes.ts index 9caef2bf9..5ec8b326f 100644 --- a/src/lib/api/routes/dashboard-link-visit-routes.ts +++ b/src/lib/api/routes/dashboard-link-visit-routes.ts @@ -7,6 +7,7 @@ import { dashboardLinkVisitRequestSchema, } from "@/lib/api/schemas/request-schemas"; import { resolveApiUserId } from "@/lib/auth/api-auth"; +import { checkUserMutationRateLimit } from "@/lib/security/user-mutation-rate-limit"; function resolveVisitTarget( schema: typeof dashboardLinkVisitQuerySchema, @@ -44,7 +45,15 @@ export async function postDashboardLinkVisitRoute(request: Request) { const userId = await resolveApiUserId(request); if (userId) { - await recordDashboardLinkClick(userId, target.slug); + const url = new URL(request.url); + const outcome = await checkUserMutationRateLimit({ + action: "dashboard:write", + host: url.host, + userId, + }); + if (outcome.allowed) { + await recordDashboardLinkClick(userId, target.slug); + } } return Response.redirect(target.url, 303); diff --git a/src/lib/api/routes/homework-completion.ts b/src/lib/api/routes/homework-completion.ts index 0be8bc3c8..42b8baea7 100644 --- a/src/lib/api/routes/homework-completion.ts +++ b/src/lib/api/routes/homework-completion.ts @@ -58,6 +58,7 @@ export async function putHomeworkCompletionRoute( export async function putHomeworkCompletionsRoute(request: Request) { const auth = await requireAuth(request, { bearerScope: { feature: "homework", action: "write" }, + rateLimit: { action: "homework:batch-write", tier: "batch" }, }); if (auth instanceof Response) return auth; diff --git a/src/lib/api/routes/mcp-cors.ts b/src/lib/api/routes/mcp-cors.ts index d58489a21..f99e86db2 100644 --- a/src/lib/api/routes/mcp-cors.ts +++ b/src/lib/api/routes/mcp-cors.ts @@ -4,7 +4,8 @@ const MCP_CORS_HEADERS = { "Access-Control-Allow-Methods": "POST, DELETE, OPTIONS", "Access-Control-Allow-Headers": "Authorization, Content-Type, MCP-Protocol-Version, MCP-Session-Id, Last-Event-ID", - "Access-Control-Expose-Headers": "MCP-Session-Id, WWW-Authenticate", + "Access-Control-Expose-Headers": + "MCP-Session-Id, Retry-After, WWW-Authenticate", } as const; function appendVaryHeader(headers: Headers, value: string) { diff --git a/src/lib/api/routes/mcp-request-handler.ts b/src/lib/api/routes/mcp-request-handler.ts index ff7ee9032..2f73de066 100644 --- a/src/lib/api/routes/mcp-request-handler.ts +++ b/src/lib/api/routes/mcp-request-handler.ts @@ -1,7 +1,17 @@ import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js"; +import { rateLimitResponse } from "@/lib/api/helpers"; import { logAppEvent } from "@/lib/log/app-logger"; import { logOAuthDebug, oauthDebugCorrelationId } from "@/lib/log/oauth-debug"; -import { extractMcpToolNamesFromRequest } from "@/lib/mcp/tool-scopes"; +import { + extractMcpToolCallNamesFromRequest, + getMcpWriteRateLimitAction, + getMcpWriteRateLimitTier, + isMcpWriteTool, +} from "@/lib/mcp/tool-scopes"; +import { + checkUserMutationRateLimit, + USER_MUTATION_RATE_LIMIT_PERIOD_SECONDS, +} from "@/lib/security/user-mutation-rate-limit"; import { validateMcpOrigin, withMcpCors } from "./mcp-cors"; import { logMcpTransportRequest, @@ -36,7 +46,8 @@ export async function handleMcpRequest(request: Request) { return originError; } - const toolNames = await extractMcpToolNamesFromRequest(request); + const toolCallNames = await extractMcpToolCallNamesFromRequest(request); + const toolNames = Array.from(new Set(toolCallNames)); const { authenticateMcpRequest } = await import("@/lib/mcp/auth"); const authResult = await authenticateMcpRequest(request, toolNames); if ("response" in authResult) { @@ -56,16 +67,45 @@ export async function handleMcpRequest(request: Request) { return withMcpCors(request, res); } + const { summarizeMcpJsonRpcRequest } = await import( + "@/lib/mcp/observability" + ); + rpcSummary = await summarizeMcpJsonRpcRequest(request); + + const userId = authResult.authInfo.extra?.userId; + if (typeof userId === "string" && userId.length > 0) { + for (const toolName of toolCallNames) { + if (!isMcpWriteTool(toolName)) continue; + const outcome = await checkUserMutationRateLimit({ + action: getMcpWriteRateLimitAction(toolName), + host: requestUrl.host, + tier: getMcpWriteRateLimitTier(toolName), + userId, + }); + if (!outcome.allowed) { + const response = rateLimitResponse( + outcome.reason, + USER_MUTATION_RATE_LIMIT_PERIOD_SECONDS, + ); + recordAndLogMcpResponse({ + context: logContext, + request, + phase: "rate-limit-rejected", + rpcSummary, + status: response.status, + start, + }); + return withMcpCors(request, response); + } + } + } + const transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: undefined, }); const { createMcpServer } = await import("@/lib/mcp/server"); - const { summarizeMcpJsonRpcRequest } = await import( - "@/lib/mcp/observability" - ); const server = createMcpServer(); const toolCount = getRegisteredToolCount(server); - rpcSummary = await summarizeMcpJsonRpcRequest(request); logAppEvent("info", "mcp.transport.rpc", { correlationId, method: request.method, diff --git a/src/lib/api/routes/mcp-request-logging.ts b/src/lib/api/routes/mcp-request-logging.ts index 608dc9090..5967b8d20 100644 --- a/src/lib/api/routes/mcp-request-logging.ts +++ b/src/lib/api/routes/mcp-request-logging.ts @@ -51,7 +51,11 @@ export function logMcpTransportResponse({ authFailureDiagnostics?: McpAuthFailureDiagnostics | null; context: McpLogContext; durationMs: number; - phase: "auth-rejected" | "handled" | "origin-rejected"; + phase: + | "auth-rejected" + | "handled" + | "origin-rejected" + | "rate-limit-rejected"; rpcSummary: McpRequestSummary | null; status: number; toolCount?: number; diff --git a/src/lib/api/routes/mcp-response-bookkeeping.ts b/src/lib/api/routes/mcp-response-bookkeeping.ts index d8e57b583..01eca9c88 100644 --- a/src/lib/api/routes/mcp-response-bookkeeping.ts +++ b/src/lib/api/routes/mcp-response-bookkeeping.ts @@ -12,7 +12,11 @@ export function recordAndLogMcpResponse(input: { request: Request; requestUrl: URL; }; - phase: "auth-rejected" | "handled" | "origin-rejected"; + phase: + | "auth-rejected" + | "handled" + | "origin-rejected" + | "rate-limit-rejected"; request: Request; rpcSummary: McpRequestSummary | null; start: number; diff --git a/src/lib/api/routes/todo-batch-route.ts b/src/lib/api/routes/todo-batch-route.ts index 599a9f1bd..e27de0c8f 100644 --- a/src/lib/api/routes/todo-batch-route.ts +++ b/src/lib/api/routes/todo-batch-route.ts @@ -21,6 +21,7 @@ import { serializeDatesDeep } from "@/lib/time/serialize-date-output"; export async function patchTodoBatchRoute(request: Request) { const auth = await requireAuth(request, { bearerScope: { feature: "todo", action: "write" }, + rateLimit: { action: "todo:batch-write", tier: "batch" }, }); if (auth instanceof Response) return auth; @@ -71,6 +72,7 @@ export async function patchTodoBatchRoute(request: Request) { export async function deleteTodoBatchRoute(request: Request) { const auth = await requireAuth(request, { bearerScope: { feature: "todo", action: "write" }, + rateLimit: { action: "todo:batch-write", tier: "batch" }, }); if (auth instanceof Response) return auth; diff --git a/src/lib/api/schemas/request-user-mutation-schemas.ts b/src/lib/api/schemas/request-user-mutation-schemas.ts index b25a7b9a9..966b629a1 100644 --- a/src/lib/api/schemas/request-user-mutation-schemas.ts +++ b/src/lib/api/schemas/request-user-mutation-schemas.ts @@ -19,9 +19,12 @@ const subscriptionSectionIdSchema = z }); const subscriptionLookupCodeSchema = z.string().trim().min(1).max(64); +const subscriptionSectionIdsSchema = z + .array(subscriptionSectionIdSchema) + .max(500); const calendarSubscriptionSelectionRequestSchema = z.object({ - sectionIds: z.array(subscriptionSectionIdSchema).max(500).optional(), + sectionIds: subscriptionSectionIdsSchema.optional(), codes: z.array(subscriptionLookupCodeSchema).max(500).optional(), semesterId: z .preprocess(parseOptionalIntLike, z.union([z.string(), z.number()])) @@ -35,11 +38,11 @@ function hasCalendarSubscriptionSelection( } export const calendarSubscriptionCreateRequestSchema = z.object({ - sectionIds: z.array(subscriptionSectionIdSchema).optional(), + sectionIds: subscriptionSectionIdsSchema.optional(), }); export const calendarSubscriptionAppendRequestSchema = z.object({ - sectionIds: z.array(subscriptionSectionIdSchema), + sectionIds: subscriptionSectionIdsSchema, }); export const calendarSubscriptionRemoveRequestSchema = diff --git a/src/lib/auth/api-auth.ts b/src/lib/auth/api-auth.ts index 572e98050..c7a59ef6f 100644 --- a/src/lib/auth/api-auth.ts +++ b/src/lib/auth/api-auth.ts @@ -1,4 +1,8 @@ -import { suspensionForbidden, unauthorized } from "@/lib/api/helpers"; +import { + rateLimitResponse, + suspensionForbidden, + unauthorized, +} from "@/lib/api/helpers"; import { getJwksUrlForOAuthVerification, getOAuthRestAudienceUrls, @@ -10,6 +14,11 @@ import { restWriteScope, } from "@/lib/oauth/constants"; import { isFeatureScope } from "@/lib/oauth/scope-registry"; +import { + checkUserMutationRateLimit, + USER_MUTATION_RATE_LIMIT_PERIOD_SECONDS, + type UserMutationRateLimitTier, +} from "@/lib/security/user-mutation-rate-limit"; import { parseBearerAuthorizationHeader } from "./authorization-header"; import { verifyAccessTokenJwt } from "./jwt-verification"; import { hasRequestAuthSignal } from "./request-auth-signal"; @@ -19,6 +28,14 @@ export type RestBearerScopeRequirement = { action: "read" | "write"; }; +export type RestAuthOptions = { + bearerScope?: RestBearerScopeRequirement; + rateLimit?: { + action?: string; + tier?: UserMutationRateLimitTier; + }; +}; + function hasRequiredRestScope( scopes: Set, requirement: RestBearerScopeRequirement, @@ -50,7 +67,7 @@ async function getLocalJwks() { */ export async function resolveApiUserId( request: Request, - options: { bearerScope?: RestBearerScopeRequirement } = {}, + options: RestAuthOptions = {}, ): Promise { const bearer = parseBearerAuthorizationHeader(request.headers); if (bearer) { @@ -98,10 +115,28 @@ export async function resolveSessionUserId( export async function requireAuth( request: Request, - options: { bearerScope?: RestBearerScopeRequirement } = {}, + options: RestAuthOptions = {}, ): Promise<{ userId: string } | Response> { const userId = await resolveApiUserId(request, options); - return userId ? { userId } : unauthorized(); + if (!userId) return unauthorized(); + + const scope = options.bearerScope; + if (scope?.action === "write") { + const outcome = await checkUserMutationRateLimit({ + action: options.rateLimit?.action ?? `${scope.feature}:write`, + host: new URL(request.url).host, + tier: options.rateLimit?.tier, + userId, + }); + if (!outcome.allowed) { + return rateLimitResponse( + outcome.reason, + USER_MUTATION_RATE_LIMIT_PERIOD_SECONDS, + ); + } + } + + return { userId }; } /** @@ -122,5 +157,17 @@ export async function requireWriteAuth( const data = await getViewerAuthDataForUserId(userId); if (!data) return unauthorized(); if (data.suspension) return suspensionForbidden(data.suspension.reason); + + const outcome = await checkUserMutationRateLimit({ + action: `${feature}:write`, + host: new URL(request.url).host, + userId, + }); + if (!outcome.allowed) { + return rateLimitResponse( + outcome.reason, + USER_MUTATION_RATE_LIMIT_PERIOD_SECONDS, + ); + } return { userId }; } diff --git a/src/lib/mcp/auth.ts b/src/lib/mcp/auth.ts index ae7964019..ce3197cb6 100644 --- a/src/lib/mcp/auth.ts +++ b/src/lib/mcp/auth.ts @@ -46,6 +46,15 @@ function toolNameCount(toolName: string | string[] | undefined) { return toolName ? 1 : 0; } +function hasRequiredFeatureScope( + grantedScopes: readonly string[], + requiredScope: string, +) { + if (grantedScopes.includes(requiredScope)) return true; + const readScope = /^(.*):read$/.exec(requiredScope); + return readScope ? grantedScopes.includes(`${readScope[1]}:write`) : false; +} + export async function authenticateMcpRequest( request: Request, toolName?: string | string[], @@ -134,12 +143,14 @@ export async function authenticateMcpRequest( } const requiredScopes = getRequiredMcpScopes(toolName); + const hasLegacyMcpToolsScope = authInfo.scopes.includes( + LEGACY_MCP_TOOLS_SCOPE, + ); const hasRequiredScope = requiredScopes.length === 0 || - requiredScopes.some( - (scope) => - authInfo.scopes.includes(scope) || - authInfo.scopes.includes(LEGACY_MCP_TOOLS_SCOPE), + hasLegacyMcpToolsScope || + requiredScopes.every((scope) => + hasRequiredFeatureScope(authInfo.scopes, scope), ); if (!hasRequiredScope) { const diagnostics: McpAuthFailureDiagnostics = { diff --git a/src/lib/mcp/tool-scopes.ts b/src/lib/mcp/tool-scopes.ts index 861f75776..d2ad94572 100644 --- a/src/lib/mcp/tool-scopes.ts +++ b/src/lib/mcp/tool-scopes.ts @@ -9,6 +9,8 @@ type ToolScopeRequirement = { feature: RestFeature; }; +const BATCH_WRITE_TOOLS = new Set(["subscribe_my_sections_by_codes"]); + /** * Maps every registered MCP tool name to the feature action scope(s) it needs. * @@ -161,6 +163,31 @@ export function getRequiredMcpScopes( return Array.from(scopes); } +export function isMcpWriteTool(name: string): boolean { + return ( + TOOL_SCOPE_MAP[name]?.some(({ action }) => action === "write") ?? false + ); +} + +export function getMcpWriteRateLimitTier(name: string): "batch" | "write" { + return BATCH_WRITE_TOOLS.has(name) ? "batch" : "write"; +} + +export function getMcpWriteRateLimitAction(name: string): string { + const features = Array.from( + new Set( + (TOOL_SCOPE_MAP[name] ?? []) + .filter(({ action }) => action === "write") + .map(({ feature }) => feature), + ), + ).sort(); + if (features.length === 0) { + throw new Error(`MCP tool ${name} has no write scope`); + } + const action = BATCH_WRITE_TOOLS.has(name) ? "batch-write" : "write"; + return `${features.join("+")}:${action}`; +} + type McpJsonRpcMessage = { method?: unknown; params?: { name?: unknown }; @@ -179,14 +206,15 @@ function isToolCallMessage(value: unknown): value is McpJsonRpcMessage & { } /** - * Parses a JSON-RPC MCP request body and returns the distinct tool names being - * invoked. Only `tools/call` messages are considered; other methods (ping, - * initialization, `tools/list`, etc.) do not carry a tool-name scope gate. + * Parses a JSON-RPC MCP request body and returns tool names in request order, + * preserving duplicate calls. Only `tools/call` messages are considered; + * other methods (ping, initialization, `tools/list`, etc.) do not carry a + * tool-name scope gate. * * The request is cloned before reading so the original body stream remains * available for downstream transport handling. */ -export async function extractMcpToolNamesFromRequest( +export async function extractMcpToolCallNamesFromRequest( request: Request, ): Promise { if (request.method !== "POST") { @@ -201,12 +229,18 @@ export async function extractMcpToolNamesFromRequest( } const messages = Array.isArray(body) ? body : [body]; - const names = new Set(); + const names: string[] = []; for (const message of messages) { if (isToolCallMessage(message)) { - names.add(message.params.name); + names.push(message.params.name); } } - return Array.from(names); + return names; +} + +export async function extractMcpToolNamesFromRequest( + request: Request, +): Promise { + return Array.from(new Set(await extractMcpToolCallNamesFromRequest(request))); } diff --git a/src/lib/metrics/analytics-engine.ts b/src/lib/metrics/analytics-engine.ts index bb261c0ed..966a4e5a2 100644 --- a/src/lib/metrics/analytics-engine.ts +++ b/src/lib/metrics/analytics-engine.ts @@ -14,7 +14,11 @@ type McpTransportAnalyticsInput = { durationMs: number; method: string; path: string; - phase: "auth-rejected" | "handled" | "origin-rejected"; + phase: + | "auth-rejected" + | "handled" + | "origin-rejected" + | "rate-limit-rejected"; rpcSummary: McpRequestSummary | null; status: number; toolCount?: number; diff --git a/src/lib/security/user-mutation-rate-limit.ts b/src/lib/security/user-mutation-rate-limit.ts new file mode 100644 index 000000000..141c44ea2 --- /dev/null +++ b/src/lib/security/user-mutation-rate-limit.ts @@ -0,0 +1,52 @@ +import { + getCloudflareUserMutationRateLimiter, + hasCloudflareRuntimeEnv, +} from "@/lib/adapters/cloudflare-runtime"; + +export const USER_MUTATION_RATE_LIMIT_PERIOD_SECONDS = 60; + +export type UserMutationRateLimitTier = "batch" | "write"; + +export type UserMutationRateLimitResult = + | { allowed: true } + | { allowed: false; reason: "limited" | "unavailable" }; + +function rateLimitKey(input: { action: string; host: string; userId: string }) { + return JSON.stringify([ + "user-mutation:v1", + input.host.trim().toLowerCase(), + input.action, + input.userId, + ]); +} + +/** + * Enforce a per-deployment, per-user, per-action mutation budget. + * + * Host-native development has no Cloudflare runtime and intentionally bypasses + * the gate. Once a Cloudflare runtime is present, a missing or failing binding + * fails closed so a deployment cannot silently lose its abuse protection. + */ +export async function checkUserMutationRateLimit(input: { + action: string; + host: string; + tier?: UserMutationRateLimitTier; + userId: string; +}): Promise { + const tier = input.tier ?? "write"; + const limiter = getCloudflareUserMutationRateLimiter(tier); + if (limiter == null) { + return hasCloudflareRuntimeEnv() + ? { allowed: false, reason: "unavailable" } + : { allowed: true }; + } + + try { + const outcome = await limiter.limit({ key: rateLimitKey(input) }); + return outcome.success + ? { allowed: true } + : { allowed: false, reason: "limited" }; + } catch { + return { allowed: false, reason: "unavailable" }; + } +} diff --git a/src/routes/api/admin/comments/[id]/+server.ts b/src/routes/api/admin/comments/[id]/+server.ts index 75f854657..ffcf757ef 100644 --- a/src/routes/api/admin/comments/[id]/+server.ts +++ b/src/routes/api/admin/comments/[id]/+server.ts @@ -11,6 +11,8 @@ import { observedApiRoute } from "@/lib/log/api-observability"; * @response 401:openApiErrorSchema * @response 403:openApiErrorSchema * @response 404:openApiErrorSchema + * @response 429:openApiErrorSchema + * @response 503:openApiErrorSchema */ export const PATCH: RequestHandler = ({ request, params }) => observedApiRoute(() => patchAdminCommentRoute(request, { id: params.id }))( diff --git a/src/routes/api/admin/descriptions/[id]/+server.ts b/src/routes/api/admin/descriptions/[id]/+server.ts index 2aee724a2..1826ae024 100644 --- a/src/routes/api/admin/descriptions/[id]/+server.ts +++ b/src/routes/api/admin/descriptions/[id]/+server.ts @@ -11,6 +11,8 @@ import { observedApiRoute } from "@/lib/log/api-observability"; * @response 401:openApiErrorSchema * @response 403:openApiErrorSchema * @response 404:openApiErrorSchema + * @response 429:openApiErrorSchema + * @response 503:openApiErrorSchema */ export const PATCH: RequestHandler = ({ request, params }) => observedApiRoute(() => diff --git a/src/routes/api/admin/homeworks/[id]/+server.ts b/src/routes/api/admin/homeworks/[id]/+server.ts index 8e93779b9..fff0c69f8 100644 --- a/src/routes/api/admin/homeworks/[id]/+server.ts +++ b/src/routes/api/admin/homeworks/[id]/+server.ts @@ -9,6 +9,8 @@ import { observedApiRoute } from "@/lib/log/api-observability"; * @response 401:openApiErrorSchema * @response 403:openApiErrorSchema * @response 404:openApiErrorSchema + * @response 429:openApiErrorSchema + * @response 503:openApiErrorSchema */ export const DELETE: RequestHandler = ({ request, params }) => observedApiRoute(() => deleteAdminHomeworkRoute(request, { id: params.id }))( diff --git a/src/routes/api/admin/suspensions/+server.ts b/src/routes/api/admin/suspensions/+server.ts index bbee0b791..77133f58f 100644 --- a/src/routes/api/admin/suspensions/+server.ts +++ b/src/routes/api/admin/suspensions/+server.ts @@ -21,6 +21,8 @@ export const GET = svelteRequestHandler( * @response 401:openApiErrorSchema * @response 403:openApiErrorSchema * @response 404:openApiErrorSchema + * @response 429:openApiErrorSchema + * @response 503:openApiErrorSchema */ export const POST = svelteRequestHandler( observedApiRoute(postAdminSuspensionRoute), diff --git a/src/routes/api/admin/suspensions/[id]/+server.ts b/src/routes/api/admin/suspensions/[id]/+server.ts index 02c84d729..33296ed64 100644 --- a/src/routes/api/admin/suspensions/[id]/+server.ts +++ b/src/routes/api/admin/suspensions/[id]/+server.ts @@ -9,6 +9,8 @@ import { observedApiRoute } from "@/lib/log/api-observability"; * @response 401:openApiErrorSchema * @response 403:openApiErrorSchema * @response 404:openApiErrorSchema + * @response 429:openApiErrorSchema + * @response 503:openApiErrorSchema */ export const PATCH: RequestHandler = ({ request, params }) => observedApiRoute(() => patchAdminSuspensionRoute(request, { id: params.id }))( diff --git a/src/routes/api/admin/users/[id]/+server.ts b/src/routes/api/admin/users/[id]/+server.ts index b5185b95b..ba636bad9 100644 --- a/src/routes/api/admin/users/[id]/+server.ts +++ b/src/routes/api/admin/users/[id]/+server.ts @@ -11,6 +11,8 @@ import { observedApiRoute } from "@/lib/log/api-observability"; * @response 401:openApiErrorSchema * @response 403:openApiErrorSchema * @response 404:openApiErrorSchema + * @response 429:openApiErrorSchema + * @response 503:openApiErrorSchema */ export const PATCH: RequestHandler = ({ request, params }) => observedApiRoute(() => patchAdminUserRoute(request, { id: params.id }))( diff --git a/src/routes/api/bus/preferences/+server.ts b/src/routes/api/bus/preferences/+server.ts index a44150dd9..fa8738692 100644 --- a/src/routes/api/bus/preferences/+server.ts +++ b/src/routes/api/bus/preferences/+server.ts @@ -19,6 +19,8 @@ export const GET = svelteRequestHandler( * @response busPreferenceResponseSchema * @response 401:openApiErrorSchema * @response 400:openApiErrorSchema + * @response 429:openApiErrorSchema + * @response 503:openApiErrorSchema */ export const POST = svelteRequestHandler( observedApiRoute(postBusPreferencesRoute), diff --git a/src/routes/api/calendar-subscriptions/+server.ts b/src/routes/api/calendar-subscriptions/+server.ts index 48be31866..7798086dc 100644 --- a/src/routes/api/calendar-subscriptions/+server.ts +++ b/src/routes/api/calendar-subscriptions/+server.ts @@ -12,6 +12,8 @@ import { observedApiRoute } from "@/lib/log/api-observability"; * @response calendarSubscriptionCreateResponseSchema * @response 400:openApiErrorSchema * @response 401:openApiErrorSchema + * @response 429:openApiErrorSchema + * @response 503:openApiErrorSchema */ export const POST = svelteRequestHandler( observedApiRoute(postCalendarSubscriptionsRoute), @@ -24,6 +26,8 @@ export const POST = svelteRequestHandler( * @response 400:openApiErrorSchema * @response 401:openApiErrorSchema * @response 404:openApiErrorSchema + * @response 429:openApiErrorSchema + * @response 503:openApiErrorSchema */ export const PATCH = svelteRequestHandler( observedApiRoute(patchCalendarSubscriptionsRoute), @@ -36,6 +40,8 @@ export const PATCH = svelteRequestHandler( * @response 400:openApiErrorSchema * @response 401:openApiErrorSchema * @response 404:openApiErrorSchema + * @response 429:openApiErrorSchema + * @response 503:openApiErrorSchema */ export const DELETE = svelteRequestHandler( observedApiRoute(deleteCalendarSubscriptionsRoute), diff --git a/src/routes/api/calendar-subscriptions/batch/+server.ts b/src/routes/api/calendar-subscriptions/batch/+server.ts index 4a197d1a2..6278df841 100644 --- a/src/routes/api/calendar-subscriptions/batch/+server.ts +++ b/src/routes/api/calendar-subscriptions/batch/+server.ts @@ -9,6 +9,8 @@ import { observedApiRoute } from "@/lib/log/api-observability"; * @response 400:openApiErrorSchema * @response 401:openApiErrorSchema * @response 404:openApiErrorSchema + * @response 429:openApiErrorSchema + * @response 503:openApiErrorSchema */ export const POST = svelteRequestHandler( observedApiRoute(postCalendarSubscriptionBatchRoute), diff --git a/src/routes/api/calendar-subscriptions/import-codes/+server.ts b/src/routes/api/calendar-subscriptions/import-codes/+server.ts index 31e39f034..7dd6afdb5 100644 --- a/src/routes/api/calendar-subscriptions/import-codes/+server.ts +++ b/src/routes/api/calendar-subscriptions/import-codes/+server.ts @@ -9,6 +9,8 @@ import { observedApiRoute } from "@/lib/log/api-observability"; * @response 401:openApiErrorSchema * @response 400:openApiErrorSchema * @response 404:openApiErrorSchema + * @response 429:openApiErrorSchema + * @response 503:openApiErrorSchema */ export const POST = svelteRequestHandler( observedApiRoute(postCalendarSubscriptionImportCodesRoute), diff --git a/src/routes/api/comments/+server.ts b/src/routes/api/comments/+server.ts index aa14fa61f..57437d6bf 100644 --- a/src/routes/api/comments/+server.ts +++ b/src/routes/api/comments/+server.ts @@ -18,5 +18,7 @@ export const GET = svelteRequestHandler(observedApiRoute(getCommentsRoute)); * @response 401:openApiErrorSchema * @response 403:openApiErrorSchema * @response 404:openApiErrorSchema + * @response 429:openApiErrorSchema + * @response 503:openApiErrorSchema */ export const POST = svelteRequestHandler(observedApiRoute(postCommentRoute)); diff --git a/src/routes/api/comments/[id]/+server.ts b/src/routes/api/comments/[id]/+server.ts index 54656c2d3..421b11e49 100644 --- a/src/routes/api/comments/[id]/+server.ts +++ b/src/routes/api/comments/[id]/+server.ts @@ -25,6 +25,8 @@ export const GET: RequestHandler = ({ request, params }) => * @response 401:openApiErrorSchema * @response 403:openApiErrorSchema * @response 404:openApiErrorSchema + * @response 429:openApiErrorSchema + * @response 503:openApiErrorSchema */ export const PATCH: RequestHandler = ({ request, params }) => observedApiRoute(() => patchCommentRoute(request, { id: params.id }))( @@ -38,6 +40,8 @@ export const PATCH: RequestHandler = ({ request, params }) => * @response 401:openApiErrorSchema * @response 403:openApiErrorSchema * @response 404:openApiErrorSchema + * @response 429:openApiErrorSchema + * @response 503:openApiErrorSchema */ export const DELETE: RequestHandler = ({ request, params }) => observedApiRoute(() => deleteCommentRoute(request, { id: params.id }))( diff --git a/src/routes/api/comments/[id]/reactions/+server.ts b/src/routes/api/comments/[id]/reactions/+server.ts index 505e2fe2e..b7b8577c3 100644 --- a/src/routes/api/comments/[id]/reactions/+server.ts +++ b/src/routes/api/comments/[id]/reactions/+server.ts @@ -14,6 +14,8 @@ import { observedApiRoute } from "@/lib/log/api-observability"; * @response 401:openApiErrorSchema * @response 403:openApiErrorSchema * @response 404:openApiErrorSchema + * @response 429:openApiErrorSchema + * @response 503:openApiErrorSchema */ export const POST: RequestHandler = ({ request, params }) => observedApiRoute(() => postCommentReactionRoute(request, { id: params.id }))( @@ -28,6 +30,8 @@ export const POST: RequestHandler = ({ request, params }) => * @response 400:openApiErrorSchema * @response 401:openApiErrorSchema * @response 403:openApiErrorSchema + * @response 429:openApiErrorSchema + * @response 503:openApiErrorSchema */ export const DELETE: RequestHandler = ({ request, params }) => observedApiRoute(() => diff --git a/src/routes/api/comments/batch/+server.ts b/src/routes/api/comments/batch/+server.ts index b884c3874..5ce6dd13f 100644 --- a/src/routes/api/comments/batch/+server.ts +++ b/src/routes/api/comments/batch/+server.ts @@ -9,6 +9,8 @@ import { observedApiRoute } from "@/lib/log/api-observability"; * @response 400:openApiErrorSchema * @response 401:openApiErrorSchema * @response 403:openApiErrorSchema + * @response 429:openApiErrorSchema + * @response 503:openApiErrorSchema */ export const DELETE = svelteRequestHandler( observedApiRoute(deleteCommentBatchRoute), diff --git a/src/routes/api/dashboard-links/pin/+server.ts b/src/routes/api/dashboard-links/pin/+server.ts index 0edc9d93e..9feaa4923 100644 --- a/src/routes/api/dashboard-links/pin/+server.ts +++ b/src/routes/api/dashboard-links/pin/+server.ts @@ -9,6 +9,8 @@ import { observedApiRoute } from "@/lib/log/api-observability"; * @response 303 * @response 400:dashboardLinkPinResponseSchema * @response 401:dashboardLinkPinResponseSchema + * @response 429:openApiErrorSchema + * @response 503:openApiErrorSchema * @response 500:dashboardLinkPinResponseSchema */ export const POST = svelteRequestHandler( diff --git a/src/routes/api/dashboard-links/pin/batch/+server.ts b/src/routes/api/dashboard-links/pin/batch/+server.ts index 4c66c44b8..be67ea4cd 100644 --- a/src/routes/api/dashboard-links/pin/batch/+server.ts +++ b/src/routes/api/dashboard-links/pin/batch/+server.ts @@ -8,6 +8,8 @@ import { observedApiRoute } from "@/lib/log/api-observability"; * @response dashboardLinkPinResponseSchema * @response 400:dashboardLinkPinResponseSchema * @response 401:openApiErrorSchema + * @response 429:openApiErrorSchema + * @response 503:openApiErrorSchema * @response 500:dashboardLinkPinResponseSchema */ export const POST = svelteRequestHandler( diff --git a/src/routes/api/descriptions/+server.ts b/src/routes/api/descriptions/+server.ts index 32499d14c..2a9c65499 100644 --- a/src/routes/api/descriptions/+server.ts +++ b/src/routes/api/descriptions/+server.ts @@ -21,6 +21,8 @@ export const GET = svelteRequestHandler(observedApiRoute(getDescriptionRoute)); * @response 401:openApiErrorSchema * @response 403:openApiErrorSchema * @response 404:openApiErrorSchema + * @response 429:openApiErrorSchema + * @response 503:openApiErrorSchema */ export const POST = svelteRequestHandler( observedApiRoute(postDescriptionRoute), diff --git a/src/routes/api/homeworks/+server.ts b/src/routes/api/homeworks/+server.ts index dfc137b3b..e13aff982 100644 --- a/src/routes/api/homeworks/+server.ts +++ b/src/routes/api/homeworks/+server.ts @@ -21,5 +21,7 @@ export const GET = svelteRequestHandler(observedApiRoute(getHomeworksRoute)); * @response 401:openApiErrorSchema * @response 403:openApiErrorSchema * @response 404:openApiErrorSchema + * @response 429:openApiErrorSchema + * @response 503:openApiErrorSchema */ export const POST = svelteRequestHandler(observedApiRoute(postHomeworkRoute)); diff --git a/src/routes/api/homeworks/[id]/+server.ts b/src/routes/api/homeworks/[id]/+server.ts index 8c4b5bef6..43224610d 100644 --- a/src/routes/api/homeworks/[id]/+server.ts +++ b/src/routes/api/homeworks/[id]/+server.ts @@ -14,6 +14,8 @@ import { observedApiRoute } from "@/lib/log/api-observability"; * @response 401:openApiErrorSchema * @response 403:openApiErrorSchema * @response 404:openApiErrorSchema + * @response 429:openApiErrorSchema + * @response 503:openApiErrorSchema */ export const PATCH: RequestHandler = ({ request, params }) => observedApiRoute(() => patchHomeworkRoute(request, { id: params.id }))( @@ -27,6 +29,8 @@ export const PATCH: RequestHandler = ({ request, params }) => * @response 401:openApiErrorSchema * @response 403:openApiErrorSchema * @response 404:openApiErrorSchema + * @response 429:openApiErrorSchema + * @response 503:openApiErrorSchema */ export const DELETE: RequestHandler = ({ request, params }) => observedApiRoute(() => deleteHomeworkRoute(request, { id: params.id }))( diff --git a/src/routes/api/homeworks/[id]/completion/+server.ts b/src/routes/api/homeworks/[id]/completion/+server.ts index 8e0a478c8..2cda6ad8f 100644 --- a/src/routes/api/homeworks/[id]/completion/+server.ts +++ b/src/routes/api/homeworks/[id]/completion/+server.ts @@ -10,6 +10,8 @@ import { observedApiRoute } from "@/lib/log/api-observability"; * @response 400:openApiErrorSchema * @response 401:openApiErrorSchema * @response 404:openApiErrorSchema + * @response 429:openApiErrorSchema + * @response 503:openApiErrorSchema */ export const PUT: RequestHandler = ({ request, params }) => observedApiRoute(() => diff --git a/src/routes/api/homeworks/completions/+server.ts b/src/routes/api/homeworks/completions/+server.ts index d9d119684..26867fe75 100644 --- a/src/routes/api/homeworks/completions/+server.ts +++ b/src/routes/api/homeworks/completions/+server.ts @@ -8,6 +8,8 @@ import { observedApiRoute } from "@/lib/log/api-observability"; * @response homeworkCompletionBatchResponseSchema * @response 400:openApiErrorSchema * @response 401:openApiErrorSchema + * @response 429:openApiErrorSchema + * @response 503:openApiErrorSchema */ export const PUT = svelteRequestHandler( observedApiRoute(putHomeworkCompletionsRoute), diff --git a/src/routes/api/mcp/+server.ts b/src/routes/api/mcp/+server.ts index 6ffe1c246..576f7ddc3 100644 --- a/src/routes/api/mcp/+server.ts +++ b/src/routes/api/mcp/+server.ts @@ -20,6 +20,8 @@ export const GET: RequestHandler = ({ request }) => mcpGetRoute(request); * @response 200 * @response 401:openApiErrorSchema * @response 403:openApiErrorSchema + * @response 429:openApiErrorSchema + * @response 503:openApiErrorSchema */ export const POST: RequestHandler = ({ request }) => mcpPostRoute(request); diff --git a/src/routes/api/todos/+server.ts b/src/routes/api/todos/+server.ts index 79617fbdf..a5ec753ec 100644 --- a/src/routes/api/todos/+server.ts +++ b/src/routes/api/todos/+server.ts @@ -16,5 +16,7 @@ export const GET = svelteRequestHandler(observedApiRoute(getTodosRoute)); * @response 201:idResponseSchema * @response 400:openApiErrorSchema * @response 401:openApiErrorSchema + * @response 429:openApiErrorSchema + * @response 503:openApiErrorSchema */ export const POST = svelteRequestHandler(observedApiRoute(postTodoRoute)); diff --git a/src/routes/api/todos/[id]/+server.ts b/src/routes/api/todos/[id]/+server.ts index d6fa720c8..3213260e8 100644 --- a/src/routes/api/todos/[id]/+server.ts +++ b/src/routes/api/todos/[id]/+server.ts @@ -11,6 +11,8 @@ import { observedApiRoute } from "@/lib/log/api-observability"; * @response 401:openApiErrorSchema * @response 403:openApiErrorSchema * @response 404:openApiErrorSchema + * @response 429:openApiErrorSchema + * @response 503:openApiErrorSchema */ export const PATCH: RequestHandler = ({ request, params }) => observedApiRoute(() => patchTodoRoute(request, { id: params.id }))(request); @@ -22,6 +24,8 @@ export const PATCH: RequestHandler = ({ request, params }) => * @response 401:openApiErrorSchema * @response 403:openApiErrorSchema * @response 404:openApiErrorSchema + * @response 429:openApiErrorSchema + * @response 503:openApiErrorSchema */ export const DELETE: RequestHandler = ({ request, params }) => observedApiRoute(() => deleteTodoRoute(request, { id: params.id }))(request); diff --git a/src/routes/api/todos/batch/+server.ts b/src/routes/api/todos/batch/+server.ts index 220b2a0f2..230098c73 100644 --- a/src/routes/api/todos/batch/+server.ts +++ b/src/routes/api/todos/batch/+server.ts @@ -11,6 +11,8 @@ import { observedApiRoute } from "@/lib/log/api-observability"; * @response todoCompletionBatchResponseSchema * @response 400:openApiErrorSchema * @response 401:openApiErrorSchema + * @response 429:openApiErrorSchema + * @response 503:openApiErrorSchema */ export const PATCH = svelteRequestHandler( observedApiRoute(patchTodoBatchRoute), @@ -22,6 +24,8 @@ export const PATCH = svelteRequestHandler( * @response todoBatchDeleteResponseSchema * @response 400:openApiErrorSchema * @response 401:openApiErrorSchema + * @response 429:openApiErrorSchema + * @response 503:openApiErrorSchema */ export const DELETE = svelteRequestHandler( observedApiRoute(deleteTodoBatchRoute), diff --git a/src/routes/api/uploads/+server.ts b/src/routes/api/uploads/+server.ts index f3061cb52..f2b239a78 100644 --- a/src/routes/api/uploads/+server.ts +++ b/src/routes/api/uploads/+server.ts @@ -18,5 +18,7 @@ export const GET = svelteRequestHandler(observedApiRoute(getUploadsRoute)); * @response 401:openApiErrorSchema * @response 403:openApiErrorSchema * @response 413:openApiErrorSchema + * @response 429:openApiErrorSchema + * @response 503:openApiErrorSchema */ export const POST = svelteRequestHandler(observedApiRoute(postUploadRoute)); diff --git a/src/routes/api/uploads/[id]/+server.ts b/src/routes/api/uploads/[id]/+server.ts index 3229e3d11..0028e2638 100644 --- a/src/routes/api/uploads/[id]/+server.ts +++ b/src/routes/api/uploads/[id]/+server.ts @@ -11,6 +11,8 @@ import { observedApiRoute } from "@/lib/log/api-observability"; * @response 401:openApiErrorSchema * @response 403:openApiErrorSchema * @response 404:openApiErrorSchema + * @response 429:openApiErrorSchema + * @response 503:openApiErrorSchema */ export const PATCH: RequestHandler = ({ request, params }) => observedApiRoute(() => patchUploadRoute(request, { id: params.id }))(request); @@ -22,7 +24,9 @@ export const PATCH: RequestHandler = ({ request, params }) => * @response 401:openApiErrorSchema * @response 403:openApiErrorSchema * @response 404:openApiErrorSchema + * @response 429:openApiErrorSchema * @response 502:openApiErrorSchema + * @response 503:openApiErrorSchema */ export const DELETE: RequestHandler = ({ request, params }) => observedApiRoute(() => deleteUploadRoute(request, { id: params.id }))( diff --git a/src/routes/api/uploads/complete/+server.ts b/src/routes/api/uploads/complete/+server.ts index 7a61fcb6b..dc1d89242 100644 --- a/src/routes/api/uploads/complete/+server.ts +++ b/src/routes/api/uploads/complete/+server.ts @@ -9,6 +9,8 @@ import { observedApiRoute } from "@/lib/log/api-observability"; * @response 400:openApiErrorSchema * @response 401:openApiErrorSchema * @response 403:openApiErrorSchema + * @response 429:openApiErrorSchema + * @response 503:openApiErrorSchema */ export const POST = svelteRequestHandler( observedApiRoute(postUploadCompleteRoute), diff --git a/src/routes/api/uploads/object/+server.ts b/src/routes/api/uploads/object/+server.ts index 8972c8336..d11bdf51a 100644 --- a/src/routes/api/uploads/object/+server.ts +++ b/src/routes/api/uploads/object/+server.ts @@ -11,6 +11,8 @@ import type { RequestHandler } from "./$types"; * @response 401:openApiErrorSchema * @response 403:openApiErrorSchema * @response 413:openApiErrorSchema + * @response 429:openApiErrorSchema + * @response 503:openApiErrorSchema */ export const PUT: RequestHandler = ({ request }) => observedApiRoute(putUploadObjectRoute)(request); diff --git a/tests/unit/admin-route-auth.test.ts b/tests/unit/admin-route-auth.test.ts index d48173b9b..cea0adf5f 100644 --- a/tests/unit/admin-route-auth.test.ts +++ b/tests/unit/admin-route-auth.test.ts @@ -1,4 +1,5 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { setCloudflareRuntimeEnv } from "@/lib/adapters/cloudflare-runtime"; const { findActiveSuspensionMock, @@ -35,7 +36,12 @@ vi.mock("@/lib/mcp/urls", () => ({ })); describe("admin 路由认证", () => { + beforeEach(() => { + setCloudflareRuntimeEnv(undefined); + }); + afterEach(() => { + setCloudflareRuntimeEnv(undefined); findActiveSuspensionMock.mockReset(); getSessionFromHeadersMock.mockReset(); resolveAdminByUserIdMock.mockReset(); @@ -170,4 +176,87 @@ describe("admin 路由认证", () => { }); expect(findActiveSuspensionMock).toHaveBeenCalledWith("admin-1"); }); + + it("管理员读取不消耗写入预算", async () => { + const limit = vi.fn().mockResolvedValue({ success: false }); + setCloudflareRuntimeEnv({ USER_WRITE_RATE_LIMITER: { limit } }); + getSessionFromHeadersMock.mockResolvedValue({ + user: { id: "admin-1" }, + }); + resolveAdminByUserIdMock.mockResolvedValue({ userId: "admin-1" }); + const { requireAdminRequest } = await import( + "@/lib/api/routes/admin-route-auth" + ); + + await expect( + requireAdminRequest( + new Request("https://example.test/api/admin/comments", { + headers: { cookie: "better-auth.session_token=session-token" }, + }), + ), + ).resolves.toEqual({ userId: "admin-1" }); + expect(limit).not.toHaveBeenCalled(); + }); + + it("管理员写入超过预算时在业务处理前返回 429", async () => { + const limit = vi.fn().mockResolvedValue({ success: false }); + setCloudflareRuntimeEnv({ USER_WRITE_RATE_LIMITER: { limit } }); + getSessionFromHeadersMock.mockResolvedValue({ + user: { id: "admin-1" }, + }); + resolveAdminByUserIdMock.mockResolvedValue({ userId: "admin-1" }); + findActiveSuspensionMock.mockResolvedValue(null); + const { requireAdminRequest } = await import( + "@/lib/api/routes/admin-route-auth" + ); + + const response = await requireAdminRequest( + new Request("https://example.test/api/admin/comments/comment-1", { + method: "DELETE", + headers: { cookie: "better-auth.session_token=session-token" }, + }), + { requireActive: true }, + ); + + expect(response).toBeInstanceOf(Response); + expect((response as Response).status).toBe(429); + expect((response as Response).headers.get("Retry-After")).toBe("60"); + expect(limit).toHaveBeenCalledWith({ + key: JSON.stringify([ + "user-mutation:v1", + "example.test", + "admin:comments:write", + "admin-1", + ]), + }); + }); + + it("百分号编码的管理员路径仍使用规范资源预算", async () => { + const limit = vi.fn().mockResolvedValue({ success: true }); + setCloudflareRuntimeEnv({ USER_WRITE_RATE_LIMITER: { limit } }); + getSessionFromHeadersMock.mockResolvedValue({ + user: { id: "admin-1" }, + }); + resolveAdminByUserIdMock.mockResolvedValue({ userId: "admin-1" }); + const { requireAdminRequest } = await import( + "@/lib/api/routes/admin-route-auth" + ); + + await expect( + requireAdminRequest( + new Request("https://example.test/api/admin/%63omments/comment-1", { + method: "DELETE", + headers: { cookie: "better-auth.session_token=session-token" }, + }), + ), + ).resolves.toEqual({ userId: "admin-1" }); + expect(limit).toHaveBeenCalledWith({ + key: JSON.stringify([ + "user-mutation:v1", + "example.test", + "admin:comments:write", + "admin-1", + ]), + }); + }); }); diff --git a/tests/unit/api-helpers.test.ts b/tests/unit/api-helpers.test.ts index cc41782bb..5fc25da5b 100644 --- a/tests/unit/api-helpers.test.ts +++ b/tests/unit/api-helpers.test.ts @@ -8,6 +8,7 @@ import { parseRouteInput, parseRouteQuery, parseRouteSearchParams, + rateLimitResponse, } from "@/lib/api/helpers"; describe("API 辅助函数", () => { @@ -17,6 +18,17 @@ describe("API 辅助函数", () => { expect(response.headers.has("x-request-id")).toBe(false); }); + it.each([ + ["limited" as const, 429, "Rate limit exceeded"], + ["unavailable" as const, 503, "Rate limiting unavailable"], + ])("为 %s 限流结果返回 Retry-After", async (reason, status, error) => { + const response = rateLimitResponse(reason, 45); + + expect(response.status).toBe(status); + expect(response.headers.get("Retry-After")).toBe("45"); + await expect(response.json()).resolves.toEqual({ error }); + }); + it("接受来自字符串和数字的安全整数", () => { expect(parseInteger("42")).toBe(42); expect(parseInteger(" -7 ")).toBe(-7); diff --git a/tests/unit/api-schemas.test.ts b/tests/unit/api-schemas.test.ts index 310bceda6..6c0b968d3 100644 --- a/tests/unit/api-schemas.test.ts +++ b/tests/unit/api-schemas.test.ts @@ -419,6 +419,11 @@ describe("其他请求 schema", () => { sectionIds: [1, 2, 3], }); expect(valid.success).toBe(true); + expect( + calendarSubscriptionCreateRequestSchema.safeParse({ + sectionIds: Array.from({ length: 501 }, (_, index) => index + 1), + }).success, + ).toBe(false); }); it("校验日历订阅追加 payload", () => { @@ -429,6 +434,11 @@ describe("其他请求 schema", () => { expect(valid.success).toBe(true); expect(missingIds.success).toBe(false); + expect( + calendarSubscriptionAppendRequestSchema.safeParse({ + sectionIds: Array.from({ length: 501 }, (_, index) => index + 1), + }).success, + ).toBe(false); }); it("校验日历订阅查询 payload", () => { @@ -478,10 +488,12 @@ describe("其他请求 schema", () => { ]) { const schema = openApiSchema(name); const properties = schema.properties as - | Record }> + | Record; maxItems?: number }> | undefined; - const items = properties?.sectionIds?.items; + const sectionIds = properties?.sectionIds; + const items = sectionIds?.items; + expect(sectionIds?.maxItems).toBe(500); expect(items).toMatchObject({ minimum: 1, type: "integer" }); expect(items).not.toHaveProperty("exclusiveMinimum"); } diff --git a/tests/unit/auth-helpers.test.ts b/tests/unit/auth-helpers.test.ts index b9c382469..61d583e7c 100644 --- a/tests/unit/auth-helpers.test.ts +++ b/tests/unit/auth-helpers.test.ts @@ -1,4 +1,5 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { setCloudflareRuntimeEnv } from "@/lib/adapters/cloudflare-runtime"; import { MCP_TOOLS_SCOPE, OAUTH_PROFILE_SCOPE, @@ -35,12 +36,17 @@ vi.mock("@/lib/mcp/urls", () => ({ describe("认证辅助函数", () => { beforeEach(() => { + setCloudflareRuntimeEnv(undefined); vi.resetModules(); getSessionFromHeadersMock.mockReset(); verifyAccessTokenJwtMock.mockReset(); getViewerAuthDataForUserIdMock.mockReset(); }); + afterEach(() => { + setCloudflareRuntimeEnv(undefined); + }); + it("使用路由请求头进行 session cookie 回退", async () => { getSessionFromHeadersMock.mockResolvedValue({ user: { id: "user-from-cookie" }, @@ -237,6 +243,120 @@ describe("认证辅助函数", () => { expect(getViewerAuthDataForUserIdMock).not.toHaveBeenCalled(); }); + it("在认证成功后拒绝超过 REST 写入预算的请求", async () => { + const limit = vi.fn().mockResolvedValue({ success: false }); + setCloudflareRuntimeEnv({ USER_WRITE_RATE_LIMITER: { limit } }); + verifyAccessTokenJwtMock.mockResolvedValue({ + scope: expandScopeClaim(OAUTH_REST_WRITE_SCOPE), + sub: "user-from-token", + }); + const { requireAuth } = await import("@/lib/auth/api-auth"); + const request = new Request("https://life.example/api/todos", { + method: "POST", + headers: { authorization: "Bearer write-token" }, + }); + + const result = await requireAuth(request, { + bearerScope: { feature: "todo", action: "write" }, + }); + + expect(result).toBeInstanceOf(Response); + expect((result as Response).status).toBe(429); + expect((result as Response).headers.get("Retry-After")).toBe("60"); + await expect((result as Response).json()).resolves.toEqual({ + error: "Rate limit exceeded", + }); + expect(limit).toHaveBeenCalledWith({ + key: JSON.stringify([ + "user-mutation:v1", + "life.example", + "todo:write", + "user-from-token", + ]), + }); + }); + + it("不为 REST 读取消耗写入预算", async () => { + const limit = vi.fn().mockResolvedValue({ success: false }); + setCloudflareRuntimeEnv({ USER_WRITE_RATE_LIMITER: { limit } }); + verifyAccessTokenJwtMock.mockResolvedValue({ + scope: expandScopeClaim(OAUTH_REST_READ_SCOPE), + sub: "user-from-token", + }); + const { requireAuth } = await import("@/lib/auth/api-auth"); + const request = new Request("https://life.example/api/todos", { + headers: { authorization: "Bearer read-token" }, + }); + + await expect( + requireAuth(request, { + bearerScope: { feature: "todo", action: "read" }, + }), + ).resolves.toEqual({ userId: "user-from-token" }); + expect(limit).not.toHaveBeenCalled(); + }); + + it("Cloudflare 写入限流绑定缺失时返回 503", async () => { + setCloudflareRuntimeEnv({ ANALYTICS: { writeDataPoint: vi.fn() } }); + verifyAccessTokenJwtMock.mockResolvedValue({ + scope: expandScopeClaim(OAUTH_REST_WRITE_SCOPE), + sub: "user-from-token", + }); + const { requireAuth } = await import("@/lib/auth/api-auth"); + + const result = await requireAuth( + new Request("https://life.example/api/todos", { + method: "POST", + headers: { authorization: "Bearer write-token" }, + }), + { bearerScope: { feature: "todo", action: "write" } }, + ); + + expect(result).toBeInstanceOf(Response); + expect((result as Response).status).toBe(503); + expect((result as Response).headers.get("Retry-After")).toBe("60"); + }); + + it("上传写入在用户与暂停检查后应用统一预算", async () => { + const limit = vi.fn().mockResolvedValue({ success: false }); + setCloudflareRuntimeEnv({ USER_WRITE_RATE_LIMITER: { limit } }); + verifyAccessTokenJwtMock.mockResolvedValue({ + scope: expandScopeClaim(OAUTH_REST_WRITE_SCOPE), + sub: "user-from-token", + }); + getViewerAuthDataForUserIdMock.mockResolvedValue({ + user: { + id: "user-from-token", + name: null, + image: null, + isAdmin: false, + }, + suspension: null, + }); + const { requireWriteAuth } = await import("@/lib/auth/api-auth"); + + const result = await requireWriteAuth( + new Request("https://life.example/api/uploads", { + method: "POST", + headers: { authorization: "Bearer write-token" }, + }), + ); + + expect(result).toBeInstanceOf(Response); + expect((result as Response).status).toBe(429); + expect(getViewerAuthDataForUserIdMock).toHaveBeenCalledWith( + "user-from-token", + ); + expect(limit).toHaveBeenCalledWith({ + key: JSON.stringify([ + "user-mutation:v1", + "life.example", + "upload:write", + "user-from-token", + ]), + }); + }); + it("当 bearer 令牌无效时不回退到 session cookie", async () => { verifyAccessTokenJwtMock.mockRejectedValue(new Error("invalid token")); getSessionFromHeadersMock.mockResolvedValue({ diff --git a/tests/unit/comment-batch-delete-route.test.ts b/tests/unit/comment-batch-delete-route.test.ts index b9c548229..455526bc6 100644 --- a/tests/unit/comment-batch-delete-route.test.ts +++ b/tests/unit/comment-batch-delete-route.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; const requireAuthMock = vi.fn(); const deleteOwnCommentMock = vi.fn(); @@ -24,17 +24,21 @@ function unauthorizedResponse() { } describe("deleteCommentBatchRoute", () => { + let deleteCommentBatchRoute: typeof import("@/lib/api/routes/comment-batch-route").deleteCommentBatchRoute; + + beforeAll(async () => { + ({ deleteCommentBatchRoute } = await import( + "@/lib/api/routes/comment-batch-route" + )); + }); + afterEach(() => { requireAuthMock.mockReset(); deleteOwnCommentMock.mockReset(); - vi.resetModules(); }); it("在解析 JSON 请求体之前先认证", async () => { requireAuthMock.mockResolvedValue(unauthorizedResponse()); - const { deleteCommentBatchRoute } = await import( - "@/lib/api/routes/comment-batch-route" - ); const response = await deleteCommentBatchRoute( deleteRequest({ ids: ["comment-1"] }), @@ -51,10 +55,6 @@ describe("deleteCommentBatchRoute", () => { .mockResolvedValueOnce({ ok: true }) .mockResolvedValueOnce({ ok: true }); - const { deleteCommentBatchRoute } = await import( - "@/lib/api/routes/comment-batch-route" - ); - const response = await deleteCommentBatchRoute( deleteRequest({ ids: ["comment-1", "comment-2"] }), ); @@ -85,10 +85,6 @@ describe("deleteCommentBatchRoute", () => { .mockResolvedValueOnce({ ok: false, error: "forbidden" }) .mockResolvedValueOnce({ ok: false, error: "locked" }); - const { deleteCommentBatchRoute } = await import( - "@/lib/api/routes/comment-batch-route" - ); - const response = await deleteCommentBatchRoute( deleteRequest({ ids: [ @@ -130,10 +126,6 @@ describe("deleteCommentBatchRoute", () => { reason: "spam", }); - const { deleteCommentBatchRoute } = await import( - "@/lib/api/routes/comment-batch-route" - ); - const response = await deleteCommentBatchRoute( deleteRequest({ ids: ["comment-1"] }), ); @@ -152,10 +144,6 @@ describe("deleteCommentBatchRoute", () => { it("拒绝空 ids 数组", async () => { requireAuthMock.mockResolvedValue({ userId: "user-1" }); - const { deleteCommentBatchRoute } = await import( - "@/lib/api/routes/comment-batch-route" - ); - const response = await deleteCommentBatchRoute(deleteRequest({ ids: [] })); expect(response.status).toBe(400); @@ -167,10 +155,6 @@ describe("deleteCommentBatchRoute", () => { it("拒绝包含空字符串 id 的 payload", async () => { requireAuthMock.mockResolvedValue({ userId: "user-1" }); - const { deleteCommentBatchRoute } = await import( - "@/lib/api/routes/comment-batch-route" - ); - const response = await deleteCommentBatchRoute( deleteRequest({ ids: ["comment-1", ""] }), ); diff --git a/tests/unit/dashboard-link-pin-route.test.ts b/tests/unit/dashboard-link-pin-route.test.ts index 92d7657e5..5ae056416 100644 --- a/tests/unit/dashboard-link-pin-route.test.ts +++ b/tests/unit/dashboard-link-pin-route.test.ts @@ -1,10 +1,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -const resolveApiUserIdMock = vi.fn(); +const requireAuthMock = vi.fn(); const transactionMock = vi.fn(); vi.mock("@/lib/auth/api-auth", () => ({ - resolveApiUserId: resolveApiUserIdMock, + requireAuth: requireAuthMock, })); vi.mock("@/lib/db/prisma", () => ({ @@ -20,7 +20,7 @@ vi.mock("@/lib/db/prisma", () => ({ describe("POST /api/dashboard-links/pin", () => { beforeEach(() => { vi.resetModules(); - resolveApiUserIdMock.mockResolvedValue("user-1"); + requireAuthMock.mockResolvedValue({ userId: "user-1" }); transactionMock.mockReset(); }); @@ -56,4 +56,53 @@ describe("POST /api/dashboard-links/pin", () => { error: "Failed to update dashboard link pin state", }); }); + + it("JSON 客户端收到保留 Retry-After 的原始限流响应", async () => { + requireAuthMock.mockResolvedValue( + Response.json( + { error: "Rate limit exceeded" }, + { status: 429, headers: { "Retry-After": "60" } }, + ), + ); + const { postDashboardLinkPinRoute } = await import( + "@/lib/api/routes/dashboard-links" + ); + + const response = await postDashboardLinkPinRoute( + new Request("http://localhost/api/dashboard-links/pin", { + method: "POST", + body: new FormData(), + headers: { accept: "application/json" }, + }), + ); + + expect(response.status).toBe(429); + expect(response.headers.get("Retry-After")).toBe("60"); + expect(transactionMock).not.toHaveBeenCalled(); + }); + + it("HTML 表单被限流时重定向并标记错误", async () => { + requireAuthMock.mockResolvedValue( + Response.json( + { error: "Rate limit exceeded" }, + { status: 429, headers: { "Retry-After": "60" } }, + ), + ); + const { postDashboardLinkPinRoute } = await import( + "@/lib/api/routes/dashboard-links" + ); + + const response = await postDashboardLinkPinRoute( + new Request("http://localhost/api/dashboard-links/pin", { + method: "POST", + body: new FormData(), + }), + ); + + expect(response.status).toBe(303); + expect(response.headers.get("Location")).toBe( + "http://localhost/?dashboardLinkPinError=1", + ); + expect(transactionMock).not.toHaveBeenCalled(); + }); }); diff --git a/tests/unit/dashboard-link-visit-route.test.ts b/tests/unit/dashboard-link-visit-route.test.ts new file mode 100644 index 000000000..590f4ea4e --- /dev/null +++ b/tests/unit/dashboard-link-visit-route.test.ts @@ -0,0 +1,76 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { setCloudflareRuntimeEnv } from "@/lib/adapters/cloudflare-runtime"; + +const { recordDashboardLinkClickMock, resolveApiUserIdMock } = vi.hoisted( + () => ({ + recordDashboardLinkClickMock: vi.fn(), + resolveApiUserIdMock: vi.fn(), + }), +); + +vi.mock("@/features/dashboard-links/server/dashboard-link-service", () => ({ + recordDashboardLinkClick: recordDashboardLinkClickMock, + resolveDashboardLinkBySlug: (slug: string) => + slug === "jw" ? { slug: "jw", url: "https://jw.ustc.edu.cn/" } : null, +})); + +vi.mock("@/lib/auth/api-auth", () => ({ + resolveApiUserId: resolveApiUserIdMock, +})); + +describe("POST /api/dashboard-links/visit", () => { + beforeEach(() => { + setCloudflareRuntimeEnv(undefined); + resolveApiUserIdMock.mockResolvedValue("user-1"); + recordDashboardLinkClickMock.mockReset(); + }); + + afterEach(() => { + setCloudflareRuntimeEnv(undefined); + }); + + it("超过点击写入预算时跳过计数但仍重定向", async () => { + const limit = vi.fn().mockResolvedValue({ success: false }); + setCloudflareRuntimeEnv({ USER_WRITE_RATE_LIMITER: { limit } }); + const { postDashboardLinkVisitRoute } = await import( + "@/lib/api/routes/dashboard-link-visit-routes" + ); + const form = new FormData(); + form.set("slug", "jw"); + + const response = await postDashboardLinkVisitRoute( + new Request("https://life.example/api/dashboard-links/visit", { + method: "POST", + body: form, + }), + ); + + expect(response.status).toBe(303); + expect(response.headers.get("Location")).toBe("https://jw.ustc.edu.cn/"); + expect(recordDashboardLinkClickMock).not.toHaveBeenCalled(); + expect(limit).toHaveBeenCalledOnce(); + }); + + it("预算允许时记录点击并重定向", async () => { + setCloudflareRuntimeEnv({ + USER_WRITE_RATE_LIMITER: { + limit: vi.fn().mockResolvedValue({ success: true }), + }, + }); + const { postDashboardLinkVisitRoute } = await import( + "@/lib/api/routes/dashboard-link-visit-routes" + ); + const form = new FormData(); + form.set("slug", "jw"); + + const response = await postDashboardLinkVisitRoute( + new Request("https://life.example/api/dashboard-links/visit", { + method: "POST", + body: form, + }), + ); + + expect(response.status).toBe(303); + expect(recordDashboardLinkClickMock).toHaveBeenCalledWith("user-1", "jw"); + }); +}); diff --git a/tests/unit/mcp-auth.test.ts b/tests/unit/mcp-auth.test.ts index 2109dc42c..72a3bb471 100644 --- a/tests/unit/mcp-auth.test.ts +++ b/tests/unit/mcp-auth.test.ts @@ -235,6 +235,56 @@ describe("authenticateMcpRequest per-tool scope enforcement", () => { } }); + it("rejects a mixed-tool batch unless every tool scope is present", async () => { + mockToken([restWriteScope("todo")]); + const { authenticateMcpRequest } = await import("@/lib/mcp/auth"); + const result = await authenticateMcpRequest( + makeAuthenticatedRequest([restWriteScope("todo")]), + ["create_my_todo", "delete_my_upload"], + ); + + expect("response" in result).toBe(true); + if ("response" in result) { + expect(result.response.status).toBe(403); + expect(result.authFailureDiagnostics).toMatchObject({ + authFailureKind: "missing_required_tool_scope", + requiredScopeCount: 2, + toolNameCount: 2, + }); + expect(result.response.headers.get("www-authenticate")).toContain( + restWriteScope("upload"), + ); + } + }); + + it("lets a feature write scope satisfy reads of the same feature", async () => { + mockToken([restWriteScope("todo")]); + const { authenticateMcpRequest } = await import("@/lib/mcp/auth"); + const result = await authenticateMcpRequest( + makeAuthenticatedRequest([restWriteScope("todo")]), + ["list_my_todos", "create_my_todo"], + ); + + expect("authInfo" in result).toBe(true); + }); + + it("requires every declared scope for a cross-cutting tool", async () => { + mockToken([restReadScope("schedule")]); + const { authenticateMcpRequest } = await import("@/lib/mcp/auth"); + const result = await authenticateMcpRequest( + makeAuthenticatedRequest([restReadScope("schedule")]), + "get_next_class", + ); + + expect("response" in result).toBe(true); + if ("response" in result) { + expect(result.response.status).toBe(403); + expect(result.response.headers.get("www-authenticate")).toContain( + restReadScope("dashboard"), + ); + } + }); + it("still requires any MCP scope before the per-tool check", async () => { mockToken(["openid"]); const { authenticateMcpRequest } = await import("@/lib/mcp/auth"); diff --git a/tests/unit/mcp-request-rate-limit.test.ts b/tests/unit/mcp-request-rate-limit.test.ts new file mode 100644 index 000000000..6209238ef --- /dev/null +++ b/tests/unit/mcp-request-rate-limit.test.ts @@ -0,0 +1,188 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { + authenticateMcpRequestMock, + checkUserMutationRateLimitMock, + connectMock, + handleTransportRequestMock, + recordAndLogMcpResponseMock, + summarizeMcpJsonRpcRequestMock, + transportConstructorMock, +} = vi.hoisted(() => ({ + authenticateMcpRequestMock: vi.fn(), + checkUserMutationRateLimitMock: vi.fn(), + connectMock: vi.fn(), + handleTransportRequestMock: vi.fn(), + recordAndLogMcpResponseMock: vi.fn(), + summarizeMcpJsonRpcRequestMock: vi.fn(), + transportConstructorMock: vi.fn(), +})); + +vi.mock( + "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js", + () => ({ + WebStandardStreamableHTTPServerTransport: class { + constructor() { + transportConstructorMock(); + } + + handleRequest = handleTransportRequestMock; + }, + }), +); + +vi.mock("@/lib/mcp/auth", () => ({ + authenticateMcpRequest: authenticateMcpRequestMock, +})); + +vi.mock("@/lib/mcp/observability", () => ({ + summarizeMcpJsonRpcRequest: summarizeMcpJsonRpcRequestMock, +})); + +vi.mock("@/lib/mcp/server", () => ({ + createMcpServer: () => ({ + _registeredTools: { create_my_todo: {} }, + connect: connectMock, + }), +})); + +vi.mock("@/lib/security/user-mutation-rate-limit", () => ({ + checkUserMutationRateLimit: checkUserMutationRateLimitMock, + USER_MUTATION_RATE_LIMIT_PERIOD_SECONDS: 60, +})); + +vi.mock("@/lib/log/app-logger", () => ({ logAppEvent: vi.fn() })); +vi.mock("@/lib/log/oauth-debug", () => ({ + logOAuthDebug: vi.fn(), + oauthDebugCorrelationId: () => "request-1", +})); +vi.mock("@/lib/api/routes/mcp-request-logging", () => ({ + logMcpTransportRequest: vi.fn(), +})); +vi.mock("@/lib/api/routes/mcp-response-bookkeeping", () => ({ + recordAndLogMcpResponse: recordAndLogMcpResponseMock, +})); + +function authenticatedUser() { + return { + authInfo: { + clientId: "client-1", + expiresAt: 1_900_000_000, + extra: { userId: "user-1" }, + scopes: ["todo:write"], + token: "token", + }, + }; +} + +describe("MCP mutation rate limits", () => { + beforeEach(() => { + vi.resetModules(); + authenticateMcpRequestMock.mockReset(); + checkUserMutationRateLimitMock.mockReset(); + connectMock.mockReset().mockResolvedValue(undefined); + handleTransportRequestMock.mockReset().mockResolvedValue( + new Response(JSON.stringify({ ok: true }), { + headers: { "content-type": "application/json" }, + }), + ); + recordAndLogMcpResponseMock.mockReset(); + summarizeMcpJsonRpcRequestMock.mockReset().mockResolvedValue({ + methodCounts: { "tools/call": 2 }, + toolCallCounts: { create_my_todo: 2 }, + }); + transportConstructorMock.mockReset(); + authenticateMcpRequestMock.mockResolvedValue(authenticatedUser()); + }); + + it("counts duplicate mutation entries and rejects the whole batch before tools run", async () => { + checkUserMutationRateLimitMock + .mockResolvedValueOnce({ allowed: true }) + .mockResolvedValueOnce({ allowed: false, reason: "limited" }); + const request = new Request("https://life.example/api/mcp", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify([ + { + jsonrpc: "2.0", + id: 1, + method: "tools/call", + params: { name: "create_my_todo", arguments: { title: "A" } }, + }, + { + jsonrpc: "2.0", + id: 2, + method: "tools/call", + params: { name: "create_my_todo", arguments: { title: "B" } }, + }, + ]), + }); + + const { handleMcpRequest } = await import( + "@/lib/api/routes/mcp-request-handler" + ); + const response = await handleMcpRequest(request); + + expect(response.status).toBe(429); + expect(response.headers.get("retry-after")).toBe("60"); + expect(response.headers.get("access-control-expose-headers")).toContain( + "Retry-After", + ); + await expect(response.json()).resolves.toEqual({ + error: "Rate limit exceeded", + }); + expect(checkUserMutationRateLimitMock).toHaveBeenCalledTimes(2); + expect(checkUserMutationRateLimitMock).toHaveBeenNthCalledWith(1, { + action: "todo:write", + host: "life.example", + tier: "write", + userId: "user-1", + }); + expect(checkUserMutationRateLimitMock).toHaveBeenNthCalledWith(2, { + action: "todo:write", + host: "life.example", + tier: "write", + userId: "user-1", + }); + expect(transportConstructorMock).not.toHaveBeenCalled(); + expect(connectMock).not.toHaveBeenCalled(); + expect(handleTransportRequestMock).not.toHaveBeenCalled(); + expect(recordAndLogMcpResponseMock).toHaveBeenCalledWith( + expect.objectContaining({ + phase: "rate-limit-rejected", + rpcSummary: expect.objectContaining({ + toolCallCounts: { create_my_todo: 2 }, + }), + status: 429, + }), + ); + }); + + it("does not consume mutation budgets for read-only tools", async () => { + summarizeMcpJsonRpcRequestMock.mockResolvedValue({ + methodCounts: { "tools/call": 1 }, + toolCallCounts: { list_my_todos: 1 }, + }); + const request = new Request("https://life.example/api/mcp", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "tools/call", + params: { name: "list_my_todos", arguments: {} }, + }), + }); + + const { handleMcpRequest } = await import( + "@/lib/api/routes/mcp-request-handler" + ); + const response = await handleMcpRequest(request); + + expect(response.status).toBe(200); + expect(checkUserMutationRateLimitMock).not.toHaveBeenCalled(); + expect(transportConstructorMock).toHaveBeenCalledOnce(); + expect(connectMock).toHaveBeenCalledOnce(); + expect(handleTransportRequestMock).toHaveBeenCalledOnce(); + }); +}); diff --git a/tests/unit/mcp-tool-scopes.test.ts b/tests/unit/mcp-tool-scopes.test.ts index 720cfe21d..b655e06d5 100644 --- a/tests/unit/mcp-tool-scopes.test.ts +++ b/tests/unit/mcp-tool-scopes.test.ts @@ -1,7 +1,11 @@ import { describe, expect, it } from "vitest"; import { + extractMcpToolCallNamesFromRequest, extractMcpToolNamesFromRequest, + getMcpWriteRateLimitAction, + getMcpWriteRateLimitTier, getRequiredMcpScopes, + isMcpWriteTool, } from "@/lib/mcp/tool-scopes"; import { restReadScope, restWriteScope } from "@/lib/oauth/constants"; @@ -80,6 +84,22 @@ describe("getRequiredMcpScopes", () => { restReadScope("upload"), ]); }); + + it("maps mutation tools to shared feature-action budgets", () => { + expect(isMcpWriteTool("create_my_todo")).toBe(true); + expect(getMcpWriteRateLimitAction("create_my_todo")).toBe("todo:write"); + expect(getMcpWriteRateLimitTier("create_my_todo")).toBe("write"); + + expect(isMcpWriteTool("subscribe_my_sections_by_codes")).toBe(true); + expect(getMcpWriteRateLimitAction("subscribe_my_sections_by_codes")).toBe( + "subscription:batch-write", + ); + expect(getMcpWriteRateLimitTier("subscribe_my_sections_by_codes")).toBe( + "batch", + ); + + expect(isMcpWriteTool("list_my_todos")).toBe(false); + }); }); describe("extractMcpToolNamesFromRequest", () => { @@ -130,6 +150,32 @@ describe("extractMcpToolNamesFromRequest", () => { expect(names.sort()).toEqual(["create_comment", "list_my_todos"]); }); + it("preserves duplicate mutation calls for per-entry batch accounting", async () => { + const request = new Request("https://life.example/api/mcp", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify([ + { + jsonrpc: "2.0", + id: 1, + method: "tools/call", + params: { name: "create_my_todo" }, + }, + { + jsonrpc: "2.0", + id: 2, + method: "tools/call", + params: { name: "create_my_todo" }, + }, + ]), + }); + + await expect(extractMcpToolCallNamesFromRequest(request)).resolves.toEqual([ + "create_my_todo", + "create_my_todo", + ]); + }); + it("ignores non-tools/call messages", async () => { const request = new Request("https://life.example/api/mcp", { method: "POST", diff --git a/tests/unit/openapi-collectors.test.ts b/tests/unit/openapi-collectors.test.ts index e68e321e8..5461f7d24 100644 --- a/tests/unit/openapi-collectors.test.ts +++ b/tests/unit/openapi-collectors.test.ts @@ -117,6 +117,35 @@ export const POST = () => new Response(); }); }); + it("documents Retry-After for mutation throttle responses", () => { + const project = new Project({ useInMemoryFileSystem: true }); + project.createSourceFile( + "src/routes/api/todos/+server.ts", + ` +/** + * Create a todo. + * @response 429:openApiErrorSchema + * @response 503:openApiErrorSchema + */ +export const POST = () => new Response(); +`, + { overwrite: true }, + ); + + const paths = collectPaths(project, new SchemaCollector()); + const responses = (paths["/api/todos"].post as Record) + .responses as Record>; + + const retryAfter = { + "Retry-After": { + description: "Seconds before retrying the mutation", + schema: { type: "integer", minimum: 0 }, + }, + }; + expect(responses["429"].headers).toEqual(retryAfter); + expect(responses["503"].headers).toEqual(retryAfter); + }); + it("parses path parameters and request body", () => { const project = new Project({ useInMemoryFileSystem: true }); project.createSourceFile( diff --git a/tests/unit/openapi-generator.test.ts b/tests/unit/openapi-generator.test.ts index 2a804f0dd..ccdadf3ab 100644 --- a/tests/unit/openapi-generator.test.ts +++ b/tests/unit/openapi-generator.test.ts @@ -1,9 +1,14 @@ -import { describe, expect, it } from "vitest"; +import { beforeAll, describe, expect, it } from "vitest"; import { generateOpenApiDocument } from "../../scripts/openapi/generate"; describe("openapi generator", () => { + let doc: ReturnType; + + beforeAll(() => { + doc = generateOpenApiDocument(); + }, 60_000); + it("returns a document with the generated metadata", () => { - const doc = generateOpenApiDocument(); expect(doc.openapi).toBe("3.0.0"); expect(doc.info.title).toBe("Life@USTC API"); expect(doc.info.version).toBe("1.0.0"); @@ -13,10 +18,9 @@ describe("openapi generator", () => { expect(doc.servers).toEqual([{ url: "/", description: "Current origin" }]); expect(doc.components?.securitySchemes).toBeDefined(); expect(doc.components?.schemas).toBeDefined(); - }, 15_000); + }); it("publishes pageSize and marks limit as its deprecated alias", () => { - const doc = generateOpenApiDocument(); const paths = doc.paths as Record< string, { @@ -56,10 +60,9 @@ describe("openapi generator", () => { expect(limit?.deprecated, path).toBe(true); expect(limit?.description, path).toContain("pageSize"); } - }, 15_000); + }); it("publishes true creates as 201 responses with Location", () => { - const doc = generateOpenApiDocument(); const paths = doc.paths as Record< string, { @@ -81,5 +84,5 @@ describe("openapi generator", () => { } expect(paths["/api/descriptions"]?.post?.responses?.["200"]).toBeDefined(); - }, 15_000); + }); }); diff --git a/tests/unit/openapi-rate-limit-contract.test.ts b/tests/unit/openapi-rate-limit-contract.test.ts new file mode 100644 index 000000000..d4c47f3f3 --- /dev/null +++ b/tests/unit/openapi-rate-limit-contract.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from "vitest"; +import openApi from "../../public/openapi.generated.json"; + +const PROTECTED_MUTATIONS = [ + ["patch", "/api/admin/comments/{id}"], + ["patch", "/api/admin/descriptions/{id}"], + ["delete", "/api/admin/homeworks/{id}"], + ["post", "/api/admin/suspensions"], + ["patch", "/api/admin/suspensions/{id}"], + ["patch", "/api/admin/users/{id}"], + ["post", "/api/bus/preferences"], + ["post", "/api/calendar-subscriptions"], + ["patch", "/api/calendar-subscriptions"], + ["delete", "/api/calendar-subscriptions"], + ["post", "/api/calendar-subscriptions/batch"], + ["post", "/api/calendar-subscriptions/import-codes"], + ["post", "/api/comments"], + ["patch", "/api/comments/{id}"], + ["delete", "/api/comments/{id}"], + ["post", "/api/comments/{id}/reactions"], + ["delete", "/api/comments/{id}/reactions"], + ["delete", "/api/comments/batch"], + ["post", "/api/dashboard-links/pin"], + ["post", "/api/dashboard-links/pin/batch"], + ["post", "/api/descriptions"], + ["post", "/api/homeworks"], + ["patch", "/api/homeworks/{id}"], + ["delete", "/api/homeworks/{id}"], + ["put", "/api/homeworks/{id}/completion"], + ["put", "/api/homeworks/completions"], + ["post", "/api/mcp"], + ["post", "/api/todos"], + ["patch", "/api/todos/{id}"], + ["delete", "/api/todos/{id}"], + ["patch", "/api/todos/batch"], + ["delete", "/api/todos/batch"], + ["post", "/api/uploads"], + ["patch", "/api/uploads/{id}"], + ["delete", "/api/uploads/{id}"], + ["post", "/api/uploads/complete"], + ["put", "/api/uploads/object"], +] as const; + +type Operation = { + responses?: Record< + string, + { headers?: Record } + >; +}; + +const paths = openApi.paths as Record>; + +describe("OpenAPI mutation rate-limit contract", () => { + it("documents 429 and fail-closed 503 only on protected mutations", () => { + const expected = new Set( + PROTECTED_MUTATIONS.map(([method, path]) => `${method} ${path}`), + ); + const documented = new Set(); + + for (const [path, methods] of Object.entries(paths)) { + for (const [method, operation] of Object.entries(methods)) { + if (!operation.responses?.["429"] && !operation.responses?.["503"]) { + continue; + } + documented.add(`${method} ${path}`); + } + } + + expect(documented).toEqual(expected); + }); + + it.each( + PROTECTED_MUTATIONS, + )("%s %s exposes Retry-After for both rejection modes", (method, path) => { + const responses = paths[path]?.[method]?.responses; + + for (const status of ["429", "503"]) { + expect( + responses?.[status]?.headers?.["Retry-After"], + `${method} ${path} ${status}`, + ).toMatchObject({ schema: { type: "integer" } }); + } + }); +}); diff --git a/tests/unit/protected-write-auth-order.test.ts b/tests/unit/protected-write-auth-order.test.ts index 14f22ef9e..7d09740d9 100644 --- a/tests/unit/protected-write-auth-order.test.ts +++ b/tests/unit/protected-write-auth-order.test.ts @@ -3,13 +3,11 @@ import { afterEach, describe, expect, it, vi } from "vitest"; const { requireAuthMock, requireWriteAuthMock, - resolveApiUserIdMock, updateDashboardLinkPinStateMock, renameOwnedUploadMock, } = vi.hoisted(() => ({ requireAuthMock: vi.fn(), requireWriteAuthMock: vi.fn(), - resolveApiUserIdMock: vi.fn(), updateDashboardLinkPinStateMock: vi.fn(), renameOwnedUploadMock: vi.fn(), })); @@ -17,7 +15,6 @@ const { vi.mock("@/lib/auth/api-auth", () => ({ requireAuth: requireAuthMock, requireWriteAuth: requireWriteAuthMock, - resolveApiUserId: resolveApiUserIdMock, })); vi.mock("@/features/comments/server/comment-mutations", () => ({ @@ -59,7 +56,6 @@ describe("受保护写入路由认证顺序", () => { afterEach(() => { requireAuthMock.mockReset(); requireWriteAuthMock.mockReset(); - resolveApiUserIdMock.mockReset(); updateDashboardLinkPinStateMock.mockReset(); renameOwnedUploadMock.mockReset(); vi.resetModules(); @@ -125,7 +121,7 @@ describe("受保护写入路由认证顺序", () => { }); it("在解析表单数据之前认证仪表盘链接置顶", async () => { - resolveApiUserIdMock.mockResolvedValue(null); + requireAuthMock.mockResolvedValue(unauthorizedResponse()); const { postDashboardLinkPinRoute } = await import( "@/lib/api/routes/dashboard-link-pin-route" ); @@ -142,7 +138,10 @@ describe("受保护写入路由认证顺序", () => { ); expect(response.status).toBe(401); - expect(resolveApiUserIdMock).toHaveBeenCalledOnce(); + expect(requireAuthMock).toHaveBeenCalledWith(expect.any(Request), { + bearerScope: { feature: "dashboard", action: "write" }, + rateLimit: { action: "dashboard:write" }, + }); expect(updateDashboardLinkPinStateMock).not.toHaveBeenCalled(); }); }); diff --git a/tests/unit/user-mutation-rate-limit.test.ts b/tests/unit/user-mutation-rate-limit.test.ts new file mode 100644 index 000000000..1e9b2845c --- /dev/null +++ b/tests/unit/user-mutation-rate-limit.test.ts @@ -0,0 +1,93 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { setCloudflareRuntimeEnv } from "@/lib/adapters/cloudflare-runtime"; +import { checkUserMutationRateLimit } from "@/lib/security/user-mutation-rate-limit"; + +describe("user mutation rate limits", () => { + beforeEach(() => setCloudflareRuntimeEnv(undefined)); + afterEach(() => setCloudflareRuntimeEnv(undefined)); + + it("explicitly bypasses the gate outside a Cloudflare runtime", async () => { + await expect( + checkUserMutationRateLimit({ + action: "todo:write", + host: "life.example", + userId: "user-1", + }), + ).resolves.toEqual({ allowed: true }); + }); + + it("uses one canonical per-host, per-user, per-feature-action key", async () => { + const limit = vi.fn().mockResolvedValue({ success: true }); + setCloudflareRuntimeEnv({ USER_WRITE_RATE_LIMITER: { limit } }); + + await expect( + checkUserMutationRateLimit({ + action: "todo:write", + host: "LIFE.EXAMPLE", + userId: "user-1", + }), + ).resolves.toEqual({ allowed: true }); + + expect(limit).toHaveBeenCalledOnce(); + expect(JSON.parse(limit.mock.calls[0][0].key)).toEqual([ + "user-mutation:v1", + "life.example", + "todo:write", + "user-1", + ]); + }); + + it("selects the stricter batch binding", async () => { + const batchLimit = vi.fn().mockResolvedValue({ success: true }); + const writeLimit = vi.fn().mockResolvedValue({ success: true }); + setCloudflareRuntimeEnv({ + USER_BATCH_WRITE_RATE_LIMITER: { limit: batchLimit }, + USER_WRITE_RATE_LIMITER: { limit: writeLimit }, + }); + + await checkUserMutationRateLimit({ + action: "subscription:batch-write", + host: "life.example", + tier: "batch", + userId: "user-1", + }); + + expect(batchLimit).toHaveBeenCalledOnce(); + expect(writeLimit).not.toHaveBeenCalled(); + }); + + it("reports a rejected budget without throwing", async () => { + setCloudflareRuntimeEnv({ + USER_WRITE_RATE_LIMITER: { + limit: vi.fn().mockResolvedValue({ success: false }), + }, + }); + + await expect( + checkUserMutationRateLimit({ + action: "comment:write", + host: "life.example", + userId: "user-1", + }), + ).resolves.toEqual({ allowed: false, reason: "limited" }); + }); + + it.each([ + {}, + { + USER_WRITE_RATE_LIMITER: { + limit: vi.fn().mockRejectedValue(new Error("binding unavailable")), + }, + }, + ])("fails closed when the Cloudflare binding is missing or errors", async (env) => { + setCloudflareRuntimeEnv(env); + + await expect( + checkUserMutationRateLimit({ + action: "comment:write", + host: "life.example", + userId: "user-1", + }), + ).resolves.toEqual({ allowed: false, reason: "unavailable" }); + }); +}); diff --git a/wrangler.e2e.jsonc b/wrangler.e2e.jsonc index 8cc74a359..d87a9b472 100644 --- a/wrangler.e2e.jsonc +++ b/wrangler.e2e.jsonc @@ -39,6 +39,24 @@ "localConnectionString": "postgresql://postgres:postgres@127.0.0.1:5432/life_ustc_dev" } ], + "ratelimits": [ + { + "name": "USER_WRITE_RATE_LIMITER", + "namespace_id": "414001", + "simple": { + "limit": 60, + "period": 60 + } + }, + { + "name": "USER_BATCH_WRITE_RATE_LIMITER", + "namespace_id": "414002", + "simple": { + "limit": 10, + "period": 60 + } + } + ], "r2_buckets": [ { "binding": "R2_UPLOADS", diff --git a/wrangler.jsonc b/wrangler.jsonc index 4944beb59..d06d6ff9a 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -58,6 +58,24 @@ "dataset": "life_ustc_runtime" } ], + "ratelimits": [ + { + "name": "USER_WRITE_RATE_LIMITER", + "namespace_id": "414001", + "simple": { + "limit": 60, + "period": 60 + } + }, + { + "name": "USER_BATCH_WRITE_RATE_LIMITER", + "namespace_id": "414002", + "simple": { + "limit": 10, + "period": 60 + } + } + ], "r2_buckets": [ { "binding": "R2_UPLOADS", From 7432e9dd390b81c0312d919595f2f5dcb36260d3 Mon Sep 17 00:00:00 2001 From: Tiankai Ma Date: Thu, 16 Jul 2026 09:26:51 +0800 Subject: [PATCH 2/2] test(e2e): avoid throttling shared fixture users --- tests/unit/wrangler-rate-limit-config.test.ts | 49 +++++++++++++++++++ wrangler.e2e.jsonc | 4 +- 2 files changed, 51 insertions(+), 2 deletions(-) create mode 100644 tests/unit/wrangler-rate-limit-config.test.ts diff --git a/tests/unit/wrangler-rate-limit-config.test.ts b/tests/unit/wrangler-rate-limit-config.test.ts new file mode 100644 index 000000000..1d09acfbf --- /dev/null +++ b/tests/unit/wrangler-rate-limit-config.test.ts @@ -0,0 +1,49 @@ +import { readFile } from "node:fs/promises"; + +import { describe, expect, it } from "vitest"; + +type RateLimitBinding = { + name: string; + namespace_id: string; + simple: { limit: number; period: number }; +}; + +async function readRateLimits(fileName: string): Promise { + const source = await readFile(new URL(`../../${fileName}`, import.meta.url)); + const config = JSON.parse(source.toString()) as { + ratelimits?: RateLimitBinding[]; + }; + return config.ratelimits ?? []; +} + +describe("Wrangler mutation rate-limit bindings", () => { + it("keeps production budgets at 60 standard and 10 batch writes per minute", async () => { + await expect(readRateLimits("wrangler.jsonc")).resolves.toEqual([ + { + name: "USER_WRITE_RATE_LIMITER", + namespace_id: "414001", + simple: { limit: 60, period: 60 }, + }, + { + name: "USER_BATCH_WRITE_RATE_LIMITER", + namespace_id: "414002", + simple: { limit: 10, period: 60 }, + }, + ]); + }); + + it("keeps E2E bindings enabled without throttling shared fixture users", async () => { + await expect(readRateLimits("wrangler.e2e.jsonc")).resolves.toEqual([ + { + name: "USER_WRITE_RATE_LIMITER", + namespace_id: "414001", + simple: { limit: 10000, period: 60 }, + }, + { + name: "USER_BATCH_WRITE_RATE_LIMITER", + namespace_id: "414002", + simple: { limit: 10000, period: 60 }, + }, + ]); + }); +}); diff --git a/wrangler.e2e.jsonc b/wrangler.e2e.jsonc index d87a9b472..db2779620 100644 --- a/wrangler.e2e.jsonc +++ b/wrangler.e2e.jsonc @@ -44,7 +44,7 @@ "name": "USER_WRITE_RATE_LIMITER", "namespace_id": "414001", "simple": { - "limit": 60, + "limit": 10000, "period": 60 } }, @@ -52,7 +52,7 @@ "name": "USER_BATCH_WRITE_RATE_LIMITER", "namespace_id": "414002", "simple": { - "limit": 10, + "limit": 10000, "period": 60 } }