Skip to main content

write-api-route

Create or modify ryOS backend API routes under api/ using the shared apiHandler wrapper, request-auth, Redis, rate limiting, and CORS conventions. Use when adding an endpoint, writing a serverless/Bun API handler, wiring auth or rate limits, or working with anything under the api/ directory.

Jump to install

Source facts

Repository
ryokun6/ryos
Last source activity
July 8, 2026 at 22:33
Detected SKILL.md language
English
Stars
1,255
Forks
182

Install options

The review-first prompt is selected by default. You can switch to a direct command or download a local copy.

Review the source files

Read SKILL.md and any companion files shown by SkillsMP before deciding whether to install.

Showing SKILL.md

SKILL.md
Source instructions · Read-only preview
name
write-api-route
description
Create or modify ryOS backend API routes under api/ using the shared apiHandler wrapper, request-auth, Redis, rate limiting, and CORS conventions. Use when adding an endpoint, writing a serverless/Bun API handler, wiring auth or rate limits, or working with anything under the api/ directory.
# Writing ryOS API Routes ryOS API routes are Node-style handlers under `api/`, served by the standalone Bun server (`scripts/api-standalone-server.ts`). The canonical reference is `docs/8.10-api-design-guide.md` — read it for the full contract. This skill is the practical checklist. ## Quick Start Checklist ``` - [ ] 1. Pick the path: api/<feature>/index.ts (collection) or api/<feature>/[id].ts (item) - [ ] 2. Wrap the handler in apiHandler({ methods, auth, ... }) - [ ] 3. Validate input (Zod via bodySchema, or _utils/_validation.ts helpers) - [ ] 4. Rate-limit public / expensive routes (_utils/_rate-limit.ts) - [ ] 5. Use shared constants/keys (_utils/constants.ts, REDIS_PREFIXES) - [ ] 6. Return explicit JSON; errors as { error: "..." } - [ ] 7. Add structured logs (logger.info / branch decisions) - [ ] 8. Write/extend an integration test in tests/ (requires `bun run dev:api`) - [ ] 9. Update the matching docs/8.*.md if the contract changed ``` ## File & Naming Conventions ```text api/ ├── _utils/ # globally shared helpers (api-handler, redis, request-auth, ...) ├── <feature>/ │ ├── index.ts # collection route (GET list / POST create) │ ├── [id].ts # item route (path param :id) │ ├── [id]/messages.ts # nested dynamic routes │ └── _helpers/ # feature-private helpers (_constants.ts, _types.ts, ...) ``` - `_utils/` = global utilities; feature `_helpers/` = domain-specific internals. - `_*.ts` / `_helpers/` are private modules (not routes). - Use `index.ts` for collections, `[id].ts` and nested folders for path params. - **Import shared modules with the `.js` extension** (e.g. `from "../_utils/api-handler.js"`) — required for Node-style ESM resolution even though the source is `.ts`. ## Primary Pattern: `apiHandler` Prefer `apiHandler` for all new JSON endpoints. It centralizes CORS/preflight, origin allowlisting, method checks, Redis injection, auth resolution, body parsing/validation, analytics, and a 500 fallback. ```typescript import { apiHandler } from "../_utils/api-handler.js"; import { z } from "zod"; const bodySchema = z.object({ name: z.string().min(1).max(100), }); export default apiHandler( { methods: ["POST"], auth: "required", // "none" | "optional" | "required" | "admin" parseJsonBody: true, // implied when bodySchema is set bodySchema, // 400 { error: "validation_error", issues } on failure // allowExpiredAuth: false, // contentType: "application/json", // pass null to disable the default header // analytics: true, }, async ({ req, res, redis, logger, startTime, origin, user, body }) => { // `user` is the authenticated user (never null when auth: "required"/"admin") // `body` is the parsed + validated payload (typed from bodySchema) logger.info("creating thing", { username: user!.username }); // ...business logic against redis... logger.response(201, Date.now() - startTime); res.status(201).json({ success: true }); } ); ``` ### Handler context `apiHandler` passes `{ req, res, redis, logger, startTime, origin, user, body }`: - `redis` — client from `createRedis()` (Upstash REST or standard Redis backend). - `logger` — request-scoped logger; `request()` is already called for you. - `user` — `null` unless authenticated; guaranteed non-null for `auth: "required"`/`"admin"`. - `body` — `null` unless `parseJsonBody`/`bodySchema`; typed when `bodySchema` is set. ## Auth Auth is unified through `_utils/request-auth.ts` (`resolveRequestAuth`). Set `auth` on `apiHandler`: - `"none"` — public. - `"optional"` — anonymous allowed, but credentials are validated if present. - `"required"` — needs **both** `Authorization: Bearer <token>` and `X-Username: <username>`. Partial creds → `400`; bad pair → `401`. - `"admin"` — required auth AND `username === "ryo"`, else `403`. For non-`apiHandler` routes (e.g. multipart uploads), call `resolveRequestAuth()` directly to keep behavior aligned. ## Rate Limiting Apply to public and expensive routes using `_utils/_rate-limit.ts`: ```typescript import * as RateLimit from "../_utils/_rate-limit.js"; import { getClientIp } from "../_utils/_rate-limit.js"; const ip = getClientIp(req); const key = RateLimit.makeKey(["rl", "feature", "burst", "ip", ip]); const result = await RateLimit.checkCounterLimit({ key, windowSeconds: 60, limit: 30 }); if (!result.allowed) { res.setHeader("Retry-After", String(result.resetSeconds)); return res.status(429).json({ error: "rate_limit_exceeded", limit: result.limit, retryAfter: result.resetSeconds, }); } ``` `getClientIp` respects `TRUSTED_PROXY_COUNT` when the API sits behind a reverse proxy. Prefer tiers from `RATE_LIMIT_TIERS` in `_utils/constants.ts` over magic numbers. ## Response & Error Shape - Success: explicit payloads (`{ success: true }`, `{ data: ... }`). - Client errors: `400/401/403/404/405/429` with JSON `{ error: "..." }` (extra fields ok if additive). - Server errors: `500 { error: "..." }` — `apiHandler` provides this automatically for thrown errors. - Streaming: use SSE helpers in `_utils/_sse.ts`; set stream headers and emit structured events (`start`, `line`, `complete`, `error`). ## Shared Utilities (use before hand-rolling) | Module | Use | |--------|-----| | `_utils/_validation.ts` | username/room/message validation, profanity filter, HTML escaping | | `_utils/_ssrf.ts` | `validatePublicUrl()`, `safeFetchWithRedirects()` for untrusted URLs | | `_utils/_sse.ts` | SSE streaming helpers | | `_utils/redis.ts` | `createRedis()` client factory | | `_utils/storage.ts` | S3-compatible object storage adapter | <!-- pragma: allowlist secret --> | `_utils/constants.ts` | `REDIS_PREFIXES`, `TTL`, `RATE_LIMIT_TIERS`, `PASSWORD`, `VALIDATION`, `TOKEN` | | `_utils/_logging.ts` | `initLogger()` (only needed for manual handlers) | Always key Redis entries with `REDIS_PREFIXES` + shared `TTL` rather than hardcoding strings. ## Manual Handlers (when `apiHandler` doesn't fit) Some endpoints (e.g. multipart `/api/audio-transcribe`) keep explicit handlers. Mirror the shared behavior manually: ```typescript import { getEffectiveOrigin, isAllowedOrigin, setCorsHeaders } from "../_utils/_cors.js"; import { initLogger } from "../_utils/_logging.js"; import { resolveRequestAuth } from "../_utils/request-auth.js"; const origin = getEffectiveOrigin(req); setCorsHeaders(res, origin, { methods: ["POST", "OPTIONS"] }); if (req.method === "OPTIONS") return res.status(204).end(); if (!isAllowedOrigin(origin)) return res.status(403).json({ error: "Unauthorized" }); // method checks → initLogger() + timing logs → resolveRequestAuth() for auth routes ``` ## Testing API integration tests require the standalone server running: ```bash # Terminal 1 bun run dev:api # exports TRUSTED_PROXY_COUNT=1 for spoofed-IP rate-limit tests # Terminal 2 bun run test:api # or: bun test tests/integration/api/test-<feature>.test.ts ``` Use helpers from `tests/helpers/test-utils.ts`: `fetchWithOrigin`, `fetchWithAuth`, `ensureUserAuth`, `makeRateLimitBypassHeaders` (random IP to dodge rate limits). Place new API suites under `tests/integration/api/` and append them to `API_TEST_FILES` in `scripts/test-groups.ts`, then run `bun run test:registration`. For pure schema/validation logic, a no-server unit test under `tests/unit/` (see the `write-tests` skill) is often enough. ## Best Practices 1. Prefer `apiHandler`; keep auth semantics via `request-auth`. 2. Validate ALL user input before use (Zod `bodySchema` is preferred). 3. Rate-limit public/expensive routes. 4. Keep response shapes stable, explicit, and backward-compatible. 5. Use SSRF-safe fetch for untrusted URLs. 6. Log request/response and key branch decisions. 7. Update `docs/8.*.md` whenever a request/response contract changes.
View on GitHub