@blamejs/core 0.7.88 → 0.7.90

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
@@ -8,6 +8,10 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.7.x
10
10
 
11
+ - **0.7.90** (2026-05-06) — `b.outbox` — transactional outbox primitive for at-least-once event publication without distributed transactions. **`b.outbox.create({ externalDb, table, publisher, ... })`** returns an outbox instance with three core operations: `enqueue(event, txn)` writes the outbox row inside the operator's transaction (using the `txClient` returned by `b.externalDb.transaction`), `start()` spins a polling publisher worker that claims rows via `SELECT ... FOR UPDATE SKIP LOCKED` (Postgres) and dispatches to the operator-supplied async `publisher(event)` callback, `stop()` gracefully shuts the worker down. Failed publishes retry with exponential backoff (`retryBackoff: { initialMs, maxMs, factor }`); rows that exceed `maxAttempts` are marked `'dead'` for operator triage and an `system.outbox.deadletter` audit event fires. Schema is operator-managed: `outbox.declareSchema(externalDb)` runs an idempotent `CREATE TABLE IF NOT EXISTS ... (id, topic, payload, key, headers, enqueued_at, next_attempt_at, published_at, attempts, last_error, status)` + a partial index on `(next_attempt_at) WHERE status = 'pending'`. `pendingCount()` / `deadCount()` expose the queue depth + DLQ depth for operator dashboards. Observability events on every state transition (`outbox.enqueued` / `outbox.published` / `outbox.publish-failed` / `outbox.dead-letter`).
12
+
13
+ - **0.7.89** (2026-05-06) — Three additive primitives bundled: TUS resumable uploads, WebAuthn Signal API, DPoP server-issued nonce challenge. **`b.middleware.tusUpload({ mountPath, store, ... })`** implements the [tus.io](https://tus.io) v1.0.0 resumable-upload protocol — POST creates uploads, HEAD reports offsets, PATCH appends chunks, DELETE terminates. Supported extensions: `creation`, `creation-with-upload`, `expiration`, `checksum`, `termination`. The `checksum` extension defaults to PQC-first algorithms (`sha3-512`, `shake256`) — operators add classical algorithms explicitly via `checksumAlgorithms`. A built-in `b.middleware.tusUpload.memoryStore({ maxSize })` ships for development; production operators implement the `{ create, head, append, setLength, terminate, purgeExpired, getBuffer }` shape against their object-store backend. Bounded chunk collection routes through `safeBuffer.boundedChunkCollector` (cap-enforced at push time, no 10-GiB pre-collect). Concatenation extension (parallel-chunk assembly) deferred — operators that need it compose against their store layer; re-open if a store-layer-only solution proves insufficient. **`b.auth.passkey.signalUnknownCredential` / `signalAllAcceptedCredentials` / `signalCurrentUserDetails`** add the W3C WebAuthn Signal API descriptor builders — when the browser implements `PublicKeyCredential.signal*`, operators emit the matching JSON descriptor to clean up stale passkeys, refresh user details, and surface revocations without forcing a re-registration. All three validate `rpId` / `userId` / `credentialId` shape (base64url) and refuse `name`/`displayName` longer than 256 chars. **`b.middleware.dpop({ requireNonce: true, nonceRotateSec? })`** implements RFC 9449 §8 server-issued DPoP-Nonce challenge — the middleware emits `DPoP-Nonce: <fresh>` on every 401 response, refuses proofs whose `nonce` claim isn't in the rolling current+previous pair, and refreshes the nonce on every successful response. The rolling-pair manager rotates without timers (lazy maybe-rotate on access); no operator nonce store needed. The `getNonce` callback path stays intact for operator-managed nonce flows.
14
+
11
15
  - **0.7.88** (2026-05-06) — `b.middleware.webAppManifest` + `b.middleware.assetlinks` — two static-content middlewares for PWA + Trusted Web Activity support. **`b.middleware.webAppManifest({ name, start_url, icons, ... })`** serves the W3C Web App Manifest at `/manifest.webmanifest` (and `/manifest.json` when `alsoAtJsonPath: true`). The framework JSON-serializes once at create() and serves with `Content-Type: application/manifest+json` per the W3C spec + `Cache-Control: public, max-age=86400` + `X-Content-Type-Options: nosniff`. The W3C-spec attribute set is allowlisted (name / short_name / description / start_url / scope / display / display_override / orientation / theme_color / background_color / icons / screenshots / shortcuts / categories / lang / dir / id / prefer_related_applications / related_applications) — typos throw at create. `name`, `start_url`, and at least one icon are required (W3C — installability minimum). HEAD + GET only. **`b.middleware.assetlinks({ statements })`** serves Digital Asset Links at `/.well-known/assetlinks.json` per Google's spec — used by Trusted Web Activity, Android App Links, Smart Lock for Passwords, WebAuthn for Android. Validates each statement carries `relation` (non-empty array) and `target` (object). Same Content-Type / Cache-Control / X-Content-Type-Options posture as the security.txt + manifest emitters.
12
16
 
13
17
  - **0.7.87** (2026-05-06) — Two route-guard middlewares for API hardening: `b.middleware.requireMethods` + `b.middleware.requireContentType`. **`b.middleware.requireMethods(["GET", "POST"])`** refuses any HTTP method outside the allowlist with `405 Method Not Allowed` + `Allow:` header listing the allowed methods (per RFC 9110 §15.5.6). Defends against unexpected verb routing — many CVE-class bugs trace to a route handler wired for GET that accidentally accepts arbitrary verbs (PROPFIND, OPTIONS, custom). **`b.middleware.requireContentType(["application/json"])`** refuses requests with a body (POST/PUT/PATCH by default) whose `Content-Type` isn't in the allowlist with `415 Unsupported Media Type` + `Accept:` header listing the allowed types (RFC 9110 §15.5.16). Defends against MIME-type confusion — a route that processes JSON shouldn't accept `application/x-www-form-urlencoded` even if the body parses. Both middlewares emit observability events (`middleware.requireMethods.denied` / `middleware.requireContentType.denied`) on every refusal for triage. Operators wanting to enforce content-type on idempotent verbs that DO carry bodies (rare DELETE-with-body shapes) override the default body-method list via `requireContentType(types, { methods })`.
package/index.js CHANGED
@@ -213,6 +213,7 @@ var dualControl = require("./lib/dual-control");
213
213
  var retention = require("./lib/retention");
214
214
  var network = require("./lib/network");
215
215
  var cloudEvents = require("./lib/cloud-events");
216
+ var outbox = require("./lib/outbox");
216
217
 
217
218
  module.exports = {
218
219
  crypto: crypto,
@@ -360,6 +361,7 @@ module.exports = {
360
361
  retention: retention,
361
362
  network: network,
362
363
  cloudEvents: cloudEvents,
364
+ outbox: outbox,
363
365
  ntpCheck: ntpCheck,
364
366
  version: constants.version,
365
367
  };
@@ -57,9 +57,15 @@
57
57
  * sessions, audit, or DB. Routes integrate that themselves; the
58
58
  * primitive stays the smallest correct surface.
59
59
  */
60
+ var safeBuffer = require("../safe-buffer");
60
61
  var _wa = require("../vendor/simplewebauthn-server.cjs");
61
62
  var { AuthError } = require("../framework-error");
62
63
 
64
+ // W3C WebAuthn name field cap — same as the rpName/userName ceiling in
65
+ // the spec's CredentialUserEntity / PublicKeyCredentialEntity dictionaries
66
+ // (no normative limit but RPs broadly cap at 256 to defeat DOM cost).
67
+ var MAX_NAME_LEN = 256; // allow:raw-byte-literal — UTF-16 codepoint count, not bytes
68
+
63
69
  function _vendor() {
64
70
  return _wa;
65
71
  }
@@ -173,9 +179,93 @@ async function verifyAuthentication(opts) {
173
179
  });
174
180
  }
