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.
- package/CHANGELOG.md +38 -0
- package/README.md +8 -0
- package/esm/commands/create-new-app/index.mjs +4 -3
- package/esm/commands/create-new-app/index.mjs.map +1 -1
- package/esm/commands/create-warlock-app/index.mjs +7 -1
- package/esm/commands/create-warlock-app/index.mjs.map +1 -1
- package/esm/features/features-map.mjs +8 -2
- package/esm/features/features-map.mjs.map +1 -1
- package/esm/helpers/app.mjs +1 -0
- package/esm/helpers/app.mjs.map +1 -1
- package/esm/prompts/ask-stack.mjs +1 -1
- package/esm/prompts/ask-stack.mjs.map +1 -1
- package/llms-full.txt +64 -28
- package/llms.txt +1 -1
- package/package.json +2 -2
- package/skills/{create-a-warlock-project/SKILL.md → SKILL.md} +63 -27
- package/templates/design-system/SOURCES.md +23 -0
- package/templates/design-system/package.json +14 -0
- package/templates/design-system/src/web/components/ui/LICENSE.md +21 -0
- package/templates/design-system/src/web/components/ui/button.tsx +61 -0
- package/templates/design-system/src/web/components/ui/calendar.tsx +178 -0
- package/templates/design-system/src/web/components/ui/checkbox.tsx +28 -0
- package/templates/design-system/src/web/components/ui/command.tsx +161 -0
- package/templates/design-system/src/web/components/ui/dialog.tsx +144 -0
- package/templates/design-system/src/web/components/ui/input.tsx +20 -0
- package/templates/design-system/src/web/components/ui/label.tsx +20 -0
- package/templates/design-system/src/web/components/ui/popover.tsx +73 -0
- package/templates/design-system/src/web/components/ui/radio-group.tsx +44 -0
- package/templates/design-system/src/web/components/ui/switch.tsx +34 -0
- package/templates/design-system/src/web/components/ui/textarea.tsx +17 -0
- package/templates/design-system/src/web/design-system/components/checkbox-input.tsx +19 -0
- package/templates/design-system/src/web/design-system/components/date-picker-input.tsx +59 -0
- package/templates/design-system/src/web/design-system/components/date-time-picker-input.tsx +67 -0
- package/templates/design-system/src/web/design-system/components/email-input.tsx +22 -0
- package/templates/design-system/src/web/design-system/components/field-frame.tsx +36 -0
- package/templates/design-system/src/web/design-system/components/file-input.tsx +24 -0
- package/templates/design-system/src/web/design-system/components/multi-select-input.tsx +94 -0
- package/templates/design-system/src/web/design-system/components/password-input.tsx +22 -0
- package/templates/design-system/src/web/design-system/components/radio-input.tsx +41 -0
- package/templates/design-system/src/web/design-system/components/select-input.tsx +94 -0
- package/templates/design-system/src/web/design-system/components/submit-button.tsx +20 -0
- package/templates/design-system/src/web/design-system/components/switch-input.tsx +19 -0
- package/templates/design-system/src/web/design-system/components/text-input.tsx +22 -0
- package/templates/design-system/src/web/design-system/components/textarea-input.tsx +22 -0
- package/templates/design-system/src/web/design-system/components/time-picker-input.tsx +22 -0
- package/templates/design-system/src/web/design-system/hooks/use-calendar-day-button.ts +9 -0
- package/templates/design-system/src/web/design-system/hooks/use-control.ts +29 -0
- package/templates/design-system/src/web/design-system/hooks/use-date-control.ts +42 -0
- package/templates/design-system/src/web/design-system/hooks/use-file-control.ts +24 -0
- package/templates/design-system/src/web/design-system/hooks/use-select-control.ts +78 -0
- package/templates/design-system/src/web/design-system/hooks/use-submit-control.ts +13 -0
- package/templates/design-system/src/web/design-system/types/control-props.ts +46 -0
- package/templates/toolchains/oxc/.oxlintrc.json +3 -1
- package/templates/warlock/.oxlintrc.json +3 -1
- package/templates/warlock/AGENTS.md +10 -10
- package/templates/warlock/e2e/home.e2e.ts +19 -0
- package/templates/warlock/package.json +1 -1
- package/templates/warlock/skills/api-design/SKILL.md +180 -57
- package/templates/warlock/skills/code-standards/SKILL.md +48 -39
- package/templates/warlock/skills/data-and-persistence/SKILL.md +72 -57
- package/templates/warlock/skills/git-workflow/SKILL.md +9 -9
- package/templates/warlock/skills/module-boundaries/SKILL.md +2 -2
- package/templates/warlock/skills/observability-and-resilience/SKILL.md +8 -6
- package/templates/warlock/skills/security-baseline/SKILL.md +23 -22
- package/templates/warlock/skills/testing-strategy/SKILL.md +53 -12
- package/templates/warlock/skills/web-code-standards/SKILL.md +195 -60
- package/templates/warlock/src/app/auth/controllers/forgot-password.controller.ts +6 -0
- package/templates/warlock/src/app/auth/controllers/login.controller.ts +17 -0
- package/templates/warlock/src/app/auth/controllers/logout-all.controller.ts +6 -0
- package/templates/warlock/src/app/auth/controllers/logout.controller.ts +6 -0
- package/templates/warlock/src/app/auth/controllers/me.controller.ts +7 -0
- package/templates/warlock/src/app/auth/controllers/refresh-token.controller.ts +12 -0
- package/templates/warlock/src/app/auth/controllers/reset-password.controller.ts +6 -0
- package/templates/warlock/src/app/auth/models/otp/otp.model.ts +9 -3
- package/templates/warlock/src/app/auth/services/otp.service.ts +18 -6
- package/templates/warlock/src/app/contact/controllers/contact.controller.ts +6 -0
- package/templates/warlock/src/app/home/controllers/home-page.controller.ts +4 -0
- package/templates/warlock/src/app/locale/controllers/locale.controller.ts +6 -0
- package/templates/warlock/src/app/posts/controllers/create-new-post.controller.ts +7 -0
- package/templates/warlock/src/app/posts/controllers/update-post.controller.ts +15 -9
- package/templates/warlock/src/app/posts/models/post/migrations/09-01-2026_02-07-51-post.migration.ts +1 -0
- package/templates/warlock/src/app/posts/models/post/post.model.ts +6 -0
- package/templates/warlock/src/app/posts/resources/post.resource.ts +1 -1
- package/templates/warlock/src/app/posts/schema/update-post.schema.ts +1 -0
- package/templates/warlock/src/app/shared/utils/format-http-error-response.ts +53 -0
- package/templates/warlock/src/app/users/commands/hello-world.command.ts +2 -1
- package/templates/warlock/src/app/users/controllers/create-new-user.controller.ts +13 -1
- package/templates/warlock/src/app/users/controllers/list-users.controller.ts +24 -1
- package/templates/warlock/src/app/users/models/user/migrations/11-12-2025_23-58-03-user.migration.ts +1 -0
- package/templates/warlock/src/app/users/models/user/user.model.ts +5 -0
- package/templates/warlock/src/app/users/repositories/users.repository.ts +2 -2
- package/templates/warlock/src/app/users/resources/user.resource.ts +1 -1
- package/templates/warlock/src/app/users/schema/list-users.schema.ts +1 -1
- package/templates/warlock/src/config/http.ts +6 -0
- package/templates/warlock/src/web/home/components/contact-field.tsx +18 -0
- package/templates/warlock/src/web/home/components/contact-section.tsx +5 -29
- package/templates/warlock/src/web/home/components/contact-submit-button.tsx +16 -0
- package/templates/warlock/src/web/home/hooks/use-contact-field.ts +9 -0
- package/templates/warlock/src/web/home/hooks/use-contact-form.ts +5 -8
- package/templates/warlock/src/web/home/hooks/use-contact-locale.ts +34 -0
- package/templates/warlock/src/web/home/hooks/use-contact-submit-button.ts +6 -0
- package/templates/warlock/src/web/home/index.page.tsx +1 -1
- package/templates/warlock/src/web/home/types/contact-controls.type.ts +13 -0
- package/templates/warlock/src/web/shared/utils/set-form-errors.ts +3 -1
- package/templates/warlock/warlock.config.ts +1 -0
- 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": "
|
|
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": "
|
|
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 `
|
|
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 `
|
|
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
|
-
- `
|
|
79
|
-
- `
|
|
80
|
-
- `
|
|
81
|
-
- `
|
|
82
|
-
- `
|
|
83
|
-
- `
|
|
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
|
-
- `
|
|
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
|
|
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
|
+
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: api-design
|
|
3
|
-
description:
|
|
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.
|
|
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 }`;
|
|
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
|
-
|
|
28
|
-
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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 {
|
|
59
|
+
import { Faq } from "../models/faq/faq.model";
|
|
51
60
|
|
|
52
|
-
export async function getFaqService(id:
|
|
53
|
-
const faq = await
|
|
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
|
-
|
|
64
|
-
|
|
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 (
|
|
83
|
-
|
|
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
|
-
|
|
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.
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
return response.badRequest({ error:
|
|
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
|
-
|
|
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
|
|
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=
|
|
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 `
|
|
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("
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
305
|
+
providerModelId: "string",
|
|
306
|
+
isFree: "boolean",
|
|
307
|
+
inputPrice: "number",
|
|
308
|
+
outputPrice: "number",
|
|
280
309
|
capabilities: "object",
|
|
281
310
|
features: "array",
|
|
282
|
-
|
|
283
|
-
|
|
311
|
+
createdBy: "string",
|
|
312
|
+
updatedBy: "string",
|
|
284
313
|
},
|
|
285
314
|
});
|
|
286
315
|
```
|
|
287
316
|
|
|
288
|
-
### 5.2 Field naming —
|
|
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
|
-
|
|
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
|
-
|
|
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. `
|
|
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).
|
|
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.
|
|
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
|
|
356
|
-
|
|
|
357
|
-
| Guarded (auth required) + body
|
|
358
|
-
| Public + body
|
|
359
|
-
| Read / list (no body)
|
|
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).
|
|
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
|
|
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
|
-
- [ ]
|
|
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.
|