create-warlock 5.34.2 → 5.34.3

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 (48) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/llms-full.txt +21 -28
  3. package/llms.txt +1 -1
  4. package/package.json +2 -2
  5. package/skills/{create-a-warlock-project/SKILL.md → SKILL.md} +20 -27
  6. package/templates/toolchains/oxc/.oxlintrc.json +3 -1
  7. package/templates/warlock/.oxlintrc.json +3 -1
  8. package/templates/warlock/AGENTS.md +10 -10
  9. package/templates/warlock/skills/api-design/SKILL.md +110 -50
  10. package/templates/warlock/skills/code-standards/SKILL.md +48 -39
  11. package/templates/warlock/skills/data-and-persistence/SKILL.md +72 -57
  12. package/templates/warlock/skills/git-workflow/SKILL.md +9 -9
  13. package/templates/warlock/skills/module-boundaries/SKILL.md +2 -2
  14. package/templates/warlock/skills/observability-and-resilience/SKILL.md +8 -6
  15. package/templates/warlock/skills/security-baseline/SKILL.md +23 -22
  16. package/templates/warlock/skills/testing-strategy/SKILL.md +7 -7
  17. package/templates/warlock/skills/web-code-standards/SKILL.md +157 -60
  18. package/templates/warlock/src/app/auth/models/otp/otp.model.ts +9 -3
  19. package/templates/warlock/src/app/auth/services/otp.service.ts +18 -6
  20. package/templates/warlock/src/app/contact/controllers/contact.controller.ts +6 -0
  21. package/templates/warlock/src/app/locale/controllers/locale.controller.ts +6 -0
  22. package/templates/warlock/src/app/posts/controllers/create-new-post.controller.ts +7 -0
  23. package/templates/warlock/src/app/posts/controllers/update-post.controller.ts +14 -9
  24. package/templates/warlock/src/app/posts/models/post/migrations/09-01-2026_02-07-51-post.migration.ts +1 -0
  25. package/templates/warlock/src/app/posts/models/post/post.model.ts +6 -0
  26. package/templates/warlock/src/app/posts/resources/post.resource.ts +1 -1
  27. package/templates/warlock/src/app/posts/schema/update-post.schema.ts +1 -0
  28. package/templates/warlock/src/app/shared/utils/format-http-error-response.ts +41 -0
  29. package/templates/warlock/src/app/users/commands/hello-world.command.ts +2 -1
  30. package/templates/warlock/src/app/users/controllers/create-new-user.controller.ts +12 -1
  31. package/templates/warlock/src/app/users/controllers/list-users.controller.ts +24 -1
  32. package/templates/warlock/src/app/users/models/user/migrations/11-12-2025_23-58-03-user.migration.ts +1 -0
  33. package/templates/warlock/src/app/users/models/user/user.model.ts +5 -0
  34. package/templates/warlock/src/app/users/repositories/users.repository.ts +2 -2
  35. package/templates/warlock/src/app/users/resources/user.resource.ts +1 -1
  36. package/templates/warlock/src/app/users/schema/list-users.schema.ts +1 -1
  37. package/templates/warlock/src/config/http.ts +6 -0
  38. package/templates/warlock/src/web/home/components/contact-field.tsx +18 -0
  39. package/templates/warlock/src/web/home/components/contact-section.tsx +5 -29
  40. package/templates/warlock/src/web/home/components/contact-submit-button.tsx +16 -0
  41. package/templates/warlock/src/web/home/hooks/use-contact-field.ts +9 -0
  42. package/templates/warlock/src/web/home/hooks/use-contact-form.ts +5 -8
  43. package/templates/warlock/src/web/home/hooks/use-contact-locale.ts +34 -0
  44. package/templates/warlock/src/web/home/hooks/use-contact-submit-button.ts +6 -0
  45. package/templates/warlock/src/web/home/index.page.tsx +1 -1
  46. package/templates/warlock/src/web/home/types/contact-controls.type.ts +13 -0
  47. package/templates/warlock/src/web/shared/utils/set-form-errors.ts +3 -1
  48. package/templates/warlock/src/web/home/components/contact-form-controls.tsx +0 -42
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: observability-and-resilience
3
- description: 'How this project stays debuggable in production and survives flaky dependencies — `log.<level>(module, action, message, context)` from `@warlock.js/logger` with `X-Request-Id` propagation (already shipped via core middleware), structured metrics naming `<area>.<noun>.<unit>`, health endpoints (`/health/live` for liveness vs `/health/ready` for readiness), timeouts on every external call (`@warlock.js/http` `timeout` per Http instance / per request), retries with exponential backoff for idempotent ops only (never auto-retry POST without an idempotency key), idempotency keys for side effects (payments, emails, webhook deliveries), circuit breakers for flaky deps, graceful shutdown via `log.configure({ autoFlushOn: ["SIGINT", "SIGTERM", "beforeExit"] })`, N+1 detection, streaming for large payloads. Triggers: making an HTTP / AI / queue / DB call; adding a timeout or retry; designing an idempotency key for a side effect; instrumenting metrics; adding a health check; tracing a request across services; user asks "timeout", "retry policy", "idempotency", "request id", "tracing", "graceful shutdown", "health check", "circuit breaker", "N+1 query", "caching strategy", "metrics naming"; touching `@warlock.js/http` / `@warlock.js/scheduler` / `@warlock.js/ai` call sites. Skip: log call signature (load `skills/code-standards/SKILL.md` § 8); secrets in logs (load `skills/security-baseline/SKILL.md` § 8); pure-function refactors with no external I/O; cache driver setup (load `@warlock.js/cache` skills); HTTP client config in depth (load `@warlock.js/http` skills).'
3
+ description: "Request correlation, structured logs, timeouts, retries, idempotency, health checks, metrics, shutdown and bounded queries. Use when adding external I/O or production instrumentation. Use security-baseline instead for secrets and sensitive data handling."
4
4
  ---
