create-warlock 5.34.2 → 5.35.0

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 (106) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/README.md +8 -0
  3. package/esm/commands/create-new-app/index.mjs +4 -3
  4. package/esm/commands/create-new-app/index.mjs.map +1 -1
  5. package/esm/commands/create-warlock-app/index.mjs +7 -1
  6. package/esm/commands/create-warlock-app/index.mjs.map +1 -1
  7. package/esm/features/features-map.mjs +8 -2
  8. package/esm/features/features-map.mjs.map +1 -1
  9. package/esm/helpers/app.mjs +1 -0
  10. package/esm/helpers/app.mjs.map +1 -1
  11. package/esm/prompts/ask-stack.mjs +1 -1
  12. package/esm/prompts/ask-stack.mjs.map +1 -1
  13. package/llms-full.txt +64 -28
  14. package/llms.txt +1 -1
  15. package/package.json +2 -2
  16. package/skills/{create-a-warlock-project/SKILL.md → SKILL.md} +63 -27
  17. package/templates/design-system/SOURCES.md +23 -0
  18. package/templates/design-system/package.json +14 -0
  19. package/templates/design-system/src/web/components/ui/LICENSE.md +21 -0
  20. package/templates/design-system/src/web/components/ui/button.tsx +61 -0
  21. package/templates/design-system/src/web/components/ui/calendar.tsx +178 -0
  22. package/templates/design-system/src/web/components/ui/checkbox.tsx +28 -0
  23. package/templates/design-system/src/web/components/ui/command.tsx +161 -0
  24. package/templates/design-system/src/web/components/ui/dialog.tsx +144 -0
  25. package/templates/design-system/src/web/components/ui/input.tsx +20 -0
  26. package/templates/design-system/src/web/components/ui/label.tsx +20 -0
  27. package/templates/design-system/src/web/components/ui/popover.tsx +73 -0
  28. package/templates/design-system/src/web/components/ui/radio-group.tsx +44 -0
  29. package/templates/design-system/src/web/components/ui/switch.tsx +34 -0
  30. package/templates/design-system/src/web/components/ui/textarea.tsx +17 -0
  31. package/templates/design-system/src/web/design-system/components/checkbox-input.tsx +19 -0
  32. package/templates/design-system/src/web/design-system/components/date-picker-input.tsx +59 -0
  33. package/templates/design-system/src/web/design-system/components/date-time-picker-input.tsx +67 -0
  34. package/templates/design-system/src/web/design-system/components/email-input.tsx +22 -0
  35. package/templates/design-system/src/web/design-system/components/field-frame.tsx +36 -0
  36. package/templates/design-system/src/web/design-system/components/file-input.tsx +24 -0
  37. package/templates/design-system/src/web/design-system/components/multi-select-input.tsx +94 -0
  38. package/templates/design-system/src/web/design-system/components/password-input.tsx +22 -0
  39. package/templates/design-system/src/web/design-system/components/radio-input.tsx +41 -0
  40. package/templates/design-system/src/web/design-system/components/select-input.tsx +94 -0
  41. package/templates/design-system/src/web/design-system/components/submit-button.tsx +20 -0
  42. package/templates/design-system/src/web/design-system/components/switch-input.tsx +19 -0
  43. package/templates/design-system/src/web/design-system/components/text-input.tsx +22 -0
  44. package/templates/design-system/src/web/design-system/components/textarea-input.tsx +22 -0
  45. package/templates/design-system/src/web/design-system/components/time-picker-input.tsx +22 -0
  46. package/templates/design-system/src/web/design-system/hooks/use-calendar-day-button.ts +9 -0
  47. package/templates/design-system/src/web/design-system/hooks/use-control.ts +29 -0
  48. package/templates/design-system/src/web/design-system/hooks/use-date-control.ts +42 -0
  49. package/templates/design-system/src/web/design-system/hooks/use-file-control.ts +24 -0
  50. package/templates/design-system/src/web/design-system/hooks/use-select-control.ts +78 -0
  51. package/templates/design-system/src/web/design-system/hooks/use-submit-control.ts +13 -0
  52. package/templates/design-system/src/web/design-system/types/control-props.ts +46 -0
  53. package/templates/toolchains/oxc/.oxlintrc.json +3 -1
  54. package/templates/warlock/.oxlintrc.json +3 -1
  55. package/templates/warlock/AGENTS.md +10 -10
  56. package/templates/warlock/e2e/home.e2e.ts +19 -0
  57. package/templates/warlock/package.json +1 -1
  58. package/templates/warlock/skills/api-design/SKILL.md +180 -57
  59. package/templates/warlock/skills/code-standards/SKILL.md +48 -39
  60. package/templates/warlock/skills/data-and-persistence/SKILL.md +72 -57
  61. package/templates/warlock/skills/git-workflow/SKILL.md +9 -9
  62. package/templates/warlock/skills/module-boundaries/SKILL.md +2 -2
  63. package/templates/warlock/skills/observability-and-resilience/SKILL.md +8 -6
  64. package/templates/warlock/skills/security-baseline/SKILL.md +23 -22
  65. package/templates/warlock/skills/testing-strategy/SKILL.md +53 -12
  66. package/templates/warlock/skills/web-code-standards/SKILL.md +195 -60
  67. package/templates/warlock/src/app/auth/controllers/forgot-password.controller.ts +6 -0
  68. package/templates/warlock/src/app/auth/controllers/login.controller.ts +17 -0
  69. package/templates/warlock/src/app/auth/controllers/logout-all.controller.ts +6 -0
  70. package/templates/warlock/src/app/auth/controllers/logout.controller.ts +6 -0
  71. package/templates/warlock/src/app/auth/controllers/me.controller.ts +7 -0
  72. package/templates/warlock/src/app/auth/controllers/refresh-token.controller.ts +12 -0
  73. package/templates/warlock/src/app/auth/controllers/reset-password.controller.ts +6 -0
  74. package/templates/warlock/src/app/auth/models/otp/otp.model.ts +9 -3
  75. package/templates/warlock/src/app/auth/services/otp.service.ts +18 -6
  76. package/templates/warlock/src/app/contact/controllers/contact.controller.ts +6 -0
  77. package/templates/warlock/src/app/home/controllers/home-page.controller.ts +4 -0
  78. package/templates/warlock/src/app/locale/controllers/locale.controller.ts +6 -0
  79. package/templates/warlock/src/app/posts/controllers/create-new-post.controller.ts +7 -0
  80. package/templates/warlock/src/app/posts/controllers/update-post.controller.ts +15 -9
  81. package/templates/warlock/src/app/posts/models/post/migrations/09-01-2026_02-07-51-post.migration.ts +1 -0
  82. package/templates/warlock/src/app/posts/models/post/post.model.ts +6 -0
  83. package/templates/warlock/src/app/posts/resources/post.resource.ts +1 -1
  84. package/templates/warlock/src/app/posts/schema/update-post.schema.ts +1 -0
  85. package/templates/warlock/src/app/shared/utils/format-http-error-response.ts +53 -0
  86. package/templates/warlock/src/app/users/commands/hello-world.command.ts +2 -1
  87. package/templates/warlock/src/app/users/controllers/create-new-user.controller.ts +13 -1
  88. package/templates/warlock/src/app/users/controllers/list-users.controller.ts +24 -1
  89. package/templates/warlock/src/app/users/models/user/migrations/11-12-2025_23-58-03-user.migration.ts +1 -0
  90. package/templates/warlock/src/app/users/models/user/user.model.ts +5 -0
  91. package/templates/warlock/src/app/users/repositories/users.repository.ts +2 -2
  92. package/templates/warlock/src/app/users/resources/user.resource.ts +1 -1
  93. package/templates/warlock/src/app/users/schema/list-users.schema.ts +1 -1
  94. package/templates/warlock/src/config/http.ts +6 -0
  95. package/templates/warlock/src/web/home/components/contact-field.tsx +18 -0
  96. package/templates/warlock/src/web/home/components/contact-section.tsx +5 -29
  97. package/templates/warlock/src/web/home/components/contact-submit-button.tsx +16 -0
  98. package/templates/warlock/src/web/home/hooks/use-contact-field.ts +9 -0
  99. package/templates/warlock/src/web/home/hooks/use-contact-form.ts +5 -8
  100. package/templates/warlock/src/web/home/hooks/use-contact-locale.ts +34 -0
  101. package/templates/warlock/src/web/home/hooks/use-contact-submit-button.ts +6 -0
  102. package/templates/warlock/src/web/home/index.page.tsx +1 -1
  103. package/templates/warlock/src/web/home/types/contact-controls.type.ts +13 -0
  104. package/templates/warlock/src/web/shared/utils/set-form-errors.ts +3 -1
  105. package/templates/warlock/warlock.config.ts +1 -0
  106. package/templates/warlock/src/web/home/components/contact-form-controls.tsx +0 -42