175
181
 
182
+ // ---- WebAuthn Signal API (W3C draft, 2024) ----
183
+ //
184
+ // The signal* methods build the JSON descriptor that the operator
185
+ // returns to the client; the browser then calls the matching
186
+ // `PublicKeyCredential.signal*` method to clean up stale passkeys
187
+ // and refresh user details. These are pure builders — no I/O — so
188
+ // validation throws at the boundary and the descriptor shape is the
189
+ // W3C draft schema verbatim.
190
+
191
+ function _b64urlValid(s) {
192
+ return typeof s === "string" && s.length > 0 && safeBuffer.BASE64URL_RE.test(s);
193
+ }
194
+
195
+ function signalUnknownCredential(opts) {
196
+ if (!opts) throw new AuthError("auth-passkey/missing-opts", "opts is required");
197
+ _requireString(opts.rpId, "rpId");
198
+ _requireString(opts.credentialId, "credentialId");
199
+ if (!_b64urlValid(opts.credentialId)) {
200
+ throw new AuthError("auth-passkey/bad-credential-id",
201
+ "credentialId must be base64url (no padding)");
202
+ }
203
+ return {
204
+ rpId: opts.rpId,
205
+ credentialId: opts.credentialId,
206
+ };
207
+ }
208
+
209
+ function signalAllAcceptedCredentials(opts) {
210
+ if (!opts) throw new AuthError("auth-passkey/missing-opts", "opts is required");
211
+ _requireString(opts.rpId, "rpId");
212
+ _requireString(opts.userId, "userId");
213
+ if (!_b64urlValid(opts.userId)) {
214
+ throw new AuthError("auth-passkey/bad-user-id",
215
+ "userId must be base64url (no padding)");
216
+ }
217
+ if (!Array.isArray(opts.allAcceptedCredentialIds)) {
218
+ throw new AuthError("auth-passkey/bad-accepted-list",
219
+ "allAcceptedCredentialIds must be an array");
220
+ }
221
+ for (var i = 0; i < opts.allAcceptedCredentialIds.length; i++) {
222
+ if (!_b64urlValid(opts.allAcceptedCredentialIds[i])) {
223
+ throw new AuthError("auth-passkey/bad-accepted-list",
224
+ "allAcceptedCredentialIds[" + i + "] must be base64url");
225
+ }
226
+ }
227
+ return {
228
+ rpId: opts.rpId,
229
+ userId: opts.userId,
230
+ allAcceptedCredentialIds: opts.allAcceptedCredentialIds.slice(),
231
+ };
232
+ }
233
+
234
+ function signalCurrentUserDetails(opts) {
235
+ if (!opts) throw new AuthError("auth-passkey/missing-opts", "opts is required");
236
+ _requireString(opts.rpId, "rpId");
237
+ _requireString(opts.userId, "userId");
238
+ if (!_b64urlValid(opts.userId)) {
239
+ throw new AuthError("auth-passkey/bad-user-id",
240
+ "userId must be base64url (no padding)");
241
+ }
242
+ _requireString(opts.name, "name");
243
+ _requireString(opts.displayName, "displayName");
244
+ // RP-relevant length cap — the descriptor is a hint to the browser,
245
+ // not a stored value, but absurdly long names indicate a misuse and
246
+ // we refuse rather than truncate silently.
247
+ if (opts.name.length > MAX_NAME_LEN) {
248
+ throw new AuthError("auth-passkey/name-too-long",
249
+ "name must be <= " + MAX_NAME_LEN + " characters");
250
+ }
251
+ if (opts.displayName.length > MAX_NAME_LEN) {
252
+ throw new AuthError("auth-passkey/displayname-too-long",
253
+ "displayName must be <= " + MAX_NAME_LEN + " characters");
254
+ }
255
+ return {
256
+ rpId: opts.rpId,
257
+ userId: opts.userId,
258
+ name: opts.name,
259
+ displayName: opts.displayName,
260
+ };
261
+ }
262
+
176
263
  module.exports = {
177
- startRegistration: startRegistration,
178
- verifyRegistration: verifyRegistration,
179
- startAuthentication: startAuthentication,
180
- verifyAuthentication: verifyAuthentication,
264
+ startRegistration: startRegistration,
265
+ verifyRegistration: verifyRegistration,
266
+ startAuthentication: startAuthentication,
267
+ verifyAuthentication: verifyAuthentication,
268
+ signalUnknownCredential: signalUnknownCredential,
269
+ signalAllAcceptedCredentials: signalAllAcceptedCredentials,
270
+ signalCurrentUserDetails: signalCurrentUserDetails,
181
271
  };
