create-warlock 4.2.6 → 4.2.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/package.json +2 -2
  3. package/templates/warlock/.env.example +36 -0
  4. package/templates/warlock/.gitattributes +1 -0
  5. package/templates/warlock/.husky/pre-commit +4 -0
  6. package/templates/warlock/.prettierignore +4 -0
  7. package/templates/warlock/.prettierrc.json +10 -0
  8. package/templates/warlock/.vscode/settings.json +41 -0
  9. package/templates/warlock/README.md +57 -0
  10. package/templates/warlock/_.gitignore +6 -0
  11. package/templates/warlock/docs/new-module.md +551 -0
  12. package/templates/warlock/eslint.config.js +98 -0
  13. package/templates/warlock/package.json +74 -0
  14. package/templates/warlock/public/home.css +523 -0
  15. package/templates/warlock/skills/api-design/SKILL.md +461 -0
  16. package/templates/warlock/skills/code-standards/SKILL.md +595 -0
  17. package/templates/warlock/skills/data-and-persistence/SKILL.md +330 -0
  18. package/templates/warlock/skills/git-workflow/SKILL.md +282 -0
  19. package/templates/warlock/skills/module-boundaries/SKILL.md +283 -0
  20. package/templates/warlock/skills/observability-and-resilience/SKILL.md +306 -0
  21. package/templates/warlock/skills/security-baseline/SKILL.md +352 -0
  22. package/templates/warlock/skills/testing-strategy/SKILL.md +323 -0
  23. package/templates/warlock/src/app/auth/controllers/forgot-password.controller.ts +28 -0
  24. package/templates/warlock/src/app/auth/controllers/login.controller.ts +22 -0
  25. package/templates/warlock/src/app/auth/controllers/logout-all.controller.ts +16 -0
  26. package/templates/warlock/src/app/auth/controllers/logout.controller.ts +16 -0
  27. package/templates/warlock/src/app/auth/controllers/me.controller.ts +13 -0
  28. package/templates/warlock/src/app/auth/controllers/refresh-token.controller.ts +29 -0
  29. package/templates/warlock/src/app/auth/controllers/reset-password.controller.ts +23 -0
  30. package/templates/warlock/src/app/auth/main.ts +9 -0
  31. package/templates/warlock/src/app/auth/models/otp/index.ts +1 -0
  32. package/templates/warlock/src/app/auth/models/otp/migrations/22-12-2025_10-30-20.otp-migration.ts +30 -0
  33. package/templates/warlock/src/app/auth/models/otp/otp.model.ts +69 -0
  34. package/templates/warlock/src/app/auth/requests/guarded.request.ts +10 -0
  35. package/templates/warlock/src/app/auth/routes.ts +22 -0
  36. package/templates/warlock/src/app/auth/schema/login.schema.ts +8 -0
  37. package/templates/warlock/src/app/auth/schema/reset-password.schema.ts +9 -0
  38. package/templates/warlock/src/app/auth/services/auth.service.ts +66 -0
  39. package/templates/warlock/src/app/auth/services/forgot-password.service.ts +28 -0
  40. package/templates/warlock/src/app/auth/services/otp.service.ts +173 -0
  41. package/templates/warlock/src/app/auth/services/reset-password.service.ts +39 -0
  42. package/templates/warlock/src/app/auth/utils/auth-error-code.ts +6 -0
  43. package/templates/warlock/src/app/auth/utils/locales.ts +89 -0
  44. package/templates/warlock/src/app/auth/utils/types.ts +14 -0
  45. package/templates/warlock/src/app/posts/controllers/create-new-post.controller.ts +21 -0
  46. package/templates/warlock/src/app/posts/controllers/update-post.controller.ts +30 -0
  47. package/templates/warlock/src/app/posts/models/post/migrations/09-01-2026_02-07-51-post.migration.ts +15 -0
  48. package/templates/warlock/src/app/posts/models/post/post.model.ts +23 -0
  49. package/templates/warlock/src/app/posts/resources/post.resource.ts +14 -0
  50. package/templates/warlock/src/app/posts/routes.ts +8 -0
  51. package/templates/warlock/src/app/posts/schema/create-post.schema.ts +9 -0
  52. package/templates/warlock/src/app/posts/schema/update-post.schema.ts +9 -0
  53. package/templates/warlock/src/app/shared/components/HomePageComponent.tsx +229 -0
  54. package/templates/warlock/src/app/shared/controllers/home-page.controller.ts +18 -0
  55. package/templates/warlock/src/app/shared/controllers/home-page.controller.tsx +8 -0
  56. package/templates/warlock/src/app/shared/routes.ts +4 -0
  57. package/templates/warlock/src/app/shared/services/scheduler.service.ts +3 -0
  58. package/templates/warlock/src/app/shared/tests/infrastructure.test.ts +22 -0
  59. package/templates/warlock/src/app/shared/utils/global-columns-schema.ts +8 -0
  60. package/templates/warlock/src/app/shared/utils/locales.ts +766 -0
  61. package/templates/warlock/src/app/shared/utils/router.ts +30 -0
  62. package/templates/warlock/src/app/uploads/controllers/fetch-uploaded-file.controller.ts +33 -0
  63. package/templates/warlock/src/app/uploads/routes.ts +4 -0
  64. package/templates/warlock/src/app/users/commands/hello-world.command.ts +8 -0
  65. package/templates/warlock/src/app/users/controllers/create-new-user.controller.ts +27 -0
  66. package/templates/warlock/src/app/users/controllers/list-users.controller.ts +12 -0
  67. package/templates/warlock/src/app/users/events/inject-created-by-user.into-model.event.ts +32 -0
  68. package/templates/warlock/src/app/users/events/sync.ts +5 -0
  69. package/templates/warlock/src/app/users/main.ts +5 -0
  70. package/templates/warlock/src/app/users/models/user/index.ts +1 -0
  71. package/templates/warlock/src/app/users/models/user/migrations/11-12-2025_23-58-03-user.migration.ts +15 -0
  72. package/templates/warlock/src/app/users/models/user/user.model.ts +64 -0
  73. package/templates/warlock/src/app/users/repositories/users.repository.ts +23 -0
  74. package/templates/warlock/src/app/users/resources/user.resource.ts +14 -0
  75. package/templates/warlock/src/app/users/routes.ts +8 -0
  76. package/templates/warlock/src/app/users/schema/create-user.schema.ts +11 -0
  77. package/templates/warlock/src/app/users/seeds/users.seed.ts +21 -0
  78. package/templates/warlock/src/app/users/services/get-users.service.ts +5 -0
  79. package/templates/warlock/src/app/users/services/list-users.service.ts +17 -0
  80. package/templates/warlock/src/app/users/services/login-social.ts +19 -0
  81. package/templates/warlock/src/config/app.ts +12 -0
  82. package/templates/warlock/src/config/auth.ts +20 -0
  83. package/templates/warlock/src/config/cache.ts +59 -0
  84. package/templates/warlock/src/config/database.ts +65 -0
  85. package/templates/warlock/src/config/http.ts +23 -0
  86. package/templates/warlock/src/config/log.ts +22 -0
  87. package/templates/warlock/src/config/mail.ts +16 -0
  88. package/templates/warlock/src/config/repository.ts +11 -0
  89. package/templates/warlock/src/config/storage.ts +34 -0
  90. package/templates/warlock/src/config/tests.ts +5 -0
  91. package/templates/warlock/src/config/validation.ts +7 -0
  92. package/templates/warlock/storage/.gitignore +2 -0
  93. package/templates/warlock/tsconfig.json +27 -0
  94. package/templates/warlock/warlock.config.ts +15 -0
  95. package/templates/warlock/yarn.lock +2332 -0
