@daloyjs/core 1.0.0-rc.3 → 1.0.0-rc.5

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 (61) hide show
  1. package/README.md +103 -41
  2. package/dist/adapters/bun.d.ts +20 -2
  3. package/dist/adapters/bun.js +41 -5
  4. package/dist/adapters/deno.js +24 -7
  5. package/dist/adapters/lambda.d.ts +59 -2
  6. package/dist/adapters/lambda.js +136 -20
  7. package/dist/adapters/node.d.ts +8 -1
  8. package/dist/adapters/node.js +104 -19
  9. package/dist/app.d.ts +131 -11
  10. package/dist/app.js +305 -217
  11. package/dist/bot-guard.js +30 -3
  12. package/dist/cli.js +41 -1
  13. package/dist/client.d.ts +64 -18
  14. package/dist/client.js +36 -6
  15. package/dist/combine.d.ts +11 -11
  16. package/dist/combine.js +90 -47
  17. package/dist/compression.d.ts +9 -0
  18. package/dist/compression.js +72 -1
  19. package/dist/conn-info.d.ts +5 -2
  20. package/dist/conn-info.js +5 -2
  21. package/dist/docs.d.ts +5 -9
  22. package/dist/docs.js +36 -14
  23. package/dist/errors.d.ts +12 -3
  24. package/dist/errors.js +12 -3
  25. package/dist/fetch-guard.d.ts +27 -19
  26. package/dist/fetch-guard.js +50 -8
  27. package/dist/http-signatures.d.ts +4 -1
  28. package/dist/http-signatures.js +13 -1
  29. package/dist/idempotency.js +2 -1
  30. package/dist/index.d.ts +5 -5
  31. package/dist/index.js +3 -3
  32. package/dist/internal-response.d.ts +15 -0
  33. package/dist/internal-response.js +27 -0
  34. package/dist/jwk.d.ts +11 -7
  35. package/dist/jwk.js +11 -7
  36. package/dist/logger.d.ts +45 -0
  37. package/dist/logger.js +137 -0
  38. package/dist/mcp.js +21 -15
  39. package/dist/middleware.d.ts +48 -7
  40. package/dist/middleware.js +129 -43
  41. package/dist/mtls.d.ts +6 -5
  42. package/dist/mtls.js +8 -9
  43. package/dist/openapi.js +1 -1
  44. package/dist/pagination.js +4 -1
  45. package/dist/response-cache.js +2 -1
  46. package/dist/router.d.ts +2 -2
  47. package/dist/router.js +24 -9
  48. package/dist/safe-redirect.d.ts +9 -2
  49. package/dist/safe-redirect.js +29 -4
  50. package/dist/sbom.cdx.json +9 -9
  51. package/dist/sbom.spdx.json +5 -5
  52. package/dist/security.d.ts +62 -0
  53. package/dist/security.js +220 -15
  54. package/dist/session.d.ts +13 -2
  55. package/dist/session.js +111 -17
  56. package/dist/tenancy.d.ts +2 -2
  57. package/dist/time-claims.js +3 -1
  58. package/dist/types.d.ts +85 -20
  59. package/dist/types.js +16 -1
  60. package/dist/waf.js +86 -26
  61. package/package.json +11 -4
package/dist/types.d.ts CHANGED
@@ -103,7 +103,8 @@ export type InferRequest<R extends RequestSchemas | undefined, P extends string>
103
103
  /**
104
104
  * Describes a single HTTP response variant declared by a route.
105
105
  *
106
- * - `description` — surfaces in OpenAPI documentation. Required.
106
+ * - `description` — optional OpenAPI documentation; omitted values receive a
107
+ * stable `HTTP <status> response` fallback.
107
108
  * - `body` — Standard-Schema validator for the response body; when
108
109
  * present, DaloyJS validates the handler's return value against it
109
110
  * (controlled by `AppOptions.validateResponses`).
@@ -114,8 +115,8 @@ export type InferRequest<R extends RequestSchemas | undefined, P extends string>
114
115
  * @since 0.1.0
115
116
  */