@@ -35,6 +35,8 @@
35
35
  * - audit.bearer.failure event when audit: true (default)
36
36
  */
37
37
 
38
+ var C = require("../constants");
39
+ var bCrypto = require("../crypto");
38
40
  var lazyRequire = require("../lazy-require");
39
41
  var requestHelpers = require("../request-helpers");
40
42
  var validateOpts = require("../validate-opts");
@@ -43,20 +45,63 @@ var { AuthError } = require("../framework-error");
43
45
  var dpop = lazyRequire(function () { return require("../auth/dpop"); });
44
46
  var audit = lazyRequire(function () { return require("../audit"); });
45
47
 
46
- function _writeUnauthorized(res, errorCode, description) {
48
+ // RFC 9449 §8 — server-issued nonce length (24 random bytes ≈ 192 bits
49
+ // of entropy after base64url, far above the spec's "unpredictable" bar).
50
+ var DPOP_NONCE_BYTES = C.BYTES.bytes(24);
51
+
52
+ function _writeUnauthorized(res, errorCode, description, freshNonce) {
47
53
  if (res.headersSent) return;
48
54
  var body = JSON.stringify({ error: errorCode, error_description: description });
49
55
  // RFC 9449 §7 — error code is invalid_dpop_proof OR use_dpop_nonce.
50
56
  var challenge = 'DPoP error="' + errorCode + '", error_description="' +
51
57
  description.replace(/"/g, "'") + '"';
52
- res.writeHead(401, { // allow:raw-byte-literal — HTTP 401 status
58
+ var headers = { // allow:raw-byte-literal — HTTP 401 status
53
59
  "Content-Type": "application/json; charset=utf-8",
54
60
  "Content-Length": Buffer.byteLength(body),
55
61
  "WWW-Authenticate": challenge,
56
- });
62
+ };
63
+ if (freshNonce) headers["DPoP-Nonce"] = freshNonce;
64
+ res.writeHead(401, headers);
57
65
  res.end(body);
58
66
  }
59
67
 
68
+ // RFC 9449 §8 — server-issued DPoP-Nonce challenge. The framework
69
+ // holds a rolling pair (current, previous) and rotates after
70
+ // rotateSec elapses. Both the current and previous values are
71
+ // accepted from clients; previous is needed to cover the brief
72
+ // race window after rotation when in-flight requests still carry
73
+ // the prior nonce. Rotation happens lazily on access; no timer.
74
+ function _nonceManager(rotateSec) {
75
+ var rotateMs = C.TIME.seconds(rotateSec);
76
+ var current = null;
77
+ var previous = null;
78
+ function _fresh() {
79
+ return {
80
+ nonce: bCrypto.generateBytes(DPOP_NONCE_BYTES).toString("base64url"),
81
+ issuedAt: Date.now(),
82
+ };
83
+ }
84
+ function _maybeRotate() {
85
+ var now = Date.now();
86
+ if (current === null) {
87
+ current = _fresh();
88
+ return;
89
+ }
90
+ if (now - current.issuedAt >= rotateMs) {
91
+ previous = current;
92
+ current = _fresh();
93
+ }
94
+ }
95
+ return {
96
+ issue: function () { _maybeRotate(); return current.nonce; },
97
+ accepts: function (n) {
98
+ _maybeRotate();
99
+ if (typeof n !== "string" || n.length === 0) return false;
100
+ return (current && n === current.nonce) || (previous && n === previous.nonce);
101
+ },
102
+ };
103
+ }
104
+
60
105
  function _reconstructHtu(req) {
61
106
  // The proof's htu is the request URI WITHOUT query/fragment. Behind
62
107
  // a reverse proxy the operator may need to override via opts.htu /
@@ -77,12 +122,38 @@ function create(opts) {
77
122
  validateOpts(opts, [
78
123
  "replayStore", "algorithms", "iatWindowSec",
79
124
  "getAccessToken", "getNonce", "getHtu", "audit",
125
+ "nonceStore", "nonceWindowSec", "nonceRotateSec", "requireNonce",
80
126
  ], "middleware.dpop");
81
127
 
82
128
  var auditOn = opts.audit !== false;
83
129
  var algorithms = opts.algorithms;
84
130
  var iatWindowSec = opts.iatWindowSec;
85
131
  var replayStore = opts.replayStore;
132
+ var requireNonce = opts.requireNonce === true;
133
+
134
+ // Server-issued DPoP-Nonce challenge flow (RFC 9449 §8). When
135
+ // requireNonce is true, the middleware refuses any proof that does
136
+ // not carry a recognised nonce, and emits a fresh DPoP-Nonce
137
+ // header on every 401 + as a refresh on every successful response.
138
+ // The rolling-pair manager rotates without timers; no operator
139
+ // store is needed.
140
+ var nonceMgr = null;
141
+ if (requireNonce) {
142
+ validateOpts.optionalPositiveFinite(opts.nonceRotateSec,
143
+ "middleware.dpop: nonceRotateSec", AuthError, "auth-dpop/bad-opt");
144
+ var rotateSec = opts.nonceRotateSec || (C.TIME.minutes(5) / C.TIME.seconds(1));
145
+ nonceMgr = _nonceManager(rotateSec);
146
+ }
147
+ // Reject the obsolete nonceStore opt with a clear migration message —
148
+ // pre-v0.7.89 docs may surface it; the rolling-pair shape supersedes.
149
+ if (opts.nonceStore !== undefined) {
150
+ throw new AuthError("auth-dpop/bad-opt",
151
+ "middleware.dpop: opts.nonceStore is not supported — use { requireNonce: true, nonceRotateSec? }; the rolling-pair manager is internal");
152
+ }
153
+ if (opts.nonceWindowSec !== undefined) {
154
+ throw new AuthError("auth-dpop/bad-opt",
155
+ "middleware.dpop: opts.nonceWindowSec is not supported — use nonceRotateSec");
156
+ }
86
157
 
87
158
  validateOpts.optionalFunction(opts.getAccessToken,
88
159
  "middleware.dpop: getAccessToken", AuthError, "auth-dpop/bad-opt");
@@ -91,10 +162,14 @@ function create(opts) {
91
162
  validateOpts.optionalFunction(opts.getHtu,
92
163
  "middleware.dpop: getHtu", AuthError, "auth-dpop/bad-opt");
93
164
 
165
+ function _freshNonce() { return nonceMgr ? nonceMgr.issue() : null; }
166
+
94
167
  return async function dpopMiddleware(req, res, next) {
95
168
  var proofHeader = req.headers && req.headers.dpop;
96
169
  if (typeof proofHeader !== "string" || proofHeader.length === 0) {
97
- return _writeUnauthorized(res, "invalid_dpop_proof", "DPoP header required");
170
+ return _writeUnauthorized(res,
171
+ nonceMgr ? "use_dpop_nonce" : "invalid_dpop_proof",
172
+ "DPoP header required", _freshNonce());
98
173
  }
99
174
  // RFC 9449 §4.1 — only ONE DPoP header value per request.
100
175
  if (Array.isArray(proofHeader)) {
@@ -117,6 +192,12 @@ function create(opts) {
117
192
  if (typeof opts.getNonce === "function") {
118
193
  try { nonce = await opts.getNonce(req); }
119
194
  catch (_e) { nonce = null; }
195
+ } else if (nonceMgr) {
196
+ // For server-managed nonces, verify() runs WITHOUT a strict
197
+ // expected-nonce; we then check the payload's nonce against
198
+ // our rolling-pair below. This lets us issue + rotate without
199
+ // requiring a request-by-request operator callback.
200
+ nonce = null;
120
201
  }
121
202
 
122
203
  var verifyOpts = { htm: htm, htu: htu };
@@ -150,7 +231,35 @@ function create(opts) {
150
231
  errorCode = "use_dpop_nonce";
151
232
  }
152
233
  return _writeUnauthorized(res, errorCode,
153
- (e && e.message) || "DPoP proof verification failed");
234
+ (e && e.message) || "DPoP proof verification failed",
235
+ _freshNonce());
236
+ }
237
+
238
+ // Server-managed nonce check — payload MUST carry a recognized
239
+ // rolling-pair nonce. Missing or stale → 401 + DPoP-Nonce.
240
+ if (nonceMgr) {
241
+ var presented = result.payload && result.payload.nonce;
242
+ if (typeof presented !== "string" || !nonceMgr.accepts(presented)) {
243
+ if (auditOn) {
244
+ try {
245
+ audit().safeEmit({
246
+ action: "auth.bearer.failure",
247
+ actor: { clientIp: requestHelpers.clientIp(req) },
248
+ outcome: "fail",
249
+ metadata: { method: "dpop", reason: "stale-nonce", route: req.url },
250
+ });
251
+ } catch (_ignored) { /* drop-silent */ }
252
+ }
253
+ return _writeUnauthorized(res, "use_dpop_nonce",
254
+ "DPoP-Nonce required (server-managed challenge)", _freshNonce());
255
+ }
256
+ }
257
+
258
+ // Refresh the nonce on every successful response so the client
259
+ // always carries the latest one (RFC 9449 §8.1 recommendation).
260
+ if (nonceMgr && !res.headersSent) {
261
+ try { res.setHeader("DPoP-Nonce", _freshNonce()); }
262
+ catch (_e) { /* drop-silent — header set best-effort */ }
154
263
  }
155
264
 
156
265
  req.dpop = result;
@@ -46,6 +46,7 @@ var requireMethods = require("./require-methods");
46
46
  var securityHeaders = require("./security-headers");
47
47
  var securityTxt = require("./security-txt");
48
48
  var sse = require("./sse");
49
+ var tusUpload = require("./tus-upload");
49
50
  var webAppManifest = require("./web-app-manifest");
50
51
 
51
52
  module.exports = {
@@ -79,6 +80,7 @@ module.exports = {
79
80
  dpop: dpop.create,
80
81
  hostAllowlist: hostAllowlist.create,
81
82
  networkAllowlist: networkAllowlist.create,
83
+ tusUpload: tusUpload.create,
82
84
  webAppManifest: webAppManifest.create,
83
85
 
84
86
  // Module exports for advanced use (constants, raw factory access)
@@ -110,6 +112,9 @@ module.exports = {
110
112
  dpop: dpop,
111
113
  hostAllowlist: hostAllowlist,
112
114
  networkAllowlist: networkAllowlist,
115
+ tusUpload: tusUpload,
113
116
  webAppManifest: webAppManifest,
114
117
  },
115
118
  };
119
+
120
+ module.exports.tusUpload.memoryStore = tusUpload.memoryStore;