@@ -0,0 +1,46 @@
1
+ import type { FormControlProps } from "@mongez/react-form";
2
+ import type { ReactNode } from "react";
3
+
4
+ export type ControlProps = Pick<
5
+ FormControlProps,
6
+ | "name"
7
+ | "rules"
8
+ | "schema"
9
+ | "validate"
10
+ | "validateOn"
11
+ | "onChange"
12
+ | "onError"
13
+ | "errors"
14
+ | "errorKeys"
15
+ | "disabled"
16
+ | "readOnly"
17
+ | "required"
18
+ > & {
19
+ id: string;
20
+ label: ReactNode;
21
+ hint?: ReactNode;
22
+ error?: ReactNode;
23
+ value?: unknown;
24
+ defaultValue?: unknown;
25
+ placeholder?: string;
26
+ className?: string;
27
+ dir?: "ltr" | "rtl";
28
+ autoComplete?: string;
29
+ rows?: number;
30
+ accept?: string;
31
+ multiple?: boolean;
32
+ };
33
+ export type Choice = { value: string; label: string; disabled?: boolean };
34
+ export type SelectProps = ControlProps & {
35
+ options: readonly Choice[];
36
+ searchLabel?: string;
37
+ emptyLabel?: string;
38
+ clearLabel?: string;
39
+ };
40
+ export type RadioProps = ControlProps & { options: readonly Choice[] };
41
+ export type CheckProps = ControlProps & { checked?: boolean; defaultChecked?: boolean };
42
+ export type DateProps = ControlProps & {
43
+ clearLabel?: string;
44
+ closeLabel?: string;
45
+ timeLabel?: string;
46
+ };
@@ -4,7 +4,9 @@
4
4
  "env": { "node": true, "es2021": true },
