Project-wide HTTP API conventions for Warlock.js apps — list endpoints return `{ <pluralResourceName>, pagination }`, single-item endpoints return `{ <resourceCamelName>: <object> }`, delete returns `204 No Content` (or `{ message }`); framework response helpers map 1:1 to status codes (`response.success` / `successCreate` / `noContent` / `accepted` / `badRequest` / `unauthorized` / `forbidden` / `notFound` / `conflict` / `unprocessableEntity` / `tooManyRequests` / `serverError`); kebab-case plural URL nouns (`/ai-models`); snake_case fields inside resources; offset pagination by default, cursor for time-ordered firehose; list query params validated by a schema (caps `limit`, whitelists `orderBy`, strips unknown keys — never raw `request.all()`); typed controllers via `GuardedRequestHandler<Schema>` (guarded) / `RequestHandler<Request<Schema>>` (public) with the schema in the `schema/` folder + `request.validated()` — the per-endpoint `requests/` folder is retired; not-found is thrown from the service (`Resou
Installation
Install with Codex or Claude Copy this prompt, paste it into Codex, Claude, or another assistant, and let it review the skill page and install it for you.
Project-wide HTTP API conventions for Warlock.js apps — list endpoints return `{ <pluralResourceName>, pagination }`, single-item endpoints return `{ <resourceCamelName>: <object> }`, delete returns `204 No Content` (or `{ message }`); framework response helpers map 1:1 to status codes (`response.success` / `successCreate` / `noContent` / `accepted` / `badRequest` / `unauthorized` / `forbidden` / `notFound` / `conflict` / `unprocessableEntity` / `tooManyRequests` / `serverError`); kebab-case plural URL nouns (`/ai-models`); snake_case fields inside resources; offset pagination by default, cursor for time-ordered firehose; list query params validated by a schema (caps `limit`, whitelists `orderBy`, strips unknown keys — never raw `request.all()`); typed controllers via `GuardedRequestHandler<Schema>` (guarded) / `RequestHandler<Request<Schema>>` (public) with the schema in the `schema/` folder + `request.validated()` — the per-endpoint `requests/` folder is retired; not-found is thrown from the service (`ResourceNotFoundError`) so controllers stay clean; resources are output-only (`defineResource`); RESTful routes via `router.route().list().show().create().update().destroy()`; versioning guidance for the rare app that needs it. Triggers: writing a controller, route handler, or HTTP endpoint; shaping a response body; choosing a status code; adding pagination to a list endpoint; designing a `.resource.ts`; defining a route; deciding what `request.validated()` vs `request.input(...)` returns; where the schema file lives; how to return not-found; whether to version an API; user asks "what status code", "how do we paginate", "what does the response look like", "list vs single envelope", "snake_case or camelCase in responses", "how do we structure a controller", "where does the schema go", "should I version my API". Skip: input validation rules (load `skills/security-baseline/SKILL.md` § 1); pagination performance / N+1 (load `skills/observability-and-resilience/SKILL.md`); data column naming inside models (load `skills/data-and-persistence/SKILL.md`); framework router internals — load `@warlock.js/core` route skills; CSS / UI work.
API design
Status: Stable
Applies to: Every HTTP endpoint, controller, resource, and route file in src/app/**.
The project-wide contract between server and client. The frontend should be able to predict the shape of any response without reading the endpoint.
Sub-agent rule: Before writing any controller, route, or resource, read this file.
1. Response envelope
The project uses three envelope shapes, one per response kind. Each kind has its own controller pattern. Do not invent a fourth.
1.1 List endpoints — { <pluralResourceName>, pagination }
The list lands under the plural name of the module's resource — users, aiModels, orders — paired with pagination. The repository returns the framework's { data, pagination }; the controller re-keys data to the plural name so the response is self-describing.
Read, create, and update endpoints return the resource under its camelCase singular name.
The controller never null-checks. Not-found is thrown from the service, so the controller has no if (!x) return response.notFound() branch. The service owns the "does this exist" question; the controller only orchestrates the happy path.
The framework maps ResourceNotFoundError to a 404 response automatically — the controller doesn't translate it.
The key is the singular camelCase of the resource: faq, aiModel, aiApiKey, user, order.
Never { data: <object> } for a single item — data is reserved for arrays.
Never spread the object at the top level (return response.success({ ...faq })) — the named wrapper preserves room for future siblings (meta, warnings, etc.).
1.3 Delete endpoints — 204 No Content (preferred)
// ✅ default — nothing to say, say nothingexportconstdeleteFaqController: RequestHandler = async (request, response) => {
awaitdeleteFaqService(request.input("id"));
return response.noContent();
};
Default to 204 No Content — a successful delete has no body worth sending.
{ message: "Faq deleted successfully" } via response.success(...) is acceptable when the client genuinely needs a human-readable confirmation. Pick one per route and stay consistent across the module.
As with reads, the service throws ResourceNotFoundError if the row doesn't exist — the controller doesn't null-check.
1.4 Error responses
Use the framework's status helpers — never hand-roll an error body.
Never 200 OK with an error field — the status code carries the outcome.
2. HTTP status codes
One canonical mapping. Use the framework helpers; the helper's name is the status code's semantic.
Helper
Code
When
response.success(data)
200
Success with a body
response.successCreate(data)
201
Resource created
response.accepted(data)
202
Async op accepted; processing started
response.noContent()
204
Success without a body (idempotent deletes)
response.badRequest(data)
400
Malformed request — broken JSON, wrong type
response.unauthorized(data)
401
No or invalid credentials
response.forbidden(data)
403
Known caller, not allowed
response.notFound(data)
404
Resource doesn't exist (or caller may not see it)
response.conflict(data)
409
State collision — already exists, version clash
response.contentTooLarge(data)
413
Payload exceeds the limit
response.unprocessableEntity(data)
422
Well-formed but semantically invalid
response.tooManyRequests(data)
429
Rate limit hit
response.serverError(data)
500
Server bug — never the client's fault
The 401 vs 403 distinction: 401 means "I don't know who you are." 403 means "I know who you are, and you can't do this." Mixing them leaks information.
404 vs 403 on private resources: if revealing existence is itself a leak ("this private workspace exists, you just can't see it"), return 404. The framework default { error: "notFound" } is fine — don't expose internal reasons.
3. Pagination
Mandatory on every list endpoint. No endpoint returns an unbounded array, even if today's dataset has 12 rows.
3.1 Offset pagination — the default
Returned by RepositoryManager.list and listCached. Use for catalog-style lists, admin tables, anywhere the user thinks in pages.
{
data: T[],
pagination: {
limit: number; // requested per-page sizeresult: number; // items in THIS page (≤ limit)page: number; // 1-indexedtotal: number; // total items across all pagespages: number; // total page count
}
}
3.2 Cursor pagination — for time-ordered firehoses
Use when the dataset is large, append-mostly, and time-ordered: chat messages, event logs, AI trips. Counting total would be wasted work; hasMore + nextCursor is enough.
Hard cap limit at a sensible ceiling (e.g. 100) so a malformed client can't ask for 50,000 rows.
3.4 Filter and sort
Filters go in query params named after the filterable field (?status=active&type=basic). The query schema (§ 3.5) validates each value's type; the repository's filterBy rules then map accepted fields to WHERE clauses.
Sort: ?orderBy=created_at&orderDir=desc. Default sort lives on the repository's defaultOptions.
3.5 Validate the query — schema first, strip unknown
List endpoints validate their query params with a @warlock.js/seal schema, exactly like write endpoints — request.validated(), never raw request.all(). The schema and the repository's filterBy are complementary: the schema is the boundary type-guard, filterBy is the field→WHERE mapping.
The schema earns its place three ways:
Kills operator injection. Declaring status: v.enum(OrderStatus) rejects a smuggled { $ne: null } at the boundary — filterBy alone may pass it straight through (see skills/security-baseline/SKILL.md § 4.2).
Makes the limit cap real.limit: v.int().min(1).max(100) turns the § 3.3 ceiling into enforcement — ?limit=9999999 becomes a 422, not a table scan.
Whitelists orderBy. Restricting sort to known indexed columns closes the slow-query vector.
Strip unknown keys — don't disallow them. Seal strips extra keys by default, which is exactly right for query strings: they routinely carry incidental params (?utm_source=..., cache-busters ?_=1718). Throwing a 422 on those is hostile. Reserve disallow-unknown for request bodies, where an unexpected key usually signals a client bug or a renamed field.
Nested resources only one level deep:/orders/:id/items. If you need two levels (/orders/:id/items/:itemId/refunds), the inner resource probably deserves to be top-level.
Actions use verb suffixes on the resource path:POST /orders/:id/cancel, not POST /cancel-order. The verb is the second-to-last segment.
Query params are filters, never identity. Identity goes in the path (/orders/:id), filters in the query string (?status=paid).
4.2 Route registration — the RESTful builder
Use router.route(...).list().show().create().update().destroy() from @warlock.js/core. The framework maps each verb to its conventional HTTP method.
Wrap auth-required routes in guarded(() => { ... }).
Public routes go outside guarded.
One routes.ts per module. Never define routes spread across multiple files within the same module.
4.3 Custom actions
For endpoints that don't fit the RESTful five (e.g. POST /orders/:id/cancel), use router.post(...) / router.get(...) directly inside the same module's routes.ts.
5. Resources (.resource.ts)
A resource is the wire format for a model. Output-only. No reconciliation, no hydration, no computed side effects — those live in services or model accessors (see project memory feedback_resources_output_only).
Field names inside the resource schema use snake_case, matching the framework's wire convention: provider_model_id, is_free, created_by. The model layer above uses camelCase; the resource is where the convention shifts to wire format.
Top-level envelope keys (the wrapper around the resource: aiModel, aiModels, pagination, message) remain camelCase.
5.3 Rules
A resource never reaches into another model — if you need composed data, the service composes the result and passes one final object to the resource.
A resource never runs business logic — if a value is computed (e.g. total_spent_cents), compute it in the service and pass the already-computed field.
Sensitive fields are omitted at the resource level, not redacted at the log layer.
6. Validation and request handling
6.1 Schema + controller — two files, no requests/
A write endpoint is two files: the schema in the module's schema/ folder, and the controller. The old per-endpoint requests/ folder is retired — the controller is typed directly via a generic request-handler type, so no separate Request<Schema> wrapper file is needed.
List endpoints follow the same two-file shape — a list-<noun>.schema.ts validates the query string (see § 3.5). Only single-identifier reads (get-<noun> by id) skip the schema and use request.input("id").
GuardedRequestHandler<Schema> (defined in app/auth/requests/guarded.request.ts) is RequestHandler<GuardedRequest<Schema>> — it both types the validated payload and guarantees request.user is present.
6.3 Accessing inputs
request.validated() — typed access to the schema-validated body. Use this in every write endpoint. The type comes from the handler's generic, so no cast is needed.
request.input(name) — single named input (path / query / body fallback). Use for read endpoints that take a single identifier.
request.all() — raw, unvalidated payload. Avoid it. List endpoints take a query schema and use request.validated() just like writes (see § 3.5); request.all() bypasses the boundary type-guard and should never appear on a public route.
6.4 Validation failures
The framework returns 422 Unprocessable Entity with field-level errors automatically when controller.validation.schema rejects the input. Do not handle validation errors inside the controller — let the framework boundary do it.
7. Caching
7.1 Response headers
Set Cache-Control on every GET endpoint, even if the value is no-store. Explicit beats default.
Read endpoints on stable data: Cache-Control: public, max-age=60
Read endpoints on user-specific data: Cache-Control: private, max-age=30
repository.listCached(...) exists for hot read paths. Use it on list endpoints whose data changes rarely. Invalidate on the corresponding write via the repository's cache invalidation hooks.
8. Versioning
Most apps on this template are single monoliths with a frontend that ships alongside the backend — so the default is no versioning at all: change the API and the frontend together, ship additive changes, never break a field that's in use.
But some apps genuinely need it — a public API with third-party consumers you don't control, a mobile app where old versions linger in users' pockets for months. When that's the case:
8.1 Default — additive-only, no prefix
Add fields freely; they don't break existing clients.
Never remove or rename a field that's in use — see § 9 deprecation for the retire path.
Never change a field's type or meaning under the same name.
This carries a surprising distance. Reach for explicit versioning only when additive-only genuinely can't express the change.
8.2 When you must version — URL prefix
Prefix the route: /v1/orders, /v2/orders. It's the most observable option — visible in logs, trivial to route per-version at the load balancer, obvious to the consumer.
When a breaking change forces /v2, move the entire namespace to /v2 at once — don't scatter /v2/orders next to /v1/users. A consumer should be able to pin "I speak v1" or "I speak v2", not track a per-endpoint matrix.
8.4 Header-based versioning — avoid
Accept: application/vnd.app.v2+json works but is invisible in logs, painful to test by hand, and easy to get wrong. Stick to the URL prefix unless a specific consumer contract demands the header form.