@blamejs/core 0.5.18 → 0.6.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/CHANGELOG.md CHANGED
@@ -6,8 +6,13 @@ Pre-1.0 the surface is intentionally evolving — every release may
6
6
  change something operators depend on. Read each entry before
7
7
  upgrading across more than a few patches at a time.
8
8
 
9
+ ## v0.6.x
10
+
11
+ - **0.6.0** (2026-05-01) — wiki restructured into 22 focused pages + missing-primitive coverage
12
+
9
13
  ## v0.5.x
10
14
 
15
+ - **0.5.18** (2026-05-01) — bypass-fix sweep: route through existing primitives
11
16
  - **0.5.17** (2026-05-01) — primitive-drift sweep: csv unify + 3 new shared primitives
12
17
  - **0.5.16** (2026-04-30) — b.otelExport: OTLP/HTTP exporter for b.observability
13
18
  - **0.5.15** (2026-04-30) — b.archive: ZIP creation
package/README.md CHANGED
@@ -17,7 +17,7 @@ The modern Node app is a 1,200-package supply-chain liability with no LTS calend
17
17
 
18
18
  ## Status
19
19
 
20
- Pre-1.0. The framework is usable end-to-end — envelope-versioned PQC crypto, sealed storage, HTTP router with a full middleware stack, vault, sessions, permissions, audit chain, scheduler, jobs, notify, mail, websocket, i18n, cluster mode, backup/restore, a wiki reference app under `examples/wiki/`. Operators can build production apps on it today; the surface is still subject to change before 1.0.
20
+ Pre-1.0. Usable end-to-end — operators can build production apps on it today; the surface is still subject to change before 1.0. Recent line is **v0.6.1** ([releases](https://github.com/blamejs/blamejs/releases) · [npm](https://www.npmjs.com/package/@blamejs/core) · [container](https://github.com/blamejs/blamejs/pkgs/container/blamejs-wiki)).
21
21
 
22
22
  ```js
23
23
  var b = require("@blamejs/core");
@@ -37,6 +37,34 @@ var b = require("@blamejs/core");
37
37
 
38
38
  **Requirements:** Node.js 24+ (current active LTS).
39
39
 
40
+ ## What ships in the box
41
+
42
+ The framework bundles the surface a typical Node app reaches for. Every primitive listed is callable today; nothing is a stub.
43
+
44
+ - **Data layer** — SQLite with sealed-by-default columns (`b.db`), migrations, seeders, atomic-file writes; S3 / R2 / B2 / GCS / Azure object store with multipart upload + SSE + bucket ops (`b.storage`, `b.objectStore`); durable queue with priority + cron + flows (`b.queue`, `b.jobs`); cluster-shared cache (`b.cache`).
45
+ - **Identity & access** — passwords (Argon2id), passkeys (WebAuthn), TOTP, JWT (PQ-default), OAuth, sessions, brute-force lockout (`b.auth.*`, `b.session`); RBAC (`b.permissions`); API keys with rotation (`b.apiKey`); break-glass column gates with second-factor + audit (`b.breakGlass`).
46
+ - **Crypto** — envelope-versioned PQC at rest (ML-KEM-1024 + P-384 hybrid, XChaCha20-Poly1305, SHAKE256), vault sealing, field-level crypto, signed webhooks (SLH-DSA-SHAKE-256f), ECIES API encryption (`b.crypto`, `b.vault`, `b.webhook`); pure-JS mTLS CA, PQC TLS gates inbound + outbound (`b.mtlsCa`, `b.pqcGate`, `b.pqcAgent`).
47
+ - **HTTP** — router with schema-validated routes + OpenAPI publication; full middleware stack (CSRF, CORS, rate-limit, security headers, CSP nonce, body parser, compression, SSE, request log) wired by `createApp`; HTTP/1.1 + HTTP/2 outbound client with SSRF gate, redirects, multipart, interceptors, progress, encrypted cookie jar (`b.httpClient`, `b.ssrfGuard`, `b.safeUrl`).
48
+ - **Defensive parsers** — `b.safeJson`, `b.safeBuffer`, `b.safeSql`, `b.safeSchema`, `b.parsers` (XML / TOML / YAML / .env), `b.config` (schema-validated env).
49
+ - **Communication** — WebSockets with channel/room fan-out across cluster replicas (`b.websocket`, `b.websocketChannels`); mail with multipart + attachments + DKIM + calendar invites + bounce intake (`b.mail`, `b.mailBounce`); generic notification dispatcher with operator-supplied transports (`b.notify`).
50
+ - **Observability** — tamper-evident audit chain with SLH-DSA-signed checkpoints, metrics, tracing (OTel pass-through when wired), PII redaction, log-stream sinks, OTLP/HTTP-JSON exporter for any OTel-compatible backend (`b.audit`, `b.metrics`, `b.tracing`, `b.redact`, `b.logStream`, `b.otelExport`).
51
+ - **i18n** — CLDR plural rules, Accept-Language negotiation, Intl formatters, RTL (`b.i18n`).
52
+ - **Format helpers** — RFC 4180 CSV with Excel formula-injection prevention (`b.csv`), RFC 9562 UUID v4 + v7 (`b.uuid`), URL-safe slugs (`b.slug`), TZ-aware datetime (`b.time`), ZIP creation (`b.archive`), HMAC-signed cursor pagination (`b.pagination`), HTML form rendering + validation + CSRF (`b.forms`).
53
+ - **Production** — cluster leader election with fenced leases over Postgres/SQLite (`b.cluster`); cron + interval scheduler that runs exactly-once globally (`b.scheduler`); retry with full-jitter backoff + circuit breaker (`b.retry`); graceful shutdown (`b.appShutdown`); NTP boot check (`b.ntpCheck`); end-to-end-encrypted backup bundles (`b.backup`).
54
+
55
+ ## Documentation
56
+
57
+ Full primitive-by-primitive docs live at [blamejs.com](https://blamejs.com), which is itself the `examples/wiki/` app running in production. The wiki is organized by concern:
58
+
59
+ - **Data** — [Database](https://blamejs.com/database) · [Object Store](https://blamejs.com/object-store) · [Queue & Cache](https://blamejs.com/queue-cache)
60
+ - **Identity** — [Authentication](https://blamejs.com/auth) · [Access Control](https://blamejs.com/access-control)
61
+ - **Crypto** — [Crypto & Vault](https://blamejs.com/crypto-vault) · [Network Crypto](https://blamejs.com/network-crypto)
62
+ - **HTTP** — [Routing](https://blamejs.com/routing) · [Middleware](https://blamejs.com/middleware) · [Outbound HTTP](https://blamejs.com/outbound-http)
63
+ - **Validation** — [Safe Parsers](https://blamejs.com/safe-parsers)
64
+ - **Communication** — [WebSockets](https://blamejs.com/websockets) · [Mail](https://blamejs.com/mail) · [Notifications](https://blamejs.com/notifications)
65
+ - **Tools** — [Observability](https://blamejs.com/observability) · [Testing](https://blamejs.com/testing) · [i18n & Locale](https://blamejs.com/i18n-locale) · [Format Helpers](https://blamejs.com/format-helpers)
66
+ - **Production** — [Cluster Mode](https://blamejs.com/cluster) · [Reliability](https://blamejs.com/reliability) · [Backup & Restore](https://blamejs.com/backup-restore)
67
+
40
68
  ## CLI
41
69
 
42
70
  `blamejs` ships an operator-facing CLI for the recurring ops work. Each subcommand boots a headless app instance from `--data-dir` (no HTTP listener), runs the operation, and shuts down. Same vault + DB + audit chain the running app uses.
@@ -87,7 +115,7 @@ Because when something breaks, `blame` should know exactly where it lives. We ow
87
115
 
88
116
  ## Contributing
89
117
 
90
- Patches welcome. See [CONTRIBUTING.md](CONTRIBUTING.md) for the dev setup, house rules (zero npm runtime deps, PQC-only crypto, audit-on-every-action, no-MVP), and the PR loop. New to the codebase? Start with [ARCHITECTURE.md](ARCHITECTURE.md) for the orientation map.
118
+ Patches welcome. See [CONTRIBUTING.md](CONTRIBUTING.md) for the dev setup, house rules (zero npm runtime deps, PQC-only crypto, audit-on-every-action, ship-complete-not-incremental), and the PR loop. New to the codebase? Start with [ARCHITECTURE.md](ARCHITECTURE.md) for the orientation map.
91
119
 
92
120
  Community standards: [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md) (Contributor Covenant 2.1). Be excellent.
93
121
 
package/lib/auth/jwt.js CHANGED
@@ -258,11 +258,28 @@ async function verify(token, opts) {
258
258
  }
259
259
  var tol = typeof opts.clockToleranceSec === "number" ? opts.clockToleranceSec : 0;
260
260
  var p = decoded.payload;
261
- if (typeof p.exp === "number" && p.exp + tol < nowSec) {
261
+
262
+ // Strict registered-claim typing per RFC 7519 §4.1. exp / nbf / iat MUST
263
+ // be NumericDate values (JSON numeric, seconds since epoch). A claim
264
+ // present-but-not-a-number is a malformed token — silently skipping the
265
+ // check would let a token with `exp: "0"` or `exp: "9999999999"` (string)
266
+ // bypass expiration enforcement entirely.
267
+ function _requireNumericDate(name, value) {
268
+ if (typeof value !== "number" || !isFinite(value)) {
269
+ throw new AuthError("auth-jwt/malformed",
270
+ "claim '" + name + "' must be a finite number (RFC 7519 NumericDate), got " +
271
+ (value === null ? "null" : typeof value));
272
+ }
273
+ }
274
+ if (p.exp !== undefined) _requireNumericDate("exp", p.exp);
275
+ if (p.nbf !== undefined) _requireNumericDate("nbf", p.nbf);
276
+ if (p.iat !== undefined) _requireNumericDate("iat", p.iat);
277
+
278
+ if (p.exp !== undefined && p.exp + tol < nowSec) {
262
279
  throw new AuthError("auth-jwt/expired",
263
280
  "token expired at exp=" + p.exp + " (now=" + nowSec + ", tolerance=" + tol + "s)");
264
281
  }
265
- if (typeof p.nbf === "number" && p.nbf - tol > nowSec) {
282
+ if (p.nbf !== undefined && p.nbf - tol > nowSec) {
266
283
  throw new AuthError("auth-jwt/not-yet-valid",
267
284
  "token not yet valid: nbf=" + p.nbf + " (now=" + nowSec + ", tolerance=" + tol + "s)");
268
285
  }
@@ -13,14 +13,19 @@
13
13
  * works behind TLS-terminating proxies where the framework can't
14
14
  * infer scheme), or
15
15
  * 2. Origin === request's inferred scheme/host/port (from req.socket
16
- * and req.headers.host — correct for direct deployments), or
17
- * 3. Origin == "null" AND Sec-Fetch-Site == "same-origin" or "none".
18
- * Browsers send `Origin: null` on form-navigation POSTs from a
19
- * page whose response carries `Referrer-Policy: no-referrer` (or
20
- * similarly strict policy) — the Origin is opaqued to prevent a
21
- * cross-site leak of the page's origin. Sec-Fetch-Site is the
22
- * Fetch-metadata signal that distinguishes a same-origin nav
23
- * from a genuine cross-site post in that opaque-origin world.
16
+ * and req.headers.host — correct for direct deployments).
17
+ *
18
+ * Origin: null shortcut (opt-in via strictNullOrigin: false): browsers
19
+ * send `Origin: null` on form-navigation POSTs from a page whose response
20
+ * carries `Referrer-Policy: no-referrer` (or similarly strict policy) —
21
+ * the Origin is opaqued to prevent a cross-site leak. The Sec-Fetch-Site
22
+ * Fetch-metadata signal distinguishes a same-origin nav from a genuine
23
+ * cross-site post in that opaque-origin world. Default is to REFUSE the
24
+ * shortcut (strictNullOrigin: true) — non-browser clients can forge
25
+ * Sec-Fetch-Site freely, and operators using `refuseUnknown: true` as a
26
+ * stricter "refuse unrecognized origin" policy expect the gate to hold.
27
+ * Operators with a no-referrer page that legitimately produces
28
+ * Origin: null on same-origin POSTs flip strictNullOrigin: false.
24
29
  *
25
30
  * Options:
26
31
  * {
@@ -33,14 +38,16 @@
33
38
  * maxAgeSeconds: 600
34
39
  * refuseUnknown: true (refuse cross-origin requests from unlisted
35
40
  * origins instead of just omitting CORS headers)
41
+ * strictNullOrigin: true (default — refuse Origin: null even with
42
+ * Sec-Fetch-Site: same-origin. Set false to
43
+ * allow the no-referrer-page edge case.)
36
44
  * }
37
45
  *
38
46
  * Audit: refuseUnknown blocks emit system.cors.block with the offending Origin.
39
47
  *
40
- * Validation tier: Tier A (config-time throw) opts.siteOrigin must
41
- * parse as an http(s) URL, opts.origins entries must be strings or
42
- * RegExp. Bad config surfaces at create() not at first cross-origin
43
- * request.
48
+ * Configuration validation: opts.siteOrigin must parse as an http(s) URL,
49
+ * opts.origins entries must be strings or RegExp. Bad config surfaces at
50
+ * create() not at first cross-origin request.
44
51
  */
45
52
  var lazyRequire = require("../lazy-require");
46
53
  var audit = lazyRequire(function () { return require("../audit"); });
@@ -104,12 +111,15 @@ function _inferRequestOrigin(req, trustProxy) {
104
111
  return _canonicalOrigin(proto + "://" + host);
105
112
  }
106
113
 
107
- function _isSameOrigin(req, originHeader, configuredSiteOrigins, trustProxy) {
108
- // Origin: null + Sec-Fetch-Site: same-origin|none browser opaqued
109
- // the Origin (typically because of Referrer-Policy: no-referrer on the
110
- // page) but is also explicitly telling us the request is same-origin.
111
- // Trust the Fetch-metadata signal in that case.
114
+ function _isSameOrigin(req, originHeader, configuredSiteOrigins, trustProxy, strictNullOrigin) {
115
+ // Origin: null arrives when a browser opaques the Origin (e.g.
116
+ // Referrer-Policy: no-referrer on the page). Sec-Fetch-Site can
117
+ // distinguish the same-origin case, but non-browser clients can forge
118
+ // that header freely strictNullOrigin: true (default) refuses the
119
+ // shortcut so refuseUnknown holds against forged callers. Operators
120
+ // with a legitimate no-referrer page flip strictNullOrigin: false.
112
121
  if (originHeader === "null") {
122
+ if (strictNullOrigin) return false;
113
123
  var sfs = req && req.headers && req.headers["sec-fetch-site"];
114
124
  if (sfs === "same-origin" || sfs === "none") return true;
115
125
  return false;
@@ -137,6 +147,7 @@ function create(opts) {
137
147
  validateOpts(opts, [
138
148
  "origins", "siteOrigin", "methods", "headers", "exposeHeaders",
139
149
  "credentials", "maxAgeSeconds", "refuseUnknown", "trustProxy",
150
+ "strictNullOrigin",
140
151
  ], "middleware.cors");
141
152
  var trustProxy = opts.trustProxy === true || typeof opts.trustProxy === "number"
142
153
  ? opts.trustProxy : false;
@@ -185,6 +196,11 @@ function create(opts) {
185
196
  var credentials = !!opts.credentials;
186
197
  var maxAge = String(opts.maxAgeSeconds || 600);
187
198
  var refuseUnknown = opts.refuseUnknown !== false;
199
+ // strictNullOrigin defaults true: refuse Origin: null even with
200
+ // Sec-Fetch-Site: same-origin (non-browser callers can forge that
201
+ // header). Operators with a no-referrer page producing legitimate
202
+ // Origin: null on same-origin POSTs flip to false explicitly.
203
+ var strictNullOrigin = opts.strictNullOrigin !== false;
188
204
 
189
205
  return function cors(req, res, next) {
190
206
  var origin = req.headers && req.headers.origin;
@@ -193,7 +209,7 @@ function create(opts) {
193
209
  // Same-origin POST/PUT/etc. carry an Origin header per the Fetch
194
210
  // spec but should not be subject to CORS allow-listing — they're
195
211
  // the operator's own site talking to itself.
196
- if (_isSameOrigin(req, origin, siteOrigins, trustProxy)) return next();
212
+ if (_isSameOrigin(req, origin, siteOrigins, trustProxy, strictNullOrigin)) return next();
197
213
 
198
214
  var matched = _matchOrigin(origin, origins);
199
215
  if (!matched) {
@@ -50,7 +50,9 @@
50
50
  * Full options:
51
51
  * {
52
52
  * cookie: true | { EITHER this...
53
- * name: "csrf",
53
+ * name: auto: "__Host-csrf" over HTTPS, "csrf" over HTTP.
54
+ * Operators with a custom name override here; the framework
55
+ * validates that __Host-* names carry path="/" and Secure.
54
56
  * sameSite: "Lax" | "Strict" | "None",
55
57
  * secure: auto-detected from request scheme,
56
58
  * path: "/",
@@ -73,7 +75,15 @@ var audit = lazyRequire(function () { return require("../audit"); });
73
75
  var DEFAULT_FIELD_NAME = "_csrf";
74
76
  var DEFAULT_HEADER_NAME = "X-CSRF-Token";
75
77
  var DEFAULT_METHODS = Object.freeze(["POST", "PUT", "DELETE", "PATCH"]);
76
- var DEFAULT_COOKIE_NAME = "csrf";
78
+
79
+ // Default cookie name uses the RFC 6265bis __Host- prefix when the request
80
+ // is over HTTPS. The prefix forces browsers to refuse the cookie unless
81
+ // it carries Secure + Path=/ + no Domain attribute — closing the
82
+ // "malicious sibling subdomain sets a cookie on the parent domain to
83
+ // subvert double-submit verification" attack class. On plain HTTP (dev),
84
+ // browsers reject __Host- entirely, so we fall back to the bare name.
85
+ var DEFAULT_COOKIE_NAME_SECURE = "__Host-csrf";
86
+ var DEFAULT_COOKIE_NAME_INSECURE = "csrf";
77
87
 
78
88
  function _parseCookieHeader(header) {
79
89
  // Minimal cookie-header parser — RFC 6265 §5.2 form. Ignores attributes,
@@ -191,7 +201,9 @@ function create(opts) {
191
201
  throw new Error("middleware.csrfProtect: opts.cookie must be true or an object");
192
202
  }
193
203
  cookieCfg = {
194
- name: raw.name || DEFAULT_COOKIE_NAME,
204
+ // name: explicit operator override wins; otherwise auto-resolved
205
+ // per-request based on whether the cookie is being issued Secure.
206
+ name: raw.name || null,
195
207
  sameSite: raw.sameSite || "Lax",
196
208
  // secure: undefined means auto-detect from request scheme; explicit
197
209
  // true/false overrides.
@@ -203,6 +215,27 @@ function create(opts) {
203
215
  if (["Lax", "Strict", "None"].indexOf(cookieCfg.sameSite) === -1) {
204
216
  throw new Error("middleware.csrfProtect: opts.cookie.sameSite must be Lax|Strict|None");
205
217
  }
218
+ // __Host- prefix safety: if operator picks a __Host- name, the
219
+ // Path/Domain/Secure constraints must be compatible. Path must be "/",
220
+ // no Domain (we never set one), Secure resolved per-request. Catch
221
+ // operator-side typos (e.g. __Host-csrf with a custom path) at boot.
222
+ if (cookieCfg.name && /^__Host-/.test(cookieCfg.name)) {
223
+ if (cookieCfg.path !== "/") {
224
+ throw new Error("middleware.csrfProtect: __Host-* cookie name requires path='/'");
225
+ }
226
+ if (cookieCfg.secure === false) {
227
+ throw new Error("middleware.csrfProtect: __Host-* cookie name requires secure (cannot be explicit false)");
228
+ }
229
+ }
230
+ }
231
+
232
+ // Resolve the cookie name for a specific request — operator override
233
+ // wins; otherwise __Host-csrf when the cookie will be Secure, plain
234
+ // csrf when over HTTP (browsers reject __Host- without Secure).
235
+ function _resolveCookieName(req) {
236
+ if (cookieCfg.name) return cookieCfg.name;
237
+ var willBeSecure = cookieCfg.secure == null ? _isHttps(req) : !!cookieCfg.secure;
238
+ return willBeSecure ? DEFAULT_COOKIE_NAME_SECURE : DEFAULT_COOKIE_NAME_INSECURE;
206
239
  }
207
240
 
208
241
  function _emitDenied(req, reason) {
@@ -221,14 +254,15 @@ function create(opts) {
221
254
  // req.csrfToken so templates have something to render.
222
255
  function _issueIfNeeded(req, res) {
223
256
  if (!cookieCfg) return null;
257
+ var cookieName = _resolveCookieName(req);
224
258
  var cookies = _parseCookieHeader(req.headers && req.headers.cookie);
225
- var existing = cookies[cookieCfg.name];
259
+ var existing = cookies[cookieName];
226
260
  if (existing && /^[a-f0-9]{2,}$/.test(existing)) {
227
261
  req.csrfToken = existing;
228
262
  return existing;
229
263
  }
230
264
  var fresh = forms.generateCsrfToken();
231
- var setCookie = _formatSetCookie(cookieCfg.name, fresh, {
265
+ var setCookie = _formatSetCookie(cookieName, fresh, {
232
266
  path: cookieCfg.path,
233
267
  sameSite: cookieCfg.sameSite,
234
268
  secure: cookieCfg.secure == null ? _isHttps(req) : !!cookieCfg.secure,
@@ -64,11 +64,23 @@ function _clientIpFor(trustProxy) {
64
64
  };
65
65
  }
66
66
 
67
+ // Reject NaN / Infinity / negative / non-positive / non-number at create
68
+ // time so a misconfigured rate-limit can't silently degrade to "no
69
+ // limit" or produce divide-by-zero verdicts at request time.
70
+ function _requirePositiveNumber(name, value) {
71
+ if (typeof value !== "number" || !isFinite(value) || value <= 0) {
72
+ throw new Error("middleware.rateLimit: " + name + " must be a positive finite number, got " +
73
+ JSON.stringify(value));
74
+ }
75
+ }
76
+
67
77
  // ---- Memory backend (token bucket) ----
68
78
 
69
79
  function _memoryBackend(opts) {
70
- var burst = opts.burst || 60;
71
- var refillPerSecond = opts.refillPerSecond || 10;
80
+ var burst = opts.burst != null ? opts.burst : 60;
81
+ var refillPerSecond = opts.refillPerSecond != null ? opts.refillPerSecond : 10;
82
+ _requirePositiveNumber("burst", burst);
83
+ _requirePositiveNumber("refillPerSecond", refillPerSecond);
72
84
  var buckets = new Map();
73
85
 
74
86
  // Periodic GC of stale buckets so the map doesn't grow unbounded.
@@ -128,9 +140,13 @@ function _memoryBackend(opts) {
128
140
  // ---- Cluster backend (fixed-window counter, SQL-backed) ----
129
141
 
130
142
  function _clusterBackend(opts) {
131
- var limit = opts.limit || 60;
132
- var windowMs = opts.windowMs || C.TIME.minutes(1);
133
- var pruneIntervalMs = opts.pruneIntervalMs || C.TIME.minutes(5);
143
+ var limit = opts.limit != null ? opts.limit : 60;
144
+ var windowMs = opts.windowMs != null ? opts.windowMs : C.TIME.minutes(1);
145
+ var pruneIntervalMs = opts.pruneIntervalMs != null
146
+ ? opts.pruneIntervalMs : C.TIME.minutes(5);
147
+ _requirePositiveNumber("limit", limit);
148
+ _requirePositiveNumber("windowMs", windowMs);
149
+ _requirePositiveNumber("pruneIntervalMs", pruneIntervalMs);
134
150
  var lastPruneAt = 0;
135
151
 
136
152
  // Best-effort prune of expired window rows. Rate-limited at the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.5.18",
3
+ "version": "0.6.1",
4
4
  "description": "The Node framework that owns its stack.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "blamejs contributors",