5
5
 
6
6
  # Observability & resilience
@@ -53,7 +53,7 @@ Set a default at the `Http` instance level; override per request when the endpoi
53
53
  // ✅ instance-level default
54
54
  const stripeClient = new Http({
55
55
  baseURL: "https://api.stripe.com",
56
- timeout: 10_000, // 10s — enough for most calls, short enough to fail fast
56
+ timeout: 10_000, // 10s — enough for most calls, short enough to fail fast
57
57
  });
58
58
 
59
59
  // ✅ per-request override for a known-slow call
@@ -132,12 +132,14 @@ Persist `key → result` with a TTL longer than your retry window (24h is typica
132
132
 
133
133
  ### 4.4 Pattern
134
134
 
135
+ This simplified service sketch also needs an atomic reservation and provider-side idempotency to prevent concurrent duplicate charges. At the HTTP boundary, require `Idempotency-Key` and run `middleware.idempotency()` after auth as described in the `api-design` skill.
136
+
135
137
  ```typescript
136
138
  // ✅
137
139
  export async function chargePaymentService(input, idempotencyKey) {
138
- const cached = await idempotencyRepository.find({ key: idempotencyKey });
140
+ const cached = await idempotencyRepository.findBy("key", idempotencyKey);
139
141
  if (cached) {
140
- return cached.result;
142
+ return cached.get("result");
141
143
  }
142
144
 
143
145
  const result = await stripeClient.post("/v1/charges", input);
@@ -261,11 +263,11 @@ These belong here because performance failures present as resilience failures
261
263
 
262
264
  A list endpoint that fires one query per item is a bug. Either eager-load relations (Cascade's `.with("relation")`) or batch-fetch the related ids in a single query and join in-process.
263
265
 
264
- A code review that lets an N+1 through is doing its job poorly. Add it to the review checklist (already in `skills/api-design/SKILL.md`).
266
+ A code review that lets an N+1 through is doing its job poorly. Add it to the review checklist (already in the `api-design` skill).
265
267
 
266
268
  ### 9.2 Pagination always
267
269
 
268
- See `skills/api-design/SKILL.md` § 3. No unbounded list ever, even if today's dataset is 12 rows.
270
+ See the `api-design` skill § 3. No unbounded list ever, even if today's dataset is 12 rows.
269
271
 
270
272
  ### 9.3 Stream large payloads
271
273
 
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: security-baseline
3
- description: 'Project-level security floor for Warlock.js apps — input validation at every boundary via `@warlock.js/seal` schemas, authentication via `@warlock.js/auth` (JWT + refresh) with `userType` config, server-side authorization never trusting client-supplied `userId`/`organizationId`, secrets only via `env(...)` + typed config files (never `process.env.X` scattered), parameterized queries through Cascade ORM (never raw concatenation), rate limiting via `@fastify/rate-limit` configured in `config/http.ts`, PII / token redaction in logs (`log.configure({ redact })`), `bcryptjs` for password hashing, `@mongez/encryption` for symmetric encryption, dependency vulnerability scanning (`yarn audit`). Triggers: writing a route handler / controller that accepts external input; touching auth, login, signup, session, token, JWT, password, refresh-token; reading secrets / API keys / env vars; writing a Cascade query or repository filter; handling file uploads, webhook payloads, queue messages; setting rate limits; user asks "is this secure", "input validation", "SQL / NoSQL injection", "XSS", "CSRF", "rate limiting", "secrets management", "authentication vs authorization", "where does X env var go"; importing `@warlock.js/seal` or `@warlock.js/auth` or `bcryptjs`. Skip: pure UI / styling that handles no data; code-style questions (load `skills/code-standards/SKILL.md`); HTTP envelope shape (load `skills/api-design/SKILL.md`); cryptography internals / writing your own crypto — stop and use a vetted lib; advanced auth flows (OAuth, SAML) — load `@warlock.js/social-auth` skill.'
3
+ description: "Input schemas, authentication, authorization, secrets, safe queries, rate limits, password handling and log redaction. Use when code accepts external input or handles identity or credentials. Use api-design instead for HTTP response contracts."
4
4
  ---
5
5
 
6
6
  # Security baseline
@@ -10,7 +10,7 @@ description: 'Project-level security floor for Warlock.js apps — input validat
10
10
 
11
11
  The non-negotiable security floor for any project on this template. Each rule maps to a real class of incident — most production breaches trace back to one of these eight being absent or sloppy.
12
12
 
13
- > **Sub-agent rule:** Before writing any code that handles external input, identity, or secrets, read this file. Code-style rules live in `skills/code-standards/SKILL.md` — this skill is about what the code must do, not how it must look.
13
+ > **Sub-agent rule:** Before writing any code that handles external input, identity, or secrets, read this file. Code-style rules live in the `code-standards` skill — this skill is about what the code must do, not how it must look.
14
14
 
15
15
  ---
16
16
 
@@ -27,13 +27,13 @@ Anything that crosses the process boundary:
27
27
  - Webhook payloads
28
28
  - Queue messages
29
29
  - Env vars (validated once at boot — see § 3)
30
- - Database rows from a *different* service (treat them as external if you don't own the writer)
30
+ - Database rows from a _different_ service (treat them as external if you don't own the writer)
31
31
 
32
32
  ### 1.2 Where validation runs
33
33
 
34
34
  At the boundary, exactly once. Never inside services.
35
35
 
36
- The Warlock pattern: schema lives in the module's `schema/` folder, the controller types itself with the schema and attaches it via `controller.validation.schema`. See `skills/api-design/SKILL.md` § 6 for the full controller shape.
36
+ The Warlock pattern: schema lives in the module's `schema/` folder, the controller types itself with the schema and attaches it via `controller.validation.schema`. See the `api-design` skill § 6 for the full controller shape.
37
37
 
38
38
  ```typescript
39
39
  // ✅ schema/create-order.schema.ts
@@ -56,10 +56,10 @@ export type CreateOrderSchema = Infer<typeof createOrderSchema>;
56
56
  import { type GuardedRequestHandler } from "app/auth/requests/guarded.request";
57
57
  import { type CreateOrderSchema, createOrderSchema } from "../schema/create-order.schema";
58
58
 
59
- export const createOrderController: GuardedRequestHandler<CreateOrderSchema> = async (
59
+ export const createOrderController: GuardedRequestHandler<CreateOrderSchema> = async ({
60
60
  request,
61
61
  response,
62
- ) => {
62
+ }) => {
63
63
  const order = await placeOrderService(request.validated()); // already typed + safe
64
64
  return response.success({ order });
65
65
  };
@@ -80,7 +80,7 @@ The framework returns `422 Unprocessable Entity` with structured field errors au
80
80
 
81
81
  - **Verify the signature first**, schema-validate second. A signature failure aborts before any other work.
82
82
  - Reject requests older than the provider's replay window (typically 5 minutes).
83
- - Idempotency-key the handler (see `skills/observability-and-resilience/SKILL.md` § 4) — providers retry.
83
+ - Idempotency-key the handler (see the `observability-and-resilience` skill § 4) — providers retry.
84
84
 
85
85
  ---
86
86
 
@@ -92,18 +92,18 @@ AuthN proves who you are. AuthZ decides what you can do. Conflating them is how
92
92
 
93
93
  ```typescript
94
94
  // ❌ trusting the client
95
- const order = await ordersRepository.find({ id, userId: request.input("userId") });
95
+ const order = await Order.findOne({ publicId: id, userId: request.input("userId") });
96
96
 
97
97
  // ✅ identity from the authenticated session
98
98
  const user = request.locals.user;
99
- const order = await ordersRepository.find({ id, userId: user.id });
99
+ const order = await Order.findOne({ publicId: id, userId: user.id });
100
100
  ```
101
101
 
102
102
  Any field whose value would determine permission (`userId`, `organizationId`, `roleId`, `isAdmin`) is read from the authenticated session — never from the request body, query, or header.
103
103
 
104
104
  ### 2.2 Authentication via `@warlock.js/auth`
105
105
 
106
- The project's auth stack: JWT access tokens (1h) + refresh tokens (30d), configured in `config/auth.ts`. Password hashing via `bcryptjs`. The framework's `authService` (`@warlock.js/auth`) is the single entry point:
106
+ The project's auth stack: JWT access tokens and optional refresh tokens, configured in `src/config/auth.ts`. The starter uses `NO_EXPIRATION` for access tokens and disables refresh tokens; choose finite lifetimes and explicitly enable refresh when the application needs sessions. Password hashing via `bcryptjs`. The framework's `authService` (`@warlock.js/auth`) is the single entry point:
107
107
 
108
108
  ```typescript
109
109
  // ✅ thin wrapper service — the framework does the work
@@ -124,13 +124,13 @@ Pattern: at the start of every mutating service, check ownership and role:
124
124
  ```typescript
125
125
  // ✅
126
126
  export async function updateOrderService(orderId: string, input, user) {
127
- const order = await ordersRepository.find({ id: orderId });
127
+ const order = await Order.findOne({ publicId: orderId });
128
128
 
129
129
  if (!order) {
130
130
  throw new OrderNotFoundError(orderId);
131
131
  }
132
132
 
133
- if (order.userId !== user.id && !user.isAdmin) {
133
+ if (order.get("userId") !== user.id && !user.isAdmin) {
134
134
  throw new ForbiddenError("not your order");
135
135
  }
136
136
 
@@ -168,7 +168,7 @@ const authConfigurations: AuthConfigurations = {
168
168
  jwt: {
169
169
  secret: env("JWT_SECRET"),
170
170
  expiresIn: "1h",
171
- refresh: { /* ... */ },
171
+ refresh: {/* ... */},
172
172
  },
173
173
  };
174
174
  ```
@@ -184,10 +184,10 @@ The logger redaction layer is the safety net, but the first line of defence is n
184
184
  log.info("auth", "login", "User authenticated", { user, password, token });
185
185
 
186
186
  // ✅
187
- log.info("auth", "login", "User authenticated", { userId: user.id });
187
+ log.info("auth", "login", "User authenticated", { userId: user.get("publicId") });
188
188
  ```
189
189
 
190
- Configure `log.configure({ redact: { paths: ["password", "token", "*.secret", "*.apiKey"] } })` at startup — see `@warlock.js/logger/redact-sensitive-log-fields/SKILL.md`.
190
+ Configure `log.configure({ redact: { paths: ["password", "token", "*.secret", "*.apiKey"] } })` at startup — see the `@warlock.js/logger` skill's `redact-sensitive-log-fields` topic.
191
191
 
192
192
  ### 3.4 Token lifetimes
193
193
 
@@ -213,7 +213,7 @@ const orders = await ordersRepository.raw(`status = '${input.status}'`);
213
213
 
214
214
  If a request body is `{ status: { $ne: null } }`, a naive `find({ status: input.status })` becomes a `$ne: null` match — returns every row. The seal schema is what prevents this: declaring `status: v.string()` rejects an object value at the boundary.
215
215
 
216
- This applies to **query strings too**, not just bodies — a `?status[$ne]=` smuggled through the query is the same attack. That's why list endpoints validate the query with a schema and never pass raw `request.all()` to the repository (see `skills/api-design/SKILL.md` § 3.5).
216
+ This applies to **query strings too**, not just bodies — a `?status[$ne]=` smuggled through the query is the same attack. That's why list endpoints validate the query with a schema and never pass raw `request.all()` to the repository (see the `api-design` skill § 3.5).
217
217
 
218
218
  This is a real reason § 1 is non-negotiable.
219
219
 
@@ -281,6 +281,7 @@ For `/auth/login` and `/auth/reset-password`, key the limiter on the **email or
281
281
  ### 6.4 Progressive lockout
282
282
 
283
283
  After N failed login attempts:
284
+
284
285
  - 3 failures → 1-minute delay
285
286
  - 5 failures → 5-minute delay + email notification
286
287
  - 10 failures → temporary account lock + manual review
@@ -291,13 +292,13 @@ Never silently succeed-and-fail (returning 200 with no body to hide whether the
291
292
 
292
293
  ## 7. Dependency hygiene
293
294
 
294
- ### 7.1 `yarn audit` in CI
295
+ ### 7.1 `npm audit` in CI
295
296
 
296
- CI runs `yarn audit --level=high` on every PR. High and critical vulnerabilities block merge. Moderate goes on the backlog with a fix-by date.
297
+ CI runs `npm audit --audit-level=high` on every PR. High and critical vulnerabilities block merge. Moderate goes on the backlog with a fix-by date.
297
298
 
298
299
  ### 7.2 Lockfile committed
299
300
 
300
- `yarn.lock` is checked in. Always. CI installs with `--frozen-lockfile`.
301
+ `package-lock.json` is checked in. CI installs with `npm ci`.
301
302
 
302
303
  ### 7.3 No `latest` in `package.json`
303
304
 
@@ -311,7 +312,7 @@ Every dependency is future risk. Before adding a 5-line utility, ask whether it'
311
312
 
312
313
  ## 8. Logging and PII
313
314
 
314
- See `skills/code-standards/SKILL.md` § 8 for the call signature. This is the **what not to log** list:
315
+ See the `code-standards` skill § 8 for the call signature. This is the **what not to log** list:
315
316
 
316
317
  ### 8.1 Never log
317
318
 
@@ -340,7 +341,7 @@ log.configure({
340
341
  });
341
342
  ```
342
343
 
343
- See `@warlock.js/logger/redact-sensitive-log-fields/SKILL.md` for the additive per-channel layer.
344
+ See the `@warlock.js/logger` skill's `redact-sensitive-log-fields` topic for the additive per-channel layer.
344
345
 
345
346
  ---
346
347
 
@@ -356,7 +357,7 @@ Before merging a change that touches input, identity, secrets, or persistence:
356
357
  - [ ] All DB queries go through Cascade — no raw string-built queries
357
358
  - [ ] Rate limit appropriate for the route family (default for general, aggressive for auth)
358
359
  - [ ] No `.env` or secrets file in the commit
359
- - [ ] `yarn audit --level=high` passes
360
+ - [ ] `npm audit --audit-level=high` passes
360
361
  - [ ] Logger redaction config covers any new sensitive field introduced
361
362
  - [ ] File uploads cap size, sniff MIME, server-generate the stored filename
362
363
  - [ ] Webhooks verify signature before any other work
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: testing-strategy
3
- description: 'Test pyramid posture for Warlock.js apps — Vitest co-located (`foo.ts` → `foo.spec.ts`); **when testing is enabled in the project (a `test`/`test:*` script in package.json OR a `vitest.config.*` / `vite.config.*` file), it is mandatory that every service ships a co-located `.service.spec.ts` and every HTTP controller / request handler ships a test — a new service or endpoint without a spec is incomplete**; unit tests dominate (~70%), integration tests via `@testcontainers/mongodb` + `@testcontainers/postgresql` (~25%), e2e for critical user journeys only (~5%); coverage is a smoke detector not a target (`yarn test:coverage` via `@vitest/coverage-v8`); flaky-test zero-tolerance (quarantine in 24h, fix or delete in 7); test isolation (no shared state, no order dependency); AAA structure with blank-line phases; mock at the boundary not deep inside; `MockSDK` from `@warlock.js/ai` for AI calls; real DB via testcontainers for integration; snapshot tests only for small deterministic output; contract tests for external integrations. Triggers: deciding whether to write a unit / integration / e2e test for a change; setting up a new `.spec.ts`; debugging a flaky test; reviewing the test plan in a PR; user asks "should I unit test this", "what is our coverage policy", "this test keeps failing intermittently", "what should I mock", "test pyramid", "integration test vs unit test", "snapshot tests", "how do I test a service that hits the DB", "how do I test AI code". Skip: Vitest assertion API mechanics — that''s the testing framework''s docs; language / runtime questions; load testing / performance benchmarking; framework-package internals — load the relevant `@warlock.js/*` skill.'
3
+ description: "Vitest test scope, co-located service and endpoint specs when testing is enabled, isolation, boundary mocks, integration tests and flaky-test policy. Use when planning or reviewing test coverage. Use git-workflow instead for CI and merge gates."
4
4
  ---
5
5
 
6
6
  # Testing strategy
@@ -32,7 +32,7 @@ Write the spec in the **same change** that adds the service or controller. A rev
32
32
 
33
33
  ### 0.2 If testing is NOT enabled
34
34
 
35
- No `test` script and no vitest/vite config — e.g. a scaffold created without the `test` feature. **Do not** add tests or testing dependencies unprompted, and don't invent a `vitest.config`. If the project is outgrowing prototype stage, *suggest* enabling the `test` feature — but never scaffold a suite the project didn't ask for.
35
+ No `test` script and no vitest/vite config — e.g. a scaffold created without the `test` feature. **Do not** add tests or testing dependencies unprompted, and don't invent a `vitest.config`. If the project is outgrowing prototype stage, _suggest_ enabling the `test` feature — but never scaffold a suite the project didn't ask for.
36
36
 
37
37
  ---
38
38
 
@@ -95,7 +95,7 @@ Not enforced numerically. Enforced culturally — when a reviewer sees ten new e
95
95
  ### 2.2 Don't test
96
96
 
97
97
  - **The framework** — `@warlock.js/*` has its own tests. Don't re-test that `response.success()` returns 200.
98
- - **Trivial getters / setters** — `cart.totalCents` returning `this.get<number>("total_cents", 0)` doesn't need a test
98
+ - **Trivial getters / setters** — `cart.totalCents` returning `this.get<number>("totalCents", 0)` doesn't need a test
99
99
  - **One-line passthroughs** — `loginService(...)` just calling `authService.login(...)` doesn't need a unit test (an integration test of the wiring is better)
100
100
  - **Generated code** — migrations, resource scaffolds
101
101
 
@@ -149,7 +149,7 @@ const result = await summarizeContentService(input, { provider: ai });
149
149
  expect(result).toBe("mocked response");
150
150
  ```
151
151
 
152
- See `@warlock.js/ai/skills/run-ai-agent/SKILL.md` for the full mock surface.
152
+ See the `@warlock.js/ai` skill's `run-ai-agent` topic for the full mock surface.
153
153
 
154
154
  ### 4.3 Database — real, via testcontainers
155
155
 
@@ -179,7 +179,7 @@ In the test environment `setupTest()` installs fakes for mail, queue, cache, sto
179
179
  ```typescript
180
180
  import { mailFake, queueFake } from "@warlock.js/core/tests";
181
181
 
182
- const order = { id: 42 }; // what the code under test placed
182
+ const order = { id: "550e8400-e29b-41d4-a716-446655440000" }; // what the code under test placed
183
183
 
184
184
  mailFake.assertSent({ to: "ada@example.test" });
185
185
  queueFake.assertPushed("invoices.send", { orderId: order.id });
@@ -235,7 +235,7 @@ Low coverage = guaranteed gaps. High coverage = no guarantee of correctness. Tre
235
235
 
236
236
  Chasing the last 5% of coverage produces brittle tests over edge-case code that nobody cares about. Spend the time on tests that catch real regressions.
237
237
 
238
- ### 6.4 The `yarn test:coverage` command
238
+ ### 6.4 The `npm run test:coverage` command
239
239
 
240
240
  Runs Vitest with `@vitest/coverage-v8` (already in dependencies). Add coverage as a CI gate per the floors above once the suite is large enough.
241
241
 
@@ -337,4 +337,4 @@ Before merging a change that should include tests (testing enabled — see § 0)
337
337
  - [ ] Test names describe behaviour, not implementation
338
338
  - [ ] No `.only`, no `.skip` without a tracking ticket
339
339
  - [ ] Snapshots reviewed line-by-line if updated
340
- - [ ] `yarn test --run` passes; `yarn test:coverage` doesn't drop the floor
340
+ - [ ] `npm run test --run` passes; `npm run test:coverage` doesn't drop the floor
@@ -1,16 +1,12 @@
1
1
  ---
2
2
  name: web-code-standards
3
- description: >-
4
- Strict separation-of-concerns standards for Warlock.js src/web pages and
5
- components: loaders delegate data and business rules to app services;
6
- components delegate all behavior to separate custom-hook files.
7
- Use alongside code-standards for web work; skip for backend-only changes.
3
+ description: "Warlock web pages and setup files, component and hook ownership, Mongez forms, accessible controls, locale behavior and UI kit choices. Use when building or reviewing src/web features. Use code-standards instead for shared TypeScript rules."
8
4
  ---
9
5
 
10
6
  # Web code standards
11
7
 
12
8
  **Applies to:** `src/web/**` in this project. Read
13
- [`code-standards`](../code-standards/SKILL.md) for the shared TypeScript rules,
9
+ the `code-standards` skill for the shared TypeScript rules,
14
10
  and the relevant `@warlock.js/web` skill before using a framework feature.
15
11
  This skill adds the web-specific rules; it does not override security or
16
12
  module-boundary requirements. When reviewing existing code, identify debt
@@ -45,7 +41,7 @@ ancestor and child dictionaries cannot redefine the same key. Keep legacy
45
41
  Within a web feature, use `components/`, `hooks/`, and `utils/` for their
46
42
  named purpose; do not create `lib/` as a catch-all. These folders have no
47
43
  barrel `index.ts`: import the named component, hook, or helper from its own
48
- file. Keep types in `types/` when a feature has several contracts, or in one
44
+ file. These exact imports keep client bundles lean and prevent server-only code from being pulled through a barrel. The `code-standards` skill allows sub-folder barrels only for `src/app/**` server modules. Keep types in `types/` when a feature has several contracts, or in one
49
45
  `<feature>.types.ts` file when they are small. Do not duplicate a contract in
50
46
  both places. Put component props in that dedicated type module by default;
51
47
  keep a tiny private prop type beside a component only when extracting it would
@@ -54,13 +50,17 @@ make the feature harder to read.
54
50
  ## 1. Keep the page contract, make the implementation thin
55
51
 
56
52
  Page logic lives in the matching `.setup.ts` file: `config`, `loader`,
57
- `register`, and `action`/`actions`. UI files contain the default
58
- component and named components such as `ErrorBoundary`. Pair `layout.tsx` with
53
+ `register`, and `action`/`actions`. The UI file owns its named default
54
+ component; re-export a framework-required named component such as `ErrorBoundary`
55
+ from that component's own file. Pair `layout.tsx` with
59
56
  `layout.setup.ts`, `root.tsx` with `root.setup.ts`, and `error.page.tsx` with
60
57
  `error.setup.ts`. Use type-only imports from setup for loader/action inference.
61
58
  The framework projects browser-safe `register()` code to the client. Put route,
62
59
  cache, validation, middleware, metadata, and sitemap policy inside `config`;
63
- metadata always belongs in `config.metadata`.
60
+ metadata always belongs in `config.metadata`. Do not add an empty setup file
61
+ when a component has no configuration, loader, action, or registration work.
62
+ The starter follows this boundary for root configuration, home route policy
63
+ and translations, and not-found metadata.
64
64
 
65
65
  Use the config type that matches the module. A page can preserve loader data
66
66
  inference for metadata by satisfying `PageConfig<typeof loader>`:
@@ -82,15 +82,18 @@ export const config = {
82
82
  ```
83
83
 
84
84
  ```tsx title="src/web/posts.page.tsx"
85
- export default function PostsPage() {
86
- return <main>Posts</main>;
85
+ import type { PageProps } from "@warlock.js/web";
86
+ import type { loader } from "./posts.setup";
87
+
88
+ export default function PostsPage({ data }: PageProps<typeof loader>) {
89
+ return <main>{data.title}</main>;
87
90
  }
88
91
  ```
89
92
 
90
- When code navigates by a page name, `href(name, params, query)` validates the
91
- published route table at runtime. `href("posts.indxe")` is a string that
92
- TypeScript accepts, then the helper throws with the known route names. Generated
93
- route-name and parameter types are not available in 5.17.
93
+ Use `href(name, params, query)` for named page navigation. Generated declarations
94
+ in `.warlock/typings/web-routes.d.ts` check route names and required path
95
+ parameters when included in the app's TypeScript project; runtime route-table
96
+ validation still applies. Do not widen those contracts to strings or add casts.
94
97
 
95
98
  Layouts use one `config satisfies LayoutConfig` object for `prefix`,
96
99
  `middleware`, `metadata`, and a sitemap default. Roots use one
@@ -133,11 +136,22 @@ queries, aggregations, and domain transformations in the service. A service
133
136
  must have a meaningful domain name and own the operation, not exist as a
134
137
  generic pass-through for a repository method.
135
138
 
136
- Extract substantive view sections into cohesive components. A component
137
- that needs any behavior gets a separate feature-named custom-hook file; a
138
- component with no behavior stays presentational and needs no hook. Split by
139
- responsibility, not by a mechanical line-count limit. Framework exports
140
- for logic remain in the setup file even when their work is delegated elsewhere.
139
+ Give every component one responsibility and one reason to change. Define one
140
+ component per file, with a named default export. Extract substantive view
141
+ sections into cohesive components. Put hooks in `hooks/` and pure UI helpers
142
+ in `utils/`, one hook or helper per file, exported by name. A hook owns state,
143
+ effects, handlers, navigation, and client mutations; a utility has no React or
144
+ browser lifecycle dependency. A presentational component needs no hook.
145
+
146
+ Keep a small piece of logic or a private helper inline only when it is tightly
147
+ coupled to that one component and keeping it there leaves the file small and
148
+ focused. A component file past roughly 150 lines, a second component, or multiple
149
+ helpers is a split signal, not a mechanical line limit: split at ownership
150
+ boundaries, never into arbitrary chunks to meet a count. The one-component rule
151
+ still applies. Framework logic exports remain in setup even when delegated.
152
+ Keep feature CSS beside the feature and import it from the owning presentation
153
+ boundary; keep loading, error, empty, focus, reduced-motion, and locale behavior
154
+ with that boundary.
141
155
 
142
156
  Preserve the framework's rendering boundary: `root.tsx` owns the document,
143
157
  `#vessel` and `<Scripts />`; the loader runs on the server, while the rendered
@@ -153,35 +167,6 @@ output; the page composes `PostArticle`, `PostComments`, and `RelatedPosts`;
153
167
  `use-post-comments.ts` owns subscription, state, and handlers for
154
168
  `PostComments`. Each piece has one reason to change.
155
169
 
156
- ## Setup and component files
157
-
158
- Use a setup file when a feature registers framework behavior. The setup file
159
- owns its `register`, `config`, and `loader` exports; its matching component
160
- only renders the supplied data. Keep loader inference type-only so it does
161
- not pull server work into a browser component:
162
-
163
- The default starter follows this boundary: `root.setup.ts` owns strict-mode
164
- configuration, `home/index.setup.ts` owns route policy, its loader, and the
165
- universal translations `register()`, and `404.setup.ts` owns not-found
166
- metadata. Do not add an empty setup file when a component has no server
167
- configuration, loader, or registration work.
168
-
169
- ```tsx
170
- import type { PageProps } from "@warlock.js/web";
171
- import type { loader } from "./posts.setup";
172
-
173
- export default function PostsPage({ data }: PageProps<typeof loader>) {
174
- return <PostsList posts={data.posts} />;
175
- }
176
- ```
177
-
178
- Each React component file has one named default component. Export hooks,
179
- utilities, and types by name. A component renders; its feature hook owns
180
- state, effects, handlers, navigation, and client mutations. A utility stays
181
- pure and has no React or browser lifecycle dependency. Keep feature CSS beside
182
- the feature and import it from the owning presentation boundary; keep loading,
183
- error, empty, focus, reduced-motion, and locale behavior with that boundary.
184
-
185
170
  ## Declaration spacing and local types
186
171
 
187
172
  Put one blank line after the final import. Keep a short related declaration run
@@ -218,17 +203,17 @@ state starts unset, write `useState<ErrorText>()`; do not add a redundant
218
203
  same validated, public data as the page. Keep private routes out of public
219
204
  discovery, and do not generate database-backed assets on every GET.
220
205
 
221
- ## 3. Components render; custom hooks own all behavior
206
+ ## 3. Components render; custom hooks own behavior
222
207
 
223
- **No behavior in React component bodies, even when it is small.** A
224
- component may declare props, call its dedicated custom hook if needed,
225
- and render JSX from its props and the hook's returned values. Put `useState`,
208
+ **Delegate behavior to the component's dedicated custom hook by default.** A
209
+ component declares props, calls that hook if needed, and renders JSX from its
210
+ props and the hook's returned values. Put `useState`,
226
211
  `useEffect`, subscriptions, timers, browser API calls, derived-value
227
212
  calculations, event handlers, mutations, and navigation decisions in a
228
213
  separate custom-hook file. Keep pure domain calculations in `src/app`
229
214
  services; a hook may call a small pure UI helper outside the component.
230
- Do not put a one-line callback or inline computation in JSX because it
231
- "doesn't warrant a hook". Pure presentational components need no hook.
215
+ Use section 1's inline exception only for small, tightly coupled logic;
216
+ reuse or file growth means it belongs in its own hook or utility file.
232
217
  JSX conditionals that only select which markup to show are presentation,
233
218
  not a license to implement business rules in render.
234
219
 
@@ -272,6 +257,117 @@ enforce the same policy independently. Use a full navigation when an auth or
272
257
  document boundary requires it, otherwise prefer the framework's navigation
273
258
  and refresh primitives so state and loader data stay in sync.
274
259
 
260
+ ### Use Mongez React Form for every form
261
+
262
+ Always use `Form`, `useFormControl`, and `useSubmitButton` from
263
+ `@mongez/react-form`; the web stack already uses this package, including the
264
+ starter's contact form. Do not add a second form library or manage field
265
+ validation and submission state in parallel React state. Keep those hooks in
266
+ the control's and submit button's dedicated hook files, following section 1.
267
+
268
+ Declare validation in a browser-safe `<Form schema={schema}>` (Seal implements
269
+ Standard Schema), a control's `schema`, or `useFormControl({ rules: [...] })`.
270
+ The `rules` array contains imported rule objects, never rule-name strings.
271
+ For example, `{ name: "email", type: "email", required: true,
272
+ rules: [requiredRule, emailRule] }` uses Mongez's rules: `requiredRule` needs
273
+ `required: true`, and `emailRule` needs the control's `type: "email"`.
274
+ Put `requiredRule` first. Schema issues map back to registered control names,
275
+ so keep schema paths and control names aligned.
276
+
277
+ Never use `required`, `pattern`, `min`, `max`, `minLength`, or `type="email"`
278
+ as browser validation, or rely on the browser's constraint UI. These may be
279
+ Mongez rule configuration props; do not spread raw control props onto DOM
280
+ inputs. Mongez v4's `Form` renders `noValidate` by default; keep it enabled
281
+ and preserve it if using a custom form host. A schema-backed email field can
282
+ render `type="text"` with `inputMode="email"` and `autoComplete="email"`,
283
+ leaving email validation entirely to the form.
284
+
285
+ Render the control's `error` through `getErrorProps()` and connect the field
286
+ with `getInputProps()` (or equivalent explicit wiring for a custom widget).
287
+ This keeps field identity, focus, blur validation, and error announcement in
288
+ the same form contract. For example, each block below is its own file:
289
+
290
+ ```ts title="src/web/contact/contact.schema.ts"
291
+ import { v } from "@warlock.js/seal";
292
+
293
+ export const contactSchema = v.object({ email: v.email() });
294
+ ```
295
+
296
+ ```ts title="src/web/contact/hooks/use-contact-email.ts"
297
+ import { useFormControl } from "@mongez/react-form";
298
+
299
+ export function useContactEmail() {
300
+ return useFormControl({ name: "email" });
301
+ }
302
+ ```
303
+
304
+ ```tsx title="src/web/contact/components/contact-email.tsx"
305
+ import { useContactEmail } from "../hooks/use-contact-email";
306
+
307
+ export default function ContactEmail() {
308
+ const { id, error, getInputProps, getErrorProps } = useContactEmail();
309
+
310
+ return (
311
+ <div>
312
+ <label htmlFor={id}>Email</label>
313
+ <input {...getInputProps({ type: "text", inputMode: "email", autoComplete: "email" })} />
314
+ {error ? <span {...getErrorProps()}>{error}</span> : null}
315
+ </div>
316
+ );
317
+ }
318
+ ```
319
+
320
+ Render `ContactEmail` inside `<Form schema={contactSchema} onSubmit={submit}>`;
321
+ the feature hook owns `submit`. The submit button's hook calls
322
+ `useSubmitButton()` and exposes `disabled` and `isSubmitting` to its component.
323
+ For API mutations, use `useSubmitForm` from `@warlock.js/web/form` in the
324
+ feature hook. With generated route declarations and a handler's `responseSchema`,
325
+ `useSubmitForm({ route })` infers the declared success body; `ApiResponse` from
326
+ `@warlock.js/web` reads a declared status body. Do not replace this inference
327
+ with casts. Server endpoints must still validate input independently.
328
+
329
+ ### Build custom controls with the project's UI kit
330
+
331
+ Do not render native `<select>`, `<input type="checkbox">`,
332
+ `<input type="radio">`, or `<input type="date">` UI. Build a searchable
333
+ select/dropdown with a search input, a custom checkbox, custom radio
334
+ controls, and a custom date picker. Each field uses `useFormControl` through
335
+ its dedicated hook; a radio group registers one selected value for the group.
336
+ Connect value/checked changes, blur, disabled state, focus refs, validation,
337
+ and errors to that control. Configure a checkbox control with
338
+ `type: "checkbox"` and wire `checked` / `setChecked`. Adapt other widget
339
+ callbacks to `changeValue`; do not blindly spread DOM input props onto a kit
340
+ component with a different callback contract. Keep search text and popup state separate from
341
+ the selected form value. A kit's hidden input for submission is acceptable;
342
+ it must not display native UI or become a second validation source.
343
+
344
+ Check the existing styling stack and UI kit before building controls:
345
+
346
+ - With Tailwind and shadcn/ui already installed, compose its components:
347
+ Command + Popover for a searchable select, Checkbox, RadioGroup, and
348
+ Calendar + Popover for dates. Keep Mongez as the form owner rather than
349
+ copying a kit example that introduces another form library.
350
+ - With Tailwind but no shadcn/ui, or with no UI kit, ask the user which kit
351
+ to use **before installing anything**. Recommend shadcn/ui for Tailwind
352
+ projects because its composable accessible primitives fit the styling
353
+ stack. Explain the recommendation for other stacks and wait for the
354
+ user's choice; never silently add dependencies.
355
+ - With another established kit and no Tailwind, use its corresponding
356
+ accessible primitives and the same Mongez adapters.
357
+
358
+ Custom controls must preserve the accessibility native controls supplied.
359
+ Follow the WAI-ARIA APG [combobox/listbox](https://www.w3.org/WAI/ARIA/apg/patterns/combobox/),
360
+ [checkbox](https://www.w3.org/WAI/ARIA/apg/patterns/checkbox/),
361
+ [radiogroup](https://www.w3.org/WAI/ARIA/apg/patterns/radio/), and
362
+ [date picker dialog/grid](https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/examples/datepicker-dialog/)
363
+ patterns. Preserve labels, visible focus, disabled/selected/checked states,
364
+ `aria-invalid`, and error descriptions linked to `getErrorProps()` output.
365
+ Support keyboard search and arrow navigation, Enter selection, Space toggling,
366
+ and Escape dismissal as appropriate. Manage popup entry and focus return;
367
+ a modal date dialog traps focus and its calendar grid supports day/month
368
+ keyboard navigation. Kit composition still needs correct labels, focus refs,
369
+ and announced errors; styling alone does not make a control accessible.
370
+
275
371
  ## 5. Ship usable, localizable presentation
276
372
 
277
373
  - Use semantic landmarks, headings, buttons, links, and explicit form labels.
@@ -311,12 +407,13 @@ Before approving a web change, ask:
311
407
  effects without untangling unrelated responsibilities?
312
408
  2. Does every loader obtain data and business decisions from a named app
313
409
  service, with models/repositories kept out of the web layer?
314
- 3. Does each behavioral component delegate all state, effects, derived
315
- values, event handlers, and mutations to its own custom hook file?
410
+ 3. Does each component have one responsibility and its own file, with hooks
411
+ and helpers extracted except for the small, tightly coupled inline case?
316
412
  4. Is the service query bounded, the resource shape intentional, and the
317
413
  cache scope safe?
318
414
  5. Do refresh, navigation, errors, retries, and cleanup leave the UI correct?
319
- 6. Are forms and interactions accessible and translated to the degree the
320
- interface promises?
415
+ 6. Do forms use Mongez schema/rules and accessible custom controls, with
416
+ native validation disabled and the UI kit choice agreed before installation?
417
+ Are interactions translated to the degree the interface promises?
321
418
  7. Are the relevant quality rules enforced or covered by focused tests,
322
419
  rather than described only in comments?
@@ -33,9 +33,15 @@ export class OTP extends Model<OTPSchema> {
33
33
  * Check if OTP is valid (not expired, not used, not max attempts)
34
34
  */
35
35
  public get isValid(): boolean {
36
- if (this.get("usedAt")) return false;
37
- if ((this.get("attempts") ?? 0) >= (this.get("maxAttempts") ?? 0)) return false;
38
- if (new Date() > new Date(this.get("expiresAt"))) return false;
36
+ if (this.get("usedAt")) {
37
+ return false;
38
+ }
39
+ if ((this.get("attempts") ?? 0) >= (this.get("maxAttempts") ?? 0)) {
40
+ return false;
41
+ }
42
+ if (new Date() > new Date(this.get("expiresAt"))) {
43
+ return false;
44
+ }
39
45
  return true;
40
46
  }
41
47
 
@@ -19,12 +19,24 @@ const DEFAULT_OTP_EXPIRATION: Duration = { minutes: 15 };
19
19
  // Parse duration to milliseconds
20
20
  function parseDurationToMs(duration: Duration): number {
21
21
  let ms = 0;
22
- if (duration.milliseconds) ms += duration.milliseconds;
23
- if (duration.seconds) ms += duration.seconds * 1000;
24
- if (duration.minutes) ms += duration.minutes * 60 * 1000;
25
- if (duration.hours) ms += duration.hours * 60 * 60 * 1000;
26
- if (duration.days) ms += duration.days * 24 * 60 * 60 * 1000;
27
- if (duration.weeks) ms += duration.weeks * 7 * 24 * 60 * 60 * 1000;
22
+ if (duration.milliseconds) {
23
+ ms += duration.milliseconds;
24
+ }
25
+ if (duration.seconds) {
26
+ ms += duration.seconds * 1000;
27
+ }
28
+ if (duration.minutes) {
29
+ ms += duration.minutes * 60 * 1000;
30
+ }
31
+ if (duration.hours) {
32
+ ms += duration.hours * 60 * 60 * 1000;
33
+ }
34
+ if (duration.days) {
35
+ ms += duration.days * 24 * 60 * 60 * 1000;
36
+ }
37
+ if (duration.weeks) {
38
+ ms += duration.weeks * 7 * 24 * 60 * 60 * 1000;
39
+ }
28
40
  return ms;
29
41
  }
30
42