116
117
  export interface ResponseSpec {
117
- /** Human-readable description emitted into the OpenAPI response object. Required. */
118
- description: string;
118
+ /** Human-readable OpenAPI description. Defaults to `HTTP <status> response`. */
119
+ description?: string;
119
120
  /** Response-body validator; handler return values are checked against it when `AppOptions.validateResponses` is on. */
120
121
  body?: StandardSchemaV1;
121
122
  /** Documented response headers keyed by header name; surfaced in the OpenAPI document. */
@@ -273,20 +274,53 @@ export interface BaseContext<P extends string, R extends RequestSchemas | undefi
273
274
  headers: Headers;
274
275
  };
275
276
  }
277
+ /**
278
+ * Minimal request context exposed before request-body I/O or schema
279
+ * validation. It is intended for cheap perimeter decisions such as bearer,
280
+ * API-key, mTLS, and header-only JWT authentication.
281
+ *
282
+ * Path, query, and header values are raw at this phase, and `body` is always
283
+ * `undefined`. Middleware that requires validated input or body bytes belongs
284
+ * in {@link Hooks.beforeHandle}.
285
+ *
286
+ * @typeParam P - Literal route path used to infer raw path-parameter names.
287
+ * @since 1.0.0
288
+ */
289
+ export interface PreBodyContext<P extends string = string> {
290
+ /** Original web-standard Request. Its body stream has not been consumed by DaloyJS. */
291
+ request: Request;
292
+ /** Raw router path parameters. */
293
+ params: PathParams<P>;
294
+ /** Raw query-string values, materialized lazily. */
295
+ query: Record<string, string | string[] | undefined>;
296
+ /** Raw request headers, materialized lazily. */
297
+ headers: Record<string, string | undefined>;
298
+ /** Always `undefined`; request-body I/O has not started. */
299
+ body: undefined;
300
+ /** Mutable per-request state shared with later hooks and the handler. */
301
+ state: AppState & Record<string, unknown>;
302
+ /** Response headers/status available to a short-circuiting perimeter hook. */
303
+ set: {
304
+ status?: number;
305
+ headers: Headers;
306
+ };
307
+ }
276
308
  /**
277
309
  * Lifecycle hooks fired around request handling. Hooks compose pipeline-style
278
310
  * — the global hooks (`AppOptions.hooks`) run first, then group hooks added
279
311
  * with `app.use()`, then per-route hooks. Returning a `Response` from
280
- * `beforeHandle` or `onSend` short-circuits/replaces the response.
312
+ * `preBody`, `beforeHandle`, or `onSend` short-circuits/replaces the response.
281
313
  *
282
314
  * Ordering for a successful request:
283
315
  * 1. `onRequest` — before any context is built (raw `Request`).
284
- * 2. `beforeHandle` with the built context; may short-circuit.
285
- * 3. *handler runs*
286
- * 4. `afterHandle` may transform the handler return value.
287
- * 5. *response is serialized + validated*
288
- * 6. `onSend` — may mutate or replace the outgoing `Response`.
289
- * 7. `onResponse` — fire-and-forget observer (cannot change anything).
316
+ * 2. `preBody` after routing, before body I/O or validation.
317
+ * 3. *request schemas are validated and the body is read when declared*
318
+ * 4. `beforeHandle` with the validated context; may short-circuit.
319
+ * 5. *handler runs*
320
+ * 6. `afterHandle` — may transform the handler return value.
321
+ * 7. *response is serialized + validated*
322
+ * 8. `onSend` — may mutate or replace the outgoing `Response`.
323
+ * 9. `onResponse` — fire-and-forget observer (cannot change anything).
290
324
  *
291
325
  * `onError` runs on the error path before serialization.
292
326
  *
@@ -295,7 +329,9 @@ export interface BaseContext<P extends string, R extends RequestSchemas | undefi
295
329
  export interface Hooks {
296
330
  /** Runs first, before validation or context building. Receives the raw web-standard `Request`. */
297
331
  onRequest?: (req: Request) => void | Promise<void>;
