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