@daloyjs/core 1.0.0-beta.0 → 1.0.0-beta.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.
package/README.md CHANGED
@@ -498,7 +498,7 @@ The core only ever sees `Request → Response`. Adapters live at the edge.
498
498
 
499
499
  ## Status
500
500
 
501
- DaloyJS is now in the **`1.0.0` beta** (`1.0.0-beta.0`). The public API is feature-complete and stable for the 1.0 line; from `1.0.0` onward, breaking changes follow SemVer and deprecations get at least one minor cycle. Small adjustments are still possible before the `1.0.0` GA if beta feedback surfaces something. The framework is already in use for production trials.
501
+ DaloyJS is now in the **`1.0.0` beta** (`1.0.0-beta.1`). The public API is feature-complete and stable for the 1.0 line; from `1.0.0` onward, breaking changes follow SemVer and deprecations get at least one minor cycle. Small adjustments are still possible before the `1.0.0` GA if beta feedback surfaces something. The framework is already in use for production trials.
502
502
 
503
503
  **Release quality bar.** Every release ships with **≥90% line + function coverage and ≥90% branch coverage**, strict TypeScript, OpenSSF Scorecard, CodeQL + Opengrep dual SAST, zizmor workflow linting, and npm provenance. Coverage was relaxed from a former 100% gate so complex security work isn't blocked chasing throwaway tests for unreachable defensive branches or tsx source-map phantoms; see [AGENTS.md](AGENTS.md) for the policy.
504
504
 
@@ -37,6 +37,15 @@ export function serve(app, opts = {}) {
37
37
  dispatchToApp(app, req, res, trustProxy, undefined);
38
38
  return;
39
39
  }
40
+ // Refuse Fetch-forbidden methods (CONNECT/TRACE/TRACK) before building a
41
+ // `Request` — `new Request` throws a `TypeError` for them, which would
42
+ // otherwise be caught and reported as a generic 500 instead of a clean,
43
+ // intentional method refusal. Placed after the GET/HEAD fast path so the
44
+ // hot path never pays for this check.
45
+ if (FETCH_FORBIDDEN_METHODS.has(method.toUpperCase())) {
46
+ writeMethodRefused(res);
47
+ return;
48
+ }
40
49
  // POST/PUT/PATCH/DELETE with a small known content-length: pre-buffer
41
50
  // bytes from the Node socket directly so the Request constructor gets a
42
51
  // Uint8Array body instead of `Readable.toWeb(req)`. This skips the
@@ -223,6 +232,45 @@ function attachClientCertificate(req, request) {
223
232
  return normalizePeerCertificate(raw, sock.authorized === true);
224
233
  });
225
234
  }