298
- /** Runs with the validated {@link BaseContext} before the handler. Returning a `Response` short-circuits the handler entirely (useful for auth guards). */
332
+ /** Runs after route matching but before request-body I/O or schema validation. Use for cheap header/certificate auth; `ctx.body` is always undefined. A successful raw `Response` requires the route's `acknowledgeNoResponseBodySchema` flag; `4xx`/`5xx` denials do not. */
333
+ preBody?: (ctx: PreBodyContext<any>) => void | Response | Promise<void | Response>;
334
+ /** Runs with the validated {@link BaseContext} before the handler. Returning a `Response` short-circuits the handler entirely. Successful raw responses require the route's `acknowledgeNoResponseBodySchema` flag; `4xx`/`5xx` auth denials do not. */
299
335
  beforeHandle?: (ctx: BaseContext<any, any>) => void | Response | Promise<void | Response>;
300
336
  /** Runs after the handler with its raw return value. Return a non-`undefined` value to replace the result before serialization and response-schema validation. */
301
337
  afterHandle?: (ctx: BaseContext<any, any>, result: unknown) => void | unknown | Promise<void | unknown>;
@@ -358,16 +394,22 @@ export interface RouteDefinition<P extends PathString = PathString, M extends Ht
358
394
  /** Optional per-route API version label. Informational metadata only; not emitted into the OpenAPI document. */
359
395
  version?: string;
360
396
  /**
361
- * Acknowledge that this route's `2xx` responses intentionally carry no
362
- * response body schema — an opaque, framework-controlled, or non-JSON body
363
- * (a raw `Response`, an HTML page, a spec document, a proxied payload).
397
+ * Acknowledge that this route's output intentionally is not protected by a
398
+ * response body schema — either because its `2xx` response declares no body
399
+ * schema or because it returns an opaque, framework-controlled raw
400
+ * `Response` (an HTML page, stream, spec document, or proxied payload).
364
401
  *
365
402
  * Setting this suppresses the `security.response.bodySchemaMissing` boot
366
403
  * warning and the `audit.response.bodySchema` `daloy doctor` finding for
367
- * this route only. It documents intent; it does not add protection —
368
- * response field-level stripping (OWASP API3) still does not run for a
369
- * `2xx` response without a body schema, so never set this on a route whose
370
- * handler builds JSON from domain objects.
404
+ * this route only. It also explicitly authorizes a handler or `afterHandle`
405
+ * hook to return a raw `Response`, or a `preBody` / `beforeHandle` hook to
406
+ * short-circuit with a successful (`2xx`/`3xx`) raw `Response`. DaloyJS fails
407
+ * closed with a `500` when those cases lack this acknowledgement. Ordinary
408
+ * `4xx`/`5xx` hook denials and errors remain available without opting out of
409
+ * response validation. This flag documents intent; it does not add
410
+ * protection — response field-level stripping (OWASP API3) does not run for
411
+ * an opaque body, so never set this on a route whose handler builds JSON from
412
+ * domain objects.
371
413
  */
372
414
  acknowledgeNoResponseBodySchema?: boolean;
373
415
  /**
@@ -489,8 +531,11 @@ export interface RouteDefinition<P extends PathString = PathString, M extends Ht
489
531
  * forwarded verbatim).
490
532
  *
491
533
  * A returned `Response` **bypasses response-schema validation and the
492
- * typed-client body type by design** there is no schema that can describe
493
- * an opaque stream. It is still finalized exactly like every other response,
534
+ * typed-client body type by design** and therefore requires
535
+ * `acknowledgeNoResponseBodySchema: true` on the route. Without that explicit
536
+ * acknowledgement, DaloyJS refuses the response with a `500` instead of
537
+ * silently weakening the contract. An acknowledged raw response is still
538
+ * finalized exactly like every other response,
494
539
  * so no security control is skipped: headers set via `ctx.set` (including
495
540
  * `secureHeaders()` and CORS) are copied onto it, `x-request-id` is added
496
541
  * when absent, any `onSend` / `onResponse` hooks run, server-fingerprint
@@ -503,6 +548,26 @@ export interface RouteDefinition<P extends PathString = PathString, M extends Ht
503
548
  */
504
549
  handler: (ctx: BaseContext<P, Req>) => HandlerReturn<Res> | Response | Promise<HandlerReturn<Res> | Response>;
