@camada/node 0.3.1 → 0.4.0

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/dist/nest.cjs CHANGED
@@ -46,10 +46,15 @@ var import_common = require("@nestjs/common");
46
46
  // src/camada.ts
47
47
  var import_node_crypto2 = require("crypto");
48
48
  var import_core3 = require("@camada/core");
49
+ var import_node_net = require("net");
49
50
  var import_iife_string = __toESM(require("@camada/browser/iife-string"), 1);
50
51
 
51
52
  // src/env.ts
52
53
  var import_core = require("@camada/core");
54
+ function parseTlsxHeader(raw) {
55
+ const n = (raw ?? "").trim().toLowerCase();
56
+ return /^[a-z0-9!#$%&'*+.^_`|~-]+$/.test(n) ? n : null;
57
+ }
53
58
  function resolveEnv(env = process.env) {
54
59
  const key = (0, import_core.parseKey)(env.CAMADA_KEY);
55
60
  const ingestToken = key?.ingestToken ?? env.CAMADA_TOKEN;
@@ -63,7 +68,8 @@ function resolveEnv(env = process.env) {
63
68
  secret: env.CAMADA_KEY || `${ingestToken}.${snapToken}`,
64
69
  snapshotUrl: env.CAMADA_SNAPSHOT_URL || `${ingestUrl}/snapshot`,
65
70
  serverless: env.CAMADA_SERVERLESS === "1",
66
- trustedProxy: (0, import_core.parseTrustedProxyEnv)(env.CAMADA_TRUSTED_PROXY)
71
+ trustedProxy: (0, import_core.parseTrustedProxyEnv)(env.CAMADA_TRUSTED_PROXY),
72
+ tlsxHeader: parseTlsxHeader(env.CAMADA_TLSX_HEADER)
67
73
  };
68
74
  }
69
75
 
@@ -95,18 +101,45 @@ function readBody(req, done) {
95
101
  req.on("end", () => done(dead ? "" : Buffer.concat(chunks).toString("utf8")));
96
102
  req.on("error", () => done(""));
97
103
  }
98
- var isHttps = (req) => !!req.socket?.encrypted || req.headers["x-forwarded-proto"] === "https";
99
- function writeChallengePage(res, html) {
100
- res.writeHead(403, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store", "x-camada-challenge": "1" });
101
- res.end(html);
104
+ var FORM_FIELDS = ["nonce", "solution", "to"];
105
+ function parsedBody(req) {
106
+ const b = req.body;
107
+ if (typeof b === "string") return b;
108
+ if (Buffer.isBuffer(b)) return b.toString("utf8");
109
+ return b && typeof b === "object" ? b : null;
110
+ }
111
+ function readForm(req, done) {
112
+ if (!req.readableEnded) {
113
+ readBody(req, (body) => done((0, import_core2.parseFormBody)(body)));
114
+ return;
115
+ }
116
+ const b = parsedBody(req);
117
+ if (typeof b === "string") {
118
+ done(b.length > BODY_MAX ? {} : (0, import_core2.parseFormBody)(b));
119
+ return;
120
+ }
121
+ const form = /* @__PURE__ */ Object.create(null);
122
+ for (const k of FORM_FIELDS) {
123
+ const v = b?.[k];
124
+ if (typeof v === "string") form[k] = v;
125
+ }
126
+ done(form);
102
127
  }
103
- function writeChallengeJson(res) {
104
- res.writeHead(403, { "content-type": "application/json", "cache-control": "no-store", "x-camada-challenge": "1" });
105
- res.end('{"error":"challenge_required"}');
128
+ var isHttps = (req) => !!req.socket?.encrypted || req.headers["x-forwarded-proto"] === "https";
129
+ function writeChallengePage(res, o) {
130
+ res.removeHeader("content-security-policy");
131
+ res.removeHeader("content-security-policy-report-only");
132
+ res.writeHead(403, {
133
+ "content-type": "text/html; charset=utf-8",
134
+ "cache-control": "no-store",
135
+ "x-camada-challenge": "1",
136
+ "content-security-policy": (0, import_core2.challengePageCsp)(o.bits)
137
+ });
138
+ res.end((0, import_core2.challengePage)(o));
106
139
  }
107
140
 
108
141
  // package.json
109
- var version = "0.3.1";
142
+ var version = "0.4.0";
110
143
  var name = "@camada/node";
111
144
 
112
145
  // src/version.ts
@@ -117,11 +150,40 @@ var SESSION_COOKIE = "_sfp";
117
150
  var SCRIPT_PATH = "/_cam/b.js";
118
151
  var FP_PATH = "/_cam/fp";
119
152
  var FP_MAX = 32 * 1024;
153
+ function isLoopbackPeer(peer) {
154
+ const k = (0, import_node_net.isIP)(peer);
155
+ if (k === 4) return peer.startsWith("127.");
156
+ return k !== 6 || peer === "::1" || /^::ffff:127\./i.test(peer);
157
+ }
158
+ var TLSX_RE = /^[A-Za-z0-9+/=_-]+$/;
120
159
  var ATTACHED = /* @__PURE__ */ Symbol.for("camada.attached");
121
160
  var headerReader = (req) => (name2) => {
122
161
  const v = req.headers[name2];
123
162
  return v == null ? null : Array.isArray(v) ? v.join(", ") : v;
124
163
  };
164
+ var splitUrl = (raw) => {
165
+ const qi = raw.indexOf("?");
166
+ return qi === -1 ? { path: raw, query: "" } : { path: raw.slice(0, qi), query: raw.slice(qi) };
167
+ };
168
+ var isPage = (req) => (0, import_core3.wantsHtml)(req.headers.accept ?? null, req.headers["sec-fetch-dest"] ?? null);
169
+ function pathAndQuery(req) {
170
+ return splitUrl(req.originalUrl || req.url || "/");
171
+ }
172
+ var atEndpoint = (req, path, endpoint) => path === endpoint || splitUrl(req.url || "/").path === endpoint;
173
+ function mountPrefix(req) {
174
+ if (!req.originalUrl) return "";
175
+ const full = splitUrl(req.originalUrl).path, rel = splitUrl(req.url || "/").path;
176
+ return rel !== "/" && full !== rel && full.endsWith(rel) ? full.slice(0, full.length - rel.length) : "";
177
+ }
178
+ function onUnixListener(socket) {
179
+ const srv = socket?.server;
180
+ if (!srv) return false;
181
+ try {
182
+ if (typeof srv.address?.() === "string") return true;
183
+ } catch {
184
+ }
185
+ return typeof srv._pipeName === "string";
186
+ }
125
187
  var cookieValue = (cookie, name2) => {
126
188
  const src = "; " + cookie;
127
189
  const i = src.indexOf("; " + name2 + "=");
@@ -141,6 +203,27 @@ function keepSessionCookie(res, cookie) {
141
203
  };
142
204
  res.setHeader("set-cookie", cookie);
143
205
  }
206
+ var NO_STORE_HEADERS = ["cache-control", "cdn-cache-control", "surrogate-control", "cloudflare-cdn-cache-control", "vercel-cdn-cache-control", "netlify-cdn-cache-control"];
207
+ function signal(res) {
208
+ const set = res.setHeader, writeHead = res.writeHead;
209
+ const isNoStore = (n) => typeof n === "string" && NO_STORE_HEADERS.includes(n.toLowerCase());
210
+ const strip = (h) => {
211
+ if (Array.isArray(h)) {
212
+ if (h.length && Array.isArray(h[0])) return h.filter((p) => !isNoStore(p[0]));
213
+ const out = [];
214
+ for (let i = 0; i < h.length; i += 2) if (!isNoStore(h[i])) out.push(h[i], h[i + 1]);
215
+ return out;
216
+ }
217
+ if (h && typeof h === "object") return Object.fromEntries(Object.entries(h).filter(([k]) => !isNoStore(k)));
218
+ return h;
219
+ };
220
+ res.writeHead = function(status, ...rest) {
221
+ if (!this.headersSent) for (const h of NO_STORE_HEADERS) set.call(this, h, "no-store");
222
+ return writeHead.call(this, status, ...rest.map(strip));
223
+ };
224
+ res.setHeader("x-camada-challenge", "1");
225
+ for (const h of NO_STORE_HEADERS) res.setHeader(h, "no-store");
226
+ }
144
227
  var Camada = class {
145
228
  env;
146
229
  snap = null;
@@ -150,16 +233,25 @@ var Camada = class {
150
233
  fpPath;
151
234
  challengeOn;
152
235
  challengePath;
236
+ challengePathSet;
237
+ // configured explicitly: used as given, never prefixed
238
+ scriptPathSet;
153
239
  kit = null;
154
240
  envSource;
241
+ tlsxHeader;
242
+ waitUntil;
155
243
  constructor(opts = {}) {
156
244
  this.envSource = opts.env ?? process.env;
157
245
  this.fetchImpl = opts.fetchImpl ?? fetch;
246
+ this.waitUntil = opts.waitUntil;
158
247
  this.scriptPath = opts.scriptPath ?? SCRIPT_PATH;
159
248
  this.fpPath = opts.fpPath ?? FP_PATH;
160
249
  this.challengeOn = opts.challenge !== false && this.envSource.CAMADA_CHALLENGE !== "0";
161
250
  this.challengePath = opts.challengePath ?? CHALLENGE_PATH;
251
+ this.challengePathSet = opts.challengePath != null;
252
+ this.scriptPathSet = opts.scriptPath != null;
162
253
  this.env = resolveEnv(this.envSource);
254
+ this.tlsxHeader = parseTlsxHeader(opts.tlsxHeader) ?? this.env?.tlsxHeader ?? null;
163
255
  if (!this.env) return;
164
256
  if (this.envSource.CAMADA_DISABLED === "1") return;
165
257
  this.snap = new import_core3.SnapshotClient({
@@ -182,9 +274,61 @@ var Camada = class {
182
274
  trustedProxy() {
183
275
  return this.env?.trustedProxy ?? this.snap?.config?.trusted_proxy ?? null;
184
276
  }
277
+ /** The client IP per the trusted-proxy config (team contract C6 v3). No socket at all, or a
278
+ * unix-socket listener (nginx/Passenger in front, where Node never has a peer): core's no-peer
279
+ * X-Forwarded-For walk, which trusts the local proxy. A TCP socket with no readable peer (the
280
+ * client reset the connection before camada ran, e.g. behind an async middleware) is
281
+ * unidentifiable: the walk would believe whatever the client wrote. So: null, the stance every
282
+ * ip-bound feature already takes (no ip rules, no challenge). */
283
+ clientIp(req) {
284
+ const peer = req.socket?.remoteAddress;
285
+ if (!peer && req.socket && !onUnixListener(req.socket)) return null;
286
+ return (0, import_core3.resolveClientIp)(peer ?? null, req.headers["x-forwarded-for"], this.trustedProxy());
287
+ }
288
+ /** The TLS fingerprint Cloudflare (a Transform Rule) put in the opt-in header, or null. Trusted only
289
+ * in `cidrs` mode and only when the socket peer itself is a NON-loopback address core's cidrs walk
290
+ * trusts, i.e. Cloudflare connects to the app directly. A loopback peer (a local reverse proxy or
291
+ * cloudflared) is never trusted and X-Forwarded-For is never consulted, so the header is not read
292
+ * and tlsx conditions fail open. Trust is core's isTrustedPeer, the same first-hop test the cidrs
293
+ * walk uses. No readable peer (unix listener included) -> null, since core's no-peer walk would
294
+ * trust everyone. */
295
+ tlsxOf(req) {
296
+ const header = this.tlsxHeader;
297
+ if (!header) return null;
298
+ const v = headerReader(req)(header);
299
+ if (v == null || v.length > 128 || !TLSX_RE.test(v)) return null;
300
+ const cfg = this.trustedProxy();
301
+ const peer = req.socket?.remoteAddress;
302
+ if (cfg?.mode !== "cidrs" || !peer) return null;
303
+ if (isLoopbackPeer(peer)) return null;
304
+ return (0, import_core3.isTrustedPeer)(peer, cfg) ? v : null;
305
+ }
306
+ /** Where the challenge form posts: under the mount prefix, so it reaches camada, unless the
307
+ * path was configured explicitly. */
308
+ challengeAction(req) {
309
+ return this.challengePathSet ? this.challengePath : (req.camadaPrefix ?? mountPrefix(req)) + this.challengePath;
310
+ }
185
311
  beaconEnabled() {
186
312
  return this.snap?.config?.beacon !== false;
187
313
  }
314
+ /** Queues a row. With CAMADA_SERVERLESS=1 it also starts the POST now: the platform freezes the
315
+ * process once the response is out, so the 15 s interval would never run. Never throws. */
316
+ enqueue(row, req) {
317
+ const q = this.queue;
318
+ q.push(row);
319
+ if (!this.env?.serverless) return;
320
+ if (req?.camadaOpen) return;
321
+ this.flushFor(req);
322
+ }
323
+ /** Serverless: start the POST now and hand it to waitUntil. Never throws. */
324
+ flushFor(req) {
325
+ const q = this.queue;
326
+ (0, import_core3.guarded)(() => {
327
+ const tracks = req?.camadaTracks ?? [];
328
+ const p = Promise.allSettled(tracks).then(() => q.flush()).then(() => q.size > 0 ? q.flush() : void 0);
329
+ this.waitUntil?.(p);
330
+ }, void 0);
331
+ }
188
332
  /** True when camada fully handled the request. Synchronous; never throws. */
189
333
  handle(req, res) {
190
334
  return (0, import_core3.guarded)(() => this.handleInner(req, res), false);
@@ -192,15 +336,13 @@ var Camada = class {
192
336
  handleInner(req, res) {
193
337
  if (this.disabled || !this.snap || !this.queue || !this.env) return false;
194
338
  const t0 = Date.now();
195
- this.snap.ensureFresh();
196
- const rawUrl = req.url || "/";
197
- const qi = rawUrl.indexOf("?");
198
- const path = qi === -1 ? rawUrl : rawUrl.slice(0, qi);
199
- const query = qi === -1 ? "" : rawUrl.slice(qi);
200
- const ip = (0, import_core3.resolveClientIp)(req.socket?.remoteAddress, req.headers["x-forwarded-for"], this.trustedProxy());
201
- const v = this.snap.verdict({ ip, path, ua: req.headers["user-agent"], header: headerReader(req) });
339
+ this.snap.ensureFresh(this.env.serverless ? this.waitUntil : void 0);
340
+ const { path, query } = pathAndQuery(req);
341
+ const ip = this.clientIp(req);
342
+ req.camadaPrefix = mountPrefix(req);
343
+ const v = this.snap.verdict({ ip, path, ua: req.headers["user-agent"], header: headerReader(req), tlsx: this.tlsxOf(req) });
202
344
  if (v.block) {
203
- const headers = { "x-block-reason": v.reason ?? "", "x-block-version": v.version ?? "", "content-type": "text/plain" };
345
+ const headers = { "x-block-reason": v.reason ?? "", "x-block-version": v.version ?? "", "content-type": "text/plain", "cache-control": "no-store" };
204
346
  if (v.rule) headers["x-block-rule"] = v.rule;
205
347
  res.writeHead(403, headers);
206
348
  res.end("Forbidden");
@@ -208,73 +350,122 @@ var Camada = class {
208
350
  ev.st = 403;
209
351
  ev.blk = v.reason;
210
352
  if (v.rule) ev.rl = v.rule;
211
- this.queue.push(ev);
353
+ this.enqueue(ev);
212
354
  return true;
213
355
  }
214
356
  if (this.challengeOn && this.kit && ip) {
215
- if (req.method === "POST" && path === this.challengePath) {
357
+ if (req.method === "POST" && atEndpoint(req, path, this.challengePath)) {
216
358
  this.verifyChallenge(req, res, ip);
217
359
  return true;
218
360
  }
219
361
  if (v.challenge && !this.challengePassed(req, ip)) {
220
- this.serveChallengeInner(req, res, ip, path + query);
221
- return true;
362
+ if (isPage(req)) {
363
+ this.serveChallengeInner(req, res, ip, path + query);
364
+ return true;
365
+ }
366
+ req.camadaSignalled = true;
222
367
  }
223
368
  }
224
369
  if (this.beaconEnabled()) {
225
- if (req.method === "GET" && path === this.scriptPath) {
370
+ if (req.method === "GET" && atEndpoint(req, path, this.scriptPath)) {
226
371
  res.writeHead(200, { "content-type": "application/javascript", "cache-control": "public, max-age=3600" });
227
372
  res.end(import_iife_string.default);
228
373
  return true;
229
374
  }
230
- if (req.method === "POST" && path === this.fpPath) {
375
+ if (req.method === "POST" && atEndpoint(req, path, this.fpPath)) {
231
376
  this.relayBeacon(req, res, ip);
232
377
  return true;
233
378
  }
234
379
  }
380
+ const { rid, sid, ns: newSession } = this.begin(req, res, ip, t0, v.warn ? v.rule : void 0);
381
+ if (req.camadaSignalled) signal(res);
382
+ const cfg = this.snap.config;
383
+ const excluded = (cfg?.exclude || []).some((x) => path.startsWith(x));
384
+ if (req.camadaSignalled || !excluded && Math.random() < (cfg?.sample ?? 1)) {
385
+ this.hookCapture(req, res, path, query, ip, { rid, sid, newSession, t0 });
386
+ }
387
+ return false;
388
+ }
389
+ /** Mints the request's rid and session (setting the cookie on a first visit), records them on
390
+ * `req.camada` and sets `x-rid`. handle() and serveChallenge() (when handle() did not run) share it. */
391
+ begin(req, res, ip, t0, wrn) {
235
392
  const rid = (0, import_node_crypto2.randomUUID)();
236
393
  let sid = cookieValue(req.headers.cookie || "", SESSION_COOKIE);
237
- const newSession = !sid;
394
+ const ns = !sid;
238
395
  if (!sid) {
239
396
  sid = (0, import_node_crypto2.randomUUID)();
240
397
  const https = req.socket.encrypted || req.headers["x-forwarded-proto"] === "https";
241
398
  keepSessionCookie(res, `${SESSION_COOKIE}=${sid}; Path=/; Max-Age=2592000; HttpOnly; SameSite=Lax${https ? "; Secure" : ""}`);
242
399
  }
243
- req.camada = { rid, ip, sid };
244
400
  res.setHeader("x-rid", rid);
245
- const cfg = this.snap.config;
246
- const excluded = (cfg?.exclude || []).some((x) => path.startsWith(x));
247
- if (!excluded && Math.random() < (cfg?.sample ?? 1)) {
248
- let shipped = false;
249
- const ship = () => (0, import_core3.guarded)(() => {
250
- if (shipped || req.camadaChallenged) return;
251
- shipped = true;
252
- const ev = this.buildEvent(req, path, query, ip, { rid, sid, newSession });
253
- ev.ts = t0;
254
- ev.st = res.statusCode;
255
- ev.dur = Date.now() - t0;
256
- if (req.route?.path) ev.rt = String(req.route.path);
257
- if (v.warn && v.rule) ev.wrn = v.rule;
258
- this.queue.push(ev);
259
- }, void 0);
260
- res.once("finish", ship);
261
- res.once("close", ship);
262
- }
263
- return false;
401
+ return req.camada = { rid, ip, sid, ns, t0, wrn };
402
+ }
403
+ /** Installs the response-finish hook that ships the request's normal capture event. */
404
+ hookCapture(req, res, path, query, ip, o) {
405
+ let shipped = false;
406
+ req.camadaOpen = true;
407
+ const ship = () => (0, import_core3.guarded)(() => {
408
+ req.camadaOpen = false;
409
+ if (shipped) return;
410
+ shipped = true;
411
+ if (req.camadaChallenged) {
412
+ if (this.env?.serverless) this.flushFor(req);
413
+ return;
414
+ }
415
+ const ev = this.buildEvent(req, path, query, ip, o);
416
+ ev.ts = o.t0;
417
+ ev.st = res.statusCode;
418
+ ev.dur = Date.now() - o.t0;
419
+ if (req.route?.path) ev.rt = String(req.route.path);
420
+ if (req.camada?.wrn) ev.wrn = req.camada.wrn;
421
+ if (req.camadaSignalled) ev.chs = 1;
422
+ this.enqueue(ev, req);
423
+ }, void 0);
424
+ res.once("finish", ship);
425
+ res.once("close", ship);
264
426
  }
265
427
  buildEvent(req, path, query, ip, o) {
266
428
  const raw = req.rawHeaders || [];
267
429
  const headers = [];
268
430
  for (let i = 0; i + 1 < raw.length; i += 2) headers.push([raw[i], raw[i + 1]]);
269
- return (0, import_core3.buildWireEvent)(
431
+ const ev = (0, import_core3.buildWireEvent)(
270
432
  { method: req.method || "GET", host: req.headers.host || "", path, query, headers, ip, httpVersion: req.httpVersion },
271
433
  { tap: import_core3.TAP_NODE, rid: o.rid, sid: o.sid, newSession: o.newSession }
272
434
  );
435
+ const tlsx = this.tlsxOf(req);
436
+ if (tlsx) ev.tlsx = tlsx;
437
+ return ev;
273
438
  }
274
439
  /** Reads the beacon POST (≤32 KB), answers 204 immediately, and queues the beacon as a `sig: 1`
275
440
  * row with the trusted-proxy-resolved client IP: it rides the next event batch, so the analyst
276
- * sees one request per flush instead of one per page view. Junk bodies are dropped, never shipped. */
441
+ * sees one request per flush instead of one per page view. Junk bodies are dropped, never shipped.
442
+ * A body parser mounted before camada (express.json(), always on Nest) has already drained the
443
+ * stream: its `req.body` is used instead, and the request is answered either way. */
277
444
  relayBeacon(req, res, ip) {
445
+ const accept = (parsed) => {
446
+ res.writeHead(204, { "cache-control": "no-store" });
447
+ res.end();
448
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed) || Buffer.isBuffer(parsed)) return;
449
+ const row = { ...parsed, sig: 1, ip, tap: import_core3.TAP_NODE, rts: Date.now() };
450
+ for (const k of import_core3.REQUEST_ROW_KEYS) delete row[k];
451
+ this.enqueue(row);
452
+ };
453
+ const fromText = (text) => {
454
+ try {
455
+ return JSON.parse(text);
456
+ } catch {
457
+ return null;
458
+ }
459
+ };
460
+ if (req.readableEnded) {
461
+ const b = parsedBody(req);
462
+ if ((typeof b === "string" ? b : b ? JSON.stringify(b) : "").length > FP_MAX) {
463
+ res.writeHead(413).end();
464
+ return;
465
+ }
466
+ accept(typeof b === "string" ? fromText(b) : b);
467
+ return;
468
+ }
278
469
  const chunks = [];
279
470
  let size = 0, dead = false;
280
471
  req.on("data", (c) => {
@@ -289,16 +480,7 @@ var Camada = class {
289
480
  });
290
481
  req.on("end", () => (0, import_core3.guarded)(() => {
291
482
  if (dead) return;
292
- res.writeHead(204, { "cache-control": "no-store" });
293
- res.end();
294
- let parsed;
295
- try {
296
- parsed = JSON.parse(Buffer.concat(chunks).toString("utf8"));
297
- } catch {
298
- return;
299
- }
300
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return;
301
- this.queue.push({ ...parsed, sig: 1, ip, tap: import_core3.TAP_NODE });
483
+ accept(fromText(Buffer.concat(chunks).toString("utf8")));
302
484
  }, void 0));
303
485
  req.on("error", () => {
304
486
  try {
@@ -310,15 +492,11 @@ var Camada = class {
310
492
  challengePassed(req, ip) {
311
493
  return !!this.kit?.tokenValid(ip, Date.now(), cookieValue(req.headers.cookie || "", import_core3.CHALLENGE_COOKIE));
312
494
  }
313
- /** 403 + the proof-of-work page (HTML navigations) or 403 JSON (everything else), plus the
495
+ /** 403 + the proof-of-work page (HTML navigations only), plus the
314
496
  * `blk: "challenge"` event — a served challenge is reported like a block (contract §D2). */
315
497
  serveChallengeInner(req, res, ip, target) {
316
498
  const to = (0, import_core3.safeReturnTo)(target);
317
- if ((0, import_core3.wantsHtml)(req.headers.accept ?? null, req.headers["sec-fetch-dest"] ?? null)) {
318
- writeChallengePage(res, (0, import_core3.challengePage)({ nonce: this.kit.nonce(ip, Date.now()), action: this.challengePath, to }));
319
- } else {
320
- writeChallengeJson(res);
321
- }
499
+ writeChallengePage(res, { nonce: this.kit.nonce(ip, Date.now()), action: this.challengeAction(req), to });
322
500
  (0, import_core3.guarded)(() => {
323
501
  const qi = target.indexOf("?");
324
502
  const ev = this.buildEvent(
@@ -330,7 +508,7 @@ var Camada = class {
330
508
  );
331
509
  ev.st = 403;
332
510
  ev.blk = "challenge";
333
- this.queue.push(ev);
511
+ this.enqueue(ev);
334
512
  }, void 0);
335
513
  }
336
514
  /** The request's existing session, so challenge rows join the session that produced them. */
@@ -340,12 +518,11 @@ var Camada = class {
340
518
  /** POST from the challenge page: validate the nonce and the proof of work, set `_cch`, 302
341
519
  * back to the (sanitised, same-site) original URL, and ship `{ st: 200, ch: 1 }`. */
342
520
  verifyChallenge(req, res, ip) {
343
- readBody(req, (body) => (0, import_core3.guarded)(() => {
344
- const form = (0, import_core3.parseFormBody)(body);
521
+ readForm(req, (form) => (0, import_core3.guarded)(() => {
345
522
  const to = (0, import_core3.safeReturnTo)(form.to);
346
523
  const now = Date.now();
347
524
  if (!this.kit.verify(ip, now, form.nonce, form.solution)) {
348
- writeChallengePage(res, (0, import_core3.challengePage)({ nonce: this.kit.nonce(ip, now), action: this.challengePath, to }));
525
+ writeChallengePage(res, { nonce: this.kit.nonce(ip, now), action: this.challengeAction(req), to });
349
526
  return;
350
527
  }
351
528
  res.writeHead(302, {
@@ -354,10 +531,10 @@ var Camada = class {
354
531
  "cache-control": "no-store"
355
532
  });
356
533
  res.end();
357
- const ev = this.buildEvent(req, this.challengePath, "", ip, { rid: (0, import_node_crypto2.randomUUID)(), sid: this.sessionOf(req), newSession: false });
534
+ const ev = this.buildEvent(req, this.challengeAction(req), "", ip, { rid: (0, import_node_crypto2.randomUUID)(), sid: this.sessionOf(req), newSession: false });
358
535
  ev.st = 200;
359
536
  ev.ch = 1;
360
- this.queue.push(ev);
537
+ this.enqueue(ev);
361
538
  }, void 0));
362
539
  }
363
540
  /**
@@ -389,28 +566,38 @@ var Camada = class {
389
566
  }
390
567
  shipUpgrade(req, t0) {
391
568
  if (this.disabled || !this.queue) return;
392
- const rawUrl = req.url || "/";
393
- const qi = rawUrl.indexOf("?");
394
- const path = qi === -1 ? rawUrl : rawUrl.slice(0, qi);
569
+ const { path, query } = pathAndQuery(req);
395
570
  const cfg = this.snap?.config;
396
571
  if ((cfg?.exclude || []).some((x) => path.startsWith(x)) || Math.random() >= (cfg?.sample ?? 1)) return;
397
- const ip = (0, import_core3.resolveClientIp)(req.socket?.remoteAddress, req.headers["x-forwarded-for"], this.trustedProxy());
398
- const ev = this.buildEvent(req, path, qi === -1 ? "" : rawUrl.slice(qi), ip, { rid: (0, import_node_crypto2.randomUUID)(), sid: this.sessionOf(req), newSession: false });
572
+ const ip = this.clientIp(req);
573
+ const ev = this.buildEvent(req, path, query, ip, { rid: (0, import_node_crypto2.randomUUID)(), sid: this.sessionOf(req), newSession: false });
399
574
  ev.ts = t0;
400
575
  ev.st = 101;
401
576
  ev.dur = Date.now() - t0;
402
- this.queue.push(ev);
577
+ this.enqueue(ev);
403
578
  }
404
579
  /** Serve the challenge for this request on demand — for a route the app wants to gate itself
405
580
  * (the example's /challenge-me). Returns false when the client already holds a valid `_cch`,
406
- * so the caller renders its own page. */
581
+ * so the caller renders its own page. A non-HTML request is never challenged: it gets the
582
+ * `x-camada-challenge: 1` header and a `chs: 1` event, and false, so the app answers. */
407
583
  serveChallenge(req, res) {
408
584
  return (0, import_core3.guarded)(() => {
409
585
  if (this.disabled || !this.kit || !this.queue) return false;
410
- const ip = (0, import_core3.resolveClientIp)(req.socket?.remoteAddress, req.headers["x-forwarded-for"], this.trustedProxy());
586
+ const ip = this.clientIp(req);
411
587
  if (!ip || this.challengePassed(req, ip)) return false;
412
- req.camadaChallenged = true;
413
- this.serveChallengeInner(req, res, ip, req.url || "/");
588
+ const r = req;
589
+ const { path, query } = pathAndQuery(r);
590
+ if (!isPage(r)) {
591
+ if (!r.camadaSignalled) signal(res);
592
+ r.camadaSignalled = true;
593
+ if (r.camadaOpen === void 0) {
594
+ const c = r.camada ?? this.begin(r, res, ip, Date.now());
595
+ this.hookCapture(r, res, path, query, ip, { rid: c.rid, sid: c.sid, newSession: c.ns, t0: c.t0 });
596
+ }
597
+ return false;
598
+ }
599
+ r.camadaChallenged = true;
600
+ this.serveChallengeInner(req, res, ip, path + query);
414
601
  return true;
415
602
  }, false);
416
603
  }
@@ -418,7 +605,9 @@ var Camada = class {
418
605
  scriptTag(req) {
419
606
  if (this.disabled || !this.beaconEnabled()) return "";
420
607
  const rid = req.camada?.rid;
421
- return `<script src="${this.scriptPath}${rid ? `?r=${rid}` : ""}" async></script>`;
608
+ const r = req;
609
+ const src = this.scriptPathSet ? this.scriptPath : (r.camadaPrefix ?? mountPrefix(r)) + this.scriptPath;
610
+ return `<script src="${src}${rid ? `?r=${rid}` : ""}" async></script>`;
422
611
  }
423
612
  /** App-context outcome events (login failed, signup, …). The identifier is HMAC-hashed
424
613
  * in-process; the raw value never reaches the queue. */
@@ -426,10 +615,12 @@ var Camada = class {
426
615
  (0, import_core3.guarded)(() => {
427
616
  if (this.disabled || !this.queue || !this.env) return;
428
617
  const ctx = req.camada;
429
- void (async () => {
618
+ const r = req;
619
+ const row = (async () => {
430
620
  const uid = data?.user ? await (0, import_core3.hashUserId)(data.user, this.env.ingestToken, (globalThis.crypto ?? import_node_crypto2.webcrypto).subtle) : null;
431
- this.queue.push({ tap: import_core3.TAP_NODE, et: event, uid, rid: ctx?.rid ?? null, sid: ctx?.sid ?? null, ip: ctx?.ip ?? null, ts: Date.now() });
621
+ this.enqueue({ tap: import_core3.TAP_NODE, et: event, uid, rid: ctx?.rid ?? null, sid: ctx?.sid ?? null, ip: ctx?.ip ?? null, ts: Date.now() }, r);
432
622
  })().catch(import_core3.logRateLimited);
623
+ if (r.camadaOpen) (r.camadaTracks ??= []).push(row);
433
624
  }, void 0);
434
625
  }
435
626
  /** Express/Connect middleware (also what NestJS on the default platform consumes). */
@@ -438,9 +629,11 @@ var Camada = class {
438
629
  if (!this.handle(req, res)) next();
439
630
  };
440
631
  }
632
+ /** Stops polling and the event queue. The promise settles once rows still queued behind a drain
633
+ * in flight or a 429 hold are sent (or counted dropped); it never rejects. */
441
634
  stop() {
442
635
  this.snap?.stop();
443
- this.queue?.stop();
636
+ return this.queue?.stop() ?? Promise.resolve();
444
637
  }
445
638
  };
446
639
  function createCamada(opts = {}) {