@blamejs/core 0.8.43 → 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.
Files changed (222) hide show
  1. package/CHANGELOG.md +92 -0
  2. package/README.md +10 -10
  3. package/index.js +52 -0
  4. package/lib/a2a.js +159 -34
  5. package/lib/acme.js +762 -0
  6. package/lib/ai-pref.js +166 -43
  7. package/lib/api-key.js +108 -47
  8. package/lib/api-snapshot.js +157 -40
  9. package/lib/app-shutdown.js +113 -77
  10. package/lib/archive.js +337 -40
  11. package/lib/arg-parser.js +697 -0
  12. package/lib/asyncapi.js +99 -55
  13. package/lib/atomic-file.js +465 -104
  14. package/lib/audit-chain.js +123 -34
  15. package/lib/audit-daily-review.js +389 -0
  16. package/lib/audit-sign.js +302 -56
  17. package/lib/audit-tools.js +412 -63
  18. package/lib/audit.js +656 -35
  19. package/lib/auth/jwt-external.js +17 -0
  20. package/lib/auth/oauth.js +7 -0
  21. package/lib/auth-bot-challenge.js +505 -0
  22. package/lib/auth-header.js +92 -25
  23. package/lib/backup/bundle.js +26 -0
  24. package/lib/backup/index.js +512 -89
  25. package/lib/backup/manifest.js +168 -7
  26. package/lib/break-glass.js +415 -39
  27. package/lib/budr.js +103 -30
  28. package/lib/bundler.js +86 -66
  29. package/lib/cache.js +192 -72
  30. package/lib/chain-writer.js +65 -40
  31. package/lib/circuit-breaker.js +56 -33
  32. package/lib/cli-helpers.js +106 -75
  33. package/lib/cli.js +6 -30
  34. package/lib/cloud-events.js +99 -32
  35. package/lib/cluster-storage.js +162 -37
  36. package/lib/cluster.js +340 -49
  37. package/lib/codepoint-class.js +66 -0
  38. package/lib/compliance.js +424 -24
  39. package/lib/config-drift.js +111 -46
  40. package/lib/config.js +94 -40
  41. package/lib/consent.js +165 -18
  42. package/lib/constants.js +1 -0
  43. package/lib/content-credentials.js +153 -48
  44. package/lib/cookies.js +154 -62
  45. package/lib/credential-hash.js +133 -61
  46. package/lib/crypto-field.js +702 -18
  47. package/lib/crypto-hpke.js +256 -0
  48. package/lib/crypto.js +744 -22
  49. package/lib/csv.js +178 -35
  50. package/lib/daemon.js +456 -0
  51. package/lib/dark-patterns.js +186 -55
  52. package/lib/db-query.js +79 -2
  53. package/lib/db.js +1431 -60
  54. package/lib/ddl-change-control.js +523 -0
  55. package/lib/deprecate.js +195 -40
  56. package/lib/dev.js +82 -39
  57. package/lib/dora.js +67 -48
  58. package/lib/dr-runbook.js +368 -0
  59. package/lib/dsr.js +142 -11
  60. package/lib/dual-control.js +91 -56
  61. package/lib/events.js +120 -41
  62. package/lib/external-db-migrate.js +192 -2
  63. package/lib/external-db.js +795 -50
  64. package/lib/fapi2.js +122 -1
  65. package/lib/fda-21cfr11.js +395 -0
  66. package/lib/fdx.js +132 -2
  67. package/lib/file-type.js +87 -0
  68. package/lib/file-upload.js +93 -0
  69. package/lib/flag.js +82 -20
  70. package/lib/forms.js +132 -29
  71. package/lib/framework-error.js +169 -0
  72. package/lib/framework-schema.js +163 -35
  73. package/lib/gate-contract.js +849 -175
  74. package/lib/graphql-federation.js +68 -7
  75. package/lib/guard-all.js +172 -55
  76. package/lib/guard-archive.js +286 -124
  77. package/lib/guard-auth.js +194 -21
  78. package/lib/guard-cidr.js +190 -28
  79. package/lib/guard-csv.js +397 -51
  80. package/lib/guard-domain.js +213 -91
  81. package/lib/guard-email.js +236 -29
  82. package/lib/guard-filename.js +307 -75
  83. package/lib/guard-graphql.js +263 -30
  84. package/lib/guard-html.js +310 -116
  85. package/lib/guard-image.js +243 -30
  86. package/lib/guard-json.js +260 -54
  87. package/lib/guard-jsonpath.js +235 -23
  88. package/lib/guard-jwt.js +284 -30
  89. package/lib/guard-markdown.js +204 -22
  90. package/lib/guard-mime.js +190 -26
  91. package/lib/guard-oauth.js +277 -28
  92. package/lib/guard-pdf.js +251 -27
  93. package/lib/guard-regex.js +226 -18
  94. package/lib/guard-shell.js +229 -26
  95. package/lib/guard-svg.js +177 -10
  96. package/lib/guard-template.js +232 -21
  97. package/lib/guard-time.js +195 -29
  98. package/lib/guard-uuid.js +189 -30
  99. package/lib/guard-xml.js +259 -36
  100. package/lib/guard-yaml.js +241 -44
  101. package/lib/honeytoken.js +63 -27
  102. package/lib/html-balance.js +83 -0
  103. package/lib/http-client.js +486 -59
  104. package/lib/http-message-signature.js +582 -0
  105. package/lib/i18n.js +102 -49
  106. package/lib/iab-mspa.js +112 -32
  107. package/lib/iab-tcf.js +107 -2
  108. package/lib/inbox.js +90 -52
  109. package/lib/keychain.js +865 -0
  110. package/lib/legal-hold.js +374 -0
  111. package/lib/local-db-thin.js +320 -0
  112. package/lib/log-stream.js +281 -51
  113. package/lib/log.js +184 -86
  114. package/lib/mail-bounce.js +107 -62
  115. package/lib/mail.js +295 -58
  116. package/lib/mcp.js +108 -27
  117. package/lib/metrics.js +98 -89
  118. package/lib/middleware/age-gate.js +36 -0
  119. package/lib/middleware/ai-act-disclosure.js +37 -0
  120. package/lib/middleware/api-encrypt.js +45 -0
  121. package/lib/middleware/assetlinks.js +40 -0
  122. package/lib/middleware/asyncapi-serve.js +35 -0
  123. package/lib/middleware/attach-user.js +40 -0
  124. package/lib/middleware/bearer-auth.js +40 -0
  125. package/lib/middleware/body-parser.js +230 -0
  126. package/lib/middleware/bot-disclose.js +34 -0
  127. package/lib/middleware/bot-guard.js +39 -0
  128. package/lib/middleware/compression.js +37 -0
  129. package/lib/middleware/cookies.js +32 -0
  130. package/lib/middleware/cors.js +40 -0
  131. package/lib/middleware/csp-nonce.js +40 -0
  132. package/lib/middleware/csp-report.js +34 -0
  133. package/lib/middleware/csrf-protect.js +43 -0
  134. package/lib/middleware/daily-byte-quota.js +53 -85
  135. package/lib/middleware/db-role-for.js +40 -0
  136. package/lib/middleware/dpop.js +40 -0
  137. package/lib/middleware/error-handler.js +37 -14
  138. package/lib/middleware/fetch-metadata.js +39 -0
  139. package/lib/middleware/flag-context.js +34 -0
  140. package/lib/middleware/gpc.js +33 -0
  141. package/lib/middleware/headers.js +35 -0
  142. package/lib/middleware/health.js +46 -0
  143. package/lib/middleware/host-allowlist.js +30 -0
  144. package/lib/middleware/network-allowlist.js +38 -0
  145. package/lib/middleware/openapi-serve.js +34 -0
  146. package/lib/middleware/rate-limit.js +160 -18
  147. package/lib/middleware/request-id.js +36 -18
  148. package/lib/middleware/request-log.js +37 -0
  149. package/lib/middleware/require-aal.js +29 -0
  150. package/lib/middleware/require-auth.js +32 -0
  151. package/lib/middleware/require-bound-key.js +41 -0
  152. package/lib/middleware/require-content-type.js +32 -0
  153. package/lib/middleware/require-methods.js +27 -0
  154. package/lib/middleware/require-mtls.js +33 -0
  155. package/lib/middleware/require-step-up.js +37 -0
  156. package/lib/middleware/security-headers.js +44 -0
  157. package/lib/middleware/security-txt.js +38 -0
  158. package/lib/middleware/span-http-server.js +37 -0
  159. package/lib/middleware/sse.js +36 -0
  160. package/lib/middleware/trace-log-correlation.js +33 -0
  161. package/lib/middleware/trace-propagate.js +32 -0
  162. package/lib/middleware/tus-upload.js +90 -0
  163. package/lib/middleware/web-app-manifest.js +53 -0
  164. package/lib/mtls-ca.js +100 -70
  165. package/lib/network-byte-quota.js +308 -0
  166. package/lib/network-heartbeat.js +135 -0
  167. package/lib/network-tls.js +534 -4
  168. package/lib/network.js +103 -0
  169. package/lib/notify.js +114 -43
  170. package/lib/ntp-check.js +192 -51
  171. package/lib/observability.js +145 -47
  172. package/lib/openapi.js +90 -44
  173. package/lib/outbox.js +99 -1
  174. package/lib/pagination.js +168 -86
  175. package/lib/parsers/index.js +16 -5
  176. package/lib/permissions.js +93 -40
  177. package/lib/pqc-agent.js +84 -8
  178. package/lib/pqc-software.js +94 -60
  179. package/lib/process-spawn.js +95 -21
  180. package/lib/pubsub.js +96 -66
  181. package/lib/queue.js +375 -54
  182. package/lib/redact.js +793 -21
  183. package/lib/render.js +139 -47
  184. package/lib/request-helpers.js +485 -121
  185. package/lib/restore-bundle.js +142 -39
  186. package/lib/restore-rollback.js +136 -45
  187. package/lib/retention.js +178 -50
  188. package/lib/retry.js +116 -33
  189. package/lib/router.js +475 -23
  190. package/lib/safe-async.js +543 -94
  191. package/lib/safe-buffer.js +337 -41
  192. package/lib/safe-json.js +467 -62
  193. package/lib/safe-jsonpath.js +285 -0
  194. package/lib/safe-schema.js +631 -87
  195. package/lib/safe-sql.js +221 -59
  196. package/lib/safe-url.js +278 -46
  197. package/lib/sandbox-worker.js +135 -0
  198. package/lib/sandbox.js +358 -0
  199. package/lib/scheduler.js +135 -70
  200. package/lib/self-update.js +647 -0
  201. package/lib/session-device-binding.js +431 -0
  202. package/lib/session.js +259 -49
  203. package/lib/slug.js +138 -26
  204. package/lib/ssrf-guard.js +316 -56
  205. package/lib/storage.js +433 -70
  206. package/lib/subject.js +405 -23
  207. package/lib/template.js +148 -8
  208. package/lib/tenant-quota.js +545 -0
  209. package/lib/testing.js +440 -53
  210. package/lib/time.js +291 -23
  211. package/lib/tls-exporter.js +239 -0
  212. package/lib/tracing.js +90 -74
  213. package/lib/uuid.js +97 -22
  214. package/lib/vault/index.js +284 -22
  215. package/lib/vault/seal-pem-file.js +66 -0
  216. package/lib/watcher.js +368 -0
  217. package/lib/webhook.js +196 -63
  218. package/lib/websocket.js +393 -68
  219. package/lib/wiki-concepts.js +338 -0
  220. package/lib/worker-pool.js +464 -0
  221. package/package.json +3 -3
  222. package/sbom.cyclonedx.json +7 -7
