@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/render.js
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Response helpers — render a value into an HTTP response with the
|
|
4
|
+
* right Content-Type, status, and body shape, in one call.
|
|
5
|
+
*
|
|
6
|
+
* Without these helpers every route handler reimplements the same
|
|
7
|
+
* five lines: pick a status code, set Content-Type, JSON.stringify
|
|
8
|
+
* (or render a template), set Content-Length, end the response. With
|
|
9
|
+
* them, each response shape is a single call:
|
|
10
|
+
*
|
|
11
|
+
* render.json(res, { ok: true })
|
|
12
|
+
* render.text(res, "OK")
|
|
13
|
+
* render.redirect(res, "/login")
|
|
14
|
+
* render.htmlString(res, "<h1>Hi</h1>")
|
|
15
|
+
* r.html(res, "home", { user: req.user }) // engine-bound (see create())
|
|
16
|
+
*
|
|
17
|
+
* The template engine isn't required to use any of the non-HTML
|
|
18
|
+
* helpers. Operators who never render server-side HTML just import
|
|
19
|
+
* the module-level json/text/redirect.
|
|
20
|
+
*
|
|
21
|
+
* Public API:
|
|
22
|
+
*
|
|
23
|
+
* render.json(res, body, opts?)
|
|
24
|
+
* → JSON-stringifies body, sets Content-Type application/json;
|
|
25
|
+
* opts.status (default 200) + opts.headers merged.
|
|
26
|
+
*
|
|
27
|
+
* render.text(res, body, opts?)
|
|
28
|
+
* → text/plain. opts.status / opts.headers / opts.charset (default utf-8).
|
|
29
|
+
*
|
|
30
|
+
* render.htmlString(res, htmlString, opts?)
|
|
31
|
+
* → text/html for a pre-rendered string; same opts shape.
|
|
32
|
+
*
|
|
33
|
+
* render.redirect(res, location, opts?)
|
|
34
|
+
* → opts.status (default 302; 301/303/307/308 also valid).
|
|
35
|
+
* Location is set; body empty.
|
|
36
|
+
*
|
|
37
|
+
* render.create({ engine }) → {
|
|
38
|
+
* html(res, viewName, data?, opts?) engine-rendered HTML
|
|
39
|
+
* json, text, htmlString, redirect re-exported for one-import ergonomics
|
|
40
|
+
* }
|
|
41
|
+
* engine is a template engine instance from
|
|
42
|
+
* b.template.create({ viewsDir }). html() throws if rendering
|
|
43
|
+
* fails — wire b.middleware.errorHandler downstream to convert
|
|
44
|
+
* to a sanitized 500 response.
|
|
45
|
+
*
|
|
46
|
+
* All helpers fall through silently when res is already finished
|
|
47
|
+
* (`writableEnded === true`). Mid-stream double-writes from a route
|
|
48
|
+
* that already sent a response (e.g. a Promise rejection after
|
|
49
|
+
* res.end) won't corrupt the wire.
|
|
50
|
+
*/
|
|
51
|
+
|
|
52
|
+
var DEFAULT_CHARSET = "utf-8";
|
|
53
|
+
|
|
54
|
+
function _alreadyDone(res) {
|
|
55
|
+
return res && res.writableEnded === true;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function _writeResponse(res, status, headers, body) {
|
|
59
|
+
if (_alreadyDone(res)) return;
|
|
60
|
+
if (typeof res.writeHead === "function") {
|
|
61
|
+
res.writeHead(status, headers);
|
|
62
|
+
} else {
|
|
63
|
+
// Plain object response (for tests). Best-effort header set.
|
|
64
|
+
res.statusCode = status;
|
|
65
|
+
if (typeof res.setHeader === "function") {
|
|
66
|
+
for (var k in headers) {
|
|
67
|
+
if (Object.prototype.hasOwnProperty.call(headers, k)) res.setHeader(k, headers[k]);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
if (typeof res.end === "function") res.end(body);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function _mergedHeaders(base, extra) {
|
|
75
|
+
if (!extra) return base;
|
|
76
|
+
var out = {};
|
|
77
|
+
for (var k in base) { if (Object.prototype.hasOwnProperty.call(base, k)) out[k] = base[k]; }
|
|
78
|
+
for (var j in extra) { if (Object.prototype.hasOwnProperty.call(extra, j)) out[j] = extra[j]; }
|
|
79
|
+
return out;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Default Cache-Control for dynamic responses. Browsers heuristically
|
|
83
|
+
// cache HTML responses without explicit headers, which causes "saved
|
|
84
|
+
// changes don't appear" bugs after a POST/redirect. `no-cache` permits
|
|
85
|
+
// caching but forces revalidation on every access — server returns 200
|
|
86
|
+
// with fresh content (or 304 if unchanged) instead of the browser
|
|
87
|
+
// silently serving stale. Operators wanting a public CDN cacheable
|
|
88
|
+
// response override via `opts.headers["Cache-Control"]`.
|
|
89
|
+
var DEFAULT_DYNAMIC_CACHE_CONTROL = "private, no-cache, must-revalidate";
|
|
90
|
+
|
|
91
|
+
function json(res, body, opts) {
|
|
92
|
+
opts = opts || {};
|
|
93
|
+
var encoded = JSON.stringify(body);
|
|
94
|
+
var headers = _mergedHeaders({
|
|
95
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
96
|
+
"Content-Length": Buffer.byteLength(encoded, "utf8"),
|
|
97
|
+
"Cache-Control": DEFAULT_DYNAMIC_CACHE_CONTROL,
|
|
98
|
+
}, opts.headers);
|
|
99
|
+
_writeResponse(res, opts.status || 200, headers, encoded);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function text(res, body, opts) {
|
|
103
|
+
opts = opts || {};
|
|
104
|
+
var encoded = body == null ? "" : String(body);
|
|
105
|
+
var charset = opts.charset || DEFAULT_CHARSET;
|
|
106
|
+
var headers = _mergedHeaders({
|
|
107
|
+
"Content-Type": "text/plain; charset=" + charset,
|
|
108
|
+
"Content-Length": Buffer.byteLength(encoded, charset),
|
|
109
|
+
"Cache-Control": DEFAULT_DYNAMIC_CACHE_CONTROL,
|
|
110
|
+
}, opts.headers);
|
|
111
|
+
_writeResponse(res, opts.status || 200, headers, encoded);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function htmlString(res, htmlBody, opts) {
|
|
115
|
+
opts = opts || {};
|
|
116
|
+
var encoded = htmlBody == null ? "" : String(htmlBody);
|
|
117
|
+
var charset = opts.charset || DEFAULT_CHARSET;
|
|
118
|
+
var headers = _mergedHeaders({
|
|
119
|
+
"Content-Type": "text/html; charset=" + charset,
|
|
120
|
+
"Content-Length": Buffer.byteLength(encoded, charset),
|
|
121
|
+
"Cache-Control": DEFAULT_DYNAMIC_CACHE_CONTROL,
|
|
122
|
+
}, opts.headers);
|
|
123
|
+
_writeResponse(res, opts.status || 200, headers, encoded);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function redirect(res, location, opts) {
|
|
127
|
+
opts = opts || {};
|
|
128
|
+
if (typeof location !== "string" || location.length === 0) {
|
|
129
|
+
throw new Error("render.redirect: location is required");
|
|
130
|
+
}
|
|
131
|
+
var status = opts.status || 302;
|
|
132
|
+
if (status < 300 || status > 399) {
|
|
133
|
+
throw new Error("render.redirect: status must be 3xx (got " + status + ")");
|
|
134
|
+
}
|
|
135
|
+
var headers = _mergedHeaders({
|
|
136
|
+
"Location": location,
|
|
137
|
+
"Content-Length": 0,
|
|
138
|
+
"Cache-Control": DEFAULT_DYNAMIC_CACHE_CONTROL,
|
|
139
|
+
}, opts.headers);
|
|
140
|
+
_writeResponse(res, status, headers, "");
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ---- Engine-bound instance ----
|
|
144
|
+
|
|
145
|
+
function create(opts) {
|
|
146
|
+
opts = opts || {};
|
|
147
|
+
if (!opts.engine || typeof opts.engine.render !== "function") {
|
|
148
|
+
throw new Error("render.create({ engine }): engine.render must be a function " +
|
|
149
|
+
"(pass a template engine from b.template.create)");
|
|
150
|
+
}
|
|
151
|
+
var engine = opts.engine;
|
|
152
|
+
|
|
153
|
+
function html(res, viewName, data, htmlOpts) {
|
|
154
|
+
htmlOpts = htmlOpts || {};
|
|
155
|
+
// Auto-merge res.locals into template data — the framework's
|
|
156
|
+
// request-scoped surface (cspNonce, requestId, current user, etc.)
|
|
157
|
+
// lands in res.locals via middleware (csp-nonce, attach-user, etc.).
|
|
158
|
+
// Operators no longer have to thread these through every render
|
|
159
|
+
// call. Operator-supplied `data` keys take precedence over locals
|
|
160
|
+
// — explicit > implicit.
|
|
161
|
+
var merged;
|
|
162
|
+
if (res && res.locals && typeof res.locals === "object") {
|
|
163
|
+
merged = {};
|
|
164
|
+
var lk = Object.keys(res.locals);
|
|
165
|
+
for (var li = 0; li < lk.length; li++) merged[lk[li]] = res.locals[lk[li]];
|
|
166
|
+
if (data) {
|
|
167
|
+
var dk = Object.keys(data);
|
|
168
|
+
for (var di = 0; di < dk.length; di++) merged[dk[di]] = data[dk[di]];
|
|
169
|
+
}
|
|
170
|
+
} else {
|
|
171
|
+
merged = data || {};
|
|
172
|
+
}
|
|
173
|
+
var body = engine.render(viewName, merged);
|
|
174
|
+
return htmlString(res, body, htmlOpts);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return {
|
|
178
|
+
html: html,
|
|
179
|
+
htmlString: htmlString,
|
|
180
|
+
json: json,
|
|
181
|
+
text: text,
|
|
182
|
+
redirect: redirect,
|
|
183
|
+
engine: engine,
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
module.exports = {
|
|
188
|
+
create: create,
|
|
189
|
+
json: json,
|
|
190
|
+
text: text,
|
|
191
|
+
htmlString: htmlString,
|
|
192
|
+
redirect: redirect,
|
|
193
|
+
};
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* request-helpers — small shared utilities for HTTP request middleware.
|
|
4
|
+
*
|
|
5
|
+
* The framework's metrics + tracing requestMiddleware both label by
|
|
6
|
+
* route TEMPLATE and capture the final response status. They had
|
|
7
|
+
* identical implementations of:
|
|
8
|
+
*
|
|
9
|
+
* 1. Reading req.routePattern with a URL-fallback
|
|
10
|
+
* 2. Wrapping res.writeHead + reading res.statusCode at res.end
|
|
11
|
+
*
|
|
12
|
+
* This module owns the two helpers so the duplication doesn't drift —
|
|
13
|
+
* if either pattern changes (e.g. handle res.statusMessage), it changes
|
|
14
|
+
* once.
|
|
15
|
+
*
|
|
16
|
+
* Public API:
|
|
17
|
+
*
|
|
18
|
+
* resolveRoute(req)
|
|
19
|
+
* Returns req.routePattern when the router populated it,
|
|
20
|
+
* otherwise the URL with query string stripped.
|
|
21
|
+
*
|
|
22
|
+
* captureResponseStatus(res, onEnd)
|
|
23
|
+
* Wraps res.writeHead + res.end. Calls onEnd(status) once when the
|
|
24
|
+
* response ends, with the final status pulled from writeHead's
|
|
25
|
+
* argument OR from res.statusCode (modern Node handlers set it
|
|
26
|
+
* directly without going through writeHead). Operators wrap their
|
|
27
|
+
* own pre-end logic by passing it as onEnd.
|
|
28
|
+
*
|
|
29
|
+
* Returns the original (unwrapped) `res.end`. Useful for unit tests
|
|
30
|
+
* that need to assert against the original behavior.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
// extractActorContext(req) — pull the 5 W's from a request for audit
|
|
34
|
+
// chain emission. WHO/WHERE/HOW columns on _blamejs_audit_log are
|
|
35
|
+
// populated from this shape:
|
|
36
|
+
//
|
|
37
|
+
// { ip, userAgent, sessionId, requestId, method, route, userId }
|
|
38
|
+
//
|
|
39
|
+
// Every field is best-effort: missing or non-request inputs return
|
|
40
|
+
// an object with whatever could be inferred plus null elsewhere.
|
|
41
|
+
// Audit chain treats null as "unknown", so partial context is safe.
|
|
42
|
+
//
|
|
43
|
+
// Caller-supplied actor (existing actor.userId, actor.ip, etc.) is
|
|
44
|
+
// merged on top of the request-derived fields — explicit operator
|
|
45
|
+
// override always wins.
|
|
46
|
+
function extractActorContext(req, override) {
|
|
47
|
+
var ctx = {
|
|
48
|
+
ip: null,
|
|
49
|
+
userAgent: null,
|
|
50
|
+
sessionId: null,
|
|
51
|
+
requestId: null,
|
|
52
|
+
method: null,
|
|
53
|
+
route: null,
|
|
54
|
+
userId: null,
|
|
55
|
+
};
|
|
56
|
+
if (req && typeof req === "object") {
|
|
57
|
+
// Direct properties first (Express-shaped frameworks set req.ip).
|
|
58
|
+
if (typeof req.ip === "string") ctx.ip = req.ip;
|
|
59
|
+
else if (req.connection && typeof req.connection.remoteAddress === "string") {
|
|
60
|
+
ctx.ip = req.connection.remoteAddress;
|
|
61
|
+
} else if (req.socket && typeof req.socket.remoteAddress === "string") {
|
|
62
|
+
ctx.ip = req.socket.remoteAddress;
|
|
63
|
+
}
|
|
64
|
+
if (req.headers && typeof req.headers["user-agent"] === "string") {
|
|
65
|
+
ctx.userAgent = req.headers["user-agent"];
|
|
66
|
+
}
|
|
67
|
+
if (req.session && typeof req.session.id === "string") ctx.sessionId = req.session.id;
|
|
68
|
+
else if (typeof req.sessionId === "string") ctx.sessionId = req.sessionId;
|
|
69
|
+
if (typeof req.requestId === "string") ctx.requestId = req.requestId;
|
|
70
|
+
else if (req.headers && typeof req.headers["x-request-id"] === "string") {
|
|
71
|
+
ctx.requestId = req.headers["x-request-id"];
|
|
72
|
+
}
|
|
73
|
+
if (typeof req.method === "string") ctx.method = req.method;
|
|
74
|
+
ctx.route = resolveRoute(req);
|
|
75
|
+
// userId from common shapes the framework's auth surfaces produce
|
|
76
|
+
if (req.user && typeof req.user.id === "string") ctx.userId = req.user.id;
|
|
77
|
+
else if (req.user && typeof req.user.userId === "string") ctx.userId = req.user.userId;
|
|
78
|
+
else if (req.apiKey && typeof req.apiKey.ownerId === "string") ctx.userId = req.apiKey.ownerId;
|
|
79
|
+
}
|
|
80
|
+
if (override && typeof override === "object") {
|
|
81
|
+
for (var k in override) {
|
|
82
|
+
if (Object.prototype.hasOwnProperty.call(override, k) && override[k] != null) {
|
|
83
|
+
ctx[k] = override[k];
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return ctx;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function resolveRoute(req) {
|
|
91
|
+
if (req && typeof req.routePattern === "string" && req.routePattern.length > 0) {
|
|
92
|
+
return req.routePattern;
|
|
93
|
+
}
|
|
94
|
+
var url = req && req.url;
|
|
95
|
+
if (typeof url !== "string" || url.length === 0) return "/";
|
|
96
|
+
var qIdx = url.indexOf("?");
|
|
97
|
+
return qIdx === -1 ? url : url.slice(0, qIdx);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function captureResponseStatus(res, onEnd) {
|
|
101
|
+
if (!res || typeof onEnd !== "function") {
|
|
102
|
+
throw new Error("captureResponseStatus: requires (res, onEnd)");
|
|
103
|
+
}
|
|
104
|
+
var origEnd = res.end;
|
|
105
|
+
var origWriteHead = res.writeHead;
|
|
106
|
+
var statusFromWriteHead = null;
|
|
107
|
+
res.writeHead = function (s) {
|
|
108
|
+
statusFromWriteHead = s;
|
|
109
|
+
return origWriteHead.apply(res, arguments);
|
|
110
|
+
};
|
|
111
|
+
res.end = function () {
|
|
112
|
+
var status = statusFromWriteHead != null
|
|
113
|
+
? statusFromWriteHead
|
|
114
|
+
: (typeof res.statusCode === "number" ? res.statusCode : 200);
|
|
115
|
+
try { onEnd(status); }
|
|
116
|
+
catch (_e) { /* onEnd never breaks the response — caller's instrumentation issue */ }
|
|
117
|
+
return origEnd.apply(res, arguments);
|
|
118
|
+
};
|
|
119
|
+
return origEnd;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// parseQualityList — RFC 9110 §12.5 Accept-* header parser.
|
|
123
|
+
//
|
|
124
|
+
// Returns `[{ value, q }]` sorted by q descending. Used by content
|
|
125
|
+
// negotiation (`Accept-Encoding`, `Accept-Language`, `Accept`, etc.).
|
|
126
|
+
// Each Accept-* middleware previously had its own copy of this loop;
|
|
127
|
+
// extracting it here keeps the q-value semantics consistent
|
|
128
|
+
// (q=0 = explicit exclusion; clamped to [0, 1]; missing q = 1).
|
|
129
|
+
//
|
|
130
|
+
// parseQualityList("br;q=1.0, gzip;q=0.5, *;q=0")
|
|
131
|
+
// → [{ value: "br", q: 1 }, { value: "gzip", q: 0.5 }, { value: "*", q: 0 }]
|
|
132
|
+
//
|
|
133
|
+
// `value` is lowercased by default; pass `{ caseSensitive: true }` to
|
|
134
|
+
// preserve case (BCP 47 language tags want case preservation since
|
|
135
|
+
// `pt-BR` and `pt-br` resolve identically but operators may match by
|
|
136
|
+
// canonical form themselves).
|
|
137
|
+
//
|
|
138
|
+
// Bad input (non-string, empty) returns []. RFC 9110 says an absent
|
|
139
|
+
// Accept header means "accept anything"; callers handle that absence
|
|
140
|
+
// at their own layer (compression's [{ encoding: "*", q: 1 }] default
|
|
141
|
+
// vs i18n's "fall back to default locale" — different semantics).
|
|
142
|
+
var Q_VALUE_RE = /(?:^|;|\s)q\s*=\s*([0-9]*\.?[0-9]+)/i;
|
|
143
|
+
|
|
144
|
+
function parseQualityList(headerValue, opts) {
|
|
145
|
+
if (typeof headerValue !== "string" || headerValue.length === 0) return [];
|
|
146
|
+
opts = opts || {};
|
|
147
|
+
var caseSensitive = opts.caseSensitive === true;
|
|
148
|
+
var parts = headerValue.split(",");
|
|
149
|
+
var out = [];
|
|
150
|
+
for (var i = 0; i < parts.length; i++) {
|
|
151
|
+
var p = parts[i].trim();
|
|
152
|
+
if (p.length === 0) continue;
|
|
153
|
+
var semi = p.indexOf(";");
|
|
154
|
+
var value, q;
|
|
155
|
+
if (semi === -1) {
|
|
156
|
+
value = caseSensitive ? p : p.toLowerCase();
|
|
157
|
+
q = 1;
|
|
158
|
+
} else {
|
|
159
|
+
var head = p.slice(0, semi).trim();
|
|
160
|
+
value = caseSensitive ? head : head.toLowerCase();
|
|
161
|
+
var rest = p.slice(semi + 1).trim();
|
|
162
|
+
var qm = rest.match(Q_VALUE_RE);
|
|
163
|
+
q = qm ? parseFloat(qm[1]) : 1;
|
|
164
|
+
if (isNaN(q) || q < 0) q = 0;
|
|
165
|
+
if (q > 1) q = 1;
|
|
166
|
+
}
|
|
167
|
+
out.push({ value: value, q: q });
|
|
168
|
+
}
|
|
169
|
+
out.sort(function (a, b) { return b.q - a.q; });
|
|
170
|
+
return out;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
module.exports = {
|
|
174
|
+
resolveRoute: resolveRoute,
|
|
175
|
+
captureResponseStatus: captureResponseStatus,
|
|
176
|
+
extractActorContext: extractActorContext,
|
|
177
|
+
parseQualityList: parseQualityList,
|
|
178
|
+
};
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* restore-bundle — extract an encrypted backup bundle to a staging dir.
|
|
4
|
+
*
|
|
5
|
+
* The mirror of backup-bundle. Reads manifest.json from a bundle
|
|
6
|
+
* directory, decrypts each per-file blob via backup-crypto, verifies
|
|
7
|
+
* each plaintext's sha3-512 checksum matches the manifest, and writes
|
|
8
|
+
* the recovered files to a staging directory the caller then atomically
|
|
9
|
+
* swaps into place. The bundle directory itself is read-only throughout.
|
|
10
|
+
*
|
|
11
|
+
* var r = await b.restoreBundle.extract({
|
|
12
|
+
* bundleDir: "./backups/2026-04-27.bundle",
|
|
13
|
+
* stagingDir: "./data.staging", // must NOT exist
|
|
14
|
+
* passphrase: Buffer.from("operator passphrase"),
|
|
15
|
+
* filter: function (entry) { return true; }, // optional
|
|
16
|
+
* progressCallback: function (event) { ... },
|
|
17
|
+
* });
|
|
18
|
+
* // → { manifest, vaultKeyJson, fileCount, totalBytes,
|
|
19
|
+
* // stagingDir, durationMs }
|
|
20
|
+
*
|
|
21
|
+
* vaultKeyJson is the decrypted vault keypair JSON the bundle carried
|
|
22
|
+
* in manifest.vaultKeyEnc. The caller decides what to do with it:
|
|
23
|
+
* write to stagingDir/vault.key for a fresh framework boot, hand to
|
|
24
|
+
* vault.init for an in-process load, etc. — restore-bundle's job ends
|
|
25
|
+
* at recovery; vault-key placement is operator policy.
|
|
26
|
+
*
|
|
27
|
+
* filter: optional predicate that lets a caller pull a subset (only
|
|
28
|
+
* the DB, only the TLS keys, etc.). The vault key is always recovered
|
|
29
|
+
* regardless of filter so the operator can read sealed values from a
|
|
30
|
+
* partial restore.
|
|
31
|
+
*
|
|
32
|
+
* Defense:
|
|
33
|
+
* - Wrong passphrase → AEAD tag check fails on first blob →
|
|
34
|
+
* restore-bundle/decrypt-failed (no plaintext leaked, no staging
|
|
35
|
+
* left behind)
|
|
36
|
+
* - Tampered blob (single byte flip in ciphertext) → same path
|
|
37
|
+
* - encryptedSize mismatch → restore-bundle/size-mismatch (cheap
|
|
38
|
+
* pre-decrypt check)
|
|
39
|
+
* - Plaintext sha3-512 != manifest.checksum → restore-bundle/
|
|
40
|
+
* checksum-mismatch (post-decrypt integrity guard)
|
|
41
|
+
* - Missing blob file → restore-bundle/missing-blob (manifest
|
|
42
|
+
* references a path the bundle dir doesn't have)
|
|
43
|
+
* - On any failure, the partially-built stagingDir is removed so a
|
|
44
|
+
* subsequent retry isn't blocked by a stale dir
|
|
45
|
+
*/
|
|
46
|
+
|
|
47
|
+
var fs = require("fs");
|
|
48
|
+
var path = require("path");
|
|
49
|
+
var atomicFile = require("./atomic-file");
|
|
50
|
+
var backupCrypto = require("./backup/crypto");
|
|
51
|
+
var backupManifest = require("./backup/manifest");
|
|
52
|
+
var { defineClass } = require("./framework-error");
|
|
53
|
+
|
|
54
|
+
var RestoreBundleError = defineClass("RestoreBundleError", { alwaysPermanent: true });
|
|
55
|
+
|
|
56
|
+
function _emit(cb, ev) {
|
|
57
|
+
if (typeof cb === "function") {
|
|
58
|
+
try { cb(ev); } catch (_e) { /* progress-callback errors are non-fatal */ }
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function _cleanupStaging(stagingDir) {
|
|
63
|
+
// Best-effort recursive remove — if cleanup fails, surface that to
|
|
64
|
+
// the caller via stderr but never override the original error
|
|
65
|
+
// we're already throwing.
|
|
66
|
+
try { fs.rmSync(stagingDir, { recursive: true, force: true }); }
|
|
67
|
+
catch (_e) { /* best-effort */ }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function extract(opts) {
|
|
71
|
+
var t0 = Date.now();
|
|
72
|
+
opts = opts || {};
|
|
73
|
+
if (typeof opts.bundleDir !== "string" || !fs.existsSync(opts.bundleDir)) {
|
|
74
|
+
throw new RestoreBundleError("restore-bundle/no-bundle",
|
|
75
|
+
"extract: opts.bundleDir is required and must exist");
|
|
76
|
+
}
|
|
77
|
+
if (typeof opts.stagingDir !== "string" || opts.stagingDir.length === 0) {
|
|
78
|
+
throw new RestoreBundleError("restore-bundle/no-staging",
|
|
79
|
+
"extract: opts.stagingDir is required");
|
|
80
|
+
}
|
|
81
|
+
if (fs.existsSync(opts.stagingDir)) {
|
|
82
|
+
throw new RestoreBundleError("restore-bundle/staging-exists",
|
|
83
|
+
"extract: stagingDir already exists: " + opts.stagingDir +
|
|
84
|
+
" (refusing to merge into existing directory — pick a fresh path)");
|
|
85
|
+
}
|
|
86
|
+
if (!Buffer.isBuffer(opts.passphrase) && typeof opts.passphrase !== "string") {
|
|
87
|
+
throw new RestoreBundleError("restore-bundle/no-passphrase",
|
|
88
|
+
"extract: opts.passphrase is required (Buffer or string)");
|
|
89
|
+
}
|
|
90
|
+
var passphrase = opts.passphrase;
|
|
91
|
+
var bundleDir = opts.bundleDir;
|
|
92
|
+
var stagingDir = opts.stagingDir;
|
|
93
|
+
var filter = typeof opts.filter === "function" ? opts.filter : null;
|
|
94
|
+
var progress = opts.progressCallback;
|
|
95
|
+
|
|
96
|
+
// 1. Read + parse + validate manifest
|
|
97
|
+
_emit(progress, { phase: "read_manifest" });
|
|
98
|
+
var manifestPath = path.join(bundleDir, "manifest.json");
|
|
99
|
+
if (!fs.existsSync(manifestPath)) {
|
|
100
|
+
throw new RestoreBundleError("restore-bundle/missing-manifest",
|
|
101
|
+
"extract: bundleDir has no manifest.json — bundle is incomplete or not a blamejs backup");
|
|
102
|
+
}
|
|
103
|
+
var manifest;
|
|
104
|
+
try {
|
|
105
|
+
manifest = backupManifest.parse(fs.readFileSync(manifestPath, "utf8"));
|
|
106
|
+
} catch (e) {
|
|
107
|
+
if (e && e.isBackupManifestError) throw e;
|
|
108
|
+
throw new RestoreBundleError("restore-bundle/bad-manifest",
|
|
109
|
+
"extract: manifest could not be parsed: " + ((e && e.message) || String(e)));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// 2. Recover the vault key (always, regardless of filter — the
|
|
113
|
+
// operator may need it to unseal post-restore even on partial
|
|
114
|
+
// restores)
|
|
115
|
+
_emit(progress, { phase: "unwrap_vault_key" });
|
|
116
|
+
var vaultKeyJson;
|
|
117
|
+
try {
|
|
118
|
+
var vkBuf = await backupCrypto.decryptWithPassphrase(
|
|
119
|
+
Buffer.from(manifest.vaultKeyEnc, "base64"),
|
|
120
|
+
passphrase,
|
|
121
|
+
manifest.vaultKeySalt);
|
|
122
|
+
vaultKeyJson = vkBuf.toString("utf8");
|
|
123
|
+
} catch (e) {
|
|
124
|
+
if (e && e.isBackupCryptoError && e.code === "backup-crypto/decrypt-failed") {
|
|
125
|
+
throw new RestoreBundleError("restore-bundle/decrypt-failed",
|
|
126
|
+
"extract: passphrase rejected (vault key did not decrypt). " +
|
|
127
|
+
"If you have multiple backup passphrases, double-check the one supplied.");
|
|
128
|
+
}
|
|
129
|
+
throw new RestoreBundleError("restore-bundle/vault-key-recovery-failed",
|
|
130
|
+
"extract: could not recover vault key from manifest: " + ((e && e.message) || String(e)));
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
atomicFile.ensureDir(stagingDir);
|
|
134
|
+
|
|
135
|
+
// 3. Walk manifest.files; decrypt + verify + write each that passes filter
|
|
136
|
+
var fileCount = 0;
|
|
137
|
+
var totalBytes = 0;
|
|
138
|
+
|
|
139
|
+
try {
|
|
140
|
+
for (var i = 0; i < manifest.files.length; i++) {
|
|
141
|
+
var entry = manifest.files[i];
|
|
142
|
+
if (filter && !filter(entry)) {
|
|
143
|
+
_emit(progress, { phase: "skip_filtered", relativePath: entry.relativePath });
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
var blobPath = path.join(bundleDir, entry.encryptedPath);
|
|
148
|
+
if (!fs.existsSync(blobPath)) {
|
|
149
|
+
throw new RestoreBundleError("restore-bundle/missing-blob",
|
|
150
|
+
"extract: manifest references '" + entry.encryptedPath +
|
|
151
|
+
"' but the bundle has no such file");
|
|
152
|
+
}
|
|
153
|
+
var blob = fs.readFileSync(blobPath);
|
|
154
|
+
if (blob.length !== entry.encryptedSize) {
|
|
155
|
+
throw new RestoreBundleError("restore-bundle/size-mismatch",
|
|
156
|
+
"extract: blob '" + entry.encryptedPath + "' has size " + blob.length +
|
|
157
|
+
" but manifest expected " + entry.encryptedSize);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
_emit(progress, {
|
|
161
|
+
phase: "decrypt", relativePath: entry.relativePath,
|
|
162
|
+
encryptedSize: entry.encryptedSize,
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
var plaintext;
|
|
166
|
+
try {
|
|
167
|
+
plaintext = await backupCrypto.decryptWithPassphrase(blob, passphrase, entry.salt);
|
|
168
|
+
} catch (e) {
|
|
169
|
+
if (e && e.isBackupCryptoError && e.code === "backup-crypto/decrypt-failed") {
|
|
170
|
+
throw new RestoreBundleError("restore-bundle/decrypt-failed",
|
|
171
|
+
"extract: blob '" + entry.encryptedPath + "' did not decrypt — " +
|
|
172
|
+
"passphrase rejected or ciphertext tampered");
|
|
173
|
+
}
|
|
174
|
+
throw e;
|
|
175
|
+
}
|
|
176
|
+
if (plaintext.length !== entry.size) {
|
|
177
|
+
throw new RestoreBundleError("restore-bundle/size-mismatch",
|
|
178
|
+
"extract: decrypted '" + entry.relativePath +
|
|
179
|
+
"' has " + plaintext.length + " bytes but manifest expected " + entry.size);
|
|
180
|
+
}
|
|
181
|
+
var actualChecksum = backupCrypto.checksum(plaintext);
|
|
182
|
+
if (actualChecksum !== entry.checksum) {
|
|
183
|
+
throw new RestoreBundleError("restore-bundle/checksum-mismatch",
|
|
184
|
+
"extract: decrypted '" + entry.relativePath + "' has checksum " + actualChecksum +
|
|
185
|
+
" but manifest declared " + entry.checksum +
|
|
186
|
+
" — bundle is corrupted or manifest tampered");
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
var destPath = path.join(stagingDir, entry.relativePath);
|
|
190
|
+
atomicFile.ensureDir(path.dirname(destPath));
|
|
191
|
+
atomicFile.writeSync(destPath, plaintext, { fileMode: 0o600 });
|
|
192
|
+
|
|
193
|
+
fileCount++;
|
|
194
|
+
totalBytes += plaintext.length;
|
|
195
|
+
}
|
|
196
|
+
} catch (e) {
|
|
197
|
+
_cleanupStaging(stagingDir);
|
|
198
|
+
throw e;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
var durationMs = Date.now() - t0;
|
|
202
|
+
_emit(progress, {
|
|
203
|
+
phase: "done",
|
|
204
|
+
fileCount: fileCount,
|
|
205
|
+
totalBytes: totalBytes,
|
|
206
|
+
durationMs: durationMs,
|
|
207
|
+
});
|
|
208
|
+
return {
|
|
209
|
+
manifest: manifest,
|
|
210
|
+
vaultKeyJson: vaultKeyJson,
|
|
211
|
+
fileCount: fileCount,
|
|
212
|
+
totalBytes: totalBytes,
|
|
213
|
+
stagingDir: stagingDir,
|
|
214
|
+
durationMs: durationMs,
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Inspect a bundle without decrypting — read the manifest and return
|
|
219
|
+
// it. Useful for dashboards and pre-flight UI: list files, sizes,
|
|
220
|
+
// timestamps, kinds without prompting for the passphrase.
|
|
221
|
+
function inspect(opts) {
|
|
222
|
+
opts = opts || {};
|
|
223
|
+
if (typeof opts.bundleDir !== "string" || !fs.existsSync(opts.bundleDir)) {
|
|
224
|
+
throw new RestoreBundleError("restore-bundle/no-bundle",
|
|
225
|
+
"inspect: opts.bundleDir is required and must exist");
|
|
226
|
+
}
|
|
227
|
+
var manifestPath = path.join(opts.bundleDir, "manifest.json");
|
|
228
|
+
if (!fs.existsSync(manifestPath)) {
|
|
229
|
+
throw new RestoreBundleError("restore-bundle/missing-manifest",
|
|
230
|
+
"inspect: bundleDir has no manifest.json");
|
|
231
|
+
}
|
|
232
|
+
return backupManifest.parse(fs.readFileSync(manifestPath, "utf8"));
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
module.exports = {
|
|
236
|
+
extract: extract,
|
|
237
|
+
inspect: inspect,
|
|
238
|
+
RestoreBundleError: RestoreBundleError,
|
|
239
|
+
};
|