@blamejs/core 0.8.43 → 0.8.50
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/session.js
CHANGED
|
@@ -1,39 +1,50 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
/**
|
|
3
|
-
*
|
|
3
|
+
* @module b.session
|
|
4
|
+
* @featured true
|
|
5
|
+
* @nav Data
|
|
6
|
+
* @title Session
|
|
4
7
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
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
|
-
*
|
|
33
|
-
* create / destroy / destroyAllForUser / touch /
|
|
34
|
-
*
|
|
35
|
-
* verify
|
|
36
|
-
*
|
|
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
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
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
|
|
3
|
+
* @module b.slug
|
|
4
|
+
* @nav Tools
|
|
5
|
+
* @title Slug
|
|
4
6
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
7
|
+
* @intro
|
|
8
|
+
* URL-safe slug generation with two normalization paths and a uniqueness
|
|
9
|
+
* helper.
|
|
8
10
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
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
|
-
*
|
|
31
|
-
* -
|
|
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);
|