package/lib/cluster.js CHANGED
@@ -1,54 +1,47 @@
1
1
  "use strict";
2
2
  /**
3
- * Cluster coordination — leader election + fencing tokens.
3
+ * @module b.cluster
4
+ * @featured true
5
+ * @nav Production
6
+ * @title Cluster
4
7
  *
5
- * Opt-in via `b.cluster.init(...)`. When init is never called, the
6
- * local process behaves as a permanent single leader: `isLeader()`
7
- * always returns true, `fencingToken()` returns 0, no heartbeat thread
8
- * runs, no DB is touched. Single-node deployments pay zero overhead.
8
+ * @intro
9
+ * Opt-in active/active leader election with fencing-tokenized writes.
10
+ * An external database is required: the framework's default provider
11
+ * stores the leader-election row, the per-chain tip rows, and the
12
+ * shared vault-key fingerprint in the same backend so every node sees
13
+ * one source of truth. When `b.cluster.init` is never called, the
14
+ * local process behaves as a permanent single leader: `isLeader()`
15
+ * always returns true, `fencingToken()` returns 0, no heartbeat runs,
16
+ * no DB is touched. Single-node deployments pay zero overhead.
9
17
  *
10
- * When init IS called, the framework starts a heartbeat that renews
11
- * the leader lease via the configured provider. On lease loss (network
12
- * partition, takeover, lease expiry) the node transitions to follower
13
- * and write-side framework primitives throw `NotLeaderError`.
18
+ * When init IS called, the framework starts a heartbeat that renews
19
+ * the leader lease via the configured provider. On lease loss (network
20
+ * partition, takeover, lease expiry) the node transitions to follower
21
+ * and write-side framework primitives throw `NotLeaderError`. The
22
+ * audit + consent chains carry a fencing token alongside every row so
23
+ * a stale leader cannot silently extend the chain after losing its
24
+ * lease — the audit-tip CHECK constraint refuses the stale token at
25
+ * the database layer. The application-level `requireLeader()` gate is
26
+ * an early-rejection optimisation; the DB constraint is canonical.
14
27
  *
15
- * Threat model:
16
- * - Two leaders writing simultaneously: prevented by fencing tokens.
17
- * Every leader-only DB write includes the current token; a
18
- * CHECK constraint on the audit-tip row rejects a stale token.
19
- * The application-layer `requireLeader()` gate is just an early
20
- * rejection optimisation; the DB constraint is the canonical guard.
21
- * - Follower receiving a write: rejected at the framework boundary.
22
- * Operators front the cluster with a load balancer that routes
23
- * write paths to the current leader.
24
- * - External-db unreachable: heartbeat fails; after `leaseTtl` no
25
- * leader exists and writes fail closed. When the DB recovers,
26
- * election resumes.
28
+ * Threat model:
29
+ * - Two leaders writing simultaneously prevented by fencing
30
+ * tokens carried into the audit-tip row.
31
+ * - Follower receiving a write rejected at the framework boundary
32
+ * via NotLeaderError. Operators front the cluster with a load
33
+ * balancer that routes write paths to the current leader; the
34
+ * discovery handler exposes which node holds the lease.
35
+ * - External-db unreachable heartbeat fails; after `leaseTtl` no
36
+ * leader exists and writes fail closed. When the DB recovers,
37
+ * election resumes.
38
+ * - Vault-key drift every node fingerprints its vault keys on
39
+ * boot and compares against a canonical fingerprint stored in
40
+ * the cluster-state row. A node holding a different key refuses
41
+ * to participate, preventing silent sealed-column corruption.
27
42
  *
28
- * Public API:
29
- * await cluster.init(opts) one-time bootstrap
30
- * cluster.isLeader() sync; true on leader (or single-node)
31
- * cluster.currentNodeId() sync; configured nodeId
32
- * cluster.endpoint() sync; this node's routable URL
33
- * (operator-supplied at init), or
34
- * null if unconfigured. Stored in
35
- * the leader-election row so
36
- * external observers can resolve
37
- * "where is the current leader?"
38
- * cluster.fencingToken() sync; current monotonic token
39
- * cluster.requireLeader() sync; throws NotLeaderError
40
- * cluster.currentLeader() async; { nodeId, leaseExpiresAt,
41
- * fencingToken,
42
- * endpoint } | null
43
- * cluster.discoveryHandler() returns an HTTP request handler
44
- * (req, res) → JSON. Mount on any
45
- * route to expose the current
46
- * leader for service-mesh / LB
47
- * consumption. 200 with leader,
48
- * 503 with `{ leader: null }`
49
- * when no leader.
50
- * cluster.onTransition(fn) register transition handler
51
- * await cluster.shutdown() releases lease, stops heartbeat
43
+ * @card
44
+ * Opt-in active/active leader election with fencing-tokenized writes.
52
45
  */
