@blamejs/core 0.4.1
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 +230 -0
- package/LICENSE +201 -0
- package/LTS-CALENDAR.md +29 -0
- package/MIGRATING.md +7 -0
- package/NOTICE +59 -0
- package/README.md +100 -0
- package/bin/blamejs.js +13 -0
- package/index.js +253 -0
- package/lib/api-key.js +705 -0
- package/lib/api-snapshot.js +335 -0
- package/lib/app-shutdown.js +381 -0
- package/lib/app.js +364 -0
- package/lib/atomic-file.js +525 -0
- package/lib/audit-chain.js +168 -0
- package/lib/audit-sign.js +319 -0
- package/lib/audit-tools.js +682 -0
- package/lib/audit.js +753 -0
- package/lib/auth/jwt.js +280 -0
- package/lib/auth/oauth.js +691 -0
- package/lib/auth/passkey.js +185 -0
- package/lib/auth/password.js +139 -0
- package/lib/auth/totp.js +17 -0
- package/lib/auth-header.js +81 -0
- package/lib/backup/bundle.js +219 -0
- package/lib/backup/crypto.js +174 -0
- package/lib/backup/index.js +490 -0
- package/lib/backup/manifest.js +275 -0
- package/lib/bundler.js +295 -0
- package/lib/cache.js +819 -0
- package/lib/chain-writer.js +234 -0
- package/lib/cli-helpers.js +201 -0
- package/lib/cli.js +1377 -0
- package/lib/cluster-provider-db.js +245 -0
- package/lib/cluster-storage.js +166 -0
- package/lib/cluster.js +691 -0
- package/lib/consent.js +222 -0
- package/lib/constants.js +186 -0
- package/lib/cookies.js +293 -0
- package/lib/credential-hash.js +303 -0
- package/lib/crypto-field.js +159 -0
- package/lib/crypto.js +250 -0
- package/lib/db-query.js +297 -0
- package/lib/db-schema.js +250 -0
- package/lib/db.js +1054 -0
- package/lib/deprecate.js +226 -0
- package/lib/dev.js +324 -0
- package/lib/error-page.js +424 -0
- package/lib/events.js +135 -0
- package/lib/external-db.js +422 -0
- package/lib/forms.js +378 -0
- package/lib/framework-error.js +189 -0
- package/lib/framework-schema.js +604 -0
- package/lib/handlers.js +350 -0
- package/lib/html-balance.js +227 -0
- package/lib/http-client.js +615 -0
- package/lib/i18n.js +780 -0
- package/lib/jobs.js +181 -0
- package/lib/lazy-require.js +48 -0
- package/lib/log-stream-local.js +137 -0
- package/lib/log-stream-webhook.js +170 -0
- package/lib/log-stream.js +211 -0
- package/lib/log.js +355 -0
- package/lib/mail-bounce.js +507 -0
- package/lib/mail.js +701 -0
- package/lib/metrics.js +647 -0
- package/lib/middleware/api-encrypt.js +553 -0
- package/lib/middleware/attach-user.js +156 -0
- package/lib/middleware/body-parser.js +883 -0
- package/lib/middleware/bot-guard.js +148 -0
- package/lib/middleware/compression.js +436 -0
- package/lib/middleware/cors.js +236 -0
- package/lib/middleware/csp-nonce.js +332 -0
- package/lib/middleware/csrf-protect.js +275 -0
- package/lib/middleware/error-handler.js +46 -0
- package/lib/middleware/health.js +358 -0
- package/lib/middleware/index.js +52 -0
- package/lib/middleware/rate-limit.js +319 -0
- package/lib/middleware/request-id.js +53 -0
- package/lib/middleware/require-auth.js +95 -0
- package/lib/middleware/security-headers.js +91 -0
- package/lib/migrations.js +353 -0
- package/lib/mtls-ca.js +333 -0
- package/lib/mtls-engine-default.js +285 -0
- package/lib/nonce-store.js +177 -0
- package/lib/notify.js +643 -0
- package/lib/ntp-check.js +178 -0
- package/lib/object-store/azure-blob.js +467 -0
- package/lib/object-store/gcs.js +469 -0
- package/lib/object-store/http-put.js +153 -0
- package/lib/object-store/index.js +140 -0
- package/lib/object-store/local.js +163 -0
- package/lib/object-store/retry.js +15 -0
- package/lib/object-store/sigv4.js +535 -0
- package/lib/observability.js +114 -0
- package/lib/pagination.js +371 -0
- package/lib/parsers/index.js +64 -0
- package/lib/parsers/safe-csv.js +224 -0
- package/lib/parsers/safe-env.js +614 -0
- package/lib/parsers/safe-toml.js +745 -0
- package/lib/parsers/safe-xml.js +379 -0
- package/lib/parsers/safe-yaml.js +977 -0
- package/lib/permissions.js +430 -0
- package/lib/pqc-agent.js +85 -0
- package/lib/pqc-gate.js +266 -0
- package/lib/protocol-dispatcher.js +144 -0
- package/lib/queue-local.js +327 -0
- package/lib/queue.js +430 -0
- package/lib/redact.js +192 -0
- package/lib/render.js +193 -0
- package/lib/request-helpers.js +178 -0
- package/lib/restore-bundle.js +239 -0
- package/lib/restore-rollback.js +254 -0
- package/lib/restore.js +301 -0
- package/lib/retry.js +329 -0
- package/lib/router.js +437 -0
- package/lib/safe-async.js +520 -0
- package/lib/safe-buffer.js +162 -0
- package/lib/safe-json.js +532 -0
- package/lib/safe-schema.js +1176 -0
- package/lib/safe-sql.js +157 -0
- package/lib/safe-url.js +109 -0
- package/lib/scheduler.js +680 -0
- package/lib/seeders.js +622 -0
- package/lib/session.js +304 -0
- package/lib/slug.js +243 -0
- package/lib/static.js +268 -0
- package/lib/storage.js +470 -0
- package/lib/subject.js +281 -0
- package/lib/template.js +781 -0
- package/lib/testing.js +621 -0
- package/lib/totp.js +285 -0
- package/lib/tracing.js +484 -0
- package/lib/validate-opts.js +56 -0
- package/lib/vault/index.js +299 -0
- package/lib/vault/passphrase-ops.js +311 -0
- package/lib/vault/passphrase-source.js +198 -0
- package/lib/vault/rotate.js +761 -0
- package/lib/vault/wrap.js +289 -0
- package/lib/vendor/MANIFEST.json +84 -0
- package/lib/vendor/argon2/argon2.cjs +466 -0
- package/lib/vendor/argon2/argon2.d.cts +62 -0
- package/lib/vendor/argon2/package.json +1 -0
- package/lib/vendor/argon2/prebuilds/darwin-arm64/argon2.armv8.glibc.node +0 -0
- package/lib/vendor/argon2/prebuilds/darwin-x64/argon2.glibc.node +0 -0
- package/lib/vendor/argon2/prebuilds/freebsd-arm64/argon2.armv8.glibc.node +0 -0
- package/lib/vendor/argon2/prebuilds/freebsd-x64/argon2.glibc.node +0 -0
- package/lib/vendor/argon2/prebuilds/linux-arm/argon2.armv7.glibc.node +0 -0
- package/lib/vendor/argon2/prebuilds/linux-arm/argon2.armv7.musl.node +0 -0
- package/lib/vendor/argon2/prebuilds/linux-arm64/argon2.armv8.glibc.node +0 -0
- package/lib/vendor/argon2/prebuilds/linux-arm64/argon2.armv8.musl.node +0 -0
- package/lib/vendor/argon2/prebuilds/linux-x64/argon2.glibc.node +0 -0
- package/lib/vendor/argon2/prebuilds/linux-x64/argon2.musl.node +0 -0
- package/lib/vendor/argon2/prebuilds/win32-x64/argon2.glibc.node +0 -0
- package/lib/vendor/noble-ciphers.cjs +9 -0
- package/lib/vendor/pki.cjs +181 -0
- package/lib/vendor/simplewebauthn-server.cjs +328 -0
- package/lib/webhook.js +632 -0
- package/lib/websocket-channels.js +413 -0
- package/lib/websocket.js +833 -0
- package/package.json +39 -0
package/lib/session.js
ADDED
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Session store — DB-backed, vault-sealed, sid-hashed-at-rest.
|
|
4
|
+
*
|
|
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.
|
|
11
|
+
*
|
|
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.
|
|
21
|
+
*
|
|
22
|
+
* Public API:
|
|
23
|
+
*
|
|
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
|
|
31
|
+
*
|
|
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)
|
|
37
|
+
*/
|
|
38
|
+
var { generateToken, sha3Hash } = require("./crypto");
|
|
39
|
+
var safeJson = require("./safe-json");
|
|
40
|
+
var cryptoField = require("./crypto-field");
|
|
41
|
+
var clusterStorage = require("./cluster-storage");
|
|
42
|
+
var cluster = require("./cluster");
|
|
43
|
+
var C = require("./constants");
|
|
44
|
+
var { SessionError } = require("./framework-error");
|
|
45
|
+
|
|
46
|
+
var _err = SessionError.factory;
|
|
47
|
+
|
|
48
|
+
var DEFAULT_TTL_MS = C.TIME.days(7);
|
|
49
|
+
var SID_NAMESPACE = "bj-session:";
|
|
50
|
+
|
|
51
|
+
// Column order used for INSERT — kept as a constant so the placeholders
|
|
52
|
+
// list and the values list stay in sync. Must match _blamejs_sessions's
|
|
53
|
+
// schema in db.js (single-node) and framework-schema.js (cluster mode).
|
|
54
|
+
var SESSION_COLS = ["sidHash", "userId", "userIdHash", "data", "createdAt", "expiresAt", "lastActivity"];
|
|
55
|
+
|
|
56
|
+
function _hashSid(sid) {
|
|
57
|
+
return sha3Hash(SID_NAMESPACE + sid);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Build a sealed row object with all SESSION_COLS keys present (null
|
|
61
|
+
// where not set). The cryptoField.sealRow call seals userId/data and
|
|
62
|
+
// produces userIdHash from userId.
|
|
63
|
+
function _sealForInsert(row) {
|
|
64
|
+
var sealed = cryptoField.sealRow("_blamejs_sessions", row);
|
|
65
|
+
for (var i = 0; i < SESSION_COLS.length; i++) {
|
|
66
|
+
if (!(SESSION_COLS[i] in sealed)) sealed[SESSION_COLS[i]] = null;
|
|
67
|
+
}
|
|
68
|
+
return sealed;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ---- Public API ----
|
|
72
|
+
|
|
73
|
+
async function create(opts) {
|
|
74
|
+
cluster.requireLeader();
|
|
75
|
+
if (!opts || !opts.userId) {
|
|
76
|
+
throw _err("INVALID_ARG", "session.create requires { userId }", true);
|
|
77
|
+
}
|
|
78
|
+
var ttl = typeof opts.ttlMs === "number" ? opts.ttlMs : DEFAULT_TTL_MS;
|
|
79
|
+
if (ttl <= 0) throw _err("INVALID_ARG", "session.create: ttlMs must be > 0", true);
|
|
80
|
+
|
|
81
|
+
var sid = generateToken(32); // 64 hex chars; only place the plaintext sid lives
|
|
82
|
+
var sidHash = _hashSid(sid);
|
|
83
|
+
var nowMs = Date.now();
|
|
84
|
+
var expiresAt = nowMs + ttl;
|
|
85
|
+
|
|
86
|
+
var sealed = _sealForInsert({
|
|
87
|
+
sidHash: sidHash,
|
|
88
|
+
userId: opts.userId,
|
|
89
|
+
data: opts.data ? JSON.stringify(opts.data) : null,
|
|
90
|
+
createdAt: nowMs,
|
|
91
|
+
expiresAt: expiresAt,
|
|
92
|
+
lastActivity: nowMs,
|
|
93
|
+
});
|
|
94
|
+
var values = SESSION_COLS.map(function (c) { return sealed[c]; });
|
|
95
|
+
var placeholders = SESSION_COLS.map(function () { return "?"; }).join(", ");
|
|
96
|
+
var quoted = SESSION_COLS.map(function (c) { return '"' + c + '"'; }).join(", ");
|
|
97
|
+
await clusterStorage.execute(
|
|
98
|
+
"INSERT INTO _blamejs_sessions (" + quoted + ") VALUES (" + placeholders + ")",
|
|
99
|
+
values
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
return { token: sid, expiresAt: expiresAt };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function verify(token) {
|
|
106
|
+
if (typeof token !== "string" || token.length === 0) return null;
|
|
107
|
+
var sidHash = _hashSid(token);
|
|
108
|
+
|
|
109
|
+
var row = await clusterStorage.executeOne(
|
|
110
|
+
"SELECT sidHash, userId, userIdHash, data, createdAt, expiresAt, lastActivity " +
|
|
111
|
+
"FROM _blamejs_sessions WHERE sidHash = ?",
|
|
112
|
+
[sidHash]
|
|
113
|
+
);
|
|
114
|
+
if (!row) return null;
|
|
115
|
+
if (Number(row.expiresAt) < Date.now()) {
|
|
116
|
+
// Expired — clean up and return null. Cleanup is leader-only;
|
|
117
|
+
// verify is anywhere, so a follower observing an expired row
|
|
118
|
+
// skips the cleanup (next leader-side call will purge it).
|
|
119
|
+
if (cluster.isLeader()) {
|
|
120
|
+
try { await _deleteBySidHash(sidHash); } catch (_e) { /* best-effort */ }
|
|
121
|
+
}
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
// Unseal sealed columns (userId, data) using the cryptoField pipeline
|
|
125
|
+
// so we return cleartext to the caller — same shape as the previous
|
|
126
|
+
// db().from(...).first() path delivered.
|
|
127
|
+
var unsealed = cryptoField.unsealRow("_blamejs_sessions", row);
|
|
128
|
+
var data = null;
|
|
129
|
+
if (unsealed.data) {
|
|
130
|
+
try { data = safeJson.parse(unsealed.data); } catch (_e) { data = null; }
|
|
131
|
+
}
|
|
132
|
+
return {
|
|
133
|
+
userId: unsealed.userId,
|
|
134
|
+
data: data,
|
|
135
|
+
createdAt: Number(unsealed.createdAt),
|
|
136
|
+
expiresAt: Number(unsealed.expiresAt),
|
|
137
|
+
lastActivity: Number(unsealed.lastActivity),
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async function destroy(token) {
|
|
142
|
+
cluster.requireLeader();
|
|
143
|
+
if (typeof token !== "string" || token.length === 0) return false;
|
|
144
|
+
return await _deleteBySidHash(_hashSid(token));
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function _deleteBySidHash(sidHash) {
|
|
148
|
+
var result = await clusterStorage.execute(
|
|
149
|
+
"DELETE FROM _blamejs_sessions WHERE sidHash = ?",
|
|
150
|
+
[sidHash]
|
|
151
|
+
);
|
|
152
|
+
return (result.rowCount || 0) > 0;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function destroyAllForUser(userId) {
|
|
156
|
+
cluster.requireLeader();
|
|
157
|
+
if (!userId) throw _err("INVALID_ARG", "session.destroyAllForUser requires a userId", true);
|
|
158
|
+
// userId is sealed; look up via derived userIdHash.
|
|
159
|
+
var lookup = cryptoField.lookupHash("_blamejs_sessions", "userId", userId);
|
|
160
|
+
if (!lookup) {
|
|
161
|
+
throw _err("MISCONFIGURED",
|
|
162
|
+
"_blamejs_sessions schema is missing the userIdHash derived hash — framework misconfigured",
|
|
163
|
+
true);
|
|
164
|
+
}
|
|
165
|
+
var result = await clusterStorage.execute(
|
|
166
|
+
"DELETE FROM _blamejs_sessions WHERE userIdHash = ?",
|
|
167
|
+
[lookup.value]
|
|
168
|
+
);
|
|
169
|
+
return result.rowCount || 0;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async function touch(token, opts) {
|
|
173
|
+
cluster.requireLeader();
|
|
174
|
+
opts = opts || {};
|
|
175
|
+
if (typeof token !== "string" || token.length === 0) return false;
|
|
176
|
+
var sidHash = _hashSid(token);
|
|
177
|
+
var nowMs = Date.now();
|
|
178
|
+
// Two SQL paths so the SET list stays static (no dynamic column
|
|
179
|
+
// assembly) and matches the call shape clusterStorage expects.
|
|
180
|
+
// extendBy resets expiresAt relative to NOW, not relative to the
|
|
181
|
+
// current expiresAt — a soaked session with continuous traffic
|
|
182
|
+
// shouldn't accumulate unbounded expiry.
|
|
183
|
+
if (typeof opts.extendBy === "number" && opts.extendBy > 0) {
|
|
184
|
+
var newExpires = nowMs + opts.extendBy;
|
|
185
|
+
var result = await clusterStorage.execute(
|
|
186
|
+
"UPDATE _blamejs_sessions SET lastActivity = ?, expiresAt = ? " +
|
|
187
|
+
"WHERE sidHash = ? AND expiresAt >= ?",
|
|
188
|
+
[nowMs, newExpires, sidHash, nowMs]
|
|
189
|
+
);
|
|
190
|
+
return (result.rowCount || 0) > 0;
|
|
191
|
+
}
|
|
192
|
+
var result2 = await clusterStorage.execute(
|
|
193
|
+
"UPDATE _blamejs_sessions SET lastActivity = ? " +
|
|
194
|
+
"WHERE sidHash = ? AND expiresAt >= ?",
|
|
195
|
+
[nowMs, sidHash, nowMs]
|
|
196
|
+
);
|
|
197
|
+
return (result2.rowCount || 0) > 0;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// rotate(oldToken, opts?) — session fixation defense. Generates a fresh
|
|
201
|
+
// sid for the same userId + data, atomically replacing the old sid in
|
|
202
|
+
// the row. Standard pattern: call after auth state changes (login from
|
|
203
|
+
// anonymous, MFA verified, role escalation) so any sid an attacker
|
|
204
|
+
// might have planted pre-login becomes invalid.
|
|
205
|
+
//
|
|
206
|
+
// opts:
|
|
207
|
+
// data: optional replacement session data (re-sealed)
|
|
208
|
+
// ttlMs: optional new TTL; if absent, expiresAt is preserved
|
|
209
|
+
// reason: free-form audit metadata ('login', 'mfa', etc.)
|
|
210
|
+
//
|
|
211
|
+
// Returns { token, expiresAt } on success, or null when the old token
|
|
212
|
+
// doesn't exist / has expired (operator distinguishes by checking
|
|
213
|
+
// for null).
|
|
214
|
+
//
|
|
215
|
+
// Atomicity: single UPDATE swaps sidHash. The old + new tokens never
|
|
216
|
+
// coexist — the moment the UPDATE commits, only the new token verifies.
|
|
217
|
+
// Backends that can't do the WHERE-guarded UPDATE atomically (none of
|
|
218
|
+
// the framework's supported backends fall in that bucket) would need
|
|
219
|
+
// a transactional shim.
|
|
220
|
+
async function rotate(oldToken, opts) {
|
|
221
|
+
cluster.requireLeader();
|
|
222
|
+
if (typeof oldToken !== "string" || oldToken.length === 0) return null;
|
|
223
|
+
opts = opts || {};
|
|
224
|
+
|
|
225
|
+
var newSid = generateToken(32);
|
|
226
|
+
var newSidHash = _hashSid(newSid);
|
|
227
|
+
var oldSidHash = _hashSid(oldToken);
|
|
228
|
+
var nowMs = Date.now();
|
|
229
|
+
var newExpires = (typeof opts.ttlMs === "number" && opts.ttlMs > 0)
|
|
230
|
+
? nowMs + opts.ttlMs : null;
|
|
231
|
+
|
|
232
|
+
var setParts = ['"sidHash" = ?', '"lastActivity" = ?'];
|
|
233
|
+
var setParams = [newSidHash, nowMs];
|
|
234
|
+
|
|
235
|
+
if (opts.data !== undefined) {
|
|
236
|
+
var dataJson = opts.data ? JSON.stringify(opts.data) : null;
|
|
237
|
+
var sealedRow = cryptoField.sealRow("_blamejs_sessions", { data: dataJson });
|
|
238
|
+
setParts.push('"data" = ?');
|
|
239
|
+
setParams.push(sealedRow.data);
|
|
240
|
+
}
|
|
241
|
+
if (newExpires !== null) {
|
|
242
|
+
setParts.push('"expiresAt" = ?');
|
|
243
|
+
setParams.push(newExpires);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
var sql = "UPDATE _blamejs_sessions SET " + setParts.join(", ") +
|
|
247
|
+
" WHERE sidHash = ? AND expiresAt >= ?";
|
|
248
|
+
var params = setParams.concat([oldSidHash, nowMs]);
|
|
249
|
+
var result = await clusterStorage.execute(sql, params);
|
|
250
|
+
if ((result.rowCount || 0) === 0) return null;
|
|
251
|
+
|
|
252
|
+
// Read the row's effective expiresAt to return — single source of truth.
|
|
253
|
+
var row = await clusterStorage.executeOne(
|
|
254
|
+
'SELECT "expiresAt" FROM _blamejs_sessions WHERE sidHash = ?',
|
|
255
|
+
[newSidHash]
|
|
256
|
+
);
|
|
257
|
+
var expiresAt = row ? Number(row.expiresAt) : null;
|
|
258
|
+
|
|
259
|
+
// Audit emit — best-effort. The framework's audit chain logs the
|
|
260
|
+
// privilege transition so post-incident review can trace which
|
|
261
|
+
// session id covered which privilege state.
|
|
262
|
+
try {
|
|
263
|
+
var audit = require("./audit");
|
|
264
|
+
audit.emit({
|
|
265
|
+
action: "auth.session.rotate",
|
|
266
|
+
outcome: "success",
|
|
267
|
+
metadata: { reason: opts.reason || "explicit" },
|
|
268
|
+
});
|
|
269
|
+
} catch (_e) { /* boot-order tolerance */ }
|
|
270
|
+
|
|
271
|
+
return { token: newSid, expiresAt: expiresAt };
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
async function purgeExpired() {
|
|
275
|
+
cluster.requireLeader();
|
|
276
|
+
var result = await clusterStorage.execute(
|
|
277
|
+
"DELETE FROM _blamejs_sessions WHERE expiresAt < ?",
|
|
278
|
+
[Date.now()]
|
|
279
|
+
);
|
|
280
|
+
return result.rowCount || 0;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
async function count() {
|
|
284
|
+
var row = await clusterStorage.executeOne(
|
|
285
|
+
"SELECT COUNT(*) AS c FROM _blamejs_sessions WHERE expiresAt >= ?",
|
|
286
|
+
[Date.now()]
|
|
287
|
+
);
|
|
288
|
+
return row ? Number(row.c) : 0;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function _resetForTest() { /* no module state to reset; clusterStorage and cryptoField own theirs */ }
|
|
292
|
+
|
|
293
|
+
module.exports = {
|
|
294
|
+
create: create,
|
|
295
|
+
verify: verify,
|
|
296
|
+
destroy: destroy,
|
|
297
|
+
destroyAllForUser: destroyAllForUser,
|
|
298
|
+
touch: touch,
|
|
299
|
+
rotate: rotate,
|
|
300
|
+
purgeExpired: purgeExpired,
|
|
301
|
+
count: count,
|
|
302
|
+
DEFAULT_TTL_MS: DEFAULT_TTL_MS,
|
|
303
|
+
_resetForTest: _resetForTest,
|
|
304
|
+
};
|
package/lib/slug.js
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* b.slug — URL-safe slug generation.
|
|
4
|
+
*
|
|
5
|
+
* b.slug("Hello, World!") // "hello-world"
|
|
6
|
+
* b.slug("café", { preserveUnicode: false }) // "cafe"
|
|
7
|
+
* b.slug("Привет мир", { preserveUnicode: true }) // "привет-мир"
|
|
8
|
+
*
|
|
9
|
+
* var slug = b.slug.create({ maxLength: 60 });
|
|
10
|
+
* slug("Title");
|
|
11
|
+
*
|
|
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.
|
|
22
|
+
*
|
|
23
|
+
* Validation tiers:
|
|
24
|
+
*
|
|
25
|
+
* - Opts at first call (every public fn) → Tier A (throw)
|
|
26
|
+
* - title not a string → Tier A (throw)
|
|
27
|
+
* - title normalizes to empty → Tier C (return opts.fallback)
|
|
28
|
+
* - unique() exhausts maxAttempts → Tier A (throw SlugError)
|
|
29
|
+
*
|
|
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).
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
var { SlugError } = require("./framework-error");
|
|
38
|
+
var _err = SlugError.factory;
|
|
39
|
+
|
|
40
|
+
// ---- Defaults ----
|
|
41
|
+
|
|
42
|
+
var DEFAULTS = Object.freeze({
|
|
43
|
+
separator: "-",
|
|
44
|
+
lowercase: true,
|
|
45
|
+
maxLength: 80,
|
|
46
|
+
preserveUnicode: false,
|
|
47
|
+
fallback: "",
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
// Common reserved web slugs. Operators extend (e.g. via Set union with
|
|
51
|
+
// router.getReservedSlugs()) and pass into b.slug.unique's isUsed predicate.
|
|
52
|
+
// Returned as a fresh Set on each access via the getter so callers cannot
|
|
53
|
+
// mutate the shared reference.
|
|
54
|
+
var _RESERVED = Object.freeze([
|
|
55
|
+
"admin", "api", "auth", "login", "logout", "signup", "signin", "signout",
|
|
56
|
+
"register", "settings", "account", "profile", "users", "user", "me",
|
|
57
|
+
"static", "assets", "public", "favicon.ico", "robots.txt", "sitemap.xml",
|
|
58
|
+
"health", "metrics", "ping", "status",
|
|
59
|
+
"docs", "doc", "help", "support", "terms", "privacy", "legal",
|
|
60
|
+
"search", "feed", "rss", "atom",
|
|
61
|
+
"new", "edit", "delete", "create", "update",
|
|
62
|
+
]);
|
|
63
|
+
|
|
64
|
+
// ---- Tier-A validation helpers ----
|
|
65
|
+
|
|
66
|
+
function _isPositiveInt(n) {
|
|
67
|
+
return typeof n === "number" && isFinite(n) && n >= 1 && Math.floor(n) === n;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function _validateOpts(name, opts) {
|
|
71
|
+
if (typeof opts.separator !== "string" || opts.separator.length !== 1) {
|
|
72
|
+
throw _err("BAD_OPT", name + ": separator must be a single-character string, got " +
|
|
73
|
+
typeof opts.separator + " " + JSON.stringify(opts.separator), true);
|
|
74
|
+
}
|
|
75
|
+
if (typeof opts.lowercase !== "boolean") {
|
|
76
|
+
throw _err("BAD_OPT", name + ": lowercase must be a boolean, got " + typeof opts.lowercase, true);
|
|
77
|
+
}
|
|
78
|
+
if (opts.maxLength !== null && !_isPositiveInt(opts.maxLength)) {
|
|
79
|
+
throw _err("BAD_OPT", name + ": maxLength must be a positive integer or null, got " +
|
|
80
|
+
typeof opts.maxLength + " " + JSON.stringify(opts.maxLength), true);
|
|
81
|
+
}
|
|
82
|
+
if (typeof opts.preserveUnicode !== "boolean") {
|
|
83
|
+
throw _err("BAD_OPT", name + ": preserveUnicode must be a boolean, got " +
|
|
84
|
+
typeof opts.preserveUnicode, true);
|
|
85
|
+
}
|
|
86
|
+
if (typeof opts.fallback !== "string") {
|
|
87
|
+
throw _err("BAD_OPT", name + ": fallback must be a string, got " + typeof opts.fallback, true);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ---- Core slugify ----
|
|
92
|
+
|
|
93
|
+
// Drops Unicode marks (combining accents) after NFKD decomposition.
|
|
94
|
+
// The two regexes are pre-compiled because slug() is on the hot path
|
|
95
|
+
// for any title-driven workflow (uploads, bundle creation, seeders).
|
|
96
|
+
var _COMBINING_MARKS = /\p{M}+/gu;
|
|
97
|
+
// Anything outside ASCII alphanumeric → separator (default ASCII path).
|
|
98
|
+
var _NON_ASCII_ALNUM = /[^a-zA-Z0-9]+/g;
|
|
99
|
+
// Unicode-preserving path: drop punctuation, symbols, separators only.
|
|
100
|
+
// \p{P} = Punctuation, \p{S} = Symbol, \p{Z} = Separator (incl. spaces),
|
|
101
|
+
// \p{C} = Control/format. Letters and Numbers in any script pass through.
|
|
102
|
+
var _UNICODE_NON_ALNUM = /[\p{P}\p{S}\p{Z}\p{C}]+/gu;
|
|
103
|
+
|
|
104
|
+
function _slugify(title, opts) {
|
|
105
|
+
if (typeof title !== "string") {
|
|
106
|
+
throw _err("BAD_TITLE", "slug: title must be a string, got " + typeof title, true);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
var sep = opts.separator;
|
|
110
|
+
// Build a regex to collapse runs of the chosen separator. Escape since
|
|
111
|
+
// separator is operator-supplied (validated single-char, but could be
|
|
112
|
+
// a regex meta-char like "." or "-").
|
|
113
|
+
var sepEsc = sep.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
114
|
+
var collapseSep = new RegExp(sepEsc + "+", "g");
|
|
115
|
+
var trimSep = new RegExp("^" + sepEsc + "+|" + sepEsc + "+$", "g");
|
|
116
|
+
|
|
117
|
+
var s = title;
|
|
118
|
+
|
|
119
|
+
if (opts.preserveUnicode) {
|
|
120
|
+
s = s.normalize("NFC");
|
|
121
|
+
s = s.replace(_UNICODE_NON_ALNUM, sep);
|
|
122
|
+
} else {
|
|
123
|
+
s = s.normalize("NFKD").replace(_COMBINING_MARKS, "");
|
|
124
|
+
s = s.replace(_NON_ASCII_ALNUM, sep);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (opts.lowercase) {
|
|
128
|
+
// Use locale-independent toLowerCase for both paths so slug output
|
|
129
|
+
// is deterministic across hosts (Turkish-locale dotted-i etc. would
|
|
130
|
+
// otherwise drift).
|
|
131
|
+
s = s.toLowerCase();
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
s = s.replace(collapseSep, sep).replace(trimSep, "");
|
|
135
|
+
|
|
136
|
+
if (opts.maxLength !== null && s.length > opts.maxLength) {
|
|
137
|
+
s = _truncateAtSeparator(s, opts.maxLength, sep);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (s.length === 0) return opts.fallback;
|
|
141
|
+
return s;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Truncate at the last separator that keeps length ≤ maxLength.
|
|
145
|
+
// If there's no separator within the cap, fall back to char truncation
|
|
146
|
+
// (single-token long inputs).
|
|
147
|
+
function _truncateAtSeparator(s, maxLength, sep) {
|
|
148
|
+
if (s.length <= maxLength) return s;
|
|
149
|
+
var slice = s.slice(0, maxLength);
|
|
150
|
+
var lastSep = slice.lastIndexOf(sep);
|
|
151
|
+
if (lastSep > 0) return slice.slice(0, lastSep);
|
|
152
|
+
return slice;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// ---- Public surface ----
|
|
156
|
+
|
|
157
|
+
function slug(title, callOpts) {
|
|
158
|
+
var opts = Object.assign({}, DEFAULTS, callOpts || {});
|
|
159
|
+
_validateOpts("slug", opts);
|
|
160
|
+
return _slugify(title, opts);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function create(creatorOpts) {
|
|
164
|
+
var merged = Object.assign({}, DEFAULTS, creatorOpts || {});
|
|
165
|
+
_validateOpts("slug.create", merged);
|
|
166
|
+
// Bound function: per-call opts override creator opts.
|
|
167
|
+
return function boundSlug(title, callOpts) {
|
|
168
|
+
var opts = Object.assign({}, merged, callOpts || {});
|
|
169
|
+
_validateOpts("slug", opts);
|
|
170
|
+
return _slugify(title, opts);
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async function unique(title, isUsed, callOpts) {
|
|
175
|
+
if (typeof isUsed !== "function") {
|
|
176
|
+
throw _err("BAD_ISUSED", "slug.unique: isUsed must be a function, got " + typeof isUsed, true);
|
|
177
|
+
}
|
|
178
|
+
callOpts = callOpts || {};
|
|
179
|
+
var opts = Object.assign({}, DEFAULTS, callOpts);
|
|
180
|
+
_validateOpts("slug.unique", opts);
|
|
181
|
+
|
|
182
|
+
var maxAttempts = (callOpts.maxAttempts !== undefined) ? callOpts.maxAttempts : 100;
|
|
183
|
+
if (!_isPositiveInt(maxAttempts)) {
|
|
184
|
+
throw _err("BAD_OPT", "slug.unique: maxAttempts must be a positive integer, got " +
|
|
185
|
+
typeof maxAttempts + " " + JSON.stringify(maxAttempts), true);
|
|
186
|
+
}
|
|
187
|
+
var start = (callOpts.start !== undefined) ? callOpts.start : 2;
|
|
188
|
+
if (!_isPositiveInt(start)) {
|
|
189
|
+
throw _err("BAD_OPT", "slug.unique: start must be a positive integer, got " +
|
|
190
|
+
typeof start + " " + JSON.stringify(start), true);
|
|
191
|
+
}
|
|
192
|
+
var suffixSep = (callOpts.suffixSeparator !== undefined) ? callOpts.suffixSeparator : opts.separator;
|
|
193
|
+
if (typeof suffixSep !== "string" || suffixSep.length === 0) {
|
|
194
|
+
throw _err("BAD_OPT", "slug.unique: suffixSeparator must be a non-empty string, got " +
|
|
195
|
+
typeof suffixSep, true);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
var base = _slugify(title, opts);
|
|
199
|
+
// First attempt: bare base.
|
|
200
|
+
var used = await isUsed(base);
|
|
201
|
+
if (!used) return base;
|
|
202
|
+
|
|
203
|
+
// Subsequent attempts: base + suffixSep + n, starting at `start`.
|
|
204
|
+
// The bare base counted as attempt 1; we have maxAttempts-1 numeric tries left.
|
|
205
|
+
for (var i = 0; i < maxAttempts - 1; i++) {
|
|
206
|
+
var n = start + i;
|
|
207
|
+
var candidate = base + suffixSep + n;
|
|
208
|
+
// If maxLength would be exceeded by the suffix, truncate the base further
|
|
209
|
+
// so the final candidate fits.
|
|
210
|
+
if (opts.maxLength !== null && candidate.length > opts.maxLength) {
|
|
211
|
+
var roomForBase = opts.maxLength - (suffixSep.length + String(n).length);
|
|
212
|
+
if (roomForBase < 1) {
|
|
213
|
+
// Pathological case: caller's maxLength can't hold any base with this suffix.
|
|
214
|
+
throw _err("UNIQUE_EXHAUSTED",
|
|
215
|
+
"slug.unique: maxLength " + opts.maxLength + " too small for suffix '" +
|
|
216
|
+
suffixSep + n + "' (base would need " + roomForBase + " chars)", true);
|
|
217
|
+
}
|
|
218
|
+
var truncBase = _truncateAtSeparator(base, roomForBase, opts.separator);
|
|
219
|
+
candidate = truncBase + suffixSep + n;
|
|
220
|
+
}
|
|
221
|
+
var taken = await isUsed(candidate);
|
|
222
|
+
if (!taken) return candidate;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
throw _err("UNIQUE_EXHAUSTED",
|
|
226
|
+
"slug.unique: exhausted " + maxAttempts + " attempts for base '" + base + "'", true);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// b.slug is the function itself with sub-API hung off it (callable
|
|
230
|
+
// namespace pattern). Operators get the 90% case (`b.slug("title")`) in
|
|
231
|
+
// one line and reach the rest via `b.slug.create`, `b.slug.unique`,
|
|
232
|
+
// `b.slug.RESERVED`, `b.slug.DEFAULTS`, `b.slug.SlugError`.
|
|
233
|
+
//
|
|
234
|
+
// The shared RESERVED Set is intentionally mutable so an app can
|
|
235
|
+
// extend it once at boot:
|
|
236
|
+
// b.slug.RESERVED.add("my-reserved-route");
|
|
237
|
+
slug.create = create;
|
|
238
|
+
slug.unique = unique;
|
|
239
|
+
slug.RESERVED = new Set(_RESERVED);
|
|
240
|
+
slug.DEFAULTS = DEFAULTS;
|
|
241
|
+
slug.SlugError = SlugError;
|
|
242
|
+
|
|
243
|
+
module.exports = slug;
|