@warlock.js/core 5.2.4 → 5.3.1

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 (38) hide show
  1. package/esm/cli/commands/generate/templates/stubs.mjs +13 -14
  2. package/esm/cli/commands/generate/templates/stubs.mjs.map +1 -1
  3. package/esm/generations/stubs.mjs +13 -25
  4. package/esm/generations/stubs.mjs.map +1 -1
  5. package/esm/http/index.d.mts +1 -1
  6. package/esm/http/middleware/inject-request-context.d.mts.map +1 -1
  7. package/esm/http/middleware/inject-request-context.mjs +2 -21
  8. package/esm/http/middleware/inject-request-context.mjs.map +1 -1
  9. package/esm/http/request.d.mts +25 -6
  10. package/esm/http/request.d.mts.map +1 -1
  11. package/esm/http/request.mjs +48 -0
  12. package/esm/http/request.mjs.map +1 -1
  13. package/esm/http/response.d.mts +3 -3
  14. package/esm/http/response.d.mts.map +1 -1
  15. package/esm/http/types.d.mts +36 -5
  16. package/esm/http/types.d.mts.map +1 -1
  17. package/esm/index.d.mts +2 -2
  18. package/esm/validation/plugins/file-plugin.mjs.map +1 -1
  19. package/esm/validation/plugins/localized-plugin.mjs +2 -2
  20. package/esm/validation/plugins/localized-plugin.mjs.map +1 -1
  21. package/esm/validation/types.d.mts +17 -4
  22. package/esm/validation/types.d.mts.map +1 -1
  23. package/llms-full.txt +109 -86
  24. package/llms.txt +1 -1
  25. package/package.json +12 -12
  26. package/skills/README.md +1 -1
  27. package/skills/create-controller/SKILL.md +9 -9
  28. package/skills/send-response/SKILL.md +51 -37
  29. package/skills/store-file/SKILL.md +8 -3
  30. package/skills/upload-file/SKILL.md +9 -7
  31. package/skills/use-app-context/SKILL.md +2 -2
  32. package/skills/use-localization/SKILL.md +6 -2
  33. package/skills/use-repository/SKILL.md +2 -2
  34. package/skills/use-request-locals/SKILL.md +3 -3
  35. package/skills/validate-input/SKILL.md +2 -2
  36. package/skills/warlock-conventions/SKILL.md +2 -2
  37. package/skills/wire-socket/SKILL.md +2 -2
  38. package/skills/write-middleware/SKILL.md +13 -15
@@ -12,7 +12,7 @@ A controller is a thin function: pull inputs from `request`, call work, return t
12
12
  ```ts title="src/app/<module>/controllers/<action>.controller.ts"
13
13
  import { type RequestHandler } from "@warlock.js/core";
14
14
 
15
- export const listProductsController: RequestHandler = async (request, response) => {
15
+ export const listProductsController: RequestHandler = async ({ request, response }) => {
16
16
  return response.success({ products: [] });
17
17
  };
18
18
  ```
@@ -67,10 +67,10 @@ import { type Request, type RequestHandler } from "@warlock.js/core";
67
67
  import { type CreateProductSchema, createProductSchema } from "../schema/create-product.schema";
68
68
  import { createProductService } from "../services/create-product.service";
69
69
 
