@blamejs/core 0.8.42 → 0.8.49
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 +93 -0
- package/README.md +10 -10
- package/index.js +52 -0
- package/lib/a2a.js +159 -34
- package/lib/acme.js +762 -0
- package/lib/ai-pref.js +166 -43
- package/lib/api-key.js +108 -47
- package/lib/api-snapshot.js +157 -40
- package/lib/app-shutdown.js +113 -77
- package/lib/archive.js +337 -40
- package/lib/arg-parser.js +697 -0
- package/lib/asyncapi.js +99 -55
- package/lib/atomic-file.js +465 -104
- package/lib/audit-chain.js +123 -34
- package/lib/audit-daily-review.js +389 -0
- package/lib/audit-sign.js +302 -56
- package/lib/audit-tools.js +412 -63
- package/lib/audit.js +656 -35
- package/lib/auth/jwt-external.js +17 -0
- package/lib/auth/oauth.js +7 -0
- package/lib/auth-bot-challenge.js +505 -0
- package/lib/auth-header.js +92 -25
- package/lib/backup/bundle.js +26 -0
- package/lib/backup/index.js +512 -89
- package/lib/backup/manifest.js +168 -7
- package/lib/break-glass.js +415 -39
- package/lib/budr.js +103 -30
- package/lib/bundler.js +86 -66
- package/lib/cache.js +192 -72
- package/lib/chain-writer.js +65 -40
- package/lib/circuit-breaker.js +56 -33
- package/lib/cli-helpers.js +106 -75
- package/lib/cli.js +6 -30
- package/lib/cloud-events.js +99 -32
- package/lib/cluster-storage.js +162 -37
- package/lib/cluster.js +340 -49
- package/lib/codepoint-class.js +66 -0
- package/lib/compliance.js +424 -24
- package/lib/config-drift.js +111 -46
- package/lib/config.js +94 -40
- package/lib/consent.js +165 -18
- package/lib/constants.js +1 -0
- package/lib/content-credentials.js +153 -48
- package/lib/cookies.js +154 -62
- package/lib/credential-hash.js +133 -61
- package/lib/crypto-field.js +702 -18
- package/lib/crypto-hpke.js +256 -0
- package/lib/crypto.js +744 -22
- package/lib/csv.js +178 -35
- package/lib/daemon.js +456 -0
- package/lib/dark-patterns.js +186 -55
- package/lib/db-query.js +79 -2
- package/lib/db.js +1431 -60
- package/lib/ddl-change-control.js +523 -0
- package/lib/deprecate.js +195 -40
- package/lib/dev.js +82 -39
- package/lib/dora.js +67 -48
- package/lib/dr-runbook.js +368 -0
- package/lib/dsr.js +142 -11
- package/lib/dual-control.js +91 -56
- package/lib/events.js +120 -41
- package/lib/external-db-migrate.js +192 -2
- package/lib/external-db.js +795 -50
- package/lib/fapi2.js +122 -1
- package/lib/fda-21cfr11.js +395 -0
- package/lib/fdx.js +132 -2
- package/lib/file-type.js +87 -0
- package/lib/file-upload.js +93 -0
- package/lib/flag.js +82 -20
- package/lib/forms.js +132 -29
- package/lib/framework-error.js +169 -0
- package/lib/framework-schema.js +163 -35
- package/lib/gate-contract.js +849 -175
- package/lib/graphql-federation.js +68 -7
- package/lib/guard-all.js +172 -55
- package/lib/guard-archive.js +286 -124
- package/lib/guard-auth.js +194 -21
- package/lib/guard-cidr.js +190 -28
- package/lib/guard-csv.js +397 -51
- package/lib/guard-domain.js +213 -91
- package/lib/guard-email.js +236 -29
- package/lib/guard-filename.js +307 -75
- package/lib/guard-graphql.js +263 -30
- package/lib/guard-html.js +310 -116
- package/lib/guard-image.js +243 -30
- package/lib/guard-json.js +260 -54
- package/lib/guard-jsonpath.js +235 -23
- package/lib/guard-jwt.js +284 -30
- package/lib/guard-markdown.js +204 -22
- package/lib/guard-mime.js +190 -26
- package/lib/guard-oauth.js +277 -28
- package/lib/guard-pdf.js +251 -27
- package/lib/guard-regex.js +226 -18
- package/lib/guard-shell.js +229 -26
- package/lib/guard-svg.js +177 -10
- package/lib/guard-template.js +232 -21
- package/lib/guard-time.js +195 -29
- package/lib/guard-uuid.js +189 -30
- package/lib/guard-xml.js +259 -36
- package/lib/guard-yaml.js +241 -44
- package/lib/honeytoken.js +63 -27
- package/lib/html-balance.js +83 -0
- package/lib/http-client.js +486 -59
- package/lib/http-message-signature.js +582 -0
- package/lib/i18n.js +102 -49
- package/lib/iab-mspa.js +112 -32
- package/lib/iab-tcf.js +107 -2
- package/lib/inbox.js +90 -52
- package/lib/keychain.js +865 -0
- package/lib/legal-hold.js +374 -0
- package/lib/local-db-thin.js +320 -0
- package/lib/log-stream.js +281 -51
- package/lib/log.js +184 -86
- package/lib/mail-bounce.js +107 -62
- package/lib/mail.js +295 -58
- package/lib/mcp.js +108 -27
- package/lib/metrics.js +98 -89
- package/lib/middleware/age-gate.js +36 -0
- package/lib/middleware/ai-act-disclosure.js +37 -0
- package/lib/middleware/api-encrypt.js +45 -0
- package/lib/middleware/assetlinks.js +40 -0
- package/lib/middleware/asyncapi-serve.js +35 -0
- package/lib/middleware/attach-user.js +40 -0
- package/lib/middleware/bearer-auth.js +40 -0
- package/lib/middleware/body-parser.js +230 -0
- package/lib/middleware/bot-disclose.js +34 -0
- package/lib/middleware/bot-guard.js +39 -0
- package/lib/middleware/compression.js +37 -0
- package/lib/middleware/cookies.js +32 -0
- package/lib/middleware/cors.js +40 -0
- package/lib/middleware/csp-nonce.js +40 -0
- package/lib/middleware/csp-report.js +34 -0
- package/lib/middleware/csrf-protect.js +43 -0
- package/lib/middleware/daily-byte-quota.js +53 -85
- package/lib/middleware/db-role-for.js +40 -0
- package/lib/middleware/dpop.js +40 -0
- package/lib/middleware/error-handler.js +37 -14
- package/lib/middleware/fetch-metadata.js +39 -0
- package/lib/middleware/flag-context.js +34 -0
- package/lib/middleware/gpc.js +33 -0
- package/lib/middleware/headers.js +35 -0
- package/lib/middleware/health.js +46 -0
- package/lib/middleware/host-allowlist.js +30 -0
- package/lib/middleware/network-allowlist.js +38 -0
- package/lib/middleware/openapi-serve.js +34 -0
- package/lib/middleware/rate-limit.js +160 -18
- package/lib/middleware/request-id.js +36 -18
- package/lib/middleware/request-log.js +37 -0
- package/lib/middleware/require-aal.js +29 -0
- package/lib/middleware/require-auth.js +32 -0
- package/lib/middleware/require-bound-key.js +41 -0
- package/lib/middleware/require-content-type.js +32 -0
- package/lib/middleware/require-methods.js +27 -0
- package/lib/middleware/require-mtls.js +33 -0
- package/lib/middleware/require-step-up.js +37 -0
- package/lib/middleware/security-headers.js +44 -0
- package/lib/middleware/security-txt.js +38 -0
- package/lib/middleware/span-http-server.js +37 -0
- package/lib/middleware/sse.js +36 -0
- package/lib/middleware/trace-log-correlation.js +33 -0
- package/lib/middleware/trace-propagate.js +32 -0
- package/lib/middleware/tus-upload.js +90 -0
- package/lib/middleware/web-app-manifest.js +53 -0
- package/lib/mtls-ca.js +100 -70
- package/lib/network-byte-quota.js +308 -0
- package/lib/network-heartbeat.js +135 -0
- package/lib/network-tls.js +534 -4
- package/lib/network.js +103 -0
- package/lib/notify.js +114 -43
- package/lib/ntp-check.js +192 -51
- package/lib/observability.js +145 -47
- package/lib/openapi.js +90 -44
- package/lib/outbox.js +99 -1
- package/lib/pagination.js +168 -86
- package/lib/parsers/index.js +16 -5
- package/lib/permissions.js +93 -40
- package/lib/pqc-agent.js +84 -8
- package/lib/pqc-software.js +94 -60
- package/lib/process-spawn.js +95 -21
- package/lib/pubsub.js +96 -66
- package/lib/queue.js +375 -54
- package/lib/redact.js +793 -21
- package/lib/render.js +139 -47
- package/lib/request-helpers.js +485 -121
- package/lib/restore-bundle.js +142 -39
- package/lib/restore-rollback.js +136 -45
- package/lib/retention.js +178 -50
- package/lib/retry.js +116 -33
- package/lib/router.js +475 -23
- package/lib/safe-async.js +543 -94
- package/lib/safe-buffer.js +337 -41
- package/lib/safe-json.js +467 -62
- package/lib/safe-jsonpath.js +285 -0
- package/lib/safe-schema.js +631 -87
- package/lib/safe-sql.js +221 -59
- package/lib/safe-url.js +278 -46
- package/lib/sandbox-worker.js +135 -0
- package/lib/sandbox.js +358 -0
- package/lib/scheduler.js +135 -70
- package/lib/self-update.js +647 -0
- package/lib/session-device-binding.js +431 -0
- package/lib/session.js +259 -49
- package/lib/slug.js +138 -26
- package/lib/ssrf-guard.js +316 -56
- package/lib/storage.js +433 -70
- package/lib/subject.js +405 -23
- package/lib/template.js +148 -8
- package/lib/tenant-quota.js +545 -0
- package/lib/testing.js +440 -53
- package/lib/time.js +291 -23
- package/lib/tls-exporter.js +239 -0
- package/lib/tracing.js +90 -74
- package/lib/uuid.js +97 -22
- package/lib/vault/index.js +284 -22
- package/lib/vault/seal-pem-file.js +66 -0
- package/lib/watcher.js +368 -0
- package/lib/webhook.js +196 -63
- package/lib/websocket.js +393 -68
- package/lib/wiki-concepts.js +338 -0
- package/lib/worker-pool.js +464 -0
- package/package.json +3 -3
- package/sbom.cyclonedx.json +7 -7
package/lib/dual-control.js
CHANGED
|
@@ -1,65 +1,34 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
/**
|
|
3
|
-
*
|
|
3
|
+
* @module b.dualControl
|
|
4
|
+
* @nav Identity
|
|
5
|
+
* @title Dual Control
|
|
4
6
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
7
|
+
* @intro
|
|
8
|
+
* M-of-N approval workflow for destructive operations (eraseHard,
|
|
9
|
+
* key rotation, etc.). b.breakGlass gates a single-actor step-up
|
|
10
|
+
* (TOTP / passkey proof from the SAME actor performing the
|
|
11
|
+
* unseal); dual-control raises the bar to "two distinct named
|
|
12
|
+
* actors must approve before the operation runs" — the standard
|
|
13
|
+
* control for HIPAA admin actions, PCI key rotation, financial
|
|
14
|
+
* close, T+1 settlement, and similar compliance-sensitive flows.
|
|
11
15
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
* });
|
|
19
|
-
*
|
|
20
|
-
* // Step 1: requester opens the request
|
|
21
|
-
* var req1 = await approvals.request({
|
|
22
|
-
* action: "<your-domain>.<verb>", // e.g. operator picks a stable name
|
|
23
|
-
* resource: { kind: "user.bulk", id: "older-than-30d" },
|
|
24
|
-
* requestedBy: actor1, // operator-shaped { id, email, ... }
|
|
25
|
-
* reason: "GDPR sweep; quarter-close",
|
|
26
|
-
* req: req, // for actor-context capture
|
|
27
|
-
* });
|
|
28
|
-
* // → { grantId, status: "pending", needs: 2, approvedBy: [actor1.id], expiresAt: ... }
|
|
29
|
-
*
|
|
30
|
-
* // Step 2: a DIFFERENT actor approves
|
|
31
|
-
* var req2 = await approvals.approve({
|
|
32
|
-
* grantId: req1.grantId,
|
|
33
|
-
* approver: actor2,
|
|
34
|
-
* reason: "verified ticket #4421",
|
|
35
|
-
* req: req,
|
|
36
|
-
* });
|
|
37
|
-
* // → { grantId, status: "approved", approvedBy: [actor1.id, actor2.id] }
|
|
38
|
-
*
|
|
39
|
-
* // Step 3: code that performs the destructive op consumes the grant
|
|
40
|
-
* var grant = await approvals.consume(req1.grantId, { req });
|
|
41
|
-
* if (!grant.ready) throw new Error("not approved or already consumed");
|
|
42
|
-
* // ... perform users.purge ...
|
|
43
|
-
*
|
|
44
|
-
* Audit posture:
|
|
45
|
-
* - Every state transition emits to b.audit:
|
|
46
|
-
* dual.grant.requested (status pending)
|
|
47
|
-
* dual.grant.approved (each approval; metadata.approverCount)
|
|
48
|
-
* dual.grant.denied (operator-callable revoke())
|
|
49
|
-
* dual.grant.consumed (the destructive op ran)
|
|
50
|
-
* dual.grant.expired (TTL hit before approve+consume)
|
|
51
|
-
* - Each event carries the grant ID + the actor 5 W's so a compliance
|
|
52
|
-
* reviewer can reconstruct the chain.
|
|
16
|
+
* Every state transition emits an audit row:
|
|
17
|
+
* `dual.grant.requested` / `dual.grant.approved` /
|
|
18
|
+
* `dual.grant.denied` / `dual.grant.consumed` /
|
|
19
|
+
* `dual.grant.expired` / `dual.grant.cancelled`. Each event
|
|
20
|
+
* carries the grant ID and the actor 5 W's so a compliance
|
|
21
|
+
* reviewer can reconstruct the chain.
|
|
53
22
|
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
23
|
+
* Grants live in a b.cache instance the operator wires in (memory
|
|
24
|
+
* backend → per-process; cluster backend → shared across nodes).
|
|
25
|
+
* The cache TTL bounds grant freshness automatically. Optional
|
|
26
|
+
* cooling-off lock (`consumeLockMs`) defends against rapid-burst
|
|
27
|
+
* compromise of requester+approver. Optional `approverRoles`
|
|
28
|
+
* restricts approval to actors carrying a named role.
|
|
57
29
|
*
|
|
58
|
-
*
|
|
59
|
-
* -
|
|
60
|
-
* - request() / approve() / consume() / revoke(): throw on missing
|
|
61
|
-
* required args, return { error } on policy denials (already
|
|
62
|
-
* consumed, expired, self-approval, etc.)
|
|
30
|
+
* @card
|
|
31
|
+
* M-of-N approval workflow for destructive operations (eraseHard, key rotation, etc.).
|
|
63
32
|
*/
|
|
64
33
|
var lazyRequire = require("./lazy-require");
|
|
65
34
|
var crypto = require("./crypto");
|
|
@@ -107,6 +76,72 @@ function _actorIdOf(actor) {
|
|
|
107
76
|
return null;
|
|
108
77
|
}
|
|
109
78
|
|
|
79
|
+
/**
|
|
80
|
+
* @primitive b.dualControl.create
|
|
81
|
+
* @signature b.dualControl.create(opts)
|
|
82
|
+
* @since 0.8.41
|
|
83
|
+
* @status stable
|
|
84
|
+
* @compliance hipaa, pci-dss, sox-404, dora, soc2
|
|
85
|
+
* @related b.breakGlass.policy.set, b.audit, b.cache.create
|
|
86
|
+
*
|
|
87
|
+
* Build a dual-control approval workflow bound to a `namespace`.
|
|
88
|
+
* Returns a handle exposing async `request(args)` (open a pending
|
|
89
|
+
* grant), `approve(args)` (a different actor approves; quorum
|
|
90
|
+
* detection is automatic), `cancel(args)` (the requester withdraws),
|
|
91
|
+
* `revoke(args)` (an admin denies), `consume(grantId, args)` (the
|
|
92
|
+
* destructive code path single-uses the grant), and `status(grantId)`
|
|
93
|
+
* for inspection. Every transition audits with the actor 5 W's.
|
|
94
|
+
* Grant lifecycle is bounded by `ttlMs`; cluster-shared grants need
|
|
95
|
+
* a cluster-backed cache.
|
|
96
|
+
*
|
|
97
|
+
* @opts
|
|
98
|
+
* namespace: string, // grant key namespace (required, non-empty)
|
|
99
|
+
* cache: b.cache, // b.cache instance — memory or cluster (required)
|
|
100
|
+
* audit: b.audit, // optional audit sink (defaults to global b.audit)
|
|
101
|
+
* ttlMs: number, // grant lifetime (default 15 minutes)
|
|
102
|
+
* minApprovers: number, // approvals required to reach quorum (default 2, ≥2)
|
|
103
|
+
* forbidSelfApprove: boolean, // requester cannot approve own grant (default true)
|
|
104
|
+
* consumeLockMs: number, // cooling-off ms after final approval (default 0)
|
|
105
|
+
* minReasonLength: number, // minimum chars on reason fields (default 0 → off)
|
|
106
|
+
* approverRoles: Array<string>, // restrict approve() to actors with one of these roles (default null)
|
|
107
|
+
* notify: function, // (event) → void; fired on every transition
|
|
108
|
+
*
|
|
109
|
+
* @example
|
|
110
|
+
* var approvals = b.dualControl.create({
|
|
111
|
+
* namespace: "users.eraseHard",
|
|
112
|
+
* cache: b.cache.create({ namespace: "dc", backend: "memory" }),
|
|
113
|
+
* audit: b.audit,
|
|
114
|
+
* ttlMs: b.constants.TIME.minutes(15),
|
|
115
|
+
* minApprovers: 2,
|
|
116
|
+
* consumeLockMs: b.constants.TIME.minutes(2),
|
|
117
|
+
* minReasonLength: 12,
|
|
118
|
+
* approverRoles: ["security:officer", "compliance:officer"],
|
|
119
|
+
* });
|
|
120
|
+
*
|
|
121
|
+
* // Step 1: requester opens the grant.
|
|
122
|
+
* var opened = await approvals.request({
|
|
123
|
+
* action: "users.eraseHard",
|
|
124
|
+
* resource: { kind: "user", id: "user-42" },
|
|
125
|
+
* requestedBy: { id: "alice", roles: ["engineer"] },
|
|
126
|
+
* reason: "GDPR Art. 17 erasure request, ticket SUP-4421",
|
|
127
|
+
* req: req,
|
|
128
|
+
* });
|
|
129
|
+
* // → { grantId: "dc-<hex>", status: "pending", needs: 2, expiresAt: ... }
|
|
130
|
+
*
|
|
131
|
+
* // Step 2: a different actor approves.
|
|
132
|
+
* var approved = await approvals.approve({
|
|
133
|
+
* grantId: opened.grantId,
|
|
134
|
+
* approver: { id: "bob", roles: ["security:officer"] },
|
|
135
|
+
* reason: "verified ticket SUP-4421 against subject identity",
|
|
136
|
+
* req: req,
|
|
137
|
+
* });
|
|
138
|
+
* // → { grantId, status: "approved", approvedBy: ["alice"... wait, no: ["bob"], ... }
|
|
139
|
+
*
|
|
140
|
+
* // Step 3: the destructive code path consumes the grant once.
|
|
141
|
+
* var grant = await approvals.consume(opened.grantId, { req: req });
|
|
142
|
+
* if (!grant.ready) throw new Error("dual-control: " + grant.reason);
|
|
143
|
+
* await b.db.eraseHard("users", { id: "user-42" });
|
|
144
|
+
*/
|
|
110
145
|
function create(opts) {
|
|
111
146
|
opts = opts || {};
|
|
112
147
|
validateOpts(opts, [
|
package/lib/events.js
CHANGED
|
@@ -1,45 +1,36 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
/**
|
|
3
|
-
*
|
|
3
|
+
* @module b.events
|
|
4
|
+
* @nav Other
|
|
5
|
+
* @title Events
|
|
4
6
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
7
|
+
* @intro
|
|
8
|
+
* In-process event emitter — namespaced channels, drop-silent on
|
|
9
|
+
* unknown listeners, audit on registration. A thin wrapper around
|
|
10
|
+
* Node's `EventEmitter` dedicated to framework-emitted breach-
|
|
11
|
+
* detection and integrity signals. Operators wire listeners to
|
|
12
|
+
* PagerDuty / Opsgenie / a Slack webhook / a notification queue and
|
|
13
|
+
* the framework fires them on the specific high-signal conditions
|
|
14
|
+
* exported on `EVENTS` (audit chain break, audit checkpoint break,
|
|
15
|
+
* audit rollback detected, NTP drift, api-encrypt failure).
|
|
10
16
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
* -
|
|
18
|
-
*
|
|
19
|
-
* - _resetForTest() clears listeners between tests without touching
|
|
20
|
-
* consumer state.
|
|
17
|
+
* Best-effort emit semantics: a listener throwing must NOT break
|
|
18
|
+
* the framework's refuse-to-boot fail-fast. `emit()` catches and
|
|
19
|
+
* swallows listener errors per-listener so the rest still fire.
|
|
20
|
+
* Stable event names live on `b.events.EVENTS` so operators
|
|
21
|
+
* reference `b.events.EVENTS.AUDIT_CHAIN_BREAK` rather than typing
|
|
22
|
+
* the raw `"audit:chain-break"` string. The default
|
|
23
|
+
* 10-listener cap is removed — operators routinely wire several
|
|
24
|
+
* notify / structured-log / file-flag listeners on the same event.
|
|
21
25
|
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
* errors logged + swallowed)
|
|
28
|
-
* b.events.listenerCount(name) diagnostic
|
|
29
|
-
* b.events.EVENTS stable name constants
|
|
26
|
+
* Listener handlers should keep work synchronous and short. Several
|
|
27
|
+
* framework signals lead to `process.exit`, so blocking network
|
|
28
|
+
* calls inside a listener would delay the fail-fast path; hand off
|
|
29
|
+
* to a queue or write a sync flag-file from the listener and let an
|
|
30
|
+
* external watcher do the network call.
|
|
30
31
|
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
* b.events.on(b.events.EVENTS.AUDIT_CHAIN_BREAK, function (info) {
|
|
35
|
-
* fs.writeFileSync("/var/run/blamejs-chain-break.flag",
|
|
36
|
-
* JSON.stringify({ at: Date.now(), info: info }));
|
|
37
|
-
* });
|
|
38
|
-
*
|
|
39
|
-
* For async fan-out (PagerDuty webhook, etc.) the listener should hand
|
|
40
|
-
* off to a queue — synchronous I/O in the listener is safe but blocking
|
|
41
|
-
* network calls would delay the framework's fail-fast path on signals
|
|
42
|
-
* that lead to process.exit.
|
|
32
|
+
* @card
|
|
33
|
+
* In-process event emitter — namespaced channels, drop-silent on unknown listeners, audit on registration.
|
|
43
34
|
*/
|
|
44
35
|
|
|
45
36
|
var nodeEvents = require("node:events");
|
|
@@ -78,26 +69,99 @@ var _emitter = new nodeEvents.EventEmitter();
|
|
|
78
69
|
// sidecar, file flag writer, etc.) and the warning isn't useful here.
|
|
79
70
|
_emitter.setMaxListeners(0);
|
|
80
71
|
|
|
72
|
+
/**
|
|
73
|
+
* @primitive b.events.on
|
|
74
|
+
* @signature b.events.on(name, fn)
|
|
75
|
+
* @since 0.4.0
|
|
76
|
+
* @related b.events.off, b.events.once, b.events.emit
|
|
77
|
+
*
|
|
78
|
+
* Register a listener for one of the stable framework event names on
|
|
79
|
+
* `b.events.EVENTS`. The listener fires every time the framework
|
|
80
|
+
* emits the named event. Listeners may throw — `emit()` swallows the
|
|
81
|
+
* throw and logs it so the emit path stays best-effort, but operators
|
|
82
|
+
* should keep handlers synchronous and short (several framework
|
|
83
|
+
* signals lead to `process.exit`).
|
|
84
|
+
*
|
|
85
|
+
* @example
|
|
86
|
+
* b.events.on(b.events.EVENTS.AUDIT_CHAIN_BREAK, function (info) {
|
|
87
|
+
* // Sync I/O only — exit may follow.
|
|
88
|
+
* require("node:fs").writeFileSync(
|
|
89
|
+
* "/var/run/blamejs-chain-break.flag",
|
|
90
|
+
* JSON.stringify({ at: Date.now(), info: info })
|
|
91
|
+
* );
|
|
92
|
+
* });
|
|
93
|
+
*/
|
|
81
94
|
function on(name, fn) {
|
|
82
95
|
_emitter.on(name, fn);
|
|
83
96
|
return _emitter;
|
|
84
97
|
}
|
|
85
98
|
|
|
99
|
+
/**
|
|
100
|
+
* @primitive b.events.off
|
|
101
|
+
* @signature b.events.off(name, fn)
|
|
102
|
+
* @since 0.4.0
|
|
103
|
+
* @related b.events.on, b.events.once
|
|
104
|
+
*
|
|
105
|
+
* Remove a previously-registered listener. The function reference
|
|
106
|
+
* must be the same object passed to `on()` / `once()` — otherwise
|
|
107
|
+
* Node's emitter silently keeps the listener registered.
|
|
108
|
+
*
|
|
109
|
+
* @example
|
|
110
|
+
* function onBreak(info) { console.error("audit chain break", info); }
|
|
111
|
+
* b.events.on(b.events.EVENTS.AUDIT_CHAIN_BREAK, onBreak);
|
|
112
|
+
* // ... later, during teardown:
|
|
113
|
+
* b.events.off(b.events.EVENTS.AUDIT_CHAIN_BREAK, onBreak);
|
|
114
|
+
* b.events.listenerCount(b.events.EVENTS.AUDIT_CHAIN_BREAK); // → 0
|
|
115
|
+
*/
|
|
86
116
|
function off(name, fn) {
|
|
87
117
|
_emitter.off(name, fn);
|
|
88
118
|
return _emitter;
|
|
89
119
|
}
|
|
90
120
|
|
|
121
|
+
/**
|
|
122
|
+
* @primitive b.events.once
|
|
123
|
+
* @signature b.events.once(name, fn)
|
|
124
|
+
* @since 0.4.0
|
|
125
|
+
* @related b.events.on, b.events.emit
|
|
126
|
+
*
|
|
127
|
+
* Register a single-fire listener — fires once and auto-removes. The
|
|
128
|
+
* auto-removal is preserved through `b.events.emit` even though emit
|
|
129
|
+
* iterates raw listeners directly to keep the best-effort contract.
|
|
130
|
+
*
|
|
131
|
+
* @example
|
|
132
|
+
* b.events.once(b.events.EVENTS.NTP_DRIFT, function (payload) {
|
|
133
|
+
* console.warn("NTP drift detected (first occurrence): " + payload.driftMs + "ms");
|
|
134
|
+
* });
|
|
135
|
+
*/
|
|
91
136
|
function once(name, fn) {
|
|
92
137
|
_emitter.once(name, fn);
|
|
93
138
|
return _emitter;
|
|
94
139
|
}
|
|
95
140
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
141
|
+
/**
|
|
142
|
+
* @primitive b.events.emit
|
|
143
|
+
* @signature b.events.emit(name, payload)
|
|
144
|
+
* @since 0.4.0
|
|
145
|
+
* @related b.events.on, b.events.once
|
|
146
|
+
*
|
|
147
|
+
* Best-effort fire — invokes every registered listener for `name`,
|
|
148
|
+
* passing `payload`. Listener throws are logged and swallowed
|
|
149
|
+
* per-listener so the rest of the chain still fires; framework
|
|
150
|
+
* callers (e.g. `db.init`'s chain-verify FATAL path) emit immediately
|
|
151
|
+
* before `process.exit` and can't tolerate a listener crashing the
|
|
152
|
+
* exit path. Returns `true` when at least one listener was registered.
|
|
153
|
+
*
|
|
154
|
+
* Operator code rarely emits onto `b.events` directly — the bus is
|
|
155
|
+
* for framework-emitted signals. Calling `emit()` from operator code
|
|
156
|
+
* is supported for tests that want to exercise listener wiring.
|
|
157
|
+
*
|
|
158
|
+
* @example
|
|
159
|
+
* var fired = false;
|
|
160
|
+
* b.events.on(b.events.EVENTS.AUDIT_CHAIN_BREAK, function () { fired = true; });
|
|
161
|
+
* var hadListener = b.events.emit(b.events.EVENTS.AUDIT_CHAIN_BREAK, { at: 1 });
|
|
162
|
+
* hadListener; // → true
|
|
163
|
+
* fired; // → true
|
|
164
|
+
*/
|
|
101
165
|
function emit(name, payload) {
|
|
102
166
|
// rawListeners returns the wrapper functions including the auto-
|
|
103
167
|
// removing wrapper that once() registers. Calling the wrapper triggers
|
|
@@ -116,6 +180,21 @@ function emit(name, payload) {
|
|
|
116
180
|
return listeners.length > 0;
|
|
117
181
|
}
|
|
118
182
|
|
|
183
|
+
/**
|
|
184
|
+
* @primitive b.events.listenerCount
|
|
185
|
+
* @signature b.events.listenerCount(name)
|
|
186
|
+
* @since 0.4.0
|
|
187
|
+
* @related b.events.on, b.events.off
|
|
188
|
+
*
|
|
189
|
+
* Diagnostic — returns the number of listeners registered for `name`.
|
|
190
|
+
* Useful in tests and during teardown to confirm `off()` removed the
|
|
191
|
+
* intended listener.
|
|
192
|
+
*
|
|
193
|
+
* @example
|
|
194
|
+
* b.events.on(b.events.EVENTS.NTP_DRIFT, function () {});
|
|
195
|
+
* b.events.on(b.events.EVENTS.NTP_DRIFT, function () {});
|
|
196
|
+
* b.events.listenerCount(b.events.EVENTS.NTP_DRIFT); // → 2
|
|
197
|
+
*/
|
|
119
198
|
function listenerCount(name) {
|
|
120
199
|
return _emitter.listenerCount(name);
|
|
121
200
|
}
|
|
@@ -50,12 +50,16 @@
|
|
|
50
50
|
*/
|
|
51
51
|
var path = require("path");
|
|
52
52
|
var atomicFile = require("./atomic-file");
|
|
53
|
+
var canonicalJson = require("./canonical-json");
|
|
54
|
+
var { sha3Hash } = require("./crypto");
|
|
53
55
|
var lazyRequire = require("./lazy-require");
|
|
54
56
|
var migrationFiles = require("./migration-files");
|
|
55
57
|
var numericBounds = require("./numeric-bounds");
|
|
56
58
|
var validateOpts = require("./validate-opts");
|
|
57
59
|
var { defineClass } = require("./framework-error");
|
|
58
60
|
|
|
61
|
+
var auditSign = lazyRequire(function () { return require("./audit-sign"); });
|
|
62
|
+
|
|
59
63
|
var ExternalDbMigrateError = defineClass("ExternalDbMigrateError", { alwaysPermanent: true });
|
|
60
64
|
|
|
61
65
|
// Lazy require — external-db imports back into this module via its
|
|
@@ -64,10 +68,47 @@ var externalDbModule = lazyRequire(function () { return require("./external-db")
|
|
|
64
68
|
|
|
65
69
|
var TRACKING_TABLE = "_blamejs_externaldb_migrations";
|
|
66
70
|
var LOCK_TABLE = "_blamejs_externaldb_migrations_lock";
|
|
71
|
+
var HISTORY_TABLE = "_blamejs_schema_version_history";
|
|
67
72
|
// Identifiers wrapped in `"..."` per project convention so a reserved-word
|
|
68
73
|
// or whitespace-bearing name resolves correctly.
|
|
69
74
|
var Q_TRACKING = '"' + TRACKING_TABLE + '"';
|
|
70
75
|
var Q_LOCK = '"' + LOCK_TABLE + '"';
|
|
76
|
+
var Q_HISTORY = '"' + HISTORY_TABLE + '"';
|
|
77
|
+
|
|
78
|
+
// Bytes that get signed for one history row. Stable forever — changing
|
|
79
|
+
// it invalidates every prior signature.
|
|
80
|
+
var HISTORY_SIGNATURE_FORMAT = "blamejs-schema-history-v1";
|
|
81
|
+
|
|
82
|
+
function _historyPayload(row) {
|
|
83
|
+
// Canonical JSON keeps the byte stream deterministic across Node
|
|
84
|
+
// versions / property-insertion order. Order-independent verifiers
|
|
85
|
+
// recompute the same bytes.
|
|
86
|
+
var payload =
|
|
87
|
+
HISTORY_SIGNATURE_FORMAT + "\n" +
|
|
88
|
+
canonicalJson.stringify({
|
|
89
|
+
version: row.version,
|
|
90
|
+
ranAt: row.ranAt,
|
|
91
|
+
ranBy: row.ranBy,
|
|
92
|
+
schemaIntrospectionHash: row.schemaIntrospectionHash,
|
|
93
|
+
});
|
|
94
|
+
return Buffer.from(payload, "utf8");
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Hash the current schema introspection — operators wiring an opts.
|
|
98
|
+
// schemaIntrospect that returns deterministic bytes get the strict
|
|
99
|
+
// guarantee that a tampered table after-the-fact will not verify. The
|
|
100
|
+
// default introspect just returns the migration name list as a JSON
|
|
101
|
+
// array, which is enough to detect "someone manually altered the
|
|
102
|
+
// migrations table."
|
|
103
|
+
async function _defaultSchemaIntrospect(xdb) {
|
|
104
|
+
var res = await xdb.query(
|
|
105
|
+
"SELECT name, appliedAt FROM " + Q_TRACKING +
|
|
106
|
+
" ORDER BY appliedAt ASC, name ASC",
|
|
107
|
+
[]
|
|
108
|
+
);
|
|
109
|
+
var rows = (res && res.rows) || [];
|
|
110
|
+
return sha3Hash(Buffer.from(canonicalJson.stringify(rows), "utf8"));
|
|
111
|
+
}
|
|
71
112
|
|
|
72
113
|
// Filename grammar lives in lib/migration-files (shared with the local
|
|
73
114
|
// migrations.js + seeders.js runners). Length capped before the regex
|
|
@@ -114,6 +155,42 @@ async function _ensureTrackingTable(xdb) {
|
|
|
114
155
|
);
|
|
115
156
|
}
|
|
116
157
|
|
|
158
|
+
async function _ensureHistoryTable(xdb) {
|
|
159
|
+
// Schema-version history table: append-only record of every migrate.up
|
|
160
|
+
// wave + signature over (version, ranAt, ranBy, schemaIntrospectionHash).
|
|
161
|
+
// Signature uses ML-DSA-87 / SLH-DSA-SHAKE-256f via b.auditSign — an
|
|
162
|
+
// attacker tampering with rows after-the-fact cannot forge a matching
|
|
163
|
+
// signature without the audit-signing private key.
|
|
164
|
+
await xdb.query(
|
|
165
|
+
"CREATE TABLE IF NOT EXISTS " + Q_HISTORY + " (" +
|
|
166
|
+
" version TEXT NOT NULL," +
|
|
167
|
+
" ranAt TEXT NOT NULL," +
|
|
168
|
+
" ranBy TEXT NOT NULL," +
|
|
169
|
+
" schemaIntrospectionHash TEXT NOT NULL," +
|
|
170
|
+
" signature TEXT," +
|
|
171
|
+
" publicKeyFingerprint TEXT," +
|
|
172
|
+
" PRIMARY KEY (version, ranAt)" +
|
|
173
|
+
")",
|
|
174
|
+
[]
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async function _writeHistoryRow(xdb, row) {
|
|
179
|
+
await xdb.query(
|
|
180
|
+
"INSERT INTO " + Q_HISTORY +
|
|
181
|
+
" (version, ranAt, ranBy, schemaIntrospectionHash, signature, publicKeyFingerprint) " +
|
|
182
|
+
" VALUES ($1, $2, $3, $4, $5, $6)",
|
|
183
|
+
[
|
|
184
|
+
row.version,
|
|
185
|
+
row.ranAt,
|
|
186
|
+
row.ranBy,
|
|
187
|
+
row.schemaIntrospectionHash,
|
|
188
|
+
row.signature,
|
|
189
|
+
row.publicKeyFingerprint,
|
|
190
|
+
]
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
|
|
117
194
|
async function _ensureLockTable(xdb) {
|
|
118
195
|
await xdb.query(
|
|
119
196
|
"CREATE TABLE IF NOT EXISTS " + Q_LOCK + " (" +
|
|
@@ -263,12 +340,23 @@ function _resolveBackendName(opts) {
|
|
|
263
340
|
|
|
264
341
|
function create(opts) {
|
|
265
342
|
opts = opts || {};
|
|
266
|
-
validateOpts(opts, [
|
|
343
|
+
validateOpts(opts, [
|
|
344
|
+
"dir", "backend", "audit", "staleAfterMs",
|
|
345
|
+
"schemaIntrospect", "ranBy", "signHistory",
|
|
346
|
+
], "b.externalDb.migrate");
|
|
267
347
|
validateOpts.requireNonEmptyString(opts.dir, "externalDb.migrate.create: opts.dir (path to migrations directory)", ExternalDbMigrateError, "externaldb-migrate/no-dir");
|
|
268
348
|
validateOpts.optionalFiniteNonNegative(opts.staleAfterMs, "externalDb.migrate: staleAfterMs", ExternalDbMigrateError, "externaldb-migrate/bad-stale");
|
|
269
349
|
validateOpts.auditShape(opts.audit, "externalDb.migrate", ExternalDbMigrateError, "externaldb-migrate/bad-audit");
|
|
350
|
+
validateOpts.optionalFunction(opts.schemaIntrospect,
|
|
351
|
+
"externalDb.migrate: schemaIntrospect", ExternalDbMigrateError,
|
|
352
|
+
"externaldb-migrate/bad-introspect");
|
|
270
353
|
var dir = opts.dir;
|
|
271
354
|
var audit = opts.audit || null;
|
|
355
|
+
var schemaIntrospect = typeof opts.schemaIntrospect === "function"
|
|
356
|
+
? opts.schemaIntrospect : _defaultSchemaIntrospect;
|
|
357
|
+
var ranBy = typeof opts.ranBy === "string" && opts.ranBy.length > 0
|
|
358
|
+
? opts.ranBy : _lockHolderId();
|
|
359
|
+
var signHistory = opts.signHistory !== false;
|
|
272
360
|
|
|
273
361
|
function _ctx(backendName) {
|
|
274
362
|
return {
|
|
@@ -306,6 +394,7 @@ function create(opts) {
|
|
|
306
394
|
return await externalDbModule().transaction(async function (xdb) {
|
|
307
395
|
await _ensureTrackingTable(xdb);
|
|
308
396
|
await _ensureLockTable(xdb);
|
|
397
|
+
await _ensureHistoryTable(xdb);
|
|
309
398
|
}, { backend: backendName }).then(async function () {
|
|
310
399
|
// Acquire the lock OUTSIDE the per-migration transaction so the
|
|
311
400
|
// lock survives across migration boundaries. We use a separate
|
|
@@ -345,11 +434,43 @@ function create(opts) {
|
|
|
345
434
|
try {
|
|
346
435
|
await externalDbModule().transaction(async function (xdb) {
|
|
347
436
|
await mod.up(xdb, ctx);
|
|
437
|
+
var ranAt = new Date().toISOString();
|
|
348
438
|
await xdb.query(
|
|
349
439
|
"INSERT INTO " + Q_TRACKING +
|
|
350
440
|
" (name, description, appliedAt) VALUES ($1, $2, $3)",
|
|
351
|
-
[file, mod.description || "",
|
|
441
|
+
[file, mod.description || "", ranAt]
|
|
352
442
|
);
|
|
443
|
+
// Schema-version history with signature. Sign post-INSERT
|
|
444
|
+
// so the introspection hash reflects the row that just
|
|
445
|
+
// landed. Sign-failure is non-fatal for the migration but
|
|
446
|
+
// emits a failure audit so the operator chases it down.
|
|
447
|
+
var historyRow = {
|
|
448
|
+
version: file,
|
|
449
|
+
ranAt: ranAt,
|
|
450
|
+
ranBy: ranBy,
|
|
451
|
+
schemaIntrospectionHash: await schemaIntrospect(xdb),
|
|
452
|
+
signature: null,
|
|
453
|
+
publicKeyFingerprint: null,
|
|
454
|
+
};
|
|
455
|
+
if (signHistory) {
|
|
456
|
+
try {
|
|
457
|
+
var payload = _historyPayload(historyRow);
|
|
458
|
+
var sigBuf = auditSign().sign(payload);
|
|
459
|
+
historyRow.signature = sigBuf.toString("base64");
|
|
460
|
+
historyRow.publicKeyFingerprint = auditSign().getPublicKeyFingerprint();
|
|
461
|
+
} catch (sigErr) {
|
|
462
|
+
_emit(audit, "migrations.history.sign_failed", "failure",
|
|
463
|
+
{ migration: file, backend: backendName },
|
|
464
|
+
(sigErr && sigErr.message) || String(sigErr));
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
await _writeHistoryRow(xdb, historyRow);
|
|
468
|
+
_emit(audit, "migrations.history.appended", "success", {
|
|
469
|
+
migration: file,
|
|
470
|
+
schemaIntrospectionHash: historyRow.schemaIntrospectionHash,
|
|
471
|
+
signed: historyRow.signature !== null,
|
|
472
|
+
backend: backendName,
|
|
473
|
+
}, null);
|
|
353
474
|
}, { backend: backendName });
|
|
354
475
|
_emit(audit, "externaldb.migrate.up", "success",
|
|
355
476
|
{ migration: file, durationMs: Date.now() - t0, backend: backendName }, null);
|
|
@@ -452,10 +573,77 @@ function create(opts) {
|
|
|
452
573
|
}
|
|
453
574
|
}
|
|
454
575
|
|
|
576
|
+
// history(opts?) — list every schema-version-history row + verify the
|
|
577
|
+
// signature on each. Returns:
|
|
578
|
+
// [{ version, ranAt, ranBy, schemaIntrospectionHash, signature,
|
|
579
|
+
// publicKeyFingerprint, verified: bool, verifyReason: string|null }]
|
|
580
|
+
//
|
|
581
|
+
// verify is `true` when the signature decodes + auditSign.verify
|
|
582
|
+
// returns true against the row's payload; `false` with reason
|
|
583
|
+
// otherwise (unsigned row / verify-failed / signing key absent /
|
|
584
|
+
// public-key-fingerprint mismatch).
|
|
585
|
+
async function history(historyOpts) {
|
|
586
|
+
historyOpts = historyOpts || {};
|
|
587
|
+
var backendName = _resolveBackendName(opts);
|
|
588
|
+
return await externalDbModule().transaction(async function (xdb) {
|
|
589
|
+
await _ensureHistoryTable(xdb);
|
|
590
|
+
var res = await xdb.query(
|
|
591
|
+
"SELECT version, ranAt, ranBy, schemaIntrospectionHash, signature, publicKeyFingerprint " +
|
|
592
|
+
"FROM " + Q_HISTORY + " ORDER BY ranAt ASC, version ASC",
|
|
593
|
+
[]
|
|
594
|
+
);
|
|
595
|
+
var out = [];
|
|
596
|
+
var rows = (res && res.rows) || [];
|
|
597
|
+
for (var i = 0; i < rows.length; i++) {
|
|
598
|
+
var row = rows[i];
|
|
599
|
+
var verified = false;
|
|
600
|
+
var verifyReason = null;
|
|
601
|
+
if (!row.signature) {
|
|
602
|
+
verifyReason = "row-unsigned";
|
|
603
|
+
} else {
|
|
604
|
+
try {
|
|
605
|
+
var payload = _historyPayload(row);
|
|
606
|
+
var sigBuf = Buffer.from(row.signature, "base64");
|
|
607
|
+
var currentFp = auditSign().getPublicKeyFingerprint();
|
|
608
|
+
if (row.publicKeyFingerprint && row.publicKeyFingerprint !== currentFp) {
|
|
609
|
+
verifyReason = "public-key-fingerprint-mismatch";
|
|
610
|
+
} else {
|
|
611
|
+
verified = !!auditSign().verify(payload, sigBuf);
|
|
612
|
+
if (!verified) verifyReason = "signature-verify-failed";
|
|
613
|
+
}
|
|
614
|
+
} catch (e) {
|
|
615
|
+
verifyReason = "verify-threw: " + ((e && e.message) || String(e));
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
out.push({
|
|
619
|
+
version: row.version,
|
|
620
|
+
ranAt: row.ranAt,
|
|
621
|
+
ranBy: row.ranBy,
|
|
622
|
+
schemaIntrospectionHash: row.schemaIntrospectionHash,
|
|
623
|
+
signature: row.signature,
|
|
624
|
+
publicKeyFingerprint: row.publicKeyFingerprint,
|
|
625
|
+
verified: verified,
|
|
626
|
+
verifyReason: verifyReason,
|
|
627
|
+
});
|
|
628
|
+
if (!verified && row.signature) {
|
|
629
|
+
_emit(audit, "migrations.history.tamper_detected", "denied", {
|
|
630
|
+
version: row.version, ranAt: row.ranAt, reason: verifyReason,
|
|
631
|
+
backend: backendName,
|
|
632
|
+
}, null);
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
_emit(audit, "migrations.history.verified", "success", {
|
|
636
|
+
rowsVerified: out.length, backend: backendName,
|
|
637
|
+
}, null);
|
|
638
|
+
return out;
|
|
639
|
+
}, { backend: backendName });
|
|
640
|
+
}
|
|
641
|
+
|
|
455
642
|
return {
|
|
456
643
|
up: up,
|
|
457
644
|
down: down,
|
|
458
645
|
status: status,
|
|
646
|
+
history: history,
|
|
459
647
|
};
|
|
460
648
|
}
|
|
461
649
|
|
|
@@ -463,4 +651,6 @@ module.exports = {
|
|
|
463
651
|
create: create,
|
|
464
652
|
ExternalDbMigrateError: ExternalDbMigrateError,
|
|
465
653
|
TRACKING_TABLE: TRACKING_TABLE,
|
|
654
|
+
HISTORY_TABLE: HISTORY_TABLE,
|
|
655
|
+
HISTORY_SIGNATURE_FORMAT: HISTORY_SIGNATURE_FORMAT,
|
|
466
656
|
};
|