@blamejs/core 0.4.16 → 0.4.17

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 CHANGED
@@ -8,6 +8,7 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.4.x
10
10
 
11
+ - **0.4.16** (2026-04-30) — b.httpClient: interceptors + progress events
11
12
  - **0.4.15** (2026-04-30) — b.httpClient: redirect-following + outbound multipart
12
13
  - **0.4.14** (2026-04-30) — b.i18n: lazy locales + ordinal plurals + onMissingKey hook
13
14
  - **0.4.13** (2026-04-30) — b.db: streaming query results
package/index.js CHANGED
@@ -83,6 +83,7 @@ var httpClient = require("./lib/http-client");
83
83
  // the bare `b.httpClient.request(...)`. The api-encrypt module owns the
84
84
  // implementation; httpClient stays free of an api-encrypt dependency.
85
85
  httpClient.encrypted = require("./lib/middleware/api-encrypt").httpClient;
86
+ httpClient.cookieJar = require("./lib/http-client-cookie-jar");
86
87
  var websocket = require("./lib/websocket");
87
88
  var safeUrl = require("./lib/safe-url");
88
89
  var ssrfGuard = require("./lib/ssrf-guard");
@@ -0,0 +1,400 @@
1
+ "use strict";
2
+ /**
3
+ * b.httpClient.cookieJar — outbound HTTP cookie store.
4
+ *
5
+ * Holds Set-Cookie state across requests so successive calls in a flow
6
+ * (login → list → mutate → logout, OAuth code-exchange → userinfo, etc.)
7
+ * carry the right Cookie header without operators threading it by hand.
8
+ * RFC 6265 attribute coverage: Domain / Path / Expires / Max-Age /
9
+ * HttpOnly / Secure / SameSite. Public Suffix List awareness is
10
+ * deliberately deferred — operators wiring jars against trusted
11
+ * domains don't need it; re-open if a real cross-eTLD bug surfaces.
12
+ *
13
+ * var jar = b.httpClient.cookieJar.create(); // in-memory
14
+ * await b.httpClient.request({ url: loginUrl, method: "POST", body, jar });
15
+ * await b.httpClient.request({ url: meUrl, jar }); // session cookie attaches
16
+ *
17
+ * Vault-encrypted persistence — every cookie value is sealed via
18
+ * b.vault.seal before it lands in the jar's store, so a memory dump or
19
+ * core file doesn't expose plaintext values:
20
+ *
21
+ * var jar = b.httpClient.cookieJar.create({ persist: "vault", vault: b.vault });
22
+ *
23
+ * The jar's lifecycle is per-process — restart loses cookies regardless
24
+ * of persist mode. File-backed and cluster-shared persistence are
25
+ * deferred; operators wanting durability serialize via getAll() and
26
+ * restore via setFromSerialized() at boot.
27
+ *
28
+ * Outbound filtering follows RFC 6265 §5.4:
29
+ * - Domain: exact-host match by default; Domain attribute allows
30
+ * subdomain match (host must be a suffix of cookie.domain).
31
+ * - Path: request path must equal cookie.path or be path-below.
32
+ * - Secure: cookie only attaches when the request URL is https:.
33
+ * - Expiry: rows past Expires / Max-Age don't attach.
34
+ * - Sort: longer path first, then earlier creation time.
35
+ *
36
+ * Cookie shape returned from getAll():
37
+ *
38
+ * {
39
+ * name, value, domain, path, hostOnly,
40
+ * expiresAt, // unix ms, or null for session cookies
41
+ * httpOnly, secure, sameSite, // attributes
42
+ * createdAt, updatedAt,
43
+ * }
44
+ */
45
+
46
+ var safeUrl = require("./safe-url");
47
+ var { defineClass } = require("./framework-error");
48
+
49
+ var CookieJarError = defineClass("CookieJarError", { alwaysPermanent: true });
50
+ var _err = CookieJarError.factory;
51
+
52
+ var DEFAULTS = Object.freeze({
53
+ persist: "memory",
54
+ });
55
+
56
+ var VALID_PERSIST = new Set(["memory", "vault"]);
57
+ var VALID_SAMESITE = new Set(["Strict", "Lax", "None"]);
58
+
59
+ // ---- Set-Cookie parser ----
60
+
61
+ function _parseHttpDate(s) {
62
+ // node:Date handles RFC 1123 / 850 / asctime — sufficient for HTTP-date.
63
+ var t = Date.parse(s);
64
+ return isNaN(t) ? null : t;
65
+ }
66
+
67
+ function _parseSetCookie(line) {
68
+ if (typeof line !== "string" || line.length === 0) return null;
69
+ var semi = line.indexOf(";");
70
+ var head = (semi === -1 ? line : line.slice(0, semi)).trim();
71
+ var eq = head.indexOf("=");
72
+ if (eq <= 0) return null;
73
+ var name = head.slice(0, eq).trim();
74
+ var value = head.slice(eq + 1).trim();
75
+ if (!name) return null;
76
+
77
+ var attrs = {};
78
+ if (semi !== -1) {
79
+ var rest = line.slice(semi + 1);
80
+ var parts = rest.split(";");
81
+ for (var i = 0; i < parts.length; i++) {
82
+ var p = parts[i].trim();
83
+ if (!p) continue;
84
+ var pi = p.indexOf("=");
85
+ var k, v;
86
+ if (pi === -1) { k = p; v = ""; }
87
+ else { k = p.slice(0, pi).trim(); v = p.slice(pi + 1).trim(); }
88
+ attrs[k.toLowerCase()] = v;
89
+ }
90
+ }
91
+ return { name: name, value: value, attrs: attrs };
92
+ }
93
+
94
+ // ---- Domain / Path matching ----
95
+
96
+ function _domainMatch(host, cookieDomain) {
97
+ if (host === cookieDomain) return true;
98
+ if (host.length > cookieDomain.length &&
99
+ host.endsWith(cookieDomain) &&
100
+ host.charAt(host.length - cookieDomain.length - 1) === ".") {
101
+ return true;
102
+ }
103
+ return false;
104
+ }
105
+
106
+ function _pathMatch(reqPath, cookiePath) {
107
+ if (cookiePath === reqPath) return true;
108
+ if (reqPath.indexOf(cookiePath) === 0) {
109
+ if (cookiePath.charAt(cookiePath.length - 1) === "/") return true;
110
+ if (reqPath.charAt(cookiePath.length) === "/") return true;
111
+ }
112
+ return false;
113
+ }
114
+
115
+ function _defaultPath(reqPath) {
116
+ // RFC 6265 §5.1.4: take everything up to the last "/", or "/" if none.
117
+ if (typeof reqPath !== "string" || reqPath.length === 0) return "/";
118
+ var qm = reqPath.indexOf("?");
119
+ var p = qm === -1 ? reqPath : reqPath.slice(0, qm);
120
+ if (p.charAt(0) !== "/") return "/";
121
+ var lastSlash = p.lastIndexOf("/");
122
+ if (lastSlash <= 0) return "/";
123
+ return p.slice(0, lastSlash);
124
+ }
125
+
126
+ // ---- Public create ----
127
+
128
+ function create(opts) {
129
+ opts = opts || {};
130
+ var persist = opts.persist === undefined ? DEFAULTS.persist : opts.persist;
131
+ if (!VALID_PERSIST.has(persist)) {
132
+ throw _err("BAD_OPT", "cookieJar.create: persist must be 'memory' or 'vault', got " +
133
+ JSON.stringify(persist));
134
+ }
135
+ var vault = opts.vault || null;
136
+ if (persist === "vault") {
137
+ if (!vault || typeof vault.seal !== "function" || typeof vault.unseal !== "function") {
138
+ throw _err("BAD_OPT",
139
+ "cookieJar.create: persist: 'vault' requires opts.vault with seal/unseal (pass b.vault)");
140
+ }
141
+ }
142
+ var clock = typeof opts.clock === "function" ? opts.clock : Date.now;
143
+
144
+ // Storage map keyed by `<domain>|<path>|<name>` so a (domain, path)
145
+ // tuple can hold multiple cookies, but a same-tuple-same-name update
146
+ // replaces the prior row per RFC 6265 §5.3.
147
+ var store = new Map();
148
+
149
+ function _seal(plain) {
150
+ if (persist !== "vault" || plain === undefined || plain === null) return String(plain == null ? "" : plain);
151
+ return vault.seal(String(plain));
152
+ }
153
+ function _unseal(blob) {
154
+ if (persist !== "vault" || blob === undefined || blob === null) return blob == null ? "" : String(blob);
155
+ return String(vault.unseal(blob));
156
+ }
157
+
158
+ function _setOne(reqUrl, parsed) {
159
+ var u;
160
+ try { u = new URL(reqUrl); } catch (_e) { return; }
161
+ var host = u.hostname.toLowerCase();
162
+ var attrs = parsed.attrs || {};
163
+
164
+ // Domain attribute: lower-case, leading-dot stripped (RFC 6265bis).
165
+ var domainAttr = attrs.domain;
166
+ var domain;
167
+ var hostOnly;
168
+ if (domainAttr) {
169
+ var d = String(domainAttr).toLowerCase();
170
+ if (d.charAt(0) === ".") d = d.slice(1);
171
+ // Don't accept a Domain that the request host doesn't match.
172
+ if (!_domainMatch(host, d)) return;
173
+ domain = d;
174
+ hostOnly = false;
175
+ } else {
176
+ domain = host;
177
+ hostOnly = true;
178
+ }
179
+
180
+ var path = (typeof attrs.path === "string" && attrs.path.charAt(0) === "/")
181
+ ? attrs.path : _defaultPath(u.pathname);
182
+
183
+ // Expires / Max-Age. Max-Age wins when both present (RFC 6265 §5.2.2).
184
+ var now = clock();
185
+ var expiresAt = null;
186
+ if (attrs["max-age"] !== undefined) {
187
+ var maxAge = parseInt(attrs["max-age"], 10);
188
+ if (!isNaN(maxAge)) {
189
+ expiresAt = maxAge <= 0 ? 0 : (now + maxAge * 1000);
190
+ }
191
+ } else if (attrs.expires) {
192
+ expiresAt = _parseHttpDate(attrs.expires);
193
+ }
194
+
195
+ // Max-Age=0 / past Expires → delete an existing matching row.
196
+ var key = domain + "|" + path + "|" + parsed.name;
197
+ if (expiresAt !== null && expiresAt <= now) {
198
+ store.delete(key);
199
+ return;
200
+ }
201
+
202
+ var sameSiteRaw = attrs.samesite;
203
+ var sameSite = null;
204
+ if (typeof sameSiteRaw === "string") {
205
+ var ssLc = sameSiteRaw.toLowerCase();
206
+ if (ssLc === "strict") sameSite = "Strict";
207
+ else if (ssLc === "lax") sameSite = "Lax";
208
+ else if (ssLc === "none") sameSite = "None";
209
+ }
210
+
211
+ var prior = store.get(key);
212
+ store.set(key, {
213
+ name: parsed.name,
214
+ value: _seal(parsed.value),
215
+ domain: domain,
216
+ path: path,
217
+ hostOnly: hostOnly,
218
+ expiresAt: expiresAt,
219
+ httpOnly: Object.prototype.hasOwnProperty.call(attrs, "httponly"),
220
+ secure: Object.prototype.hasOwnProperty.call(attrs, "secure"),
221
+ sameSite: sameSite,
222
+ createdAt: prior ? prior.createdAt : now,
223
+ updatedAt: now,
224
+ });
225
+ }
226
+
227
+ // ---- Public API ----
228
+
229
+ function setFromResponse(reqUrl, setCookieHeader) {
230
+ if (!setCookieHeader) return;
231
+ var lines = Array.isArray(setCookieHeader) ? setCookieHeader : [setCookieHeader];
232
+ for (var i = 0; i < lines.length; i++) {
233
+ var parsed = _parseSetCookie(lines[i]);
234
+ if (parsed) _setOne(reqUrl, parsed);
235
+ }
236
+ }
237
+
238
+ function cookieHeaderFor(reqUrl) {
239
+ var u;
240
+ try { u = new URL(reqUrl); } catch (_e) { return null; }
241
+ var host = u.hostname.toLowerCase();
242
+ var path = u.pathname || "/";
243
+ var isSecure = u.protocol === "https:";
244
+ var now = clock();
245
+
246
+ var matches = [];
247
+ for (var entry of store.values()) {
248
+ // Expiry
249
+ if (entry.expiresAt !== null && entry.expiresAt <= now) continue;
250
+ // Domain
251
+ if (entry.hostOnly) {
252
+ if (entry.domain !== host) continue;
253
+ } else {
254
+ if (!_domainMatch(host, entry.domain)) continue;
255
+ }
256
+ // Path
257
+ if (!_pathMatch(path, entry.path)) continue;
258
+ // Secure
259
+ if (entry.secure && !isSecure) continue;
260
+ matches.push(entry);
261
+ }
262
+ if (matches.length === 0) return null;
263
+
264
+ // Sort: longer path first, then earlier creation time.
265
+ matches.sort(function (a, b) {
266
+ if (a.path.length !== b.path.length) return b.path.length - a.path.length;
267
+ return a.createdAt - b.createdAt;
268
+ });
269
+ var pieces = matches.map(function (e) {
270
+ return e.name + "=" + _unseal(e.value);
271
+ });
272
+ return pieces.join("; ");
273
+ }
274
+
275
+ function getAll() {
276
+ var now = clock();
277
+ var out = [];
278
+ for (var entry of store.values()) {
279
+ if (entry.expiresAt !== null && entry.expiresAt <= now) continue;
280
+ out.push({
281
+ name: entry.name,
282
+ value: _unseal(entry.value),
283
+ domain: entry.domain,
284
+ path: entry.path,
285
+ hostOnly: entry.hostOnly,
286
+ expiresAt: entry.expiresAt,
287
+ httpOnly: entry.httpOnly,
288
+ secure: entry.secure,
289
+ sameSite: entry.sameSite,
290
+ createdAt: entry.createdAt,
291
+ updatedAt: entry.updatedAt,
292
+ });
293
+ }
294
+ return out;
295
+ }
296
+
297
+ function clear(filter) {
298
+ if (!filter) {
299
+ var n = store.size;
300
+ store.clear();
301
+ return n;
302
+ }
303
+ if (typeof filter !== "object") {
304
+ throw _err("BAD_OPT", "cookieJar.clear: filter must be an object or undefined");
305
+ }
306
+ var purged = 0;
307
+ var keysToDelete = [];
308
+ for (var pair of store.entries()) {
309
+ var key = pair[0];
310
+ var entry = pair[1];
311
+ if (filter.domain && entry.domain !== filter.domain) continue;
312
+ if (filter.name && entry.name !== filter.name) continue;
313
+ if (filter.path && entry.path !== filter.path) continue;
314
+ keysToDelete.push(key);
315
+ }
316
+ for (var i = 0; i < keysToDelete.length; i++) {
317
+ store.delete(keysToDelete[i]);
318
+ purged++;
319
+ }
320
+ return purged;
321
+ }
322
+
323
+ function size() {
324
+ var now = clock();
325
+ var n = 0;
326
+ for (var entry of store.values()) {
327
+ if (entry.expiresAt !== null && entry.expiresAt <= now) continue;
328
+ n++;
329
+ }
330
+ return n;
331
+ }
332
+
333
+ // Round-trip helpers — operators with restart-survival needs serialize
334
+ // via getAll(), persist however they like, restore via setFromSerialized.
335
+ function setFromSerialized(rows) {
336
+ if (!Array.isArray(rows)) {
337
+ throw _err("BAD_OPT", "cookieJar.setFromSerialized: rows must be an array");
338
+ }
339
+ var now = clock();
340
+ for (var i = 0; i < rows.length; i++) {
341
+ var r = rows[i];
342
+ if (!r || typeof r.name !== "string" || typeof r.domain !== "string" || typeof r.path !== "string") continue;
343
+ var key = r.domain + "|" + r.path + "|" + r.name;
344
+ if (r.expiresAt !== null && r.expiresAt !== undefined && r.expiresAt <= now) continue;
345
+ store.set(key, {
346
+ name: r.name,
347
+ value: _seal(r.value),
348
+ domain: r.domain,
349
+ path: r.path,
350
+ hostOnly: !!r.hostOnly,
351
+ expiresAt: typeof r.expiresAt === "number" ? r.expiresAt : null,
352
+ httpOnly: !!r.httpOnly,
353
+ secure: !!r.secure,
354
+ sameSite: VALID_SAMESITE.has(r.sameSite) ? r.sameSite : null,
355
+ createdAt: typeof r.createdAt === "number" ? r.createdAt : now,
356
+ updatedAt: typeof r.updatedAt === "number" ? r.updatedAt : now,
357
+ });
358
+ }
359
+ }
360
+
361
+ // Raw-store accessor for tests — returns the literal Map entries with
362
+ // the value field as it sits in memory (sealed when persist === "vault").
363
+ // Operators don't call this; if they need stored state, getAll() returns
364
+ // the unsealed form. Exposed so the no-plaintext assertion is verifiable.
365
+ function _storeForTest() {
366
+ var rows = [];
367
+ for (var entry of store.values()) {
368
+ rows.push({
369
+ name: entry.name,
370
+ valueRaw: entry.value,
371
+ domain: entry.domain,
372
+ path: entry.path,
373
+ expiresAt: entry.expiresAt,
374
+ });
375
+ }
376
+ return rows;
377
+ }
378
+
379
+ return {
380
+ setFromResponse: setFromResponse,
381
+ cookieHeaderFor: cookieHeaderFor,
382
+ getAll: getAll,
383
+ clear: clear,
384
+ size: size,
385
+ setFromSerialized: setFromSerialized,
386
+ persist: persist,
387
+ _storeForTest: _storeForTest,
388
+ };
389
+ }
390
+
391
+ module.exports = {
392
+ create: create,
393
+ CookieJarError: CookieJarError,
394
+ DEFAULTS: DEFAULTS,
395
+ // Exposed for tests + advanced operator wiring.
396
+ _parseSetCookie: _parseSetCookie,
397
+ };
398
+ // safeUrl reserved for future scheme validation hooks (e.g. operator-supplied
399
+ // allowedProtocols filter on cookie attachment paths).
400
+ void safeUrl;
@@ -294,6 +294,28 @@ function _fromH2Headers(h2Headers) {
294
294
 
295
295
  var REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
296
296
 
297
+ // http-client-cookie-jar is exposed on b.httpClient.cookieJar via index.js.
298
+ // http-client itself only consumes the jar shape passed by operators via
299
+ // the request opt; no direct require is needed here.
300
+
301
+ // Merge a jar-derived Cookie header with any caller-supplied Cookie
302
+ // header. Operators who override Cookie explicitly always win — the jar
303
+ // supplements rather than replaces.
304
+ function _attachJarCookie(headers, jar, url) {
305
+ if (!jar) return headers;
306
+ var jarHeader = jar.cookieHeaderFor(url);
307
+ if (!jarHeader) return headers;
308
+ var merged = Object.assign({}, headers || {});
309
+ var existing = null;
310
+ var keys = Object.keys(merged);
311
+ for (var i = 0; i < keys.length; i++) {
312
+ if (keys[i].toLowerCase() === "cookie") { existing = keys[i]; break; }
313
+ }
314
+ if (existing) merged[existing] = merged[existing] + "; " + jarHeader;
315
+ else merged.Cookie = jarHeader;
316
+ return merged;
317
+ }
318
+
297
319
  // Build a multipart/form-data body from { fields, files } shape.
298
320
  // Mirrors the wire format that lib/middleware/body-parser.js's multipart
299
321
  // parser accepts so round-trip from one blamejs app's outbound to
@@ -398,6 +420,14 @@ function request(opts) {
398
420
  return Promise.reject(_makeError(opts.errorClass, "BAD_ARG",
399
421
  "onDownloadProgress must be a function", true));
400
422
  }
423
+ if (opts.jar !== undefined && opts.jar !== null) {
424
+ if (typeof opts.jar !== "object" ||
425
+ typeof opts.jar.cookieHeaderFor !== "function" ||
426
+ typeof opts.jar.setFromResponse !== "function") {
427
+ return Promise.reject(_makeError(opts.errorClass, "BAD_ARG",
428
+ "jar must be a b.httpClient.cookieJar.create() instance", true));
429
+ }
430
+ }
401
431
 
402
432
  // before interceptors — run in array order. Each may return a modified
403
433
  // opts object (or return nothing to leave the running opts as-is).
@@ -557,6 +587,17 @@ function _requestSingle(opts) {
557
587
  return Promise.reject(e);
558
588
  }
559
589
 
590
+ // Attach jar-derived Cookie header BEFORE the request fires; record
591
+ // Set-Cookie response headers AFTER. Both halves run when opts.jar
592
+ // is set; redirect-following naturally re-runs both paths per hop
593
+ // because each hop calls _requestSingle.
594
+ if (opts.jar) {
595
+ var headersWithJar = _attachJarCookie(opts.headers, opts.jar, opts.url);
596
+ if (headersWithJar !== opts.headers) {
597
+ opts = Object.assign({}, opts, { headers: headersWithJar });
598
+ }
599
+ }
600
+
560
601
  // SSRF gate — refuse private / loopback / link-local / cloud-metadata
561
602
  // / reserved IP destinations by default. Operators on internal mesh
562
603
  // pass `allowInternal: true` (or a CIDR list for narrower bypass).
@@ -626,6 +667,14 @@ function _requestH1(transport, u, opts) {
626
667
  var req = transport.lib.request(reqOpts, function (res) {
627
668
  if (observer) observer("response:headers", { statusCode: res.statusCode, headers: res.headers });
628
669
 
670
+ // Save Set-Cookie into the jar (if wired) BEFORE delivering the
671
+ // response object — operator inspecting the response can already
672
+ // count on the jar carrying the new state.
673
+ if (opts.jar && res.headers && res.headers["set-cookie"]) {
674
+ try { opts.jar.setFromResponse(opts.url, res.headers["set-cookie"]); }
675
+ catch (_e) { /* jar is best-effort — never break the response */ }
676
+ }
677
+
629
678
  // Download total: Content-Length when present, null otherwise.
630
679
  var dlTotal = null;
631
680
  if (res.headers && typeof res.headers["content-length"] === "string") {
@@ -825,6 +874,13 @@ function _requestH2(transport, u, opts) {
825
874
 
826
875
  if (observer) observer("response:headers", { statusCode: statusCode, headers: responseHeaders });
827
876
 
877
+ // Save Set-Cookie to the jar (h2 set-cookie comes through as
878
+ // either a single string or array, same shape as h1).
879
+ if (opts.jar && responseHeaders["set-cookie"]) {
880
+ try { opts.jar.setFromResponse(opts.url, responseHeaders["set-cookie"]); }
881
+ catch (_e) { /* jar best-effort */ }
882
+ }
883
+
828
884
  if (responseMode === "stream") {
829
885
  if (statusCode >= 400) {
830
886
  stream.resume();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.4.16",
3
+ "version": "0.4.17",
4
4
  "description": "The Node framework that owns its stack.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "blamejs contributors",