5
5
  "categories": { "correctness": "error" },
6
6
  "rules": {
7
- "typescript/no-explicit-any": "off",
7
+ "typescript/no-explicit-any": "error",
8
+ "curly": "error",
9
+ "no-console": "error",
8
10
  "no-unused-vars": [
9
11
  "warn",
10
12
  {
@@ -4,7 +4,9 @@
4
4
  "env": { "node": true, "es2021": true },
5
5
  "categories": { "correctness": "error" },
6
6
  "rules": {
7
- "typescript/no-explicit-any": "off",
7
+ "typescript/no-explicit-any": "error",
8
+ "curly": "error",
9
+ "no-console": "error",
8
10
  "no-unused-vars": [
9
11
  "warn",
10
12
  {
@@ -5,7 +5,7 @@
5
5
  > `.gemini/GEMINI.md`, `.github/copilot-instructions.md`, `CONVENTIONS.md`, …)
6
6
  > are **derived** from this file by
7
7
  > [agent-kit](https://github.com/hassanzohdy/agent-kit). Edit **this** file, then
8
- > run `yarn skills:sync` (or `npx agent-kit sync`) to regenerate them — never edit
8
+ > run `npm run skills:sync` (or `npx agent-kit sync`) to regenerate them — never edit
9
9
  > the derived files directly.
10
10
 
11
11
  ## Project overview
@@ -51,7 +51,7 @@ The rule is: **skill first, source second, web last.**
51
51
  `.cursor/skills/`, GitHub Copilot → `.github/skills/`, Codex → `.codex/skills/`,
52
52
  and the equivalent directory for other agents).
53
53
 
54
- If a skill you expect is missing, run `yarn skills:sync` (it also runs
54
+ If a skill you expect is missing, run `npm run skills:sync` (it also runs
55
55
  automatically on `postinstall`).
56
56
 
57
57
  ## Available project skills (`skills/`)
@@ -75,19 +75,19 @@ automatically on `postinstall`).
75
75
 
76
76
  ## Commands
77
77
 
78
- - `yarn dev` — start the dev server (`warlock dev`)
79
- - `yarn build` — type-check then build for production (`tsc && warlock build`)
80
- - `yarn start` — start the production server (`warlock start`)
81
- - `yarn migrate` / `yarn migrate.fresh` / `yarn migrate.list` — run migrations
82
- - `yarn seed` — run database seeders
83
- - `yarn lint` / `yarn format` / `yarn tsc` — lint and format with the selected toolchain, and type-check (`tsc --noEmit`)
78
+ - `npm run dev` — start the dev server (`warlock dev`)
79
+ - `npm run build` — type-check then build for production (`tsc && warlock build`)
80
+ - `npm run start` — start the production server (`warlock start`)
81
+ - `npm run migrate` / `npm run migrate.fresh` / `npm run migrate.list` — run migrations
82
+ - `npm run seed` — run database seeders
83
+ - `npm run lint` / `npm run format` / `npm run tsc` — lint and format with the selected toolchain, and type-check (`tsc --noEmit`)
84
84
  - `npx warlock check` — verify a change: typecheck, lint, the affected tests and the framework's conventions in one report (`--changed` for only what changed); run it after every change and loop until it exits 0
85
- - `yarn skills:sync` — re-sync skills from `node_modules` and regenerate the
85
+ - `npm run skills:sync` — re-sync skills from `node_modules` and regenerate the
86
86
  derived per-agent files from this `AGENTS.md`
87
87
 
88
88
  ## Code style
89
89
 
90
- Follow the **code-standards** skill (`skills/code-standards/SKILL.md`) for all
90
+ Follow the `code-standards` skill for all
91
91
  TypeScript — read it before writing or reviewing any `.ts` / `.tsx`. In short:
92
92
  `interface` for contracts and `type` for data shapes, no `any`, access modifiers
93
93
  on every class member, single-responsibility files with the documented naming
@@ -0,0 +1,19 @@
1
+ import { defineE2E, defaultViewports } from "@warlock.js/core/e2e";
2
+
3
+ export default defineE2E({
4
+ app: {
5
+ build: [process.execPath, "node_modules/@warlock.js/core/bin/warlock.js", "build"],
6
+ start: [process.execPath, "dist/app.js"],
7
+ exclude: ["storage", "data"],
8
+ },
9
+ run: async ({ page }) => {
10
+ for (const viewport of defaultViewports) {
11
+ await page.setViewport(viewport);
12
+ await page.goto("/");
13
+ await page.waitFor({ selector: "main" });
14
+ await page.assertAccessible();
15
+ await page.assertViewport();
16
+ await page.screenshot("home");
17
+ }
18
+ },
19
+ });
@@ -46,7 +46,7 @@
46
46
  "@mongez/agent-kit": "^1.3.1",
47
47
  "@types/node": "^25.0.3",
48
48
  "typescript": "7.0.2",
49
- "vitest": "4.0.5",
49
+ "vitest": "4.1.10",
50
50
  "oxlint": "1.88.0",
51
51
  "oxfmt": "0.73.0"
52
52
  }
@@ -1,6 +1,6 @@
1
1
  ---
2
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.'
3
+ description: "HTTP envelopes, camelCase resources, opaque public IDs, status codes, pagination, responseSchema, routes and idempotency headers. Use when designing or reviewing an API endpoint. Use security-baseline instead for input trust, authentication and authorization."
4
4
  ---
5
5
 
6
6
  # API design
@@ -16,21 +16,30 @@ The project-wide contract between server and client. The frontend should be able
16
16
 
17
17
  ## 1. Response envelope
18
18
 
19
- The project uses **three envelope shapes**, one per response kind. Each kind has its own controller pattern. Do not invent a fourth.
19
+ The project uses **three envelope shapes**, one per response kind. Each success kind has its own controller pattern. All errors use § 1.4.
20
20
 
21
21
  ### 1.1 List endpoints — `{ <pluralResourceName>, pagination }`
22
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.
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 }`; `listEndpoint` re-keys `data` to the plural name and enforces bounded page controls.
24
24
 
25
25
  ```typescript
26
26
  // ✅ list-users.controller.ts
27
- export const listUsersController: RequestHandler = async ({ request, response }) => {
28
- const { data, pagination } = await listUsersService(request.validated());
27
+ import { listEndpoint, type Request, type RequestHandler } from "@warlock.js/core";
28
+ import type { ListUsersQuery } from "../schema/list-users.schema";
29
+ import { usersRepository } from "../repositories/users.repository";
29
30
 
30
- return response.success({
31
- users: data,
32
- pagination,
33
- });
31
+ export const listUsersController: RequestHandler<Request<ListUsersQuery>> = async ({
32
+ request,
33
+ response,
34
+ }) => {
35
+ return response.success(
36
+ await listEndpoint(request, (options) => usersRepository.list(options), {
37
+ pluralKey: "users",
38
+ maxLimit: 100,
39
+ orderBy: ["createdAt"],
40
+ filters: ["status"],
41
+ }),
42
+ );
34
43
  };
35
44
  ```
36
45
 
@@ -47,10 +56,10 @@ Read, create, and update endpoints return the resource under its camelCase singu
47
56
  ```typescript
48
57
  // ✅ get-faq.service.ts — the service throws
49
58
  import { ResourceNotFoundError } from "@warlock.js/core";
50
- import { faqsRepository } from "../repositories/faqs.repository";
59
+ import { Faq } from "../models/faq/faq.model";
51
60
 
52
- export async function getFaqService(id: number | string) {
53
- const faq = await faqsRepository.getCached(id);
61
+ export async function getFaqService(id: string) {
62
+ const faq = await Faq.findOne({ publicId: id });
54
63
 
55
64
  if (!faq) {
56
65
  throw new ResourceNotFoundError("Faq resource not found!");
@@ -60,13 +69,20 @@ export async function getFaqService(id: number | string) {
60
69
  }
61
70
 
62
71
  // ✅ 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"));
72
+ import { v } from "@warlock.js/seal";
73
+ import type { Request, RequestHandler } from "@warlock.js/core";
74
+ export const getFaqController: RequestHandler<Request<{ id: string }>> = async ({
75
+ request,
76
+ response,
77
+ }) => {
78
+ const faq = await getFaqService(request.validated().id);
65
79
 
66
80
  return response.success({
67
81
  faq,
68
82
  });
69
83
  };
84
+
85
+ getFaqController.validation = { params: v.object({ id: v.string().uuid() }) };
70
86
  ```
71
87
 
72
88
  The framework maps `ResourceNotFoundError` to a `404` response automatically — the controller doesn't translate it.
@@ -79,11 +95,16 @@ The framework maps `ResourceNotFoundError` to a `404` response automatically —
79
95
 
80
96
  ```typescript
81
97
  // ✅ default — nothing to say, say nothing
82
- export const deleteFaqController: RequestHandler = async (request, response) => {
83
- await deleteFaqService(request.input("id"));
98
+ export const deleteFaqController: RequestHandler<Request<{ id: string }>> = async ({
99
+ request,
100
+ response,
101
+ }) => {
102
+ await deleteFaqService(request.validated().id);
84
103
 
85
104
  return response.noContent();
86
105
  };
106
+
107
+ deleteFaqController.validation = { params: v.object({ id: v.string().uuid() }) };
87
108
  ```
88
109
 
89
110
  - Default to `204 No Content` — a successful delete has no body worth sending.
@@ -92,21 +113,28 @@ export const deleteFaqController: RequestHandler = async (request, response) =>
92
113
 
93
114
  ### 1.4 Error responses
94
115
 
95
- Use the framework's status helpers — never hand-roll an error body.
116
+ Every JSON error response has this shape, with optional `details` for safe field errors or domain context:
117
+
118
+ ```json
119
+ {
120
+ "error": { "code": "notFound", "message": "Resource not found." },
121
+ "requestId": "request-correlation-id"
122
+ }
123
+ ```
124
+
125
+ Use the matching status helper or throw a framework error from the service. The starter's `http.errorResponse` formatter normalizes helper bodies and framework-generated JSON errors, including unmatched-route 404, validation 422, rate-limit 429, and 500. It removes stacks and masks ordinary 5xx messages. Typed `AppError` messages and details are explicitly public and are preserved, including for 5xx responses. `requestId` is the real Warlock `request.id`, read from the framework's correlation header at the transport boundary; a rejection before Warlock creates a Request uses Fastify's request ID and echoes that same value in the header. Do not generate a new ID inside a controller or use a user ID. See the `observability-and-resilience` skill for trace propagation.
96
126
 
97
127
  ```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
128
+ return response.notFound(); // formatter supplies { error: { code, message }, requestId }
129
+ return response.conflict({
130
+ error: { code: "orderCancelled", message: "Order already cancelled." },
131
+ });
132
+ return response.badRequest({ error: { code: "invalidRequest", message: "Invalid request." } });
107
133
  ```
108
134
 
109
- Never `200 OK` with an `error` field — the status code carries the outcome.
135
+ `http.errorMapper` translates thrown non-`HttpError` domain errors to `{ status, message?, payload? }`; it does **not** format response bodies. It skips `AppError` and `HttpError` subclasses, returned validation/helper responses, and Fastify's unmatched-route and global rate-limit responses. The starter config uses it to keep unknown thrown errors opaque; `http.errorResponse` handles the final JSON shape for all these paths. HTML error pages, files, and already-started streams retain their own contracts; neither hook rewrites their bodies.
136
+
137
+ Never send a `200 OK` with an `error` field. Never put secrets, stack traces, raw request bodies, or internal database IDs in `details`.
110
138
 
111
139
  ---
112
140
 
@@ -132,7 +160,7 @@ One canonical mapping. Use the framework helpers; the helper's name **is** the s
132
160
 
133
161
  **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
162
 
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.
163
+ **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 canonical error with code `notFound` is sufficient — don't expose internal reasons.
136
164
 
137
165
  ---
138
166
 
@@ -184,7 +212,7 @@ Use when the dataset is large, append-mostly, and time-ordered: chat messages, e
184
212
 
185
213
  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
214
 
187
- Sort: `?orderBy=created_at&orderDir=desc`. Default sort lives on the repository's `defaultOptions`.
215
+ Sort: `?orderBy=createdAt&orderDir=desc`. Default sort lives on the repository's `defaultOptions`.
188
216
 
189
217
  ### 3.5 Validate the query — schema first, strip unknown
190
218
 
@@ -192,7 +220,7 @@ List endpoints validate their query params with a `@warlock.js/seal` schema, exa
192
220
 
193
221
  The schema earns its place three ways:
194
222
 
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).
223
+ - **Kills operator injection.** Declaring `status: v.enum(OrderStatus)` rejects a smuggled `{ $ne: null }` at the boundary — `filterBy` alone may pass it straight through (see the `security-baseline` skill § 4.2).
196
224
  - **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
225
  - **Whitelists `orderBy`.** Restricting sort to known indexed columns closes the slow-query vector.
198
226
 
@@ -205,7 +233,7 @@ export const listOrdersSchema = v.object({
205
233
  status: v.enum(OrderStatus).optional(),
206
234
  page: v.int().min(1).default(1),
207
235
  limit: v.int().min(1).max(100).default(20),
208
- orderBy: v.literal("created_at", "total_cents").optional(),
236
+ orderBy: v.literal("createdAt", "totalCents").optional(),
209
237
  });
210
238
 
211
239
  export type ListOrdersSchema = Infer<typeof listOrdersSchema>;
@@ -214,7 +242,7 @@ export type ListOrdersSchema = Infer<typeof listOrdersSchema>;
214
242
  const { data, pagination } = await listOrdersService(request.validated());
215
243
  ```
216
244
 
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.
245
+ **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
246
 
219
247
  ---
220
248
 
@@ -224,7 +252,8 @@ const { data, pagination } = await listOrdersService(request.validated());
224
252
 
225
253
  - **Plural, kebab-case nouns:** `/ai-models`, `/ai-api-keys`, `/contacts`.
226
254
  - **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.
255
+ - **Actions use verb suffixes on the resource path:** `POST /orders/:id/cancel`, not `POST /cancel-order`. The verb is the final segment.
256
+ - **Public path IDs are opaque UUIDs.** `/orders/:id` resolves the model's `publicId`; resource `id` is that same value. Cascade's default numeric `id` and its stringifying `uuid` getter are internal identities, not public UUID generators. See the `data-and-persistence` skill.
228
257
  - **Query params are filters, never identity.** Identity goes in the path (`/orders/:id`), filters in the query string (`?status=paid`).
229
258
 
230
259
  ### 4.2 Route registration — the RESTful builder
@@ -259,7 +288,7 @@ For endpoints that don't fit the RESTful five (e.g. `POST /orders/:id/cancel`),
259
288
 
260
289
  ## 5. Resources (`.resource.ts`)
261
290
 
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`).
291
+ 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.
263
292
 
264
293
  ### 5.1 Definition
265
294
 
@@ -269,32 +298,42 @@ import { defineResource } from "@warlock.js/core";
269
298
 
270
299
  export const AiModelResource = defineResource({
271
300
  schema: {
272
- id: "string",
301
+ id: ["publicId", "string"],
273
302
  provider: "string",
274
303
  name: "string",
275
304
  description: "string",
276
- provider_model_id: "string",
277
- is_free: "boolean",
278
- input_price: "number",
279
- output_price: "number",
305
+ providerModelId: "string",
306
+ isFree: "boolean",
307
+ inputPrice: "number",
308
+ outputPrice: "number",
280
309
  capabilities: "object",
281
310
  features: "array",
282
- created_by: "string",
283
- updated_by: "string",
311
+ createdBy: "string",
312
+ updatedBy: "string",
284
313
  },
285
314
  });
286
315
  ```
287
316
 
288
- ### 5.2 Field naming — snake_case
317
+ ### 5.2 Field naming — camelCase everywhere
318
+
319
+ Envelope keys **and resource fields** are camelCase: `aiModel`, `providerModelId`, `isFree`, `createdBy`. `defineResource` preserves each schema key as the output key; it does not automatically convert naming styles. A string cast reads the same source key. A tuple `[sourceKey, castType]` reads a differently named stored column:
289
320
 
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.
321
+ ```typescript
322
+ export const OrderResource = defineResource({
323
+ schema: {
324
+ id: ["publicId", "string"],
325
+ totalCents: ["total_cents", "int"],
326
+ createdAt: ["created_at", "date"],
327
+ },
328
+ });
329
+ ```
291
330
 
292
- Top-level envelope keys (the wrapper around the resource: `aiModel`, `aiModels`, `pagination`, `message`) remain camelCase.
331
+ This supports legacy snake_case storage without leaking snake_case into JSON. New starter domain columns use camelCase; driver-managed timestamps can still be snake_case (PostgreSQL). Select the tuple's source key to match the actual stored field. See the `@warlock.js/core` skill's `define-resource` topic.
293
332
 
294
333
  ### 5.3 Rules
295
334
 
296
335
  - 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.
336
+ - A resource never runs business logic — if a value is computed (e.g. `totalSpentCents`), compute it in the service and pass the already-computed field.
298
337
  - Sensitive fields are omitted at the resource level, not redacted at the log layer.
299
338
 
300
339
  ---
@@ -305,7 +344,7 @@ Top-level envelope keys (the wrapper around the resource: `aiModel`, `aiModels`,
305
344
 
306
345
  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
346
 
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")`.
347
+ List endpoints follow the **same** two-file shape — a `list-<noun>.schema.ts` validates the query string (see § 3.5). Validate public IDs as strings (UUIDs for starter models), including single-identifier reads. A service accepts the validated public ID and looks up `publicId`, never the sequential primary key.
309
348
 
310
349
  ```
311
350
  schema/create-lead.schema.ts ← v.object({...}) + Infer<>
@@ -334,13 +373,13 @@ import { type GuardedRequestHandler } from "app/auth/requests/guarded.request";
334
373
  import { type CreateLeadSchema, createLeadSchema } from "../schema/create-lead.schema";
335
374
  import { createLeadService } from "../services/create-lead.service";
336
375
 
337
- export const createLeadController: GuardedRequestHandler<CreateLeadSchema> = async (
376
+ export const createLeadController: GuardedRequestHandler<CreateLeadSchema> = async ({
338
377
  request,
339
378
  response,
340
- ) => {
379
+ }) => {
341
380
  const lead = await createLeadService(request.validated());
342
381
 
343
- return response.success({
382
+ return response.successCreate({
344
383
  lead,
345
384
  });
346
385
  };
@@ -352,24 +391,51 @@ createLeadController.validation = {
352
391
 
353
392
  ### 6.2 Picking the handler type
354
393
 
355
- | Route kind | Controller type | What it adds |
356
- | -------------------------------- | ------------------------------------- | ------------------------------------- |
357
- | Guarded (auth required) + body | `GuardedRequestHandler<Schema>` | typed `request.validated()` + `request.locals.user` |
358
- | Public + body | `RequestHandler<Request<Schema>>` | typed `request.validated()` |
359
- | Read / list (no body) | `RequestHandler` | nothing extra |
394
+ | Route kind | Controller type | What it adds |
395
+ | ------------------------------ | --------------------------------- | --------------------------------------------------- |
396
+ | Guarded (auth required) + body | `GuardedRequestHandler<Schema>` | typed `request.validated()` + `request.locals.user` |
397
+ | Public + body | `RequestHandler<Request<Schema>>` | typed `request.validated()` |
398
+ | Read / list (no body) | `RequestHandler` | nothing extra |
360
399
 
361
400
  `GuardedRequestHandler<Schema>` (defined in `app/auth/requests/guarded.request.ts`) is `RequestHandler<GuardedRequest<Schema>>` — it both types the validated payload and guarantees `request.locals.user` is present.
362
401
 
363
402
  ### 6.3 Accessing inputs
364
403
 
365
404
  - `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.
405
+ - `request.input(name)` — raw single named input (path / query / body fallback). Validate it before passing an identifier to a service; a TypeScript annotation alone is not validation.
367
406
  - `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
407
 
369
408
  ### 6.4 Validation failures
370
409
 
371
410
  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
411
 
412
+ ### 6.5 Declare every controller's responseSchema
413
+
414
+ Attach `responseSchema` beside `validation` on every controller, including list/read/delete handlers. It declares each documented status body; it is not runtime response validation. Export resources from their own `.resource.ts` files so route generation can resolve them.
415
+
416
+ ```typescript
417
+ createLeadController.responseSchema = {
418
+ 201: { body: { lead: LeadResource } },
419
+ 422: { body: { error: { code: "string", message: "string" }, requestId: "string" } },
420
+ };
421
+ ```
422
+
423
+ Use `response.successCreate({ lead })` for that 201. List schemas describe the plural resource array plus `pagination`; 204 has no body. `npx warlock generate.typings` (also run by `npx warlock dev`) generates named route declarations. The web `ApiResponse` type and `useSubmitForm({ route })` infer these declared status bodies. `npx warlock generate.openapi` uses the same declarations for OpenAPI. See the `@warlock.js/core` skill's `create-controller` and `generate-openapi` topics and the `@warlock.js/web` skill's `submit-a-form` topic.
424
+
425
+ ### 6.6 Side effects require an Idempotency-Key header
426
+
427
+ For payments, email sends, webhook dispatch, and other retry-sensitive writes, require `Idempotency-Key` at the HTTP boundary. The caller creates one key per user intent and reuses it for retries with the same body:
428
+
429
+ ```http
430
+ POST /orders
431
+ Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000
432
+ Content-Type: application/json
433
+
434
+ {"cartId":"6ba7b810-9dad-41d1-80b4-00c04fd430c8"}
435
+ ```
436
+
437
+ Attach `middleware.idempotency()` **after authentication**. It replays a cached status/body for the same caller, key and body and marks `Idempotent-Replay: true`; a changed body with the same key returns 422, an in-flight request returns 409. The middleware passes through a missing key, so add an app middleware that validates and requires `request.header("idempotency-key")` before it. Configure a shared cache driver for multiple processes and define a retention TTL. Do not cache a retry-sensitive operation only in component state. The service must also make downstream side effects idempotent: a response cache alone cannot make a remote charge and a local write atomic. See the `@warlock.js/core` skill's `use-middleware` topic.
438
+
373
439
  ---
374
440
 
375
441
  ## 7. Caching
@@ -453,9 +519,66 @@ Before merging any controller / route / resource change:
453
519
  - [ ] Routes registered via `router.route(...).list().show().create().update().destroy()` (or explicit `router.<verb>(...)` for custom actions)
454
520
  - [ ] Auth-required routes wrapped in `guarded(() => { ... })`
455
521
  - [ ] 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()`
522
+ - [ ] Read endpoint validates its opaque public ID before service lookup
457
523
  - [ ] Resource is output-only — no logic, no reaching into other models
458
- - [ ] Resource field names are snake_case
524
+ - [ ] Envelope and resource keys are camelCase; legacy stored columns use source-key tuples
525
+ - [ ] Every controller declares `responseSchema`; generated client/OpenAPI types match its statuses
526
+ - [ ] Errors use `{ error: { code, message }, details?, requestId }`, including framework failures
527
+ - [ ] Side-effecting endpoints require `Idempotency-Key`, scoped after auth and reused on retries
459
528
  - [ ] `Cache-Control` set explicitly on `GET` endpoints
460
529
  - [ ] Versioning: additive-only by default; explicit `/vN` prefix only when a real breaking change forces it
461
530
  - [ ] Deprecated fields announced via `Deprecation` / `Sunset` headers, not silently changed
531
+
532
+ ## Framework helpers for API contracts
533
+
534
+ Throw `AppError` for new coded domain failures. Its positional constructor is
535
+ `(code, status, publicMessage, details?)`; status must be 400–599. Details are
536
+ typed and must be safe to disclose. Existing `HttpError` subclasses remain
537
+ separate and retain their constructors and payload behavior. Neither needs
538
+ `http.errorMapper`. The starter formatter reads `context.appError` natively and
539
+ keeps this project's `{ error: { code, message }, details?, requestId }` contract.
540
+ Core's unformatted default places details inside `error`; declare the actual
541
+ formatted shape in controller response schemas.
542
+
543
+ ```ts
544
+ import { AppError } from "@warlock.js/core/errors";
545
+
546
+ throw new AppError("quotaExceeded", 429, "Try again later.", { retryAfter: 30 });
547
+ ```
548
+
549
+ For lists, use the `listEndpoint` example in § 1.1. Type the controller as
550
+ `RequestHandler<Request<ListUsersQuery>>`, attach the query schema with
551
+ `validating: ["query"]`, and configure repository `filterBy`. The helper reads
552
+ only `request.validated()`, defaults to page 1 / limit 20, clamps limits to
553
+ `maxLimit` (100 by default), rejects unlisted sort columns, and forwards only
554
+ named filters as flat repository options. Both `(options) => repo.list(options)`
555
+ and `(options) => repo.listCached(options)` preserve the repository receiver.
556
+ Page pagination is forced; cursor endpoints use a separate controller.
557
+
558
+ Test the declared response contract through actual HTTP, not just generated
559
+ types. Keep the existing `startHttpTestServer({ port: 0 })` / `setupTest` setup
560
+ and import routes in the worker so its registry contains handler schemas:
561
+
562
+ ```ts
563
+ import { it } from "vitest";
564
+ import { router } from "@warlock.js/core";
565
+ import { assertResponseContracts } from "@warlock.js/core/tests";
566
+ import "../routes";
567
+
568
+ it("matches user response declarations", async () => {
569
+ await assertResponseContracts({
570
+ routes: router.list().filter((route) => route.path === "/users"),
571
+ cases: () => [{ path: "/users?page=1&limit=10", status: 200 }],
572
+ requireAllStatuses: true,
573
+ });
574
+ });
575
+ ```
576
+
577
+ Omit `routes` to discover all registered routes with schemas. Supply fixtures
578
+ for every declared status when using `requireAllStatuses`; parameterized routes
579
+ and methods other than GET always require fixtures. Cases support a concrete
580
+ `path`, `init` (headers/serialized body), and expected `status`. Use isolated
581
+ seed data and cleanup for mutations. Pass `client` for an authenticated client
582
+ or `createTestHttpServer()` instance. Checks reject undeclared statuses, missing
583
+ fields and type mismatches; unsupported computed/builder shapes fail explicitly.
584
+ See core's `test-http` topic for lifecycle, fixture and schema limitations.