@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.
Files changed (160) hide show
  1. package/CHANGELOG.md +230 -0
  2. package/LICENSE +201 -0
  3. package/LTS-CALENDAR.md +29 -0
  4. package/MIGRATING.md +7 -0
  5. package/NOTICE +59 -0
  6. package/README.md +100 -0
  7. package/bin/blamejs.js +13 -0
  8. package/index.js +253 -0
  9. package/lib/api-key.js +705 -0
  10. package/lib/api-snapshot.js +335 -0
  11. package/lib/app-shutdown.js +381 -0
  12. package/lib/app.js +364 -0
  13. package/lib/atomic-file.js +525 -0
  14. package/lib/audit-chain.js +168 -0
  15. package/lib/audit-sign.js +319 -0
  16. package/lib/audit-tools.js +682 -0
  17. package/lib/audit.js +753 -0
  18. package/lib/auth/jwt.js +280 -0
  19. package/lib/auth/oauth.js +691 -0
  20. package/lib/auth/passkey.js +185 -0
  21. package/lib/auth/password.js +139 -0
  22. package/lib/auth/totp.js +17 -0
  23. package/lib/auth-header.js +81 -0
  24. package/lib/backup/bundle.js +219 -0
  25. package/lib/backup/crypto.js +174 -0
  26. package/lib/backup/index.js +490 -0
  27. package/lib/backup/manifest.js +275 -0
  28. package/lib/bundler.js +295 -0
  29. package/lib/cache.js +819 -0
  30. package/lib/chain-writer.js +234 -0
  31. package/lib/cli-helpers.js +201 -0
  32. package/lib/cli.js +1377 -0
  33. package/lib/cluster-provider-db.js +245 -0
  34. package/lib/cluster-storage.js +166 -0
  35. package/lib/cluster.js +691 -0
  36. package/lib/consent.js +222 -0
  37. package/lib/constants.js +186 -0
  38. package/lib/cookies.js +293 -0
  39. package/lib/credential-hash.js +303 -0
  40. package/lib/crypto-field.js +159 -0
  41. package/lib/crypto.js +250 -0
  42. package/lib/db-query.js +297 -0
  43. package/lib/db-schema.js +250 -0
  44. package/lib/db.js +1054 -0
  45. package/lib/deprecate.js +226 -0
  46. package/lib/dev.js +324 -0
  47. package/lib/error-page.js +424 -0
  48. package/lib/events.js +135 -0
  49. package/lib/external-db.js +422 -0
  50. package/lib/forms.js +378 -0
  51. package/lib/framework-error.js +189 -0
  52. package/lib/framework-schema.js +604 -0
  53. package/lib/handlers.js +350 -0
  54. package/lib/html-balance.js +227 -0
  55. package/lib/http-client.js +615 -0
  56. package/lib/i18n.js +780 -0
  57. package/lib/jobs.js +181 -0
  58. package/lib/lazy-require.js +48 -0
  59. package/lib/log-stream-local.js +137 -0
  60. package/lib/log-stream-webhook.js +170 -0
  61. package/lib/log-stream.js +211 -0
  62. package/lib/log.js +355 -0
  63. package/lib/mail-bounce.js +507 -0
  64. package/lib/mail.js +701 -0
  65. package/lib/metrics.js +647 -0
  66. package/lib/middleware/api-encrypt.js +553 -0
  67. package/lib/middleware/attach-user.js +156 -0
  68. package/lib/middleware/body-parser.js +883 -0
  69. package/lib/middleware/bot-guard.js +148 -0
  70. package/lib/middleware/compression.js +436 -0
  71. package/lib/middleware/cors.js +236 -0
  72. package/lib/middleware/csp-nonce.js +332 -0
  73. package/lib/middleware/csrf-protect.js +275 -0
  74. package/lib/middleware/error-handler.js +46 -0
  75. package/lib/middleware/health.js +358 -0
  76. package/lib/middleware/index.js +52 -0
  77. package/lib/middleware/rate-limit.js +319 -0
  78. package/lib/middleware/request-id.js +53 -0
  79. package/lib/middleware/require-auth.js +95 -0
  80. package/lib/middleware/security-headers.js +91 -0
  81. package/lib/migrations.js +353 -0
  82. package/lib/mtls-ca.js +333 -0
  83. package/lib/mtls-engine-default.js +285 -0
  84. package/lib/nonce-store.js +177 -0
  85. package/lib/notify.js +643 -0
  86. package/lib/ntp-check.js +178 -0
  87. package/lib/object-store/azure-blob.js +467 -0
  88. package/lib/object-store/gcs.js +469 -0
  89. package/lib/object-store/http-put.js +153 -0
  90. package/lib/object-store/index.js +140 -0
  91. package/lib/object-store/local.js +163 -0
  92. package/lib/object-store/retry.js +15 -0
  93. package/lib/object-store/sigv4.js +535 -0
  94. package/lib/observability.js +114 -0
  95. package/lib/pagination.js +371 -0
  96. package/lib/parsers/index.js +64 -0
  97. package/lib/parsers/safe-csv.js +224 -0
  98. package/lib/parsers/safe-env.js +614 -0
  99. package/lib/parsers/safe-toml.js +745 -0
  100. package/lib/parsers/safe-xml.js +379 -0
  101. package/lib/parsers/safe-yaml.js +977 -0
  102. package/lib/permissions.js +430 -0
  103. package/lib/pqc-agent.js +85 -0
  104. package/lib/pqc-gate.js +266 -0
  105. package/lib/protocol-dispatcher.js +144 -0
  106. package/lib/queue-local.js +327 -0
  107. package/lib/queue.js +430 -0
  108. package/lib/redact.js +192 -0
  109. package/lib/render.js +193 -0
  110. package/lib/request-helpers.js +178 -0
  111. package/lib/restore-bundle.js +239 -0
  112. package/lib/restore-rollback.js +254 -0
  113. package/lib/restore.js +301 -0
  114. package/lib/retry.js +329 -0
  115. package/lib/router.js +437 -0
  116. package/lib/safe-async.js +520 -0
  117. package/lib/safe-buffer.js +162 -0
  118. package/lib/safe-json.js +532 -0
  119. package/lib/safe-schema.js +1176 -0
  120. package/lib/safe-sql.js +157 -0
  121. package/lib/safe-url.js +109 -0
  122. package/lib/scheduler.js +680 -0
  123. package/lib/seeders.js +622 -0
  124. package/lib/session.js +304 -0
  125. package/lib/slug.js +243 -0
  126. package/lib/static.js +268 -0
  127. package/lib/storage.js +470 -0
  128. package/lib/subject.js +281 -0
  129. package/lib/template.js +781 -0
  130. package/lib/testing.js +621 -0
  131. package/lib/totp.js +285 -0
  132. package/lib/tracing.js +484 -0
  133. package/lib/validate-opts.js +56 -0
  134. package/lib/vault/index.js +299 -0
  135. package/lib/vault/passphrase-ops.js +311 -0
  136. package/lib/vault/passphrase-source.js +198 -0
  137. package/lib/vault/rotate.js +761 -0
  138. package/lib/vault/wrap.js +289 -0
  139. package/lib/vendor/MANIFEST.json +84 -0
  140. package/lib/vendor/argon2/argon2.cjs +466 -0
  141. package/lib/vendor/argon2/argon2.d.cts +62 -0
  142. package/lib/vendor/argon2/package.json +1 -0
  143. package/lib/vendor/argon2/prebuilds/darwin-arm64/argon2.armv8.glibc.node +0 -0
  144. package/lib/vendor/argon2/prebuilds/darwin-x64/argon2.glibc.node +0 -0
  145. package/lib/vendor/argon2/prebuilds/freebsd-arm64/argon2.armv8.glibc.node +0 -0
  146. package/lib/vendor/argon2/prebuilds/freebsd-x64/argon2.glibc.node +0 -0
  147. package/lib/vendor/argon2/prebuilds/linux-arm/argon2.armv7.glibc.node +0 -0
  148. package/lib/vendor/argon2/prebuilds/linux-arm/argon2.armv7.musl.node +0 -0
  149. package/lib/vendor/argon2/prebuilds/linux-arm64/argon2.armv8.glibc.node +0 -0
  150. package/lib/vendor/argon2/prebuilds/linux-arm64/argon2.armv8.musl.node +0 -0
  151. package/lib/vendor/argon2/prebuilds/linux-x64/argon2.glibc.node +0 -0
  152. package/lib/vendor/argon2/prebuilds/linux-x64/argon2.musl.node +0 -0
  153. package/lib/vendor/argon2/prebuilds/win32-x64/argon2.glibc.node +0 -0
  154. package/lib/vendor/noble-ciphers.cjs +9 -0
  155. package/lib/vendor/pki.cjs +181 -0
  156. package/lib/vendor/simplewebauthn-server.cjs +328 -0
  157. package/lib/webhook.js +632 -0
  158. package/lib/websocket-channels.js +413 -0
  159. package/lib/websocket.js +833 -0
  160. 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
+ };