505
550
  }
551
+ /**
552
+ * Define a route contract outside an {@link "./app.js".App} while preserving
553
+ * its literal path, method, operation id, schemas, and contextually typed
554
+ * handler.
555
+ *
556
+ * Export contracts from route modules, collect them in a literal tuple, then
557
+ * register the tuple with {@link "./app.js".App.registerRoutes}. The returned
558
+ * App retains the complete tuple for the no-codegen typed client.
559
+ *
560
+ * @param definition - Complete route contract and handler.
561
+ * @returns The same definition object, unchanged at runtime.
562
+ * @since 1.0.0
563
+ */
564
+ export declare function defineRoute<const P extends PathString, const M extends HttpMethod, Req extends RequestSchemas | undefined, Res extends ResponsesMap, const Op extends string | undefined = undefined>(definition: RouteDefinition<P, M, Req, Res> & {
565
+ operationId?: Op;
566
+ }): RouteDefinition<P, M, Req, Res> & (Op extends string ? {
567
+ operationId: Op;
568
+ } : {
569
+ operationId?: undefined;
570
+ });
506
571
  /**
507
572
  * One operation inside an OpenAPI Callback Object. Mirrors a route minus
508
573
  * `path` (the URL is supplied at runtime via the expression key) and
package/dist/types.js CHANGED
@@ -1 +1,16 @@
1
- export {};
1
+ /**
2
+ * Define a route contract outside an {@link "./app.js".App} while preserving
3
+ * its literal path, method, operation id, schemas, and contextually typed
4
+ * handler.
5
+ *
6
+ * Export contracts from route modules, collect them in a literal tuple, then
7
+ * register the tuple with {@link "./app.js".App.registerRoutes}. The returned
8
+ * App retains the complete tuple for the no-codegen typed client.
9
+ *
10
+ * @param definition - Complete route contract and handler.
11
+ * @returns The same definition object, unchanged at runtime.
12
+ * @since 1.0.0
13
+ */
14
+ export function defineRoute(definition) {
15
+ return definition;
16
+ }
package/dist/waf.js CHANGED
@@ -161,6 +161,75 @@ function safeDecode(value) {
161
161
  return value;
162
162
  }
163
163
  }
