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