235
+ /**
236
+ * Methods the WHATWG Fetch standard forbids on a `Request` (`CONNECT`,
237
+ * `TRACE`, `TRACK`). `new Request(url, { method })` throws a `TypeError` for
238
+ * these, so the Node adapter refuses them *before* constructing a `Request` —
239
+ * otherwise that `TypeError` surfaces as a generic `500` instead of a
240
+ * deliberate method refusal. Refusing `TRACE`/`TRACK` also closes Cross-Site
241
+ * Tracing, and `CONNECT` has no meaning for an origin server, so a categorical
242
+ * refusal is the correct secure default. (`CONNECT` is normally routed to
243
+ * Node's `connect` event rather than the request listener; it is included here
244
+ * defensively for runtimes/proxies that surface it as a normal request.)
245
+ */
246
+ const FETCH_FORBIDDEN_METHODS = new Set([
247
+ "CONNECT",
248
+ "TRACE",
249
+ "TRACK",
250
+ ]);
251
+ /**
252
+ * Refuse a Fetch-forbidden HTTP method with a spec-correct `501 Not
253
+ * Implemented`. `501` is more accurate than `405` here because the method is
254
+ * unsupported for *every* resource (not just the matched route), and unlike
255
+ * `405` it does not require an `Allow` header the adapter cannot compute before
256
+ * routing. Mirrors {@link writeAdapterError}'s RFC 9457 problem+json shape;
257
+ * `Connection: close` avoids reusing a socket whose (illegal) request body was
258
+ * never drained.
259
+ *
260
+ * @param res - The Node {@link ServerResponse} to write the refusal to.
261
+ */
262
+ function writeMethodRefused(res) {
263
+ if (res.headersSent)
264
+ return;
265
+ res.statusCode = 501;
266
+ res.setHeader("content-type", "application/problem+json");
267
+ res.setHeader("connection", "close");
268
+ res.end(JSON.stringify({
269
+ type: "https://daloyjs.dev/errors/not-implemented",
270
+ title: "Not Implemented",
271
+ status: 501,
272
+ }));
273
+ }
226
274
  function writeAdapterError(res, e) {
227
275
  if (!res.headersSent) {
228
276
  res.statusCode = 500;
package/dist/app.d.ts CHANGED
@@ -13,6 +13,8 @@ import { type BehindProxyConfig } from "./conn-info.js";
13
13
  export declare function _resetCrashHandlersForTests(): void;
14
14
  /** @internal Test-only helper to reset the latch between tests. */
15
15
  export declare function _resetInsecureDefaultsLogForTests(): void;
16
+ /** @internal Test-only helper to reset the indeterminate-env warning latch. */
17
+ export declare function _resetIndeterminateEnvWarningForTests(): void;
16
18
  /**
17
19
  * Named security posture preset. Currently only one value is supported:
18
20
  *
@@ -992,6 +994,32 @@ export declare class App<Routes extends readonly RouteDefinition<any, any, any,
992
994
  * a misconfigured surface.
993
995
  */
994
996
  private assertSecureHookConfig;
997
+ /**
998
+ * Whether the resolved runtime environment is indeterminate: no explicit
999
+ * {@link AppOptions.env} / {@link AppOptions.production} was provided AND
1000
+ * `process.env.NODE_ENV` is unset or empty. This is the common default on
1001
+ * edge runtimes (Cloudflare Workers, Deno Deploy, Vercel Edge), which have no
1002
+ * `NODE_ENV`. Deliberately does not sniff the runtime — it only reports
1003
+ * whether the environment signal is absent — so it stays runtime-portable.
1004
+ *
1005
+ * @returns `true` when neither an explicit option nor `NODE_ENV` resolves the
1006
+ * environment; `false` otherwise (including when `NODE_ENV` is `development` /
1007
+ * `test`, which is a known, non-production answer).
1008
+ */
1009
+ private isEnvIndeterminate;
1010
+ /**
1011
+ * Emit a single once-per-process warning when a production-only secure-default
1012
+ * refusal (wildcard CORS origin, weak session secret) would not fire purely
1013
+ * because the environment is indeterminate (see {@link isEnvIndeterminate}).
1014
+ * Routed through the logger, so `logger: false` silences it. Only warns when a
1015
+ * risky config is actually present, so a clean app stays quiet. Changes no
1016
+ * enforcement; pure observability for the documented "set `env` on edge"
1017
+ * requirement.
1018
+ *
1019
+ * @param record - The hook object, carrying the wildcard-CORS / session
1020
+ * markers used by {@link assertSecureHookConfig}.
1021
+ */
1022
+ private warnIndeterminateEnvSecurity;
995
1023
  private assertRouteAuthPayloadConfig;
996
1024
  private resetBootGuardCache;
997
1025
  /**
package/dist/app.js CHANGED
@@ -43,6 +43,18 @@ let insecureDefaultsLoggedThisProcess = false;
43
43
  export function _resetInsecureDefaultsLogForTests() {
44
44
  insecureDefaultsLoggedThisProcess = false;
45
45
  }
46
+ /**
47
+ * Once-per-process latch for the "production-only secure-default guard skipped
48
+ * because the runtime environment is indeterminate" warning (see
49
+ * {@link App.warnIndeterminateEnvSecurity}). Mirrors
50
+ * {@link insecureDefaultsLoggedThisProcess}: one heads-up per process, not one
51
+ * per `App` / per `.use()` call.
52
+ */
53
+ let indeterminateEnvSecurityWarnedThisProcess = false;
54
+ /** @internal Test-only helper to reset the indeterminate-env warning latch. */
55
+ export function _resetIndeterminateEnvWarningForTests() {
56
+ indeterminateEnvSecurityWarnedThisProcess = false;
57
+ }
46
58
  /**
47
59
  * The exact set of fields the `"internal-service"` preset flips off when
48
60
  * the caller has not set them explicitly. Surfaced through the boot
@@ -613,9 +625,22 @@ export class App {
613
625
  assertSecureHookConfig(hooks) {
614
626
  if (this.options.secureDefaults === false)
615
627
  return;
616
- if (!this.isProduction())
617
- return;
618
628
  const record = hooks;
629
+ if (!this.isProduction()) {
630
+ // The refusals below are production-only. When the environment is
631
+ // *indeterminate* (no explicit `env` / `production` option and no
632
+ // `NODE_ENV` — the common default on edge runtimes such as Workers /
633
+ // Deno Deploy / Vercel Edge), those refusals never fire, so a risky
634
+ // config could ship unguarded. Surface that once as a warning when such
635
+ // a config is actually present. Enforcement is unchanged — this only
636
+ // makes the silent skip observable. The runtime itself is deliberately
637
+ // NOT sniffed (that would couple the core to specific platforms and
638
+ // break runtime portability); we key only on "is the env signal absent?".
639
+ if (this.isEnvIndeterminate()) {
640
+ this.warnIndeterminateEnvSecurity(record);
641
+ }
642
+ return;
643
+ }
619
644
  if (record[CORS_WILDCARD_ORIGIN_MARKER] === true) {
620
645
  throw new Error('cors({ origin: "*" }) refused in production: a wildcard CORS origin exposes every state-changing route cross-origin. ' +
621
646
  "Replace the wildcard with an explicit allowlist (string[] or predicate), or pass " +
@@ -630,6 +655,70 @@ export class App {
630
655
  }
631
656
  }
632
657
  }
658
+ /**
659
+ * Whether the resolved runtime environment is indeterminate: no explicit
660
+ * {@link AppOptions.env} / {@link AppOptions.production} was provided AND
661
+ * `process.env.NODE_ENV` is unset or empty. This is the common default on
662
+ * edge runtimes (Cloudflare Workers, Deno Deploy, Vercel Edge), which have no
663
+ * `NODE_ENV`. Deliberately does not sniff the runtime — it only reports
664
+ * whether the environment signal is absent — so it stays runtime-portable.
665
+ *
666
+ * @returns `true` when neither an explicit option nor `NODE_ENV` resolves the
667
+ * environment; `false` otherwise (including when `NODE_ENV` is `development` /
668
+ * `test`, which is a known, non-production answer).
669
+ */
670
+ isEnvIndeterminate() {
671
+ if (this.options.env !== undefined ||
672
+ this.options.production !== undefined) {
673
+ return false;
674
+ }
675
+ const nodeEnv = typeof process !== "undefined" && typeof process.env !== "undefined"
676
+ ? process.env.NODE_ENV
677
+ : undefined;
678
+ return nodeEnv === undefined || nodeEnv === "";
679
+ }
680
+ /**
681
+ * Emit a single once-per-process warning when a production-only secure-default
682
+ * refusal (wildcard CORS origin, weak session secret) would not fire purely
683
+ * because the environment is indeterminate (see {@link isEnvIndeterminate}).
684
+ * Routed through the logger, so `logger: false` silences it. Only warns when a
685
+ * risky config is actually present, so a clean app stays quiet. Changes no
686
+ * enforcement; pure observability for the documented "set `env` on edge"
687
+ * requirement.
688
+ *
689
+ * @param record - The hook object, carrying the wildcard-CORS / session
690
+ * markers used by {@link assertSecureHookConfig}.
691
+ */
692
+ warnIndeterminateEnvSecurity(record) {
693
+ if (indeterminateEnvSecurityWarnedThisProcess)
694
+ return;
695
+ const risky = [];
696
+ if (record[CORS_WILDCARD_ORIGIN_MARKER] === true) {
697
+ risky.push('cors({ origin: "*" })');
698
+ }
699
+ if (record[SESSION_HOOK_MARKER] === true) {
700
+ const secrets = record[SESSION_SECRETS_MARKER];
701
+ if (Array.isArray(secrets)) {
702
+ const hasWeak = secrets.some((s) => {
703
+ try {
704
+ assertStrongSecret(s, "session");
705
+ return false;
706
+ }
707
+ catch {
708
+ return true;
709
+ }
710
+ });
711
+ if (hasWeak)
712
+ risky.push("a weak session secret");
713
+ }
714
+ }
715
+ if (risky.length === 0)
716
+ return;
717
+ indeterminateEnvSecurityWarnedThisProcess = true;
718
+ this.log.warn({ event: "secure_defaults.env_indeterminate", risky }, `DaloyJS: ${risky.join(" and ")} present, but the runtime environment is indeterminate ` +
719
+ `(no env option and no NODE_ENV). The production-only refuse-to-boot guard is therefore ` +
720
+ `inactive. If this is production (e.g. an edge runtime), set app({ env: "production" }).`);
721
+ }
633
722
  assertRouteAuthPayloadConfig(route) {
634
723
  const auth = route.auth;
635
724
  if (!auth || auth.payload !== false)
@@ -3034,12 +3123,27 @@ async function readBody(req, ct, limit, multipart) {
3034
3123
  return out;
3035
3124
  }
3036
3125
  if (ct.includes("multipart/form-data")) {
3037
- // Multipart: rely on platform parser, but enforce content-length first.
3126
+ // Fast-fail on an honestly-declared oversize body.
3038
3127
  const cl = req.headers.get("content-length");
3039
3128
  if (cl && Number(cl) > limit) {
3040
3129
  throw new PayloadTooLargeError(limit);
3041
3130
  }
3042
- const fd = await req.formData();
3131
+ // Then cap the ACTUAL bytes before handing them to the platform
3132
+ // formData() parser. Content-Length is not a sufficient gate on its own: a
3133
+ // chunked upload sends none, and a lying small value slips past the check —
3134
+ // in both cases the platform parser would otherwise buffer the whole body
3135
+ // in memory on runtimes whose adapter does not cap at the socket layer
3136
+ // (Workers / Deno / Vercel Edge). `readBodyLimited` streams the body and
3137
+ // throws `PayloadTooLargeError` the instant it exceeds `limit`; we then
3138
+ // re-parse the bounded bytes with the standard `formData()` parser,
3139
+ // preserving the multipart boundary via the original Content-Type. This is
3140
+ // Web-standard only (`Request` + `formData`), so it stays runtime-portable.
3141
+ const bytes = await readBodyLimited(req, limit);
3142
+ const fd = await new Request(req.url, {
3143
+ method: "POST",
3144
+ headers: { "content-type": ct },
3145
+ body: bytes,
3146
+ }).formData();
3043
3147
  const out = {};
3044
3148
  let fields = 0;
3045
3149
  let files = 0;
package/dist/index.d.ts CHANGED
@@ -4,6 +4,7 @@ export { findRoutesMissingResponseBodySchema } from "./app.js";
4
4
  export { _resetPackageJsonCacheForTests } from "./app.js";
5
5
  export { _resetCrashHandlersForTests } from "./app.js";
6
6
  export { _resetInsecureDefaultsLogForTests } from "./app.js";
7
+ export { _resetIndeterminateEnvWarningForTests } from "./app.js";
7
8
  export type { AppOptions, AppOpenAPIOptions, DocsRouteOptions, AsyncAPIRouteOptions, HealthRouteOptions, CspReportRouteOptions, MetricsRouteOptions, IntrospectedRoute, PluginInstalledEvent, PluginExtension, ShutdownEvent, SecurityPreset, } from "./app.js";
8
9
  export { getConnInfo, setConnInfo, assertBehindProxy, resolveClientIp, readRemoteAddress, readRemotePort, pickForwardedForByHops, } from "./conn-info.js";
9
10
  export type { BehindProxyConfig, ConnInfo } from "./conn-info.js";
package/dist/index.js CHANGED
@@ -4,6 +4,7 @@ export { findRoutesMissingResponseBodySchema } from "./app.js";
4
4
  export { _resetPackageJsonCacheForTests } from "./app.js";
5
5
  export { _resetCrashHandlersForTests } from "./app.js";
6
6
  export { _resetInsecureDefaultsLogForTests } from "./app.js";
7
+ export { _resetIndeterminateEnvWarningForTests } from "./app.js";
7
8
  export { getConnInfo, setConnInfo, assertBehindProxy, resolveClientIp, readRemoteAddress, readRemotePort, pickForwardedForByHops, } from "./conn-info.js";
8
9
  export { subdomains, PSL_SNAPSHOT_DATE, PSL_PUBLIC_SUFFIXES, MAX_SNAPSHOT_AGE_DAYS, } from "./subdomains.js";
9
10
  export { defineDependency, DEPENDENCY_MARKER } from "./dependency.js";
@@ -1,15 +1,15 @@
1
1
  {
2
2
  "bomFormat": "CycloneDX",
3
3
  "specVersion": "1.5",
4
- "serialNumber": "urn:uuid:24c97016-8019-52fa-bf46-65a4325559ec",
4
+ "serialNumber": "urn:uuid:3322791e-92a4-5b93-a136-5a15b3fe53aa",
5
5
  "version": 1,
6
6
  "metadata": {
7
- "timestamp": "2026-06-21T08:21:23.570Z",
7
+ "timestamp": "2026-06-21T13:50:39.481Z",
8
8
  "tools": [
9
9
  {
10
10
  "vendor": "DaloyJS",
11
11
  "name": "daloy-generate-sbom",
12
- "version": "1.0.0-beta.0"
12
+ "version": "1.0.0-beta.1"
13
13
  }
14
14
  ],
15
15
  "authors": [
@@ -19,11 +19,11 @@
19
19
  ],
20
20
  "component": {
21
21
  "type": "library",
22
- "bom-ref": "pkg:npm/@daloyjs/core@1.0.0-beta.0",
22
+ "bom-ref": "pkg:npm/@daloyjs/core@1.0.0-beta.1",
23
23
  "name": "@daloyjs/core",
24
- "version": "1.0.0-beta.0",
24
+ "version": "1.0.0-beta.1",
25
25
  "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.",
26
- "purl": "pkg:npm/@daloyjs/core@1.0.0-beta.0",
26
+ "purl": "pkg:npm/@daloyjs/core@1.0.0-beta.1",
27
27
  "licenses": [
28
28
  {
29
29
  "license": {
@@ -46,9 +46,9 @@
46
46
  }
47
47
  ],
48
48
  "swid": {
49
- "tagId": "swidtag--daloyjs-core-1.0.0-beta.0",
49
+ "tagId": "swidtag--daloyjs-core-1.0.0-beta.1",
50
50
  "name": "@daloyjs/core",
51
- "version": "1.0.0-beta.0",
51
+ "version": "1.0.0-beta.1",
52
52
  "tagVersion": 0,
53
53
  "patch": false
54
54
  }
@@ -57,7 +57,7 @@
57
57
  "components": [],
58
58
  "dependencies": [
59
59
  {
60
- "ref": "pkg:npm/@daloyjs/core@1.0.0-beta.0",
60
+ "ref": "pkg:npm/@daloyjs/core@1.0.0-beta.1",
61
61
  "dependsOn": []
62
62
  }
63
63
  ]
@@ -2,10 +2,10 @@
2
2
  "spdxVersion": "SPDX-2.3",
3
3
  "dataLicense": "CC0-1.0",
4
4
  "SPDXID": "SPDXRef-DOCUMENT",
5
- "name": "@daloyjs/core-1.0.0-beta.0",
6
- "documentNamespace": "https://github.com/daloyjs/daloy/sbom/@daloyjs/core-1.0.0-beta.0-24c97016-8019-52fa-bf46-65a4325559ec",
5
+ "name": "@daloyjs/core-1.0.0-beta.1",
6
+ "documentNamespace": "https://github.com/daloyjs/daloy/sbom/@daloyjs/core-1.0.0-beta.1-3322791e-92a4-5b93-a136-5a15b3fe53aa",
7
7
  "creationInfo": {
8
- "created": "2026-06-21T08:21:23.570Z",
8
+ "created": "2026-06-21T13:50:39.481Z",
9
9
  "creators": [
10
10
  "Tool: daloy-generate-sbom",
11
11
  "Organization: DaloyJS"
@@ -16,7 +16,7 @@
16
16
  {
17
17
  "SPDXID": "SPDXRef-Package--daloyjs-core",
18
18
  "name": "@daloyjs/core",
19
- "versionInfo": "1.0.0-beta.0",
19
+ "versionInfo": "1.0.0-beta.1",
20
20
  "downloadLocation": "https://github.com/daloyjs/daloy",
21
21
  "filesAnalyzed": false,
22
22
  "licenseConcluded": "MIT",
@@ -27,7 +27,7 @@
27
27
  {
28
28
  "referenceCategory": "PACKAGE-MANAGER",
29
29
  "referenceType": "purl",
30
- "referenceLocator": "pkg:npm/@daloyjs/core@1.0.0-beta.0"
30
+ "referenceLocator": "pkg:npm/@daloyjs/core@1.0.0-beta.1"
31
31
  }
32
32
  ]
33
33
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@daloyjs/core",
3
- "version": "1.0.0-beta.0",
3
+ "version": "1.0.0-beta.1",
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": {