164
+ /**
165
+ * Maximum percent-decode passes applied when expanding inspection variants.
166
+ *
167
+ * One pass matches what most HTTP stacks hand the handler. A second pass
168
+ * catches classic double-encoding WAF evasions (`%2527` → `%27` → `'`). A
169
+ * third is omitted on purpose: deeper recursive decoding inflates false
170
+ * positives on legitimately percent-bearing text and is not how frameworks
171
+ * deliver query/path values.
172
+ */
173
+ const MAX_DECODE_PASSES = 2;
174
+ /**
175
+ * Expand a single inbound string into the variants the WAF should scan.
176
+ *
177
+ * Includes the raw value, up to {@link MAX_DECODE_PASSES} percent-decodes,
178
+ * a `+`→space form (URLSearchParams parity), and a SQL-comment-stripped
179
+ * form so comment-split keywords (e.g. OR wrapped in block comments) score
180
+ * the same as the whitespace-separated form.
181
+ *
182
+ * Scanning variants is pure defense-in-depth: the handler still receives
183
+ * whatever the framework's single-decode path produced. Each variant is
184
+ * truncated to `maxValueLength` and deduplicated so hostile inputs cannot
185
+ * explode the scan set.
186
+ *
187
+ * @param value - Raw or already-decoded string from path/query/header/body.
188
+ * @param maxValueLength - Cap applied to every variant before scanning.
189
+ * @returns Deduplicated inspection variants in stable insertion order.
190
+ */
191
+ function inspectionVariants(value, maxValueLength) {
192
+ const seen = new Set();
193
+ const out = [];
194
+ const push = (v) => {
195
+ const truncated = v.length > maxValueLength ? v.slice(0, maxValueLength) : v;
196
+ if (!seen.has(truncated)) {
197
+ seen.add(truncated);
198
+ out.push(truncated);
199
+ }
200
+ };
201
+ let current = value;
202
+ push(current);
203
+ for (let i = 0; i < MAX_DECODE_PASSES; i++) {
204
+ const decoded = safeDecode(current);
205
+ if (decoded === current)
206
+ break;
207
+ push(decoded);
208
+ current = decoded;
209
+ }
210
+ // Snapshot before secondary transforms so we only expand the decode chain.
211
+ const decodedChain = out.slice();
212
+ for (const v of decodedChain) {
213
+ if (v.includes("+"))
214
+ push(v.replace(/\+/g, " "));
215
+ if (v.includes("/*"))
216
+ push(v.replace(/\/\*[\s\S]*?\*\//g, " "));
217
+ }
218
+ return out;
219
+ }
220
+ /**
221
+ * Scan every inspection variant of `value` for the active rule set.
222
+ *
223
+ * @see inspectionVariants
224
+ */
225
+ function scanValueVariants(value, location, rules, scored, maxValueLength) {
226
+ for (const variant of inspectionVariants(value, maxValueLength)) {
227
+ scanValue(variant, location, rules, scored);
228
+ // Early exit once every rule has already fired — no further variants needed.
229
+ if (scored.size === rules.length)
230
+ return;
231
+ }
232
+ }
164
233
  /**
165
234
  * Collect up to `maxNodes` string values from a parsed body value (object /
166
235
  * array / scalar), each truncated to `maxValueLength`. Depth and node count are
@@ -268,37 +337,29 @@ export function waf(opts = {}) {
268
337
  const scored = new Map();
269
338
  const url = new URL(ctx.request.url);
270
339
  if (inspectPath) {
271
- scanValue(safeDecode(url.pathname), "path", rules, scored);
340
+ // Path is scanned across raw + up to two decode passes so double-
341
+ // encoded traversal / injection tokens in path segments still score.
342
+ scanValueVariants(url.pathname, "path", rules, scored, maxValueLength);
272
343
  }
273
344
  if (inspectQuery && url.search.length > 1) {
274
- // Scan both the raw query string and a best-effort decoded form so an
275
- // encoded payload (`%27%20OR%201=1`) is caught after normalization.
276
- // This is a SINGLE decode on purpose: the framework's request path also
277
- // decodes the query exactly once, so the WAF sees the same bytes the
278
- // handler will. Recursive decoding is deliberately avoided — it would
279
- // false-positive on values that legitimately contain percent-encoded
280
- // text, and a double-encoded payload stays inert (`%3Cscript%3E`) all
281
- // the way to the handler. See red-team-attacks-6 "DOCUMENTED LIMITATION".
345
+ // Scan the raw query, bounded multi-decode variants, and each
346
+ // URLSearchParams key/value. Multi-decode (max 2) closes classic
347
+ // double-encoding WAF evasions (`%2527` `%27` `'`) without open-
348
+ // ended recursive decoding. URLSearchParams also turns `+` into
349
+ // space; inspectionVariants covers that form so `1+OR+1=1` scores
350
+ // the same as `1 OR 1=1` (parser-differential defense).
282
351
  const raw = url.search.slice(1);
283
- scanValue(raw, "query", rules, scored);
284
- const decoded = safeDecode(raw);
285
- if (decoded !== raw)
286
- scanValue(decoded, "query", rules, scored);
287
- // Additionally inspect each key/value the way the app's OWN query parser
288
- // (`URLSearchParams`) decodes them: notably `+` becomes a space, which a
289
- // plain `decodeURIComponent` does NOT do. Without this, `1+OR+1=1` slipped
290
- // past the WAF while the handler still received `1 OR 1=1` (a parser
291
- // differential — the WAF must inspect the bytes the app actually parses).
352
+ scanValueVariants(raw, "query", rules, scored, maxValueLength);
292
353
  for (const [k, v] of url.searchParams) {
293
- scanValue(k, "query", rules, scored);
294
- scanValue(v, "query", rules, scored);
354
+ scanValueVariants(k, "query", rules, scored, maxValueLength);
355
+ scanValueVariants(v, "query", rules, scored, maxValueLength);
295
356
  }
296
357
  }
297
358
  if (headerAllowlist.length > 0) {
298
359
  for (const name of headerAllowlist) {
299
360
  const value = ctx.request.headers.get(name);
300
361
  if (value)
301
- scanValue(value, "header", rules, scored);
362
+ scanValueVariants(value, "header", rules, scored, maxValueLength);
302
363
  }
303
364
  }
304
365
  if (inspectBody && ctx.body !== undefined && ctx.body !== null) {
@@ -316,14 +377,13 @@ export function waf(opts = {}) {
316
377
  });
317
378
  }
318
379
  if (typeof ctx.body === "string") {
319
- scanValue(ctx.body.length > maxValueLength
320
- ? ctx.body.slice(0, maxValueLength)
321
- : ctx.body, "body", rules, scored);
380
+ scanValueVariants(ctx.body, "body", rules, scored, maxValueLength);
322
381
  }
323
382
  else if (typeof ctx.body === "object") {
324
383
  const strings = collectBodyStrings(ctx.body, maxBodyNodes, maxValueLength);
325
- for (const value of strings)
326
- scanValue(value, "body", rules, scored);
384
+ for (const value of strings) {
385
+ scanValueVariants(value, "body", rules, scored, maxValueLength);
386
+ }
327
387
  }
328
388
  }
329
389
  if (scored.size === 0)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@daloyjs/core",
3
- "version": "1.0.0-rc.3",
3
+ "version": "1.0.0-rc.5",
4
4
  "description": "DaloyJS is a runtime-portable, contract-first TypeScript web framework with built-in OpenAPI (Hey API), typed client generation, large-scale maintainability, and security-first defaults. Hono-grade portability, Elysia-grade DX, FastAPI-grade docs, Fastify-grade ops — distributed via pnpm.",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -46,6 +46,10 @@
46
46
  "types": "./dist/index.d.ts",
47
47
  "import": "./dist/index.js"
48
48
  },
49
+ "./app": {
50
+ "types": "./dist/app.d.ts",
51
+ "import": "./dist/app.js"
52
+ },
49
53
  "./node": {
50
54
  "types": "./dist/adapters/node.d.ts",
51
55
  "import": "./dist/adapters/node.js"
@@ -232,12 +236,12 @@
232
236
  }
233
237
  },
234
238
  "devDependencies": {
235
- "@hey-api/openapi-ts": "^0.99.0",
239
+ "@hey-api/openapi-ts": "0.0.0-next-20260711024907",
236
240
  "@types/node": "^26.0.1",
237
241
  "fast-check": "^4.8.0",
238
242
  "prettier": "^3.8.3",
239
243
  "tsx": "^4.22.3",
240
- "typescript": "^6.0.3",
244
+ "typescript": "^7.0.2",
241
245
  "zod": "^4.4.3"
242
246
  },
243
247
  "scripts": {
@@ -245,7 +249,10 @@
245
249
  "dev": "tsc -w -p tsconfig.json",
246
250
  "example": "node --import tsx examples/basic.ts",
247
251
  "bench": "node --import tsx bench/router.bench.ts",
248
- "bench:serverless": "node --import tsx bench/serverless-cold-path.bench.ts",
252
+ "bench:serverless": "pnpm build && node --import tsx bench/serverless-cold-path.bench.ts",
253
+ "bench:json": "node --import tsx bench/json-body.bench.ts",
254
+ "bench:json-e2e": "node --import tsx bench/json-body-e2e.bench.ts",
255
+ "bench:ablation": "pnpm build && node --import tsx bench/ablation.bench.ts",
249
256
  "test": "node --import tsx --test tests/**/*.test.ts",
250
257
  "test:red-team": "node --import tsx --test tests/red-team-attacks.test.ts tests/red-team-attacks-2.test.ts tests/red-team-attacks-3.test.ts tests/red-team-attacks-4.test.ts tests/red-team-attacks-5.test.ts tests/red-team-attacks-6.test.ts tests/red-team-attacks-7.test.ts tests/red-team-attacks-8.test.ts tests/red-team-attacks-9.test.ts tests/red-team-attacks-10.test.ts",
251
258
  "red-team:live": "node --import tsx red-team-live/run.ts",