70
- export const createProductController: RequestHandler<Request<CreateProductSchema>> = async (
70
+ export const createProductController: RequestHandler<Request<CreateProductSchema>> = async ({
71
71
  request,
72
72
  response,
73
- ) => {
73
+ }) => {
74
74
  const product = await createProductService(request.validated());
75
75
 
76
76
  return response.successCreate({ product });
@@ -96,10 +96,10 @@ Routes behind `authMiddleware` need `request.user` typed. Project conventions ad
96
96
  import { type GuardedRequestHandler } from "app/auth/requests/guarded.request";
97
97
  import { type CreateProductSchema, createProductSchema } from "../schema/create-product.schema";
98
98
 
99
- export const createProductController: GuardedRequestHandler<CreateProductSchema> = async (
99
+ export const createProductController: GuardedRequestHandler<CreateProductSchema> = async ({
100
100
  request,
101
101
  response,
102
- ) => {
102
+ }) => {
103
103
  // request.user is typed
104
104
  const product = await createProductService(request.validated());
105
105
  return response.successCreate({ product });
@@ -146,7 +146,7 @@ If your controller is over ~30 lines, the work probably belongs in a service.
146
146
  import { type RequestHandler } from "@warlock.js/core";
147
147
  import { listProductsService } from "../services/list-products.service";
148
148
 
149
- export const listProductsController: RequestHandler = async (request, response) => {
149
+ export const listProductsController: RequestHandler = async ({ request, response }) => {
150
150
  const { data: products, pagination } = await listProductsService({
151
151
  ...request.all(),
152
152
  organization_id: request.user.organizationId,
@@ -163,10 +163,10 @@ import { type Request, type RequestHandler } from "@warlock.js/core";
163
163
  import { type CreateProductSchema, createProductSchema } from "../schema/create-product.schema";
164
164
  import { createProductService } from "../services/create-product.service";
165
165
 
166
- export const createProductController: RequestHandler<Request<CreateProductSchema>> = async (
166
+ export const createProductController: RequestHandler<Request<CreateProductSchema>> = async ({
167
167
  request,
168
168
  response,
169
- ) => {
169
+ }) => {
170
170
  const product = await createProductService(request.validated());
171
171
 
172
172
  return response.successCreate({ product });
@@ -200,7 +200,7 @@ export async function getProductService(id: string) {
200
200
  import type { RequestHandler } from "@warlock.js/core";
201
201
  import { getProductService } from "../services/get-product.service";
202
202
 
203
- export const getProductController: RequestHandler = async (request, response) => {
203
+ export const getProductController: RequestHandler = async ({ request, response }) => {
204
204
  const product = await getProductService(request.input("id"));
205
205
 
206
206
  return response.success({ product });
@@ -10,9 +10,9 @@ description: 'Send HTTP responses via @warlock.js/core''s Response helpers — s
10
10
  ## The shape
11
11
 
12
12
  ```ts
13
- import type { RequestHandler, Response } from "@warlock.js/core";
13
+ import type { RequestHandler } from "@warlock.js/core";
14
14
 
15
- export const myController: RequestHandler = async (request, response: Response) => {
15
+ export const myController: RequestHandler = async ({ request, response }) => {
16
16
  // …choose a helper and return it
17
17
  return response.success({ data: "…" });
18
18
  };
@@ -22,11 +22,11 @@ Always `return response.<helper>(...)`. The return value drives Fastify's send.
22
22
 
23
23
  ## Success helpers
24
24
 
25
- | Method | Status | When |
26
- | ----------------------------------- | ------ | ----------------------------------------- |
27
- | `response.success(data?)` | 200 | normal read / update |
28
- | `response.successCreate(data)` | 201 | resource created (POST) |
29
- | `response.noContent()` | 204 | delete succeeded, no body needed |
25
+ | Method | Status | When |
26
+ | ------------------------------ | ------ | -------------------------------- |
27
+ | `response.success(data?)` | 200 | normal read / update |
28
+ | `response.successCreate(data)` | 201 | resource created (POST) |
29
+ | `response.noContent()` | 204 | delete succeeded, no body needed |
30
30
 
31
31
  ```ts
32
32
  return response.success({ products: [...] });
@@ -40,13 +40,13 @@ return response.noContent();
40
40
 
41
41
  ## Client-error helpers
42
42
 
43
- | Method | Status | When |
44
- | --------------------------------------------------- | ------ | ------------------------------------- |
45
- | `response.badRequest(data)` | 400 | malformed or invalid input |
46
- | `response.unauthorized(data?)` | 401 | missing/invalid auth token |
47
- | `response.forbidden(data?)` | 403 | authenticated but not allowed |
48
- | `response.notFound(data?)` | 404 | record missing |
49
- | `response.conflict(data?)` | 409 | uniqueness violation, state conflict |
43
+ | Method | Status | When |
44
+ | ------------------------------ | ------ | ------------------------------------ |
45
+ | `response.badRequest(data)` | 400 | malformed or invalid input |
46
+ | `response.unauthorized(data?)` | 401 | missing/invalid auth token |
47
+ | `response.forbidden(data?)` | 403 | authenticated but not allowed |
48
+ | `response.notFound(data?)` | 404 | record missing |
49
+ | `response.conflict(data?)` | 409 | uniqueness violation, state conflict |
50
50
 
51
51
  ```ts
52
52
  return response.badRequest({ error: t("validation.invalid") });
@@ -65,8 +65,8 @@ Most error helpers accept an optional payload — if you omit it, they send a de
65
65
  ## Redirects
66
66
 
67
67
  ```ts
68
- return response.redirect("/login"); // 302
69
- return response.redirect("/new-home", 301); // permanent
68
+ return response.redirect("/login"); // 302
69
+ return response.redirect("/new-home", 301); // permanent
70
70
  ```
71
71
 
72
72
  ## Files
@@ -98,19 +98,19 @@ stream.end();
98
98
 
99
99
  ## Throwing HTTP errors
100
100
 
101
- Most of the time, controllers don't need to *choose* an error helper — they throw from the service layer instead. The request middleware (`http/middleware/inject-request-context.ts`) catches every `HttpError` subclass and produces the matching response. The error classes mirror the helpers above:
101
+ Most of the time, controllers don't need to _choose_ an error helper — they throw from the service layer instead. The request middleware (`http/middleware/inject-request-context.ts`) catches every `HttpError` subclass and produces the matching response. The error classes mirror the helpers above:
102
102
 
103
103
  ```ts
104
104
  import {
105
- ResourceNotFoundError, // 404
106
- UnAuthorizedError, // 401
107
- ForbiddenError, // 403
108
- BadRequestError, // 400
109
- ConflictError, // 409
110
- NotAcceptableError, // 406
111
- NotAllowedError, // 405
112
- ServerError, // 500
113
- HttpError, // base class — `new HttpError(status, message, payload?)` for arbitrary codes
105
+ ResourceNotFoundError, // 404
106
+ UnAuthorizedError, // 401
107
+ ForbiddenError, // 403
108
+ BadRequestError, // 400
109
+ ConflictError, // 409
110
+ NotAcceptableError, // 406
111
+ NotAllowedError, // 405
112
+ ServerError, // 500
113
+ HttpError, // base class — `new HttpError(status, message, payload?)` for arbitrary codes
114
114
  } from "@warlock.js/core";
115
115
 
116
116
  throw new ResourceNotFoundError("product.notFound");
@@ -118,13 +118,27 @@ throw new ForbiddenError("permission.denied", { resource: "product", id });
118
118
  throw new ConflictError("user.duplicateEmail");
119
119
  ```
120
120
 
121
- Each class takes `(message, payload?)`. The payload merges into the response body alongside `error`. In development mode, the stack trace is included too.
121
+ Each class takes `(message, payload?)`. The framework keeps the optional
122
+ detail nested under `payload`:
123
+
124
+ ```json
125
+ {
126
+ "error": "Product not found",
127
+ "payload": { "id": 42 }
128
+ }
129
+ ```
130
+
131
+ Every unhandled error response also carries
132
+ `Cache-Control: private, no-store`, regardless of status. The floor is
133
+ applied once at the shared error funnel, so API-route errors cannot be stored
134
+ and replayed across users. In development mode, the stack trace is included
135
+ too.
122
136
 
123
137
  Pick the class, throw from the service or use-case, and forget about response shaping at the call site. The controller stays focused on the success path:
124
138
 
125
139
  ```ts
126
- export const getProductController: RequestHandler = async (request, response) => {
127
- const product = await getProductService(request.input("id")); // throws ResourceNotFoundError on miss
140
+ export const getProductController: RequestHandler = async ({ request, response }) => {
141
+ const product = await getProductService(request.input("id")); // throws ResourceNotFoundError on miss
128
142
  return response.success({ product });
129
143
  };
130
144
  ```
@@ -136,9 +150,9 @@ See [`create-controller`](../create-controller/SKILL.md) for the "throw from ser
136
150
  ```ts
137
151
  const sse = response.sse();
138
152
 
139
- sse.send("tick", { count: 1 }); // event name, data, optional id
140
- sse.send("tick", { count: 2 }, "msg-2"); // third arg is the SSE event id
141
- sse.comment("keep-alive"); // invisible to the client, prevents timeout
153
+ sse.send("tick", { count: 1 }); // event name, data, optional id
154
+ sse.send("tick", { count: 2 }, "msg-2"); // third arg is the SSE event id
155
+ sse.comment("keep-alive"); // invisible to the client, prevents timeout
142
156
  sse.end();
143
157
  ```
144
158
 
@@ -178,11 +192,11 @@ response.clearCookie("session_id");
178
192
 
179
193
  Every response cookie gets `httpOnly: true`, `sameSite: "lax"`, and `secure: true` outside development, unless you override them:
180
194
 
181
- | Flag | Default | Why it's the default |
182
- |---|---|---|
183
- | `httpOnly` | `true` | without it, any injected script can read the cookie |
184
- | `sameSite` | `"lax"` | without it, the cookie rides along on cross-site requests |
185
- | `secure` | `true`, except in development | without it, the cookie travels in cleartext |
195
+ | Flag | Default | Why it's the default |
196
+ | ---------- | ----------------------------- | --------------------------------------------------------- |
197
+ | `httpOnly` | `true` | without it, any injected script can read the cookie |
198
+ | `sameSite` | `"lax"` | without it, the cookie rides along on cross-site requests |
199
+ | `secure` | `true`, except in development | without it, the cookie travels in cleartext |
186
200
 
187
201
  `secure` is relaxed in development only — browsers drop a `Secure` cookie over plain http, which would silently break every local login. It stays on in test and staging.
188
202
 
@@ -345,12 +345,17 @@ Set `STORAGE_DRIVER=r2` in production, leave unset in dev — same code uses loc
345
345
  ### Uploading a request file
346
346
 
347
347
  ```ts
348
- import type { RequestHandler, Response } from "@warlock.js/core";
348
+ import { type GuardedRequestHandler } from "app/auth/requests/guarded.request";
349
349
  import { storage } from "@warlock.js/core";
350
350
 
351
- export const uploadAvatarController: RequestHandler = async (request, response: Response) => {
351
+ export const uploadAvatarController: GuardedRequestHandler = async ({ request, response }) => {
352
352
  const upload = request.file("avatar");
353
- const file = await storage.put(upload, `avatars/${request.user.id}/${upload.fileName}`);
353
+
354
+ if (!upload) {
355
+ return response.badRequest({ error: "missing file" });
356
+ }
357
+
358
+ const file = await storage.put(upload, `avatars/${request.user.id}/${upload.name}`);
354
359
 
355
360
  return response.successCreate({ url: file.url, hash: file.hash });
356
361
  };
@@ -9,8 +9,8 @@ Multipart uploads come in as `UploadedFile` instances. The class wraps Fastify's
9
9
 
10
10
  ## The shape
11
11
 
12
- ```ts title="src/app/uploads/schema/index.ts"
13
- import { v } from "@warlock.js/seal";
12
+ ```ts title="src/app/uploads/schema/upload-avatar.schema.ts"
13
+ import { v, type Infer } from "@warlock.js/seal";
14
14
 
15
15
  export const uploadAvatarSchema = v.object({
16
16
  avatar: v
@@ -19,16 +19,18 @@ export const uploadAvatarSchema = v.object({
19
19
  .maxSize({ unit: "MB", size: 5 })
20
20
  .mimeType(["image/jpeg", "image/png", "image/webp"]),
21
21
  });
22
+
23
+ export type UploadAvatarSchema = Infer<typeof uploadAvatarSchema>;
22
24
  ```
23
25
 
24
26
  ```ts title="src/app/uploads/controllers/upload-avatar.controller.ts"
25
- import { type GuardedRequestHandler } from "app/auth/types/guarded-request.type";
27
+ import { type GuardedRequestHandler } from "app/auth/requests/guarded.request";
26
28
  import { type UploadAvatarSchema, uploadAvatarSchema } from "../schema/upload-avatar.schema";
27
29
 
28
- export const uploadAvatarController: GuardedRequestHandler<UploadAvatarSchema> = async (
30
+ export const uploadAvatarController: GuardedRequestHandler<UploadAvatarSchema> = async ({
29
31
  request,
30
32
  response,
31
- ) => {
33
+ }) => {
32
34
  const { avatar } = request.validated();
33
35
 
34
36
  const file = await avatar
@@ -54,7 +56,7 @@ Inside a controller:
54
56
  ```ts
55
57
  import type { RequestHandler, UploadedFile } from "@warlock.js/core";
56
58
 
57
- export const uploadController: RequestHandler = async (request, response) => {
59
+ export const uploadController: RequestHandler = async ({ request, response }) => {
58
60
  // option A — direct from request, no validation
59
61
  const file: UploadedFile | undefined = request.file("avatar");
60
62
 
@@ -236,7 +238,7 @@ export const uploadFilesSchema = v.object({
236
238
  ```ts title="src/app/uploads/controllers/create-upload.controller.ts"
237
239
  import type { RequestHandler } from "@warlock.js/core";
238
240
 
239
- export const createUploadController: RequestHandler = async (request, response) => {
241
+ export const createUploadController: RequestHandler = async ({ request, response }) => {
240
242
  const { files } = request.validated();
241
243
 
242
244
  const saved = await Promise.all(
@@ -232,9 +232,9 @@ const template = await readFile(appPath("mailers/templates/welcome.html"), "utf-
232
232
  ### Health endpoint
233
233
 
234
234
  ```ts title="src/app/system/controllers/health.controller.ts"
235
- import { Application, type RequestHandler, type Response } from "@warlock.js/core";
235
+ import { Application, type RequestHandler } from "@warlock.js/core";
236
236
 
237
- export const healthController: RequestHandler = async (_request, response: Response) => {
237
+ export const healthController: RequestHandler = async ({ response }) => {
238
238
  return response.success({
239
239
  status: "ok",
240
240
  environment: Application.environment,
@@ -229,10 +229,14 @@ The error's message is locale-aware — the `inject-request-context` middleware
229
229
  ### Translated response message in a controller
230
230
 
231
231
  ```ts
232
- export const createProductController: GuardedRequestHandler<CreateProductSchema> = async (
232
+ import { type GuardedRequestHandler } from "app/auth/requests/guarded.request";
233
+ import { type CreateProductSchema } from "../schema/create-product.schema";
234
+ import { createProductService } from "../services/create-product.service";
235
+
236
+ export const createProductController: GuardedRequestHandler<CreateProductSchema> = async ({
233
237
  request,
234
238
  response,
235
- ) => {
239
+ }) => {
236
240
  const product = await createProductService(request.validated());
237
241
  return response.success({
238
242
  message: request.t("products.created"),
@@ -276,10 +276,10 @@ export async function listFaqsService(filters: FaqListOptions) {
276
276
  ```
277
277
 
278
278
  ```ts title="src/app/faqs/controllers/list-faqs.controller.ts"
279
- import type { RequestHandler, Response } from "@warlock.js/core";
279
+ import { type GuardedRequestHandler } from "app/auth/requests/guarded.request";
280
280
  import { listFaqsService } from "../services/list-faqs.service";
281
281
 
282
- export const listFaqsController: RequestHandler = async (request, response: Response) => {
282
+ export const listFaqsController: GuardedRequestHandler = async ({ request, response }) => {
283
283
  const { data, pagination } = await listFaqsService({
284
284
  ...request.all(),
285
285
  organization_id: request.user.organizationId,
@@ -30,7 +30,7 @@ public locals: RequestLocals = {};
30
30
  ```ts title="src/app/observability/middleware/request-timing.middleware.ts"
31
31
  import type { Middleware } from "@warlock.js/core";
32
32
 
33
- export const requestTimingMiddleware: Middleware = request => {
33
+ export const requestTimingMiddleware: Middleware = (request) => {
34
34
  request.startedAt = Date.now();
35
35
  };
36
36
  ```
@@ -38,7 +38,7 @@ export const requestTimingMiddleware: Middleware = request => {
38
38
  ```ts title="src/app/observability/controllers/timing.controller.ts"
39
39
  import type { RequestHandler } from "@warlock.js/core";
40
40
 
41
- export const timingController: RequestHandler = async (request, response) => {
41
+ export const timingController: RequestHandler = async ({ request, response }) => {
42
42
  return response.success({ elapsedMs: Date.now() - request.startedAt });
43
43
  };
44
44
  ```
@@ -123,7 +123,7 @@ Feature-local files such as `src/app/organizations/request-locals.d.ts` are equa
123
123
 
124
124
  Two rules the scaffold's own comments spell out, and both bite silently:
125
125
 
126
- - **Keep the trailing `export {}`.** `declare module "x"` inside a file with no top-level import or export declares an *ambient* module, which REPLACES `@warlock.js/core`'s real typings instead of merging into them — every framework export vanishes. The `export {}` is what makes the file a module and the block an augmentation. It is not an unused statement to clean up.
126
+ - **Keep the trailing `export {}`.** `declare module "x"` inside a file with no top-level import or export declares an _ambient_ module, which REPLACES `@warlock.js/core`'s real typings instead of merging into them — every framework export vanishes. The `export {}` is what makes the file a module and the block an augmentation. It is not an unused statement to clean up.
127
127
  - **Keep them `interface`, not `type`.** This project otherwise prefers `type`; these are the named exception, because declaration merging is interface-only. `type RequestUser = { ... }` is a duplicate-identifier error, not an augmentation.
128
128
 
129
129
  On a project scaffolded before 5.1 there is no `src/typings.d.ts`, and `tsconfig.json` carries `"typeRoots": ["./src/typings.d.ts"]` — wrong twice, since `typeRoots` takes directories of `@types` packages rather than files, and that file did not exist. Drop the `typeRoots` entry, create the file, and list it under `include`.
@@ -26,10 +26,10 @@ import type { Request, RequestHandler } from "@warlock.js/core";
26
26
  import { type CreateProductSchema, createProductSchema } from "../schema/create-product.schema";
27
27
  import { createProductService } from "../services/create-product.service";
28
28
 
29
- export const createProductController: RequestHandler<Request<CreateProductSchema>> = async (
29
+ export const createProductController: RequestHandler<Request<CreateProductSchema>> = async ({
30
30
  request,
31
31
  response,
32
- ) => {
32
+ }) => {
33
33
  const product = await createProductService(request.validated());
34
34
 
35
35
  return response.successCreate({ product });
@@ -38,11 +38,11 @@ This skill is the foundation. Every other warlock skill (`register-route`, `crea
38
38
  ```
39
39
 
40
40
  ```ts title="src/app/<module>/controllers/create-<thing>.controller.ts"
41
- import { type GuardedRequestHandler } from "app/auth/types/guarded-request.type";
41
+ import { type GuardedRequestHandler } from "app/auth/requests/guarded.request";
42
42
  import { type CreateThingSchema, createThingSchema } from "../schema/create-thing.schema";
43
43
  import { createThingService } from "../services/create-thing.service";
44
44
 
45
- export const createThingController: GuardedRequestHandler<CreateThingSchema> = async (request, response) => {
45
+ export const createThingController: GuardedRequestHandler<CreateThingSchema> = async ({ request, response }) => {
46
46
  const thing = await createThingService(request.validated());
47
47
  return response.success({ thing });
48
48
  };
@@ -123,10 +123,10 @@ export async function notifyUserService(user: User, payload: unknown) {
123
123
  Then from a controller:
124
124
 
125
125
  ```ts
126
- import type { GuardedRequestHandler } from "app/auth/types/guarded-request.type";
126
+ import type { GuardedRequestHandler } from "app/auth/requests/guarded.request";
127
127
  import { notifyUserService } from "../services/notify-user.service";
128
128
 
129
- export const sendNotificationController: GuardedRequestHandler = async (request, response) => {
129
+ export const sendNotificationController: GuardedRequestHandler = async ({ request, response }) => {
130
130
  await notifyUserService(request.user, request.input("payload"));
131
131
  return response.success({ delivered: true });
132
132
  };
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: write-middleware
3
- description: 'Author HTTP middleware for @warlock.js/core — the `(request, response)` signature, short-circuit by returning a response, enrich the request with extra fields, register per-route, per-group, or app-wide. Triggers: `Middleware`, `MiddlewareResponse`, `router.group`, `guarded`, `request.detectIp`, `authMiddleware`; "write a custom middleware", "short-circuit a request", "enrich the request with extra fields", "per-route vs per-group middleware"; typical import `import type { Middleware } from "@warlock.js/core"`. Skip: built-in middleware catalog — `@warlock.js/core/use-middleware/SKILL.md`; route attachment — `@warlock.js/core/register-route/SKILL.md`; response helpers — `@warlock.js/core/send-response/SKILL.md`; competing patterns: `express` `(req, res, next)` middleware, Fastify `preHandler` hooks.'
3
+ description: 'Author HTTP middleware for @warlock.js/core — the `({ request, response })` signature, short-circuit by returning a response, enrich the request with extra fields, register per-route, per-group, or app-wide. Triggers: `Middleware`, `MiddlewareResponse`, `router.group`, `guarded`, `request.detectIp`, `authMiddleware`; "write a custom middleware", "short-circuit a request", "enrich the request with extra fields", "per-route vs per-group middleware"; typical import `import type { Middleware } from "@warlock.js/core"`. Skip: built-in middleware catalog — `@warlock.js/core/use-middleware/SKILL.md`; route attachment — `@warlock.js/core/register-route/SKILL.md`; response helpers — `@warlock.js/core/send-response/SKILL.md`; competing patterns: `express` `(req, res, next)` middleware, Fastify `preHandler` hooks.'
4
4
  ---
5
5
 
6
6
  # Warlock — write a middleware
@@ -12,7 +12,7 @@ Middleware is a plain function that runs before the controller. Two outcomes: re
12
12
  ```ts title="src/app/<module>/utils/<name>.middleware.ts"
13
13
  import type { Middleware } from "@warlock.js/core";
14
14
 
15
- export const requireApiKey: Middleware = (request, response) => {
15
+ export const requireApiKey: Middleware = ({ request, response }) => {
16
16
  const key = request.header("X-API-Key");
17
17
 
18
18
  if (!key || key !== process.env.API_KEY) {
@@ -23,16 +23,14 @@ export const requireApiKey: Middleware = (request, response) => {
23
23
  };
24
24
  ```
25
25
 
26
- That's the contract: `(request: Request, response: Response) => Response | undefined | void`. Async is fine — return a `Promise<Response | undefined | void>`.
26
+ That's the contract: `(context: HttpContext<Request>) => Response | undefined | void`, where `context` is `{ request, response }`. Async is fine — return a `Promise<Response | undefined | void>`.
27
27
 
28
28
  The real type, from `@warlock.js/core/src/router/types.ts`:
29
29
 
30
30
  ```ts
31
31
  export type Middleware<MiddlewareRequest extends Request = Request> = {
32
- (request: MiddlewareRequest, response: Response): MiddlewareResponse;
32
+ (context: HttpContext<MiddlewareRequest>): MiddlewareResponse;
33
33
  };
34
-
35
- export type MiddlewareResponse = ReturnedResponse | undefined | void;
36
34
  ```
37
35
 
38
36
  ## Short-circuit vs continue
@@ -42,7 +40,7 @@ The pattern is "return a response to stop, return nothing to continue":
42
40
  ```ts
43
41
  import type { Middleware } from "@warlock.js/core";
44
42
 
45
- export const requireFeatureFlag: Middleware = async (request, response) => {
43
+ export const requireFeatureFlag: Middleware = async ({ request, response }) => {
46
44
  const flag = await loadFeatureFlag(request.input("organization_id"));
47
45
 
48
46
  if (!flag.enabled) {
@@ -60,8 +58,8 @@ If you short-circuit, the controller never runs. The response helper you pick (`
60
58
  You can attach arbitrary fields to `request` from a middleware, and they survive into the controller. The cleanest pattern is to extend `Request` via module augmentation in a `.d.ts` and assign in the middleware:
61
59
 
62
60
  ```ts title="src/app/feature-flags/middleware/load-feature-flag.middleware.ts"
63
- import type { Middleware } from "@warlock.js/core";
64
- import type { FeatureFlag } from "../models/feature-flag";
61
+ import type { Middleware, Request, RequestUser } from "@warlock.js/core";
62
+ import { FeatureFlag } from "../models/feature-flag";
65
63
 
66
64
  declare module "@warlock.js/core" {
67
65
  interface Request {
@@ -69,7 +67,7 @@ declare module "@warlock.js/core" {
69
67
  }
70
68
  }
71
69
 
72
- export const loadFeatureFlag: Middleware = async (request) => {
70
+ export const loadFeatureFlag: Middleware<Request & { user: RequestUser }> = async ({ request }) => {
73
71
  request.featureFlag = await FeatureFlag.findBy("organization_id", request.user.organizationId);
74
72
  };
75
73
  ```
@@ -139,7 +137,7 @@ export function guarded(callback: () => void) {
139
137
  }
140
138
 
141
139
  export function guardedAdmin(callback: () => void) {
142
- router.group({ prefix: "/admin", middleware: [authMiddleware()] }, callback);
140
+ router.group({ prefix: "/admin", middleware: [authMiddleware("admin")] }, callback);
143
141
  }
144
142
 
145
143
  export function publicRoutes(callback: () => void) {
@@ -157,7 +155,7 @@ guarded(() => {
157
155
  });
158
156
  ```
159
157
 
160
- `authMiddleware(allowedUserType?)` accepts a user-type string or array. Without an arg it just verifies the token is present; with `"user"` / `"admin"` it also checks the decoded `userType` matches.
158
+ `authMiddleware(allowedUserType, tokenFrom?)` requires a user-type string or array `[]` accepts any authenticated user without checking `userType`; `"user"` / `"admin"` also checks the decoded `userType` matches.
161
159
 
162
160
  ## Common patterns
163
161
 
@@ -184,13 +182,13 @@ router.group(
184
182
  ```ts
185
183
  import type { Middleware } from "@warlock.js/core";
186
184
 
187
- export const optionalAuth: Middleware = async (request, response) => {
185
+ export const optionalAuth: Middleware = async ({ request, response }) => {
188
186
  if (!request.authorizationValue) {
189
187
  return; // anonymous — let it through
190
188
  }
191
189
 
192
190
  // token present → enforce it
193
- return authMiddleware("user")(request, response);
191
+ return authMiddleware("user")({ request, response });
194
192
  };
195
193
  ```
196
194
 
@@ -206,7 +204,7 @@ export const optionalAuth: Middleware = async (request, response) => {
206
204
  - **Group middleware runs before per-route middleware**, in array order. The full chain is `app.all → group → per-route → controller`. Mind the order if you stack auth + rate-limit + audit.
207
205
  - **Middleware can be async.** Returning `Promise<undefined>` continues the chain. Returning `Promise<Response>` short-circuits. The framework awaits the result.
208
206
  - **Don't mutate `request.payload` directly.** Use `request.setValidatedData(...)` or attach a new named field (`request.featureFlag = ...`). The internals expect `payload.all` shapes to come from the validator pipeline.
209
- - **`Middleware` is generic over the request type.** For middleware that assumes a validated schema, narrow it: `const m: Middleware<CreateProductRequest> = (request) => { ... }`. But most middlewares run before validation, so the default `Middleware` is right.
207
+ - **`Middleware` is generic over the request type.** For middleware that assumes a validated schema, narrow it: `const m: Middleware<CreateProductRequest> = ({ request }) => { ... }`. But most middlewares run before validation, so the default `Middleware` is right.
210
208
  - **No `next()` parameter.** Express-style `next()` doesn't apply here. The framework chains based on return value.
211
209
  - **`request.baseRequest` / `response.baseResponse` are escape hatches, not API.** They expose the underlying Fastify primitives for cases the framework hasn't covered yet (streaming was the historical precedent). Prefer framework helpers first — `response.send()`, `response.header()`, `response.replay()`, `request.input()`, `request.detectIp()`, etc. If you find yourself reaching for `baseResponse` or `baseRequest` for non-streaming work, that's a missing helper — file an issue. The cache and idempotency middlewares both shipped with a quietly-broken FastifyReply-return bug because they bypassed the helper layer; the framework now guards `Response.send()` against double-send, but the right answer is "use the helper."
212
210
  - **Behind any proxy, use `request.detectIp()` not `request.ip`.** `request.ip` is the immediate peer (likely your load balancer); `request.detectIp()` honors `X-Real-IP` / `X-Forwarded-For`. Either way, only trust the result as far as you trust the upstream chain — those headers are client-settable; verify the request came through your trusted edge before treating the value as authoritative.