@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.
Files changed (222) hide show
  1. package/CHANGELOG.md +93 -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/session.js CHANGED
@@ -1,39 +1,50 @@
1
1
  "use strict";
2
2
  /**
3
- * Session store — DB-backed, vault-sealed, sid-hashed-at-rest.
3
+ * @module b.session
4
+ * @featured true
5
+ * @nav Data
6
+ * @title Session
4
7
  *
5
- * Single-node: stored in the framework's main DB under `_blamejs_sessions`
6
- * (baked into db.js's FRAMEWORK_SCHEMA apps cannot opt out).
7
- * Cluster mode: stored in external-db under the same name (via
8
- * frameworkSchema.ensureSchema). cluster-storage.execute routes the SQL
9
- * to the right place based on cluster.isClusterMode(); session.js itself
10
- * doesn't branch on mode.
8
+ * @intro
9
+ * Server-side session store with idle + absolute timeouts, encrypted
10
+ * at rest, sealed columns, audit on every login / logout, and
11
+ * cluster-aware leader gating.
11
12
  *
12
- * Token discipline:
13
- * - The session id (sid) is a 32-byte random value returned to the caller
14
- * once. The caller stores it in a cookie / authorization header / etc.
15
- * - The DB primary key is sha3('bj-session:' || sid) — the sid itself
16
- * never lands in the database. DB exfiltration alone cannot impersonate
17
- * a session: the attacker would also need the original sid (which only
18
- * the user has).
19
- * - data is vault-sealed JSON; userId is sealed; userIdHash indexes for
20
- * destroyAllForUser without unsealing every row.
13
+ * The session id (sid) is a 32-byte random value returned to the
14
+ * caller once and stored client-side (cookie / authorization header).
15
+ * The DB primary key is `sha3('bj-session:' || sid)` the plaintext
16
+ * sid never lands in the database. DB exfiltration alone cannot
17
+ * impersonate a session: the attacker would also need the original
18
+ * sid the user holds. The `data` column is vault-sealed JSON;
19
+ * `userId` is sealed; `userIdHash` indexes for destroyAllForUser
20
+ * without unsealing every row.
21
21
  *
22
- * Public API:
22
+ * Idle + absolute timeout enforcement follows OWASP ASVS 5.0 §3.3
23
+ * and NIST SP 800-63B-4. Defaults: idle 30 minutes, absolute 12
24
+ * hours. Both shorten the effective lifetime even when the operator
25
+ * picked a long ttlMs; repeated `touch({ extendBy })` cannot push
26
+ * `expiresAt` past the absolute ceiling.
23
27
  *
24
- * session.create({ userId, data?, ttlMs? }) → { token, expiresAt }
25
- * session.verify(token) → { userId, data, createdAt, expiresAt, lastActivity } or null
26
- * session.destroy(token) boolean
27
- * session.destroyAllForUser(userId) → number deleted
28
- * session.touch(token, { extendBy? }) → boolean (updates lastActivity, optionally extends expiresAt)
29
- * session.purgeExpired() → number deleted
30
- * session.count() → number of active (non-expired) sessions
28
+ * Storage placement is mode-driven: single-node lives in the
29
+ * framework's main DB under `_blamejs_sessions` (baked into db.js's
30
+ * schema — apps cannot opt out); cluster mode lives in external-db
31
+ * under the same name. `clusterStorage.execute` routes by
32
+ * `cluster.isClusterMode()`; this module does not branch on mode.
31
33
  *
32
- * Cluster posture per blamejs-cluster-spec.md:
33
- * create / destroy / destroyAllForUser / touch / purgeExpired
34
- * leader-only (cluster.requireLeader gate at call entry)
35
- * verify / count
36
- * — anywhere (any node can read shared session state)
34
+ * Cluster posture per blamejs-cluster-spec.md:
35
+ * `create` / `destroy` / `destroyAllForUser` / `touch` / `rotate` /
36
+ * `purgeExpired` are leader-only (gated by `cluster.requireLeader`
37
+ * at call entry); `verify` and `count` run anywhere.
38
+ *
39
+ * Optional fingerprint binding: pass `{ req, fingerprintFields }` to
40
+ * `create` and `verify` to bind a session to a stable hash of
41
+ * client-IP / user-agent / accept-language. Drift produces an audit
42
+ * event and surfaces as `fingerprintDrift: true`; strict operators
43
+ * pass `requireFingerprintMatch: true` (or a `maxAnomalyScore`
44
+ * threshold with a `scorer`) to refuse the session on drift.
45
+ *
46
+ * @card
47
+ * Server-side session store with idle + absolute timeouts, encrypted at rest, sealed columns, audit on every login / logout, and cluster-aware leader gating.
37
48
  */