53
46
  var C = require("./constants");
54
47
  var clusterProviderDb = require("./cluster-provider-db");
@@ -131,6 +124,52 @@ function _emitTransition(kind, detail) {
131
124
 
132
125
  // ---- init ----
133
126
 
127
+ /**
128
+ * @primitive b.cluster.init
129
+ * @signature b.cluster.init(opts)
130
+ * @since 0.4.0
131
+ * @status stable
132
+ * @compliance soc2, dora
133
+ * @related b.cluster.shutdown, b.cluster.requireLeader, b.cluster.currentLeader
134
+ *
135
+ * One-time cluster bootstrap. Configures the leader-election provider,
136
+ * validates the operator-supplied endpoint, runs boot-time rollback
137
+ * detection on the audit + consent chains, fingerprints this node's
138
+ * vault keys against the canonical cluster-state row, then starts the
139
+ * heartbeat that acquires and renews the leader lease. Throws on
140
+ * second invocation, on missing nodeId, on a leaseTtl below 10s, on a
141
+ * heartbeat that doesn't fit comfortably inside the lease, on a role
142
+ * outside `leader` / `follower`, and on a chain or vault-key mismatch
143
+ * that would let this node corrupt cluster state.
144
+ *
145
+ * @opts
146
+ * nodeId: string, // required; stable identity
147
+ * role: "leader"|"follower",
148
+ * leaseTtl: number, // ms; default 30000, min 10000
149
+ * heartbeatInterval: number, // ms; default 10000, min 1000
150
+ * endpoint: string, // routable URL of THIS node
151
+ * allowedProtocols: number, // safeUrl.ALLOW_HTTP_TLS by default
152
+ * provider: object, // custom election provider
153
+ * externalDbBackend: object, // required when no custom provider
154
+ * dialect: "postgres"|"sqlite"|"mysql",
155
+ * onTransition: function (event),
156
+ *
157
+ * @example
158
+ * await b.cluster.init({
159
+ * nodeId: "api-01",
160
+ * role: "leader",
161
+ * leaseTtl: 30000,
162
+ * heartbeatInterval: 10000,
163
+ * endpoint: "https://api-01.example.internal:8443",
164
+ * externalDbBackend: b.externalDb.backend("primary"),
165
+ * dialect: "postgres",
166
+ * onTransition: function (event) {
167
+ * // event.kind ∈ { "lease-acquired", "lease-lost", "lease-released" }
168
+ * console.log("cluster transition:", event.kind, event.fencingToken);
169
+ * },
170
+ * });
171
+ * // → undefined (heartbeat now running)
172
+ */
134
173
  async function init(opts) {
135
174
  if (initialized) {
136
175
  throw _err("ALREADY_INITIALIZED", "cluster.init() called twice", true);
@@ -527,12 +566,54 @@ async function _heartbeat() {
527
566
 
528
567
  // ---- public sync surface ----
529
568
 
569
+ /**
570
+ * @primitive b.cluster.isLeader
571
+ * @signature b.cluster.isLeader()
572
+ * @since 0.4.0
573
+ * @status stable
574
+ * @related b.cluster.requireLeader, b.cluster.fencingToken, b.cluster.currentLeader
575
+ *
576
+ * Synchronous leader check. Returns `true` when this node currently
577
+ * holds a non-expired lease, OR when `b.cluster.init` was never called
578
+ * (single-node permanent-leader fallback). Returns `false` after a
579
+ * graceful `shutdown()`, after lease loss, or while a follower is
580
+ * waiting for its first lease. Cheap; safe to call on every request to
581
+ * branch leader-only work (scheduled jobs, cache warmers, write-side
582
+ * sweeps).
583
+ *
584
+ * @example
585
+ * if (b.cluster.isLeader()) {
586
+ * // Run scheduled tick on the leader only.
587
+ * await runHourlyRollup();
588
+ * }
589
+ * // → undefined
590
+ */
530
591
  function isLeader() {
531
592
  if (terminated) return false; // post-shutdown: never leader
532
593
  if (!initialized) return true; // never-initialized: permanent leader
533
594
  return !!lease && Date.now() < lease.expiresAt;
534
595
  }
535
596
 
597
+ /**
598
+ * @primitive b.cluster.isClusterMode
599
+ * @signature b.cluster.isClusterMode()
600
+ * @since 0.4.0
601
+ * @status stable
602
+ * @related b.cluster.init, b.cluster.externalDbBackend
603
+ *
604
+ * Returns `true` when `b.cluster.init` has been called AND an
605
+ * externalDbBackend is wired — i.e. framework state (audit, consent,
606
+ * fencing-tokenized writes) should route to the shared external DB.
607
+ * Returns `false` in single-node fallback or when a custom provider
608
+ * was supplied without an externalDbBackend; in that case the operator
609
+ * owns write-dispatch.
610
+ *
611
+ * @example
612
+ * if (b.cluster.isClusterMode()) {
613
+ * console.log("framework state lives on", b.cluster.externalDbBackend());
614
+ * }
615
+ * // → undefined
616
+ */
536
617
  // Has cluster.init been called with a real configuration? Used by
537
618
  // write-dispatch code (audit, consent, …) to decide whether framework
538
619
  // state should go to local SQLite or external-db.
@@ -540,31 +621,146 @@ function isClusterMode() {
540
621
  return initialized && !!configuredExternalDbBackend;
541
622
  }
542
623
 
624
+ /**
625
+ * @primitive b.cluster.externalDbBackend
626
+ * @signature b.cluster.externalDbBackend()
627
+ * @since 0.4.0
628
+ * @status stable
629
+ * @related b.cluster.init, b.cluster.dialect, b.cluster.isClusterMode
630
+ *
631
+ * Returns the externalDb backend handle wired at init, or `null` in
632
+ * single-node fallback / when a custom provider was supplied without
633
+ * one. Internal write-dispatch code (audit, consent, fencing-tokenized
634
+ * primitives) calls this to route framework state to the shared
635
+ * backend; operator code rarely needs it directly.
636
+ *
637
+ * @example
638
+ * var backend = b.cluster.externalDbBackend();
639
+ * if (backend) {
640
+ * // Framework state lands on the shared cluster DB.
641
+ * }
642
+ * // → undefined
643
+ */
543
644
  function externalDbBackend() {
544
645
  return configuredExternalDbBackend;
545
646
  }
546
647
 
648
+ /**
649
+ * @primitive b.cluster.dialect
650
+ * @signature b.cluster.dialect()
651
+ * @since 0.4.0
652
+ * @status stable
653
+ * @related b.cluster.externalDbBackend, b.cluster.init
654
+ *
655
+ * Returns the SQL dialect string wired at init — `"postgres"`,
656
+ * `"sqlite"`, or `"mysql"`. Used by write-dispatch code that emits raw
657
+ * placeholder syntax (`$1` vs `?`) against the shared backend.
658
+ *
659
+ * @example
660
+ * var ph = b.cluster.dialect() === "postgres" ? "$1" : "?";
661
+ * // → undefined
662
+ */
547
663
  function dialect() {
548
664
  return configuredDialect;
549
665
  }
550
666
 
667
+ /**
668
+ * @primitive b.cluster.currentNodeId
669
+ * @signature b.cluster.currentNodeId()
670
+ * @since 0.4.0
671
+ * @status stable
672
+ * @related b.cluster.endpoint, b.cluster.currentLeader
673
+ *
674
+ * Returns this node's configured nodeId, or `"single-node-local"` in
675
+ * the permanent-leader fallback when init was never called. Stable
676
+ * across the lifetime of the process — operators use it to tag audit
677
+ * metadata and observability events with the node identity.
678
+ *
679
+ * @example
680
+ * b.audit.safeEmit({
681
+ * action: "system.bootstrapped",
682
+ * actor: { systemNode: b.cluster.currentNodeId() },
683
+ * outcome: "success",
684
+ * });
685
+ * // → undefined
686
+ */
551
687
  function currentNodeId() {
552
688
  return initialized ? nodeId : "single-node-local";
553
689
  }
554
690
 
555
- // This node's routable endpoint (operator-configured at cluster.init).
556
- // Returns null when not configured or in single-node fallback. External
557
- // observers should call discoveryHandler() / currentLeader() instead —
558
- // this getter is for the local node's own self-identity.
691
+ /**
692
+ * @primitive b.cluster.endpoint
693
+ * @signature b.cluster.endpoint()
694
+ * @since 0.7.30
695
+ * @status stable
696
+ * @related b.cluster.discoveryHandler, b.cluster.currentLeader
697
+ *
698
+ * This node's routable endpoint URL — the value supplied as
699
+ * `opts.endpoint` to `b.cluster.init`. Returns `null` when not
700
+ * configured or in single-node fallback. External observers wanting
701
+ * to learn the leader's URL should call `discoveryHandler()` /
702
+ * `currentLeader()` instead; this getter is for the local node's own
703
+ * self-identity.
704
+ *
705
+ * @example
706
+ * var here = b.cluster.endpoint();
707
+ * // → "https://api-01.example.internal:8443"
708
+ */
559
709
  function endpoint() {
560
710
  return configuredEndpoint;
561
711
  }
562
712
 
713
+ /**
714
+ * @primitive b.cluster.fencingToken
715
+ * @signature b.cluster.fencingToken()
716
+ * @since 0.4.0
717
+ * @status stable
718
+ * @compliance soc2
719
+ * @related b.cluster.isLeader, b.cluster.currentLeader
720
+ *
721
+ * Current monotonic fencing token for this node's lease. Increments
722
+ * with every successful acquisition; a stale leader's token is
723
+ * strictly less than the new leader's, and the audit-tip CHECK
724
+ * constraint refuses inserts carrying a stale token. Returns `0` when
725
+ * no lease is held (follower, between leases, single-node fallback).
726
+ *
727
+ * @example
728
+ * var token = b.cluster.fencingToken();
729
+ * // → 42
730
+ */
563
731
  function fencingToken() {
564
732
  if (!initialized) return 0;
565
733
  return lease ? lease.fencingToken : 0;
566
734
  }
567
735
 
736
+ /**
737
+ * @primitive b.cluster.requireLeader
738
+ * @signature b.cluster.requireLeader()
739
+ * @since 0.4.0
740
+ * @status stable
741
+ * @related b.cluster.isLeader, b.cluster.currentLeader
742
+ *
743
+ * Throws `NotLeaderError` (statusCode 503) when this node is not the
744
+ * current leader. Use at the top of write-side handlers so a follower
745
+ * receiving a misrouted request rejects fast instead of producing a
746
+ * downstream fencing-token rejection. Single-node deployments where
747
+ * init was never called short-circuit through `isLeader() === true`
748
+ * and never throw.
749
+ *
750
+ * @example
751
+ * try {
752
+ * b.cluster.requireLeader();
753
+ * await runHourlyRollup();
754
+ * } catch (e) {
755
+ * if (e.isNotLeaderError) {
756
+ * // Operator's load balancer should retry on the leader.
757
+ * res.writeHead(503).end();
758
+ * return;
759
+ * }
760
+ * throw e;
761
+ * }
762
+ * // → undefined
763
+ */
568
764
  function requireLeader() {
569
765
  if (!isLeader()) {
570
766
  throw new NotLeaderError(
@@ -574,6 +770,28 @@ function requireLeader() {
574
770
  }
575
771
  }
576
772
 
773
+ /**
774
+ * @primitive b.cluster.currentLeader
775
+ * @signature b.cluster.currentLeader()
776
+ * @since 0.4.0
777
+ * @status stable
778
+ * @related b.cluster.discoveryHandler, b.cluster.endpoint, b.cluster.isLeader
779
+ *
780
+ * Async snapshot of the cluster's current leader. Returns
781
+ * `{ nodeId, leaseExpiresAt, fencingToken, endpoint }` when a leader
782
+ * holds a non-expired lease, or `null` when no node currently holds
783
+ * the lease (election in progress, DB unreachable, lease expired).
784
+ * In single-node fallback, returns the synthetic
785
+ * `{ nodeId: "single-node-local", leaseExpiresAt: Infinity, ... }`
786
+ * record so callers don't need a second branch.
787
+ *
788
+ * @example
789
+ * var leader = await b.cluster.currentLeader();
790
+ * if (leader && leader.endpoint) {
791
+ * console.log("forward write to", leader.endpoint);
792
+ * }
793
+ * // → undefined
794
+ */
577
795
  async function currentLeader() {
578
796
  if (!initialized) {
579
797
  return {
@@ -605,6 +823,31 @@ async function currentLeader() {
605
823
  // Handler is method-agnostic so it works behind any HTTP probe shape
606
824
  // (GET, HEAD, etc.). Cache-Control: no-store to avoid stale-leader
607
825
  // responses pinned by a caching proxy during a takeover.
826
+ /**
827
+ * @primitive b.cluster.discoveryHandler
828
+ * @signature b.cluster.discoveryHandler()
829
+ * @since 0.7.30
830
+ * @status stable
831
+ * @related b.cluster.currentLeader, b.cluster.endpoint
832
+ *
833
+ * Returns an HTTP `(req, res)` handler suitable for mounting on any
834
+ * route (e.g. `/cluster/leader`). Replies 200 JSON with
835
+ * `{ leader, self }` when a leader holds the lease, 503 JSON with
836
+ * `{ leader: null, self }` when no leader exists or the DB is
837
+ * unreachable. Method-agnostic; emits `Cache-Control: no-store` so
838
+ * caching proxies don't pin a stale leader during a takeover. No auth
839
+ * — intended for infrastructure inside the trust boundary (load
840
+ * balancers, healthchecks, dashboards). Operators exposing the
841
+ * endpoint externally should layer auth via their own middleware.
842
+ *
843
+ * @example
844
+ * var leaderProbe = b.cluster.discoveryHandler();
845
+ * server.on("request", function (req, res) {
846
+ * if (req.url === "/cluster/leader") return leaderProbe(req, res);
847
+ * // ... rest of routing
848
+ * });
849
+ * // → undefined
850
+ */
608
851
  function discoveryHandler() {
609
852
  return async function (req, res) {
610
853
  var selfInfo = {
@@ -644,6 +887,31 @@ function discoveryHandler() {
644
887
  };
645
888
  }
646
889
 
890
+ /**
891
+ * @primitive b.cluster.onTransition
892
+ * @signature b.cluster.onTransition(handler)
893
+ * @since 0.4.0
894
+ * @status stable
895
+ * @related b.cluster.init, b.cluster.shutdown
896
+ *
897
+ * Register a callback fired on every cluster role transition. Event
898
+ * shape: `{ kind, nodeId, at, fencingToken? }` where `kind` is one of
899
+ * `"lease-acquired"`, `"lease-lost"`, `"lease-released"`. Multiple
900
+ * handlers can be registered; each runs in registration order and
901
+ * a throwing handler is logged but doesn't break the chain. Throws
902
+ * synchronously when `handler` is not a function.
903
+ *
904
+ * @example
905
+ * b.cluster.onTransition(function (event) {
906
+ * b.audit.safeEmit({
907
+ * action: "system.cluster_transition",
908
+ * actor: { systemNode: event.nodeId },
909
+ * outcome: "success",
910
+ * metadata: { kind: event.kind, fencingToken: event.fencingToken },
911
+ * });
912
+ * });
913
+ * // → undefined
914
+ */
647
915
  function onTransition(handler) {
648
916
  if (typeof handler !== "function") {
649
917
  throw _err("INVALID_HANDLER", "onTransition expects a function", true);
@@ -651,6 +919,29 @@ function onTransition(handler) {
651
919
  transitionHandlers.push(handler);
652
920
  }
653
921
 
922
+ /**
923
+ * @primitive b.cluster.shutdown
924
+ * @signature b.cluster.shutdown()
925
+ * @since 0.4.0
926
+ * @status stable
927
+ * @related b.cluster.init, b.cluster.onTransition
928
+ *
929
+ * Graceful cluster exit. Stops the heartbeat, releases the lease via
930
+ * the provider so the next election round can fire immediately
931
+ * (instead of waiting for `leaseTtl` to expire), emits a
932
+ * `lease-released` transition, and resets internal state. Idempotent
933
+ * when init was never called. After shutdown, `isLeader()` returns
934
+ * `false` permanently for this process; a fresh `init()` is required
935
+ * to participate again. Wire into the framework's appShutdown hook so
936
+ * SIGTERM frees the lease before the new replica boots.
937
+ *
938
+ * @example
939
+ * process.on("SIGTERM", async function () {
940
+ * await b.cluster.shutdown();
941
+ * process.exit(0);
942
+ * });
943
+ * // → undefined
944
+ */
654
945
  async function shutdown() {
655
946
  if (!initialized) return;
656
947
  if (heartbeatTimer) {
@@ -86,6 +86,69 @@ var NULL_RE_G = new RegExp(hex4(0x0000), "g");
86
86
  var NULL_BYTE = fromCp(0x0000);
87
87
  var BOM_CHAR = fromCp(0xFEFF);
88
88
 
89
+ // Unicode script-range catalog for IDN-homograph / mixed-script
90
+ // confusable detection (UTS #39). Used by guard-domain, guard-email,
91
+ // safe-url IDN host-label classification, and any future caller that
92
+ // needs "is this label entirely one writing system?". Centralizing the
93
+ // table keeps the codepoint definitions in one place — adding a script
94
+ // is a single edit.
95
+ var SCRIPT_RANGES = {
96
+ latin: [[0x0041, 0x005A], [0x0061, 0x007A],
97
+ [0x00C0, 0x024F], [0x1E00, 0x1EFF]], // allow:raw-byte-literal — Unicode script ranges
98
+ cyrillic: [[0x0400, 0x04FF], [0x0500, 0x052F]], // allow:raw-byte-literal — Unicode Cyrillic + Cyrillic Supplement
99
+ greek: [[0x0370, 0x03FF], [0x1F00, 0x1FFF]], // allow:raw-byte-literal — Unicode Greek + Greek Extended
100
+ armenian: [[0x0530, 0x058F]], // allow:raw-byte-literal — Unicode Armenian
101
+ cherokee: [[0x13A0, 0x13FF], [0xAB70, 0xABBF]], // allow:raw-byte-literal — Unicode Cherokee + Cherokee Supplement
102
+ han: [[0x4E00, 0x9FFF]], // allow:raw-byte-literal — CJK Unified Ideographs
103
+ hiragana: [[0x3040, 0x309F]], // allow:raw-byte-literal — Hiragana
104
+ katakana: [[0x30A0, 0x30FF]], // allow:raw-byte-literal — Katakana
105
+ hangul: [[0xAC00, 0xD7AF]], // allow:raw-byte-literal — Hangul Syllables
106
+ arabic: [[0x0600, 0x06FF]], // allow:raw-byte-literal — Arabic
107
+ hebrew: [[0x0590, 0x05FF]], // allow:raw-byte-literal — Hebrew
108
+ };
109
+
110
+ // scriptFor(cp) — returns the script-name string for a codepoint, or
111
+ // null when the codepoint is in a script not in the catalog (digits,
112
+ // punctuation, symbols, etc. are not script-classifying).
113
+ function scriptFor(cp) {
114
+ var keys = Object.keys(SCRIPT_RANGES);
115
+ for (var i = 0; i < keys.length; i += 1) {
116
+ var ranges = SCRIPT_RANGES[keys[i]];
117
+ for (var j = 0; j < ranges.length; j += 1) {
118
+ if (cp >= ranges[j][0] && cp <= ranges[j][1]) return keys[i];
119
+ }
120
+ }
121
+ return null;
122
+ }
123
+
124
+ // detectMixedScripts(label, allowedScripts?) — returns null when the
125
+ // label is single-script (or every script appears in the optional
126
+ // allowedScripts allowlist), or an array of the detected script names
127
+ // when the label mixes scripts (homograph attack shape — Cyrillic 'а'
128
+ // inside an otherwise-Latin label, etc.). The result is the FULL set
129
+ // of scripts seen; callers decide refuse / audit / strip.
130
+ //
131
+ // allowedScripts: an array of script names the caller treats as
132
+ // acceptable; when supplied, a label whose every script is on the list
133
+ // returns null even if multiple scripts appear (legitimate mixed-
134
+ // script content like an English word inside a Japanese label).
135
+ function detectMixedScripts(label, allowedScripts) {
136
+ if (typeof label !== "string" || label.length === 0) return null;
137
+ var seen = {};
138
+ for (var i = 0; i < label.length; i += 1) {
139
+ var script = scriptFor(label.charCodeAt(i));
140
+ if (script === null) continue;
141
+ seen[script] = true;
142
+ }
143
+ var scripts = Object.keys(seen);
144
+ if (scripts.length <= 1) return null;
145
+ if (!allowedScripts) return scripts;
146
+ for (var k = 0; k < scripts.length; k += 1) {
147
+ if (allowedScripts.indexOf(scripts[k]) === -1) return scripts;
148
+ }
149
+ return null;
150
+ }
151
+
89
152
  // detectCharThreats — returns an array of issue objects for character-
90
153
  // class threats (bidi / null / C0-control) per the opts policy. Emits
91
154
  // at most one issue per class. Used by guard-* primitives' detection
@@ -193,4 +256,7 @@ module.exports = {
193
256
  applyCharStripPolicies: applyCharStripPolicies,
194
257
  assertNoCharThreats: assertNoCharThreats,
195
258
  detectCharThreats: detectCharThreats,
259
+ SCRIPT_RANGES: SCRIPT_RANGES,
260
+ scriptFor: scriptFor,
261
+ detectMixedScripts: detectMixedScripts,
196
262
  };