@blamejs/core 0.14.16 → 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.
- package/CHANGELOG.md +4 -0
- package/README.md +2 -2
- package/lib/agent-orchestrator.js +10 -4
- package/lib/ai-prompt.js +1 -1
- package/lib/app-shutdown.js +28 -0
- package/lib/archive-read.js +215 -16
- package/lib/breach-deadline.js +166 -1
- package/lib/cloud-events.js +3 -1
- package/lib/codepoint-class.js +21 -0
- package/lib/db-schema.js +120 -3
- package/lib/db.js +10 -3
- package/lib/error-page.js +93 -9
- package/lib/external-db.js +164 -13
- package/lib/guard-email.js +36 -3
- package/lib/http-client.js +37 -7
- package/lib/mail-send-deliver.js +15 -5
- package/lib/mail-sieve.js +2 -1
- package/lib/middleware/ai-act-disclosure.js +88 -19
- package/lib/middleware/api-encrypt.js +58 -11
- package/lib/middleware/asyncapi-serve.js +56 -4
- package/lib/middleware/attach-user.js +45 -10
- package/lib/middleware/body-parser.js +70 -14
- package/lib/middleware/csp-report.js +30 -2
- package/lib/middleware/deny-response.js +29 -9
- package/lib/middleware/openapi-serve.js +56 -4
- package/lib/middleware/scim-server.js +7 -4
- package/lib/problem-details.js +15 -3
- package/lib/queue-local.js +148 -38
- package/lib/queue.js +41 -11
- package/lib/render.js +21 -3
- package/lib/router.js +13 -6
- package/lib/safe-buffer.js +55 -0
- package/lib/sse.js +7 -5
- package/lib/static.js +46 -17
- package/lib/uri-template.js +3 -1
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
package/lib/breach-deadline.js
CHANGED
|
@@ -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
|
},
|
package/lib/cloud-events.js
CHANGED
|
@@ -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
|
-
|
|
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 {
|
package/lib/codepoint-class.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
@@ -394,19 +460,37 @@ function create(opts) {
|
|
|
394
460
|
if (mode === "dev" && info.stack && showStack && info.status >= 500) {
|
|
395
461
|
errorObj.stack = info.stack;
|
|
396
462
|
}
|
|
463
|
+
var errorBody = { error: errorObj };
|
|
464
|
+
if (req && typeof req.apiEncryptEncode === "function") {
|
|
465
|
+
try { errorBody = req.apiEncryptEncode(errorBody); } catch (_e) { errorBody = { error: errorObj }; }
|
|
466
|
+
}
|
|
397
467
|
_writeResponse(res, info.status, "application/json; charset=utf-8",
|
|
398
|
-
JSON.stringify(
|
|
468
|
+
JSON.stringify(errorBody));
|
|
399
469
|
return;
|
|
400
470
|
}
|
|
401
471
|
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
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
|
+
}
|
|
410
494
|
_writeResponse(res, info.status, "text/html; charset=utf-8", html);
|
|
411
495
|
}
|
|
412
496
|
|