@@ -0,0 +1,461 @@
1
+ ---
2
+ name: api-design
3
+ description: '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.'
4
+ ---
5
+
6
+ # API design
7
+
8
+ **Status:** Stable
9
+ **Applies to:** Every HTTP endpoint, controller, resource, and route file in `src/app/**`.
10
+
11
+ The project-wide contract between server and client. The frontend should be able to predict the shape of any response without reading the endpoint.
12
+
13
+ > **Sub-agent rule:** Before writing any controller, route, or resource, read this file.
14
+
15
+ ---
16
+
17
+ ## 1. Response envelope
18
+
19
+ The project uses **three envelope shapes**, one per response kind. Each kind has its own controller pattern. Do not invent a fourth.
20
+
21
+ ### 1.1 List endpoints — `{ <pluralResourceName>, pagination }`
22
+
23
+ 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.
24
+
25
+ ```typescript
26
+ // ✅ list-users.controller.ts
27
+ export const listUsersController: RequestHandler = async (request, response) => {
28
+ const { data, pagination } = await listUsersService(request.all());
29
+
30
+ return response.success({
31
+ users: data,
32
+ pagination,
33
+ });
34
+ };
35
+ ```
36
+
37
+ - The key is the **plural camelCase** of the resource: `users`, `aiModels`, `aiApiKeys`, `orders`.
38
+ - `pagination` is always the framework's `PaginationResult["pagination"]` (see § 3).
39
+ - Don't return a bare `data` key — the plural name tells the consumer what the array holds without reading the URL.
40
+
41
+ ### 1.2 Single-item endpoints — `{ <resourceCamelName>: <object> }`
42
+
43
+ Read, create, and update endpoints return the resource under its camelCase singular name.
44
+
45
+ **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.
46
+
47
+ ```typescript
48
+ // ✅ get-faq.service.ts — the service throws
49
+ import { ResourceNotFoundError } from "@warlock.js/core";
50
+ import { faqsRepository } from "../repositories/faqs.repository";
51
+
52
+ export async function getFaqService(id: number | string) {
53
+ const faq = await faqsRepository.getCached(id);
54
+
55
+ if (!faq) {
56
+ throw new ResourceNotFoundError("Faq resource not found!");
57
+ }
58
+
59
+ return faq;
60
+ }
61
+
62
+ // ✅ get-faq.controller.ts — stays clean, no null-check
63
+ export const getFaqController: RequestHandler = async (request, response) => {
64
+ const faq = await getFaqService(request.input("id"));
65
+
66
+ return response.success({
67
+ faq,
68
+ });
69
+ };
70
+ ```
71
+
72
+ The framework maps `ResourceNotFoundError` to a `404` response automatically — the controller doesn't translate it.
73
+
74
+ - The key is the **singular camelCase** of the resource: `faq`, `aiModel`, `aiApiKey`, `user`, `order`.
75
+ - Never `{ data: <object> }` for a single item — `data` is reserved for arrays.
76
+ - Never spread the object at the top level (`return response.success({ ...faq })`) — the named wrapper preserves room for future siblings (`meta`, `warnings`, etc.).
77
+
78
+ ### 1.3 Delete endpoints — `204 No Content` (preferred)
79
+
80
+ ```typescript
81
+ // ✅ default — nothing to say, say nothing
82
+ export const deleteFaqController: RequestHandler = async (request, response) => {
83
+ await deleteFaqService(request.input("id"));
84
+
85
+ return response.noContent();
86
+ };
87
+ ```
88
+
89
+ - Default to `204 No Content` — a successful delete has no body worth sending.
90
+ - `{ 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.
91
+ - As with reads, the service throws `ResourceNotFoundError` if the row doesn't exist — the controller doesn't null-check.
92
+
93
+ ### 1.4 Error responses
94
+
95
+ Use the framework's status helpers — never hand-roll an error body.
96
+
97
+ ```typescript
98
+ // ✅
99
+ return response.notFound(); // { error: "notFound" }, 404
100
+ return response.unauthorized(); // { error: "unauthorized" }, 401
101
+ return response.conflict({ error: "Order already cancelled" });
102
+ return response.badRequest({ error: "Validation failed", otherInfo: "whatever" });
103
+
104
+ // ❌
105
+ return response.send({ error: "..." }, 404); // bypassing the helper
106
+ return response.success({ error: "..." }); // 200 with an error body — never
107
+ ```
108
+
109
+ Never `200 OK` with an `error` field — the status code carries the outcome.
110
+
111
+ ---
112
+
113
+ ## 2. HTTP status codes
114
+
115
+ One canonical mapping. Use the framework helpers; the helper's name **is** the status code's semantic.
116
+
117
+ | Helper | Code | When |
118
+ | ------------------------------------ | ---- | ------------------------------------------------- |
119
+ | `response.success(data)` | 200 | Success with a body |
120
+ | `response.successCreate(data)` | 201 | Resource created |
121
+ | `response.accepted(data)` | 202 | Async op accepted; processing started |
122
+ | `response.noContent()` | 204 | Success without a body (idempotent deletes) |
123
+ | `response.badRequest(data)` | 400 | Malformed request — broken JSON, wrong type |
124
+ | `response.unauthorized(data)` | 401 | No or invalid credentials |
125
+ | `response.forbidden(data)` | 403 | Known caller, not allowed |
126
+ | `response.notFound(data)` | 404 | Resource doesn't exist (or caller may not see it) |
127
+ | `response.conflict(data)` | 409 | State collision — already exists, version clash |
128
+ | `response.contentTooLarge(data)` | 413 | Payload exceeds the limit |
129
+ | `response.unprocessableEntity(data)` | 422 | Well-formed but semantically invalid |
130
+ | `response.tooManyRequests(data)` | 429 | Rate limit hit |
131
+ | `response.serverError(data)` | 500 | Server bug — never the client's fault |
132
+
133
+ **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.
134
+
135
+ **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.
136
+
137
+ ---
138
+
139
+ ## 3. Pagination
140
+
141
+ **Mandatory on every list endpoint.** No endpoint returns an unbounded array, even if today's dataset has 12 rows.
142
+
143
+ ### 3.1 Offset pagination — the default
144
+
145
+ Returned by `RepositoryManager.list` and `listCached`. Use for catalog-style lists, admin tables, anywhere the user thinks in pages.
146
+
147
+ ```typescript
148
+ {
149
+ data: T[],
150
+ pagination: {
151
+ limit: number; // requested per-page size
152
+ result: number; // items in THIS page (≤ limit)
153
+ page: number; // 1-indexed
154
+ total: number; // total items across all pages
155
+ pages: number; // total page count
156
+ }
157
+ }
158
+ ```
159
+
160
+ ### 3.2 Cursor pagination — for time-ordered firehoses
161
+
162
+ 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.
163
+
164
+ ```typescript
165
+ {
166
+ data: T[],
167
+ pagination: {
168
+ limit: number;
169
+ result: number;
170
+ hasMore: boolean;
171
+ nextCursor?: string | number;
172
+ prevCursor?: string | number;
173
+ }
174
+ }
175
+ ```
176
+
177
+ ### 3.3 Request shape
178
+
179
+ - Offset: `?page=1&limit=20`
180
+ - Cursor: `?limit=20&cursor=<value>`
181
+ - Hard cap `limit` at a sensible ceiling (e.g. 100) so a malformed client can't ask for 50,000 rows.
182
+
183
+ ### 3.4 Filter and sort
184
+
185
+ 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.
186
+
187
+ Sort: `?orderBy=created_at&orderDir=desc`. Default sort lives on the repository's `defaultOptions`.
188
+
189
+ ### 3.5 Validate the query — schema first, strip unknown
190
+
191
+ 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.
192
+
193
+ The schema earns its place three ways:
194
+
195
+ - **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).
196
+ - **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.
197
+ - **Whitelists `orderBy`.** Restricting sort to known indexed columns closes the slow-query vector.
198
+
199
+ ```typescript
200
+ // ✅ schema/list-orders.schema.ts
201
+ import { v, type Infer } from "@warlock.js/seal";
202
+ import { OrderStatus } from "../types/order-status.type";
203
+
204
+ export const listOrdersSchema = v.object({
205
+ status: v.enum(OrderStatus).optional(),
206
+ page: v.int().min(1).default(1),
207
+ limit: v.int().min(1).max(100).default(20),
208
+ orderBy: v.literal("created_at", "total_cents").optional(),
209
+ });
210
+
211
+ export type ListOrdersSchema = Infer<typeof listOrdersSchema>;
212
+
213
+ // ✅ controller — validated, not request.all()
214
+ const { data, pagination } = await listOrdersService(request.validated());
215
+ ```
216
+
217
+ **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.
218
+
219
+ ---
220
+
221
+ ## 4. URLs and routes
222
+
223
+ ### 4.1 Naming
224
+
225
+ - **Plural, kebab-case nouns:** `/ai-models`, `/ai-api-keys`, `/contacts`.
226
+ - **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.
227
+ - **Actions use verb suffixes on the resource path:** `POST /orders/:id/cancel`, not `POST /cancel-order`. The verb is the second-to-last segment.
228
+ - **Query params are filters, never identity.** Identity goes in the path (`/orders/:id`), filters in the query string (`?status=paid`).
229
+
230
+ ### 4.2 Route registration — the RESTful builder
231
+
232
+ Use `router.route(...).list().show().create().update().destroy()` from `@warlock.js/core`. The framework maps each verb to its conventional HTTP method.
233
+
234
+ ```typescript
235
+ // ✅ routes.ts
236
+ import { router } from "@warlock.js/core";
237
+ import { guarded } from "app/shared/utils/router";
238
+
239
+ guarded(() => {
240
+ router
241
+ .route("/ai-models")
242
+ .list(listAiModelsController)
243
+ .show(getAiModelController)
244
+ .create(createAiModelController)
245
+ .update(updateAiModelController)
246
+ .destroy(deleteAiModelController);
247
+ });
248
+ ```
249
+
250
+ - Wrap auth-required routes in `guarded(() => { ... })`.
251
+ - Public routes go outside `guarded`.
252
+ - One `routes.ts` per module. Never define routes spread across multiple files within the same module.
253
+
254
+ ### 4.3 Custom actions
255
+
256
+ 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`.
257
+
258
+ ---
259
+
260
+ ## 5. Resources (`.resource.ts`)
261
+
262
+ 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`).
263
+
264
+ ### 5.1 Definition
265
+
266
+ ```typescript
267
+ // ✅ ai-model.resource.ts
268
+ import { defineResource } from "@warlock.js/core";
269
+
270
+ export const AiModelResource = defineResource({
271
+ schema: {
272
+ id: "string",
273
+ provider: "string",
274
+ name: "string",
275
+ description: "string",
276
+ provider_model_id: "string",
277
+ is_free: "boolean",
278
+ input_price: "number",
279
+ output_price: "number",
280
+ capabilities: "object",
281
+ features: "array",
282
+ created_by: "string",
283
+ updated_by: "string",
284
+ },
285
+ });
286
+ ```
287
+
288
+ ### 5.2 Field naming — snake_case
289
+
290
+ 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.
291
+
292
+ Top-level envelope keys (the wrapper around the resource: `aiModel`, `aiModels`, `pagination`, `message`) remain camelCase.
293
+
294
+ ### 5.3 Rules
295
+
296
+ - 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.
297
+ - 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.
298
+ - Sensitive fields are omitted at the resource level, not redacted at the log layer.
299
+
300
+ ---
301
+
302
+ ## 6. Validation and request handling
303
+
304
+ ### 6.1 Schema + controller — two files, no `requests/`
305
+
306
+ 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.
307
+
308
+ 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")`.
309
+
310
+ ```
311
+ schema/create-lead.schema.ts ← v.object({...}) + Infer<>
312
+ controllers/create-lead.controller.ts ← typed handler + attached schema
313
+ ```
314
+
315
+ The schema file exports **both** the value and the inferred type:
316
+
317
+ ```typescript
318
+ // ✅ schema/create-lead.schema.ts
319
+ import { v, type Infer } from "@warlock.js/seal";
320
+
321
+ export const createLeadSchema = v.object({
322
+ name: v.string(),
323
+ email: v.string(),
324
+ });
325
+
326
+ export type CreateLeadSchema = Infer<typeof createLeadSchema>;
327
+ ```
328
+
329
+ The controller imports both, types itself with the schema as the request payload, and attaches the schema for the framework to validate against:
330
+
331
+ ```typescript
332
+ // ✅ controllers/create-lead.controller.ts — guarded route
333
+ import { type GuardedRequestHandler } from "app/auth/requests/guarded.request";
334
+ import { type CreateLeadSchema, createLeadSchema } from "../schema/create-lead.schema";
335
+ import { createLeadService } from "../services/create-lead.service";
336
+
337
+ export const createLeadController: GuardedRequestHandler<CreateLeadSchema> = async (
338
+ request,
339
+ response,
340
+ ) => {
341
+ const lead = await createLeadService(request.validated());
342
+
343
+ return response.success({
344
+ lead,
345
+ });
346
+ };
347
+
348
+ createLeadController.validation = {
349
+ schema: createLeadSchema,
350
+ };
351
+ ```
352
+
353
+ ### 6.2 Picking the handler type
354
+
355
+ | Route kind | Controller type | What it adds |
356
+ | -------------------------------- | ------------------------------------- | ------------------------------------- |
357
+ | Guarded (auth required) + body | `GuardedRequestHandler<Schema>` | typed `request.validated()` + `request.user` |
358
+ | Public + body | `RequestHandler<Request<Schema>>` | typed `request.validated()` |
359
+ | Read / list (no body) | `RequestHandler` | nothing extra |
360
+
361
+ `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.
362
+
363
+ ### 6.3 Accessing inputs
364
+
365
+ - `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.
366
+ - `request.input(name)` — single named input (path / query / body fallback). Use for read endpoints that take a single identifier.
367
+ - `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.
368
+
369
+ ### 6.4 Validation failures
370
+
371
+ 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.
372
+
373
+ ---
374
+
375
+ ## 7. Caching
376
+
377
+ ### 7.1 Response headers
378
+
379
+ Set `Cache-Control` on every `GET` endpoint, even if the value is `no-store`. Explicit beats default.
380
+
381
+ - Read endpoints on stable data: `Cache-Control: public, max-age=60`
382
+ - Read endpoints on user-specific data: `Cache-Control: private, max-age=30`
383
+ - Mutations: `Cache-Control: no-store` (always)
384
+ - Auth-related responses: `Cache-Control: no-store, private`
385
+
386
+ ### 7.2 Repository-level caching
387
+
388
+ `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.
389
+
390
+ ---
391
+
392
+ ## 8. Versioning
393
+
394
+ 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.
395
+
396
+ 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:
397
+
398
+ ### 8.1 Default — additive-only, no prefix
399
+
400
+ - Add fields freely; they don't break existing clients.
401
+ - Never remove or rename a field that's in use — see § 9 deprecation for the retire path.
402
+ - Never change a field's type or meaning under the same name.
403
+
404
+ This carries a surprising distance. Reach for explicit versioning only when additive-only genuinely can't express the change.
405
+
406
+ ### 8.2 When you must version — URL prefix
407
+
408
+ 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.
409
+
410
+ ```typescript
411
+ // ✅ versioned route group
412
+ router.prefix("/v1", () => {
413
+ router.route("/orders").list(listOrdersControllerV1);
414
+ });
415
+ ```
416
+
417
+ ### 8.3 Carve a whole version, not one endpoint
418
+
419
+ 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.
420
+
421
+ ### 8.4 Header-based versioning — avoid
422
+
423
+ `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.
424
+
425
+ ---
426
+
427
+ ## 9. Deprecation
428
+
429
+ When a field, endpoint, or shape needs to retire:
430
+
431
+ 1. **Announce in response headers** — `Deprecation: true`, `Sunset: <RFC 1123 date>`.
432
+ 2. **Document in the module's README** with the removal date.
433
+ 3. **Track usage** via metrics — never remove until traffic is near-zero.
434
+ 4. **Removal lands in a new route or new version**, never silently mid-version.
435
+
436
+ Minimum lead time before removal: **60 days** for internal-only callers, **90 days** for any externally-consumed surface.
437
+
438
+ ---
439
+
440
+ ## 10. Review checklist
441
+
442
+ Before merging any controller / route / resource change:
443
+
444
+ - [ ] List endpoints return `{ <pluralResourceName>, pagination }` — not a bare `data` key
445
+ - [ ] Single-item endpoints return `{ <resourceCamelName>: <object> }`
446
+ - [ ] Delete endpoints return `204 No Content` (or `{ message }` when confirmation is genuinely needed)
447
+ - [ ] Not-found is thrown from the service (`ResourceNotFoundError`) — controllers do not null-check
448
+ - [ ] Status code comes from a framework helper (`response.success`, `notFound`, etc.) — no hand-rolled `send(data, 4xx)`
449
+ - [ ] No `200 OK` with an `error` field
450
+ - [ ] List endpoint has pagination — no unbounded arrays
451
+ - [ ] List endpoint validates its query with a schema (`request.validated()`, not `request.all()`) — caps `limit`, whitelists `orderBy`, strips unknown keys
452
+ - [ ] URL is kebab-case plural noun; custom actions use verb suffix
453
+ - [ ] Routes registered via `router.route(...).list().show().create().update().destroy()` (or explicit `router.<verb>(...)` for custom actions)
454
+ - [ ] Auth-required routes wrapped in `guarded(() => { ... })`
455
+ - [ ] Write endpoint: schema in `schema/` folder, controller typed `GuardedRequestHandler<Schema>` / `RequestHandler<Request<Schema>>`, `controller.validation = { schema }`, no separate `requests/` file
456
+ - [ ] Read endpoint uses `request.input(...)` for identity, not `request.all()`
457
+ - [ ] Resource is output-only — no logic, no reaching into other models
458
+ - [ ] Resource field names are snake_case
459
+ - [ ] `Cache-Control` set explicitly on `GET` endpoints
460
+ - [ ] Versioning: additive-only by default; explicit `/vN` prefix only when a real breaking change forces it
461
+ - [ ] Deprecated fields announced via `Deprecation` / `Sunset` headers, not silently changed