@blamejs/core 0.14.17 → 0.14.18

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.
@@ -33,6 +33,7 @@ var defineClass = require("./framework-error").defineClass;
33
33
  var lazyRequire = require("./lazy-require");
34
34
 
35
35
  var audit = lazyRequire(function () { return require("./audit"); });
36
+ var incidentReport = lazyRequire(function () { return require("./incident-report"); });
36
37
 
37
38
  var BreachError = defineClass("BreachError", { alwaysPermanent: true });
38
39
 
@@ -259,10 +260,174 @@ function createReporter(opts) {
259
260
  };
260
261
  }
261
262
 
263
+ // Resolve a per-state breach deadline entry to its wall-clock due-by ms.
264
+ // Hard-deadline states expose `dueBy`; "without unreasonable delay"
265
+ // states expose the operator-defensible `ceilingDueBy`.
266
+ function _dueByOf(deadlineEntry) {
267
+ return deadlineEntry.kind === "hard-deadline"
268
+ ? deadlineEntry.dueBy
269
+ : deadlineEntry.ceilingDueBy;
270
+ }
271
+
272
+ /**
273
+ * @primitive b.breach.deadline.createClock
274
+ * @signature b.breach.deadline.createClock(opts?)
275
+ * @since 0.14.18
276
+ * @status stable
277
+ * @compliance gdpr, soc2
278
+ *
279
+ * Detection-to-notification running clock for US state breach
280
+ * deadlines. `forStates` / `report.create` compute the static
281
+ * per-state due-by timestamps; this clock turns them into a live
282
+ * escalation loop. It composes the regime-agnostic
283
+ * `b.incident.report.createDeadlineClock` (one underlying clock,
284
+ * one synthetic incident per affected state) so a breach with
285
+ * residents in many states escalates each state's statutory wall
286
+ * independently: an "approaching" warning fires as a state's
287
+ * deadline nears, a "passed" alert fires when it elapses, and
288
+ * `acknowledgeSubmission(breachId, state)` silences a state once its
289
+ * notice is filed. Each (breach, state) escalation fires at most once
290
+ * per phase regardless of tick cadence.
291
+ *
292
+ * The per-state deadline carries the same statutory data the registry
293
+ * encodes — hard-deadline states (e.g. TX Tex. Bus. & Com. Code
294
+ * §521.053, 60 days; CO Colo. Rev. Stat. §6-1-716, 30 days) use the
295
+ * statutory wall; "without unreasonable delay" states (e.g. CA Cal.
296
+ * Civ. Code §1798.82) use the operator-defensible ASAP ceiling. The
297
+ * statutory hour/day counts are never re-encoded here — they are read
298
+ * from STATE_DEADLINES. This mirrors the multi-regime clock in
299
+ * `b.incident.report` (GDPR Art.33 72h, NIS2 Art.23(4) 24h, DORA
300
+ * Art.19 4h, HIPAA 45 CFR 164.404/408 60 days) for the federal regimes
301
+ * that run alongside the state statutes.
302
+ *
303
+ * @opts
304
+ * audit: boolean, // emit tamper-evident clock audits — default true
305
+ * notify: object, // { send(payload) } escalation sink — best-effort, drop-silent
306
+ * approachThresholds: number[], // unitless proportions of detected-to-due — default [0.5, 0.75, 0.9]
307
+ * intervalMs: number, // auto-tick cadence — default C.TIME.minutes(1)
308
+ * autoStart: boolean, // start the auto-tick timer immediately — default true
309
+ * now: function, // injectable clock source for testing — default Date.now
310
+ *
311
+ * @example
312
+ * var reporter = b.breach.report.create({ audit: b.audit });
313
+ * var rec = reporter.open({
314
+ * detectedAt: Date.now(),
315
+ * affectedStates: ["CA", "TX"],
316
+ * impact: { individualsAffected: 5000 },
317
+ * });
318
+ * var clock = b.breach.deadline.createClock({
319
+ * notify: { send: function (p) { alertOnCall(p); } },
320
+ * autoStart: false,
321
+ * });
322
+ * clock.trackReport(rec);
323
+ * // later, on each operator-controlled evaluation:
324
+ * clock.tick();
325
+ * await reporter.fileNotice(rec.id, "CA", { method: "email" });
326
+ * clock.acknowledgeSubmission(rec.id, "CA"); // silence CA escalation
327
+ */
328
+ function createDeadlineClock(opts) {
329
+ opts = opts || {};
330
+ // Compose the regime-agnostic clock — it owns the tick loop,
331
+ // once-per-phase firing, audit/notify fan-out, and timer lifecycle.
332
+ // The breach clock only adapts per-state breach deadlines onto its
333
+ // single-stage `final` slot and tracks the (breach, state) keying.
334
+ var inner = incidentReport().createDeadlineClock({
335
+ audit: opts.audit,
336
+ notify: opts.notify,
337
+ approachThresholds: opts.approachThresholds,
338
+ intervalMs: opts.intervalMs,
339
+ autoStart: opts.autoStart,
340
+ now: opts.now,
341
+ });
342
+
343
+ // breachId -> { detectedAt, states: { STATE -> innerIncidentId } }
344
+ var tracked = new Map();
345
+
346
+ function _innerId(breachId, state) {
347
+ return breachId + "::" + state;
348
+ }
349
+
350
+ function trackReport(report) {
351
+ if (!report || typeof report !== "object" || typeof report.id !== "string" || report.id.length === 0) {
352
+ throw new BreachError("breach-clock/bad-report",
353
+ "breach.deadline.createClock.trackReport: report must be a breach.report record with a string id");
354
+ }
355
+ if (typeof report.detectedAt !== "number" || !isFinite(report.detectedAt)) {
356
+ throw new BreachError("breach-clock/bad-detected-at",
357
+ "breach.deadline.createClock.trackReport: report.detectedAt must be a finite Unix-ms timestamp");
358
+ }
359
+ if (!Array.isArray(report.deadlines) || report.deadlines.length === 0) {
360
+ throw new BreachError("breach-clock/bad-deadlines",
361
+ "breach.deadline.createClock.trackReport: report.deadlines must be the non-empty per-state array from breach.report.open");
362
+ }
363
+ var entry = tracked.get(report.id) || { detectedAt: report.detectedAt, states: {} };
364
+ for (var i = 0; i < report.deadlines.length; i += 1) {
365
+ var d = report.deadlines[i];
366
+ var state = d.state;
367
+ if (entry.states[state]) continue; // idempotent re-track of the same state
368
+ var innerId = _innerId(report.id, state);
369
+ // The statutory wall is carried on the `final` stage so the
370
+ // approach-then-pass escalation runs against the per-state
371
+ // deadline; initial/intermediate are left undefined (the inner
372
+ // tick skips stages with no due-by).
373
+ inner.track({
374
+ id: innerId,
375
+ detectedAt: report.detectedAt,
376
+ regime: d.statute || state,
377
+ dueBy: { final: _dueByOf(d) },
378
+ });
379
+ entry.states[state] = innerId;
380
+ }
381
+ tracked.set(report.id, entry);
382
+ return report.id;
383
+ }
384
+
385
+ function untrack(breachId) {
386
+ var entry = tracked.get(breachId);
387
+ if (!entry) return false;
388
+ var states = Object.keys(entry.states);
389
+ for (var i = 0; i < states.length; i += 1) {
390
+ inner.untrack(entry.states[states[i]]);
391
+ }
392
+ return tracked.delete(breachId);
393
+ }
394
+
395
+ function acknowledgeSubmission(breachId, state, info) {
396
+ var entry = tracked.get(breachId);
397
+ var stateUp = (typeof state === "string") ? state.toUpperCase() : state;
398
+ if (!entry || !entry.states[stateUp]) {
399
+ throw new BreachError("breach-clock/unknown-tracked-state",
400
+ "breach.deadline.createClock.acknowledgeSubmission: no tracked breach '" + breachId + "' for state '" + state + "'");
401
+ }
402
+ return inner.acknowledgeSubmission(entry.states[stateUp], "final", info);
403
+ }
404
+
405
+ function status() {
406
+ var innerStatus = inner.status();
407
+ return {
408
+ breaches: tracked.size,
409
+ tracked: innerStatus.tracked,
410
+ running: innerStatus.running,
411
+ intervalMs: innerStatus.intervalMs,
412
+ };
413
+ }
414
+
415
+ return {
416
+ trackReport: trackReport,
417
+ untrack: untrack,
418
+ acknowledgeSubmission: acknowledgeSubmission,
419
+ tick: inner.tick,
420
+ start: inner.start,
421
+ stop: inner.stop,
422
+ status: status,
423
+ };
424
+ }
425
+
262
426
  module.exports = {
263
- // b.breach.deadline.* — registry lookups
427
+ // b.breach.deadline.* — registry lookups + running clock
264
428
  deadline: {
265
429
  forStates: forStates,
430
+ createClock: createDeadlineClock,
266
431
  STATE_DEADLINES: STATE_DEADLINES,
267
432
  WITHOUT_UNREASONABLE_DELAY: WITHOUT_UNREASONABLE_DELAY,
268
433
  },
@@ -40,6 +40,7 @@ var rfc3339 = require("./rfc3339");
40
40
  var safeJson = require("./safe-json");
41
41
  var safeBuffer = require("./safe-buffer");
42
42
  var C = require("./constants");
43
+ var codepointClass = require("./codepoint-class");
43
44
  var { defineClass } = require("./framework-error");
44
45
 
45
46
  var CloudEventsError = defineClass("CloudEventsError", { alwaysPermanent: true });
@@ -527,7 +528,8 @@ function _pctDecode(s) {
527
528
  var bytes = [];
528
529
  var i = 0;
529
530
  while (i < s.length) {
530
- if (s[i] === "%" && /^[0-9A-Fa-f]{2}$/.test(s.slice(i + 1, i + 3))) {
531
+ // allow:regex-no-length-cap the slice is a fixed 2-char window
532
+ if (s[i] === "%" && codepointClass.HEX_PAIR_RE.test(s.slice(i + 1, i + 3))) {
531
533
  bytes.push(parseInt(s.slice(i + 1, i + 3), 16)); // 16 is the hex radix
532
534
  i += 3;
533
535
  } else {
@@ -252,10 +252,31 @@ function applyCharStripPolicies(text, opts) {
252
252
  return out;
253
253
  }
254
254
 
255
+ // REGEXP_META_RE — the full ECMAScript RegExp metacharacter set
256
+ // (. * + ? ^ $ { } ( ) | [ ] \).
257
+ var REGEXP_META_RE = /[.*+?^${}()|[\]\\]/g;
258
+
259
+ // escapeRegExp — escape every RegExp metacharacter in a string so an
260
+ // operator- or input-supplied token matches literally when spliced into a
261
+ // `new RegExp(...)`. Three lib sites previously rolled the identical body;
262
+ // centralized here so a token destined for dynamic compilation cannot
263
+ // inject a pattern.
264
+ function escapeRegExp(s) {
265
+ return String(s).replace(REGEXP_META_RE, "\\$&");
266
+ }
267
+
268
+ // HEX_PAIR_RE — a percent-escape's two-hex-digit value (RFC 3986 §2.1
269
+ // pct-encoded). Percent-decoders test the two characters after a `%`
270
+ // against this before parseInt with the hex radix; shared so the literal
271
+ // lives once.
272
+ var HEX_PAIR_RE = /^[0-9A-Fa-f]{2}$/;
273
+
255
274
  module.exports = {
256
275
  hex4: hex4,
257
276
  charClass: charClass,
258
277
  fromCp: fromCp,
278
+ escapeRegExp: escapeRegExp,
279
+ HEX_PAIR_RE: HEX_PAIR_RE,
259
280
  BIDI_RANGES: BIDI_RANGES,
260
281
  C0_CTRL_RANGES: C0_CTRL_RANGES,
261
282
  ZERO_WIDTH_RANGES: ZERO_WIDTH_RANGES,
package/lib/db-schema.js CHANGED
@@ -39,6 +39,7 @@
39
39
  var nodePath = require("node:path");
40
40
  var atomicFile = require("./atomic-file");
41
41
  var safeSql = require("./safe-sql");
42
+ var observability = require("./observability");
42
43
 
43
44
  // SQLite raw-SQL helper. node:sqlite DatabaseSync exposes a method on the
44
45
  // database object that runs raw SQL without bind parameters — used for DDL,
@@ -109,22 +110,61 @@ function ensureMigrationsTable(database) {
109
110
 
110
111
  // ---- Declarative reconcile ----
111
112
 
112
- function reconcile(database, schema) {
113
+ // reconcile — declarative schema reconcile: CREATE TABLE IF NOT EXISTS +
114
+ // additive ALTER TABLE ADD COLUMN + CREATE INDEX IF NOT EXISTS for every
115
+ // table in `schema`. Never drops columns or tables (data-loss safety).
116
+ //
117
+ // `opts.onDrift` adds opt-in detection of config-vs-live divergence — a
118
+ // compliance-evidence concern: the live DB should match the declared data
119
+ // model so an auditor can trust the schema config as ground truth (the
120
+ // change-/configuration-management control families in ISO 27001:2022
121
+ // A.8.9 and SOC 2 CC8.1 turn on "the running system equals the approved
122
+ // definition"). Detection covers the two cases reconcile's additive path
123
+ // cannot fix on its own:
124
+ //
125
+ // - undeclared (extra) columns present in the live table but absent
126
+ // from the declared schema — an out-of-band ALTER / hand-edit;
127
+ // - declared columns still missing from the live table after the
128
+ // ADD COLUMN pass (e.g. a column whose DDL the engine rejected).
129
+ //
130
+ // Dropped columns are never acted on — reconcile is non-destructive by
131
+ // contract; this is detection + an operator-chosen reaction only.
132
+ //
133
+ // onDrift values (config-time enum; bad value throws):
134
+ // "ignore" (default) — pre-detection behavior, byte-for-byte; no
135
+ // detection side effects. Existing deployments
136
+ // with drift are not broken.
137
+ // "warn" — detect + emit a "db.schema.drift" observability event per
138
+ // drifted table; never throws.
139
+ // "refuse" — detect + THROW on the first drifted table, so a strict-
140
+ // schema posture refuses to boot under divergence. The
141
+ // operator's explicit posture choice.
142
+ //
143
+ // Returns a { tables: [...], drifted: boolean } report.
144
+ function reconcile(database, schema, opts) {
113
145
  if (!Array.isArray(schema)) {
114
146
  throw new Error("db.init({ schema }) must be an array of table definitions");
115
147
  }
148
+ var driftMode = resolveDriftMode(opts);
149
+ var report = { tables: [], drifted: false };
116
150
  for (var i = 0; i < schema.length; i++) {
117
- reconcileTable(database, schema[i]);
151
+ var tableReport = reconcileTable(database, schema[i], { onDrift: driftMode });
152
+ if (tableReport.drift) {
153
+ report.tables.push(tableReport.drift);
154
+ report.drifted = true;
155
+ }
118
156
  }
157
+ return report;
119
158
  }
120
159
 
121
- function reconcileTable(database, table) {
160
+ function reconcileTable(database, table, opts) {
122
161
  if (!table || !table.name) {
123
162
  throw new Error("schema entry missing required 'name' property");
124
163
  }
125
164
  if (!table.columns || typeof table.columns !== "object") {
126
165
  throw new Error("schema entry '" + table.name + "' missing 'columns' object");
127
166
  }
167
+ var driftMode = resolveDriftMode(opts);
128
168
 
129
169
  var name = table.name;
130
170
  validateIdent(name, "table name");
@@ -203,6 +243,62 @@ function reconcileTable(database, table) {
203
243
  reconcileIndex(database, name, table.indexes[k]);
204
244
  }
205
245
  }
246
+
247
+ // Schema-drift detection (opt-in; default "ignore" => no-op). Compares
248
+ // the live table's columns against the declared model AFTER the additive
249
+ // ADD COLUMN pass so the diff reflects what reconcile could not fix:
250
+ // - extra = live-but-undeclared (out-of-band ALTER / hand-edit);
251
+ // - missing = declared-but-still-absent (ADD COLUMN could not apply).
252
+ // Dropped columns are never acted on — reconcile stays non-destructive.
253
+ if (driftMode !== "ignore") {
254
+ var drift = _detectColumnDrift(database, name, table.columns);
255
+ if (drift) {
256
+ if (driftMode === "refuse") {
257
+ throw new Error(_driftMessage(name, drift));
258
+ }
259
+ // "warn": drop-silent observability sink (hot-path-safe), then
260
+ // report back to the caller for operator-visible logging.
261
+ observability.safeEvent("db.schema.drift", 1, {
262
+ table: name,
263
+ extraCount: String(drift.extra.length),
264
+ missingCount: String(drift.missing.length),
265
+ });
266
+ return { drift: drift };
267
+ }
268
+ }
269
+ return { drift: null };
270
+ }
271
+
272
+ // _detectColumnDrift — diff the live table's columns against the declared
273
+ // column set. Returns null when they agree, else { table, extra, missing }
274
+ // with sorted column-name arrays. Pure read (PRAGMA table_info); never
275
+ // issues DDL.
276
+ function _detectColumnDrift(database, tableName, declaredColumns) {
277
+ var liveCols = listColumns(database, tableName);
278
+ var declaredSet = new Set();
279
+ for (var col in declaredColumns) {
280
+ if (Object.prototype.hasOwnProperty.call(declaredColumns, col)) declaredSet.add(col);
281
+ }
282
+ var extra = [];
283
+ liveCols.forEach(function (c) { if (!declaredSet.has(c)) extra.push(c); });
284
+ var missing = [];
285
+ declaredSet.forEach(function (c) { if (!liveCols.has(c)) missing.push(c); });
286
+ if (extra.length === 0 && missing.length === 0) return null;
287
+ extra.sort();
288
+ missing.sort();
289
+ return { table: tableName, extra: extra, missing: missing };
290
+ }
291
+
292
+ function _driftMessage(tableName, drift) {
293
+ var parts = [];
294
+ if (drift.extra.length) {
295
+ parts.push("undeclared column(s) [" + drift.extra.join(", ") + "]");
296
+ }
297
+ if (drift.missing.length) {
298
+ parts.push("missing declared column(s) [" + drift.missing.join(", ") + "]");
299
+ }
300
+ return "schema drift on table '" + tableName + "': " + parts.join("; ") +
301
+ " (onDrift: 'refuse')";
206
302
  }
207
303
 
208
304
  function _validateAction(action, label, tableName) {
@@ -214,6 +310,26 @@ function _validateAction(action, label, tableName) {
214
310
  return up;
215
311
  }
216
312
 
313
+ // onDrift reaction modes. "ignore" preserves pre-drift-detection
314
+ // behavior byte-for-byte; "warn" emits an observability signal and
315
+ // reports; "refuse" throws so a strict-schema posture refuses to boot
316
+ // when the live DB has diverged from the declared model.
317
+ var DRIFT_MODES = ["ignore", "warn", "refuse"];
318
+
319
+ // resolveDriftMode — config-time enum validation. Undefined => "ignore"
320
+ // (default; existing deployments see zero behavior change). A bad value
321
+ // is an operator typo at config time → THROW (entry-point tier).
322
+ function resolveDriftMode(opts) {
323
+ if (!opts || opts.onDrift === undefined || opts.onDrift === null) return "ignore";
324
+ var mode = opts.onDrift;
325
+ if (typeof mode !== "string" || DRIFT_MODES.indexOf(mode) === -1) {
326
+ throw new TypeError(
327
+ "db reconcile: onDrift must be one of " + DRIFT_MODES.join(", ") +
328
+ " (got: " + (typeof mode === "string" ? "'" + mode + "'" : typeof mode) + ")");
329
+ }
330
+ return mode;
331
+ }
332
+
217
333
  function reconcileIndex(database, tableName, idx) {
218
334
  var cols, indexName, unique;
219
335
  if (typeof idx === "string") {
@@ -319,4 +435,5 @@ module.exports = {
319
435
  runSqlOnHandle: runSqlOnHandle,
320
436
  runInTransaction: runInTransaction,
321
437
  MIGRATIONS_TABLE: MIGRATIONS_TABLE,
438
+ DRIFT_MODES: DRIFT_MODES,
322
439
  };
package/lib/db.js CHANGED
@@ -1340,8 +1340,10 @@ async function init(opts) {
1340
1340
  };
1341
1341
  }
1342
1342
 
1343
- // Declarative schema reconcile (framework + app tables)
1344
- dbSchema.reconcile(database, fullSchema);
1343
+ // Declarative schema reconcile (framework + app tables). opts.onDrift
1344
+ // ("ignore" default / "warn" / "refuse") opts into config-vs-live
1345
+ // column-drift detection; unset = unchanged additive reconcile.
1346
+ dbSchema.reconcile(database, fullSchema, { onDrift: opts.onDrift });
1345
1347
 
1346
1348
  // Append-only enforcement on audit_log + consent_log via SQLite triggers.
1347
1349
  // Apps cannot UPDATE or DELETE these tables; the framework's audit.record /
@@ -1763,7 +1765,12 @@ var _SLOW_QUERY_BUCKETS_LOCAL = Object.freeze([
1763
1765
  { ms: C.TIME.seconds(5), label: "5s" },
1764
1766
  { ms: C.TIME.seconds(1), label: "1s" },
1765
1767
  ]);
1766
- var _STATEMENT_CLASS_RE_LOCAL = /^\s*(?:\/\*[\s\S]*?\*\/\s*|--[^\n]*\n\s*)*([A-Za-z]+)/;
1768
+ // Linear (non-backtracking) comment/whitespace skip — each outer
1769
+ // iteration consumes one whitespace char, one complete block comment
1770
+ // (star-not-slash form, never a lazy `[\s\S]*?`), or one complete line
1771
+ // comment, disjoint by first char, so a crafted SQL string of nested
1772
+ // `/**/` or `*/--` runs cannot backtrack polynomially (CWE-1333 ReDoS).
1773
+ var _STATEMENT_CLASS_RE_LOCAL = /^(?:\s|\/\*(?:[^*]|\*(?!\/))*\*\/|--[^\n]*\n)*([A-Za-z]+)/;
1767
1774
  function _classifyStatementLocal(sql) {
1768
1775
  if (typeof sql !== "string" || sql.length === 0) return "UNKNOWN";
1769
1776
  var m = _STATEMENT_CLASS_RE_LOCAL.exec(sql);
package/lib/error-page.js CHANGED
@@ -20,6 +20,12 @@
20
20
  * showEnvVars: false, // default false (env can carry secrets)
21
21
  * // optional override — return true to take over the response
22
22
  * onError: function (err, req, res, ctx) { … },
23
+ * // optional shaping hooks (run AFTER classification + audit, before
24
+ * // the response is written; the default envelope is preserved when
25
+ * // they're absent):
26
+ * problemDetails: true, // emit RFC 9457 application/problem+json on the JSON path
27
+ * jsonFormatter: function (info, req) { … }, // → { contentType, body } for the JSON path
28
+ * renderHtml: function (info, req) { … }, // → string body for the HTML path
23
29
  * });
24
30
  * router.onError(handler);
25
31
  *
@@ -40,6 +46,7 @@
40
46
 
41
47
  var lazyRequire = require("./lazy-require");
42
48
  var logModule = require("./log");
49
+ var problemDetails = require("./problem-details");
43
50
  var requestHelpers = require("./request-helpers");
44
51
  var safeEnv = require("./parsers/safe-env");
45
52
  var template = require("./template");
@@ -309,6 +316,23 @@ function create(opts) {
309
316
  var showEnvVars = mode === "dev" && opts.showEnvVars === true;
310
317
  var onErrorHook = typeof opts.onError === "function" ? opts.onError : null;
311
318
 
319
+ // Response-shaping hooks. They run after classification + audit and
320
+ // replace only the body/content-type the framework would have written
321
+ // — status, headers, logging, audit, and the v0.14.17 encrypted-error
322
+ // sealing are unchanged. Config-time validation: a non-function hook
323
+ // is an operator typo at boot, so it throws rather than silently no-op.
324
+ if (opts.jsonFormatter !== undefined && opts.jsonFormatter !== null &&
325
+ typeof opts.jsonFormatter !== "function") {
326
+ throw new TypeError("errors-page: opts.jsonFormatter must be a function");
327
+ }
328
+ if (opts.renderHtml !== undefined && opts.renderHtml !== null &&
329
+ typeof opts.renderHtml !== "function") {
330
+ throw new TypeError("errors-page: opts.renderHtml must be a function");
331
+ }
332
+ var jsonFormatter = typeof opts.jsonFormatter === "function" ? opts.jsonFormatter : null;
333
+ var renderHtmlHook = typeof opts.renderHtml === "function" ? opts.renderHtml : null;
334
+ var emitProblemDetails = opts.problemDetails === true;
335
+
312
336
  var _log = logModule.makeViaOrFallback(log, bootLog);
313
337
 
314
338
  function handler(err, req, res) {
@@ -386,6 +410,48 @@ function create(opts) {
386
410
  };
387
411
 
388
412
  if (_wantsJson(req, defaultFormat)) {
413
+ // Full-override formatter: the operator owns content-type AND body
414
+ // verbatim. A throwing formatter must never mask the original
415
+ // error, so it falls through to the default envelope. Operators
416
+ // who want in-session sealing compose req.apiEncryptEncode in their
417
+ // own formatter (it's exposed on req); the framework writes the
418
+ // returned body as-is.
419
+ if (jsonFormatter) {
420
+ var shaped = null;
421
+ try { shaped = jsonFormatter(info, req); }
422
+ catch (e) {
423
+ _log("error", "errors-page jsonFormatter threw", { error: (e && e.message) || String(e) });
424
+ shaped = null;
425
+ }
426
+ if (shaped && typeof shaped === "object" && shaped.body !== undefined) {
427
+ var fmtType = typeof shaped.contentType === "string" && shaped.contentType.length > 0
428
+ ? shaped.contentType : "application/json; charset=utf-8";
429
+ var fmtBody = typeof shaped.body === "string" || Buffer.isBuffer(shaped.body)
430
+ ? shaped.body : JSON.stringify(shaped.body);
431
+ _writeResponse(res, info.status, fmtType, fmtBody);
432
+ return;
433
+ }
434
+ }
435
+
436
+ // RFC 9457 problem+json envelope (Problem Details for HTTP APIs).
437
+ // problemDetails.respond seals via req.apiEncryptEncode when an
438
+ // encrypted session is active and falls back to plaintext
439
+ // problem+json on encrypt failure — same contract as the default
440
+ // envelope below.
441
+ if (emitProblemDetails) {
442
+ var problem = problemDetails.create({
443
+ type: "about:blank",
444
+ title: STATUS_REASONS[info.status] || "Error",
445
+ status: info.status,
446
+ detail: info.publicMessage,
447
+ code: info.code || (info.status >= 500 ? "internal_error" : "error"),
448
+ });
449
+ if (res && res.writableEnded) return;
450
+ try { problemDetails.respond(res, problem, req); }
451
+ catch (_e) { /* response already torn down */ }
452
+ return;
453
+ }
454
+
389
455
  var errorObj = {
390
456
  code: info.code || (info.status >= 500 ? "internal_error" : "error"),
391
457
  message: info.publicMessage,
@@ -403,14 +469,28 @@ function create(opts) {
403
469
  return;
404
470
  }
405
471
 
406
- var html = (mode === "dev")
407
- ? _renderDevHtml(info, {
408
- brand: brand,
409
- showStack: showStack,
410
- showRequestInfo: showRequestInfo,
411
- showEnvVars: showEnvVars,
412
- }, ctx)
413
- : _renderProdHtml(info, { brand: brand, contact: contact });
472
+ // HTML path. The renderHtml hook replaces the page body after
473
+ // classification + audit have run; a throwing hook falls back to the
474
+ // built-in dev/prod page rather than masking the error.
475
+ var html = null;
476
+ if (renderHtmlHook) {
477
+ try {
478
+ var custom = renderHtmlHook(info, req);
479
+ if (typeof custom === "string") html = custom;
480
+ } catch (e) {
481
+ _log("error", "errors-page renderHtml hook threw", { error: (e && e.message) || String(e) });
482
+ }
483
+ }
484
+ if (html === null) {
485
+ html = (mode === "dev")
486
+ ? _renderDevHtml(info, {
487
+ brand: brand,
488
+ showStack: showStack,
489
+ showRequestInfo: showRequestInfo,
490
+ showEnvVars: showEnvVars,
491
+ }, ctx)
492
+ : _renderProdHtml(info, { brand: brand, contact: contact });
493
+ }
414
494
  _writeResponse(res, info.status, "text/html; charset=utf-8", html);
415
495
  }
416
496