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