38
49
  var audit = require("./audit");
39
50
  var canonicalJson = require("./canonical-json");
@@ -159,6 +170,42 @@ function _hashFingerprint(sid, inputs) {
159
170
  return sha3Hash("bj-session-fingerprint:" + sid + ":" + canonical);
160
171
  }
161
172
 
173
+ /**
174
+ * @primitive b.session.create
175
+ * @signature b.session.create(opts)
176
+ * @since 0.1.0
177
+ * @related b.session.verify, b.session.rotate, b.session.destroy
178
+ *
179
+ * Mint a fresh session for a known userId and return the plaintext sid
180
+ * the caller stores client-side (cookie / authorization header). The
181
+ * sid is 32 random bytes (256-bit entropy floor); the DB stores
182
+ * `sha3('bj-session:' || sid)` so DB exfiltration alone cannot
183
+ * impersonate the session. `data` is vault-sealed JSON; `userId` is
184
+ * sealed; a derived `userIdHash` indexes for fast `destroyAllForUser`.
185
+ * Leader-only — followers raise NotLeaderError.
186
+ *
187
+ * Pass `{ req, fingerprintFields }` to bind the session to a stable
188
+ * hash of client-IP / user-agent / accept-language; the binding is
189
+ * checked on every `verify` call.
190
+ *
191
+ * @opts
192
+ * {
193
+ * userId: string, // required — opaque user id (sealed at rest)
194
+ * data?: object, // optional sealed JSON payload
195
+ * ttlMs?: number, // session lifetime; default 7d, max ~10y
196
+ * req?: IncomingMessage, // bind fingerprint to this request's signals
197
+ * fingerprintFields?: Array<string|fn>, // default ["clientIp","userAgent","acceptLanguage"]
198
+ * }
199
+ *
200
+ * @example
201
+ * var s = await b.session.create({
202
+ * userId: "user-42",
203
+ * data: { roles: ["admin"] },
204
+ * ttlMs: b.constants.TIME.hours(8),
205
+ * });
206
+ * res.setHeader("Set-Cookie", "sid=" + s.token + "; HttpOnly; Secure; SameSite=Strict");
207
+ * // → { token: "9f2c…", expiresAt: 1735689600000 }
208
+ */
162
209
  async function create(opts) {
163
210
  cluster.requireLeader();
164
211
  if (!opts || !opts.userId) {
@@ -203,6 +250,48 @@ async function create(opts) {
203
250
  return { token: sid, expiresAt: expiresAt };
204
251
  }
205
252
 
253
+ /**
254
+ * @primitive b.session.verify
255
+ * @signature b.session.verify(token, opts?)
256
+ * @since 0.1.0
257
+ * @related b.session.create, b.session.touch, b.session.rotate
258
+ *
259
+ * Look up a session by its plaintext sid, enforce TTL + idle +
260
+ * absolute timeouts, optionally check fingerprint drift, and return
261
+ * the unsealed payload. Returns `null` for unknown / expired / idle-
262
+ * expired / absolute-expired sessions; runs anywhere (leader or
263
+ * follower). On expiry, leader nodes best-effort delete the row;
264
+ * followers skip cleanup.
265
+ *
266
+ * `idleTimeoutMs` defaults to 30 minutes, `absoluteTimeoutMs` to 12
267
+ * hours; pass 0 to disable either floor. Pass `{ req }` to evaluate
268
+ * the bound fingerprint — the result carries `fingerprintDrift: true`
269
+ * on mismatch (audit event always fires). `requireFingerprintMatch:
270
+ * true` or a `maxAnomalyScore` threshold (with a `scorer` callback)
271
+ * makes drift refuse the session by returning `null`.
272
+ *
273
+ * @opts
274
+ * {
275
+ * idleTimeoutMs?: number, // default 30m; 0 disables
276
+ * absoluteTimeoutMs?: number, // default 12h; 0 disables
277
+ * req?: IncomingMessage, // for fingerprint check
278
+ * fingerprintFields?: Array<string|fn>,
279
+ * requireFingerprintMatch?: boolean, // strict — drift kills the session
280
+ * maxAnomalyScore?: number, // 0..1; drift above kills
281
+ * scorer?: function, // ({storedHash,currentInputs,currentHash,sessionAge}) -> 0..1
282
+ * }
283
+ *
284
+ * @example
285
+ * var info = await b.session.verify(req.cookies.sid, { req: req });
286
+ * if (!info) {
287
+ * res.statusCode = 401;
288
+ * res.end("login required");
289
+ * return;
290
+ * }
291
+ * var userId = info.userId;
292
+ * var roles = (info.data && info.data.roles) || [];
293
+ * // → { userId: "user-42", data: { roles: ["admin"] }, createdAt: ..., expiresAt: ..., lastActivity: ..., fingerprintDrift: false, fingerprintAnomalyScore: null }
294
+ */
206
295
  async function verify(token, verifyOpts) {
207
296
  if (typeof token !== "string" || token.length === 0) return null;
208
297
  verifyOpts = verifyOpts || {};
@@ -364,6 +453,24 @@ async function verify(token, verifyOpts) {
364
453
  };
365
454
  }
366
455
 
456
+ /**
457
+ * @primitive b.session.destroy
458
+ * @signature b.session.destroy(token)
459
+ * @since 0.1.0
460
+ * @related b.session.destroyAllForUser, b.session.create
461
+ *
462
+ * Revoke a single session by sid. Returns `true` when a row was
463
+ * deleted, `false` when the sid is unknown / already gone / empty.
464
+ * Standard logout flow: clear the client's cookie AND call
465
+ * `destroy(sid)` so the row vanishes from the DB and verify(sid)
466
+ * starts returning null cluster-wide. Leader-only.
467
+ *
468
+ * @example
469
+ * await b.session.destroy(req.cookies.sid);
470
+ * res.setHeader("Set-Cookie", "sid=; HttpOnly; Max-Age=0");
471
+ * res.end("logged out");
472
+ * // → true
473
+ */
367
474
  async function destroy(token) {
368
475
  cluster.requireLeader();
369
476
  if (typeof token !== "string" || token.length === 0) return false;
@@ -378,6 +485,24 @@ async function _deleteBySidHash(sidHash) {
378
485
  return (result.rowCount || 0) > 0;
379
486
  }
380
487
 
488
+ /**
489
+ * @primitive b.session.destroyAllForUser
490
+ * @signature b.session.destroyAllForUser(userId)
491
+ * @since 0.1.0
492
+ * @related b.session.destroy, b.session.rotate
493
+ *
494
+ * Revoke every active session for a userId at once. Returns the count
495
+ * of rows deleted. Use after password change, role revocation,
496
+ * compromised-account reports, or "log me out everywhere" UI flows.
497
+ * Lookup goes through the derived `userIdHash` — no row needs
498
+ * unsealing to find matches. Leader-only.
499
+ *
500
+ * @example
501
+ * var revoked = await b.session.destroyAllForUser("user-42");
502
+ * b.audit.emit({ action: "auth.session.revoke_all", outcome: "success",
503
+ * metadata: { userId: "user-42", count: revoked } });
504
+ * // → 3
505
+ */
381
506
  async function destroyAllForUser(userId) {
382
507
  cluster.requireLeader();
383
508
  if (!userId) throw _err("INVALID_ARG", "session.destroyAllForUser requires a userId", true);
@@ -395,6 +520,33 @@ async function destroyAllForUser(userId) {
395
520
  return result.rowCount || 0;
396
521
  }
397
522
 
523
+ /**
524
+ * @primitive b.session.touch
525
+ * @signature b.session.touch(token, opts)
526
+ * @since 0.1.0
527
+ * @related b.session.verify, b.session.rotate
528
+ *
529
+ * Refresh `lastActivity` (resets the idle-timeout countdown) and
530
+ * optionally extend `expiresAt`. Returns `true` when a non-expired
531
+ * row was updated, `false` when the sid is unknown or the row is
532
+ * already past its TTL. Pass `extendBy` to push `expiresAt` forward
533
+ * relative to NOW (not the existing expiry — soaked sessions with
534
+ * continuous traffic don't accumulate unbounded expiry); the
535
+ * framework's MAX_TTL_MS bound applies. Leader-only.
536
+ *
537
+ * @opts
538
+ * {
539
+ * extendBy?: number, // ms to set new expiresAt = now + extendBy
540
+ * }
541
+ *
542
+ * @example
543
+ * // Bump idle clock on every request:
544
+ * await b.session.touch(req.cookies.sid);
545
+ *
546
+ * // Sliding-window: extend by another 8 hours when activity continues.
547
+ * await b.session.touch(req.cookies.sid, { extendBy: b.constants.TIME.hours(8) });
548
+ * // → true
549
+ */
398
550
  async function touch(token, opts) {
399
551
  cluster.requireLeader();
400
552
  opts = opts || {};
@@ -426,26 +578,42 @@ async function touch(token, opts) {
426
578
  return (result2.rowCount || 0) > 0;
427
579
  }
428
580
 
429
- // rotate(oldToken, opts?) — session fixation defense. Generates a fresh
430
- // sid for the same userId + data, atomically replacing the old sid in
431
- // the row. Standard pattern: call after auth state changes (login from
432
- // anonymous, MFA verified, role escalation) so any sid an attacker
433
- // might have planted pre-login becomes invalid.
434
- //
435
- // opts:
436
- // data: optional replacement session data (re-sealed)
437
- // ttlMs: optional new TTL; if absent, expiresAt is preserved
438
- // reason: free-form audit metadata ('login', 'mfa', etc.)
439
- //
440
- // Returns { token, expiresAt } on success, or null when the old token
441
- // doesn't exist / has expired (operator distinguishes by checking
442
- // for null).
443
- //
444
- // Atomicity: single UPDATE swaps sidHash. The old + new tokens never
445
- // coexist the moment the UPDATE commits, only the new token verifies.
446
- // Backends that can't do the WHERE-guarded UPDATE atomically (none of
447
- // the framework's supported backends fall in that bucket) would need
448
- // a transactional shim.
581
+ /**
582
+ * @primitive b.session.rotate
583
+ * @signature b.session.rotate(oldToken, opts)
584
+ * @since 0.1.0
585
+ * @related b.session.create, b.session.verify, b.session.destroy
586
+ *
587
+ * Session-fixation defense: generate a fresh sid for the same userId +
588
+ * data, atomically replacing the old sid in the row. Call after every
589
+ * auth state change (login from anonymous, multifactor verified, role
590
+ * escalation) so any sid an attacker planted pre-login becomes invalid.
591
+ * Returns `{ token, expiresAt }` on success, `null` when the old token
592
+ * is unknown / expired (operator distinguishes by checking for null).
593
+ * Leader-only.
594
+ *
595
+ * Atomicity: a single WHERE-guarded UPDATE swaps `sidHash`. The old
596
+ * and new tokens never coexist the moment the UPDATE commits, only
597
+ * the new token verifies. Audit event `auth.session.rotate` fires
598
+ * best-effort with `metadata.reason`.
599
+ *
600
+ * @opts
601
+ * {
602
+ * data?: object, // replacement session data (re-sealed)
603
+ * ttlMs?: number, // new TTL; if absent, existing expiresAt preserved
604
+ * reason?: string, // audit metadata ("login", "mfa", "role-change")
605
+ * }
606
+ *
607
+ * @example
608
+ * var rotated = await b.session.rotate(req.cookies.sid, {
609
+ * ttlMs: b.constants.TIME.hours(8),
610
+ * reason: "mfa",
611
+ * });
612
+ * if (rotated) {
613
+ * res.setHeader("Set-Cookie", "sid=" + rotated.token + "; HttpOnly; Secure; SameSite=Strict");
614
+ * }
615
+ * // → { token: "7a1e…", expiresAt: 1735689600000 }
616
+ */
449
617
  async function rotate(oldToken, opts) {
450
618
  cluster.requireLeader();
451
619
  if (typeof oldToken !== "string" || oldToken.length === 0) return null;
@@ -502,6 +670,30 @@ async function rotate(oldToken, opts) {
502
670
  return { token: newSid, expiresAt: expiresAt };
503
671
  }
504
672
 
673
+ /**
674
+ * @primitive b.session.purgeExpired
675
+ * @signature b.session.purgeExpired()
676
+ * @since 0.1.0
677
+ * @related b.session.count, b.session.destroy
678
+ *
679
+ * Bulk-delete every row whose `expiresAt` is in the past. Returns the
680
+ * count of rows removed. The framework purges opportunistically on
681
+ * `verify` (leader-side), but a periodic sweep keeps the table from
682
+ * accumulating dead rows when verify traffic is sparse. Safe to schedule
683
+ * on a recurring timer (the framework's scheduler primitive is the
684
+ * intended caller). Leader-only.
685
+ *
686
+ * @example
687
+ * // Hourly purge from a scheduler:
688
+ * b.scheduler.every(b.constants.TIME.hours(1), async function () {
689
+ * var dropped = await b.session.purgeExpired();
690
+ * b.audit.emit({
691
+ * action: "auth.session.purge_expired", outcome: "success",
692
+ * metadata: { dropped: dropped },
693
+ * });
694
+ * });
695
+ * // → 17
696
+ */
505
697
  async function purgeExpired() {
506
698
  cluster.requireLeader();
507
699
  var result = await clusterStorage.execute(
@@ -511,6 +703,24 @@ async function purgeExpired() {
511
703
  return result.rowCount || 0;
512
704
  }
513
705
 
706
+ /**
707
+ * @primitive b.session.count
708
+ * @signature b.session.count()
709
+ * @since 0.1.0
710
+ * @related b.session.purgeExpired, b.session.destroyAllForUser
711
+ *
712
+ * Return the number of currently-live sessions (rows whose `expiresAt`
713
+ * is in the future). Useful for ops dashboards, capacity tracking, and
714
+ * "active users" metrics. Runs anywhere — leader or follower — because
715
+ * it only reads. Note that idle-timeout-eligible rows are still counted
716
+ * until a `verify` or `purgeExpired` removes them; the value is an
717
+ * upper bound on truly-active sessions.
718
+ *
719
+ * @example
720
+ * var live = await b.session.count();
721
+ * b.observability.event({ name: "session.live", value: live });
722
+ * // → 482
723
+ */
514
724
  async function count() {
515
725
  var row = await clusterStorage.executeOne(
516
726
  "SELECT COUNT(*) AS c FROM _blamejs_sessions WHERE expiresAt >= ?",
package/lib/slug.js CHANGED
@@ -1,37 +1,37 @@
1
1
  "use strict";
2
2
  /**
3
- * b.slug — URL-safe slug generation.
3
+ * @module b.slug
4
+ * @nav Tools
5
+ * @title Slug
4
6
  *
5
- * b.slug("Hello, World!") // "hello-world"
6
- * b.slug("café", { preserveUnicode: false }) // "cafe"
7
- * b.slug("Привет мир", { preserveUnicode: true }) // "привет-мир"
7
+ * @intro
8
+ * URL-safe slug generation with two normalization paths and a uniqueness
9
+ * helper.
8
10
  *
9
- * var slug = b.slug.create({ maxLength: 60 });
10
- * slug("Title");
11
+ * The default ASCII path uses Unicode NFKD decomposition + combining-mark
12
+ * strip (`café` → `cafe`) and drops anything outside `[a-zA-Z0-9]`. The
13
+ * `preserveUnicode: true` path uses NFC and only drops Unicode
14
+ * punctuation, symbols, and separators — Cyrillic, Greek, CJK, and other
15
+ * scripts pass through. Operators with non-Latin user content opt into
16
+ * preserveUnicode.
11
17
  *
12
- * var s = await b.slug.unique("Hello, World!", function (cand) {
13
- * return db.bundles.exists({ slug: cand });
14
- * });
15
- * // → "hello-world", or "hello-world-2", "hello-world-3", ...
16
- *
17
- * The default ASCII path uses Unicode NFKD decomposition + combining-mark
18
- * strip (`café` → `cafe`) and drops anything outside `[a-zA-Z0-9]`. The
19
- * `preserveUnicode: true` path uses NFC and only drops Unicode punctuation,
20
- * symbols, and separators — Cyrillic, Greek, CJK, and other scripts pass
21
- * through. Operators with non-Latin user content opt into preserveUnicode.
18
+ * `b.slug` is a callable function with the rest of the API hung off it
19
+ * (callable-namespace pattern): `b.slug.create` builds a bound slugger
20
+ * pre-configured with operator opts, and `b.slug.unique` resolves the
21
+ * first un-taken candidate against an operator-supplied `isUsed`
22
+ * predicate (numeric suffixes `-2`, `-3`, … on collision).
22
23
  *
23
- * Validation policy:
24
+ * Validation policy: opts and `title` are validated at the call site
25
+ * (throw on bad input). Titles that normalize to empty are tolerant —
26
+ * `opts.fallback` is returned instead of throwing. `unique()` exhausting
27
+ * `maxAttempts` throws `SlugError`.
24
28
  *
25
- * - Opts at first call (every public fn) throw at call site
26
- * - title not a string → throw at call site
27
- * - title normalizes to empty → return opts.fallback (tolerant)
28
- * - unique() exhausts maxAttempts → throw SlugError at call site
29
+ * Reserved web slugs (admin, api, login, ) live on `b.slug.RESERVED`
30
+ * as a mutable Set; operators extend it once at boot and pass it into
31
+ * their `isUsed` predicate.
29
32
  *
30
- * Out of scope (v1):
31
- * - Word-by-word transliteration tables (Russian English, Chinese
32
- * Pinyin). Use preserveUnicode: true as the v1 escape hatch.
33
- * - Stemming / lemmatization / stopword removal.
34
- * - HTML-tag stripping (sanitize textually before slugging).
33
+ * @card
34
+ * URL-safe slug generation with two normalization paths and a uniqueness helper.
35
35
  */
36
36
 
37
37
  var numericChecks = require("./numeric-checks");
@@ -180,12 +180,83 @@ function _truncateAtSeparator(s, maxLength, sep) {
180
180
 
181
181
  // ---- Public surface ----
182
182
 
183
+ /**
184
+ * @primitive b.slug
185
+ * @signature b.slug(title, callOpts)
186
+ * @since 0.1.0
187
+ * @related b.slug.create, b.slug.unique
188
+ *
189
+ * Slugify a title. The default path produces lowercase ASCII separated by
190
+ * `-`: accents fold (`café` → `cafe`), runs of non-alphanumerics collapse
191
+ * to a single separator, and the result is trimmed of leading/trailing
192
+ * separators and capped at `maxLength` (truncating at a separator
193
+ * boundary when possible). Empty results return `opts.fallback`.
194
+ *
195
+ * `preserveUnicode: true` keeps letters and digits in any script and
196
+ * only drops punctuation/symbols/separators — the right choice for
197
+ * non-Latin user content.
198
+ *
199
+ * @opts
200
+ * separator: string, // single-char join between tokens (default "-")
201
+ * lowercase: boolean, // lowercase output (default true, locale-independent)
202
+ * maxLength: number, // hard cap on output length, or null for none (default 80)
203
+ * preserveUnicode: boolean, // keep non-ASCII letters/digits (default false)
204
+ * fallback: string, // returned when title normalizes to empty (default "")
205
+ *
206
+ * @example
207
+ * b.slug("Hello, World!");
208
+ * // → "hello-world"
209
+ *
210
+ * b.slug("café résumé");
211
+ * // → "cafe-resume"
212
+ *
213
+ * b.slug("Привет мир", { preserveUnicode: true });
214
+ * // → "привет-мир"
215
+ *
216
+ * b.slug("a".repeat(200), { maxLength: 10 });
217
+ * // → "aaaaaaaaaa"
218
+ *
219
+ * b.slug("---", { fallback: "untitled" });
220
+ * // → "untitled"
221
+ */
183
222
  function slug(title, callOpts) {
184
223
  var opts = Object.assign({}, DEFAULTS, callOpts || {});
185
224
  _validateOpts("slug", opts);
186
225
  return _slugify(title, opts);
187
226
  }
188
227
 
228
+ /**
229
+ * @primitive b.slug.create
230
+ * @signature b.slug.create(creatorOpts)
231
+ * @since 0.1.0
232
+ * @related b.slug, b.slug.unique
233
+ *
234
+ * Build a bound slugger pre-configured with operator opts. Returns a
235
+ * function with the same signature as `b.slug` — per-call opts merge
236
+ * over the bound opts, so the operator picks defaults once at boot and
237
+ * call sites stay short. Useful when one section of the app slugs with
238
+ * non-default settings (longer maxLength, Unicode-preserving, custom
239
+ * separator).
240
+ *
241
+ * @opts
242
+ * separator: string, // single-char join between tokens (default "-")
243
+ * lowercase: boolean, // lowercase output (default true)
244
+ * maxLength: number, // hard cap on output length, or null for none (default 80)
245
+ * preserveUnicode: boolean, // keep non-ASCII letters/digits (default false)
246
+ * fallback: string, // returned when title normalizes to empty (default "")
247
+ *
248
+ * @example
249
+ * var titleSlug = b.slug.create({ maxLength: 60, preserveUnicode: true });
250
+ * titleSlug("Привет мир");
251
+ * // → "привет-мир"
252
+ *
253
+ * titleSlug("Hello, World!");
254
+ * // → "hello-world"
255
+ *
256
+ * // Per-call opts override creator opts:
257
+ * titleSlug("Hello, World!", { separator: "_" });
258
+ * // → "hello_world"
259
+ */
189
260
  function create(creatorOpts) {
190
261
  var merged = Object.assign({}, DEFAULTS, creatorOpts || {});
191
262
  _validateOpts("slug.create", merged);
@@ -197,6 +268,47 @@ function create(creatorOpts) {
197
268
  };
198
269
  }
199
270
 
271
+ /**
272
+ * @primitive b.slug.unique
273
+ * @signature b.slug.unique(title, isUsed, callOpts)
274
+ * @since 0.1.0
275
+ * @related b.slug, b.slug.create
276
+ *
277
+ * Resolve the first un-taken slug for `title` against an operator-supplied
278
+ * `isUsed(candidate)` predicate (sync or async). The bare slug is tried
279
+ * first; on collision the function appends a numeric suffix (`-2`, `-3`,
280
+ * …) and re-checks until `isUsed` returns falsy or `maxAttempts` is
281
+ * exhausted. When the suffix would push past `maxLength`, the base is
282
+ * truncated at a separator boundary so the final candidate fits. Throws
283
+ * `SlugError` on exhaustion.
284
+ *
285
+ * @opts
286
+ * separator: string, // single-char join between tokens (default "-")
287
+ * lowercase: boolean, // lowercase output (default true)
288
+ * maxLength: number, // hard cap on output length, or null for none (default 80)
289
+ * preserveUnicode: boolean, // keep non-ASCII letters/digits (default false)
290
+ * fallback: string, // returned when title normalizes to empty (default "")
291
+ * maxAttempts: number, // total tries including bare base (default 100)
292
+ * start: number, // first numeric suffix (default 2)
293
+ * suffixSeparator: string, // separator between base and suffix (default opts.separator)
294
+ *
295
+ * @example
296
+ * var taken = new Set(["hello-world", "hello-world-2"]);
297
+ * async function isUsed(cand) { return taken.has(cand); }
298
+ *
299
+ * var s1 = await b.slug.unique("Hello, World!", isUsed);
300
+ * // → "hello-world-3"
301
+ *
302
+ * var s2 = await b.slug.unique("Brand New Title", isUsed);
303
+ * // → "brand-new-title"
304
+ *
305
+ * // Custom suffix separator + start index:
306
+ * var s3 = await b.slug.unique("Hello, World!", isUsed, {
307
+ * suffixSeparator: "_",
308
+ * start: 10,
309
+ * });
310
+ * // → "hello-world_10"
311
+ */
200
312
  async function unique(title, isUsed, callOpts) {
201
313
  if (typeof isUsed !== "function") {
202
314
  throw _err("BAD_ISUSED", "slug.unique: isUsed must be a function, got " + typeof isUsed, true);