@camada/node 0.3.0 → 0.3.2

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/README.md CHANGED
@@ -23,8 +23,16 @@ CAMADA_INGEST_URL=http://localhost:8787 # dev only; defaults to productio
23
23
  **Express / Connect**
24
24
  ```js
25
25
  import camada from '@camada/node';
26
- app.use(camada.express());
26
+ app.use(camada.express()); // before express.json() / urlencoded() and before helmet
27
27
  ```
28
+ Mount camada first. It still works behind body parsers (it uses the `req.body` they leave for
29
+ its challenge and beacon POSTs) and behind helmet (the challenge page replaces any inherited
30
+ `Content-Security-Policy` with its own), but first is cheapest: a blocked request never pays for
31
+ parsing. Under a prefix (`app.use('/api', camada.express())`) rules and events see the full path
32
+ (`/api/…`), and camada's own endpoints answer under the prefix (`/api/_cam/b.js`, `/api/_cam/fp`,
33
+ `/api/__camada/challenge`). The beacon tag (`scriptTag()`) and the challenge form point under the
34
+ prefix too, unless you set `scriptPath` or `challengePath` yourself: an explicit path is used
35
+ exactly as given. The beacon posts next to its script, so `fpPath` can stay as it is.
28
36
 
29
37
  **Fastify**
30
38
  ```js
@@ -37,12 +45,18 @@ await app.register(camadaFastify);
37
45
  import { CamadaModule } from '@camada/node/nest';
38
46
  @Module({ imports: [CamadaModule.forRoot()] }) // Nest ≤10: CamadaModule.forRoot({ routes: '*' })
39
47
  ```
48
+ Nest registers its body parsers before module middleware; camada reads what they parsed, so the
49
+ default `bodyParser: true` is fine. Tested on Nest 11. Nest 10 (`routes: '*'`) is checked through
50
+ the Express 4 wildcard mount it registers, not against Nest 10 itself.
40
51
 
41
52
  **Koa**
42
53
  ```js
43
54
  import { camadaKoa } from '@camada/node/koa';
44
- app.use(camadaKoa());
55
+ app.use(camadaKoa()); // before @koa/bodyparser
45
56
  ```
57
+ Register it first. Behind `@koa/bodyparser` or `koa-bodyparser` it reads the form and beacon from
58
+ `ctx.request.body`. Under `koa-mount` it works like an Express prefix mount: rules and events see the
59
+ full path, and its own endpoints answer under the prefix.
46
60
 
47
61
  ## What it does per request
48
62
 
@@ -50,6 +64,9 @@ app.use(camadaKoa());
50
64
  poll and event batch carries `x-camada-sdk: @camada/node/<version>`.
51
65
  2. Resolves the client IP per your tenant's trusted-proxy config — raw `X-Forwarded-For` is
52
66
  never trusted without it (`CAMADA_TRUSTED_PROXY=hops:1|cidrs:…|vercel` overrides locally).
67
+ On a unix-socket listener (nginx or Passenger in front) Node has no peer address, so the walk
68
+ runs without one and trusts the local proxy that owns the socket. A TCP request whose peer is
69
+ already gone (the client reset the connection) gets no IP: no ip rules, no challenge.
53
70
  3. Runs your ordered custom rules (see below), then the allow, block and challenge lists.
54
71
  Blocked → `403` with `x-block-reason` before your app; the event still ships, with
55
72
  `st: 403` and `blk: <reason>` (ip4|ip6|path|rule) so the analyst counts SDK blocks apart
@@ -145,10 +162,23 @@ handshake your library then rejects still ships `st: 101`.
145
162
 
146
163
  - `CAMADA_DISABLED=1` — kill switch, checked per request.
147
164
  - `CAMADA_SERVERLESS=1` — lazy snapshot refresh (no interval timer); cold invocations fail
148
- open and catch up asynchronously.
165
+ open and catch up asynchronously. Each request's event (and every `track()` row) is POSTed as
166
+ the response finishes instead of waiting for the 15 s batch timer, which a frozen function
167
+ never runs. Pass the platform's `waitUntil` so the invocation stays alive for that POST:
168
+ ```js
169
+ import { waitUntil } from '@vercel/functions';
170
+ const camada = createCamada({ waitUntil }); // also holds the lazy snapshot refresh open
171
+ ```
172
+ Where the platform has no `waitUntil`, the POST starts before the response completes but can
173
+ still be frozen mid-flight; use a fetch adapter (`@camada/hono`, `@camada/next`) there.
174
+ - The challenge page sends its own `Content-Security-Policy` allowing only its inline script and
175
+ style by `sha256` hash, and drops any `Content-Security-Policy(-Report-Only)` set before it.
149
176
  - Enforcement scope at this position: IP, path, user-agent and request-header conditions. ASN,
150
177
  country and TLS-fingerprint conditions can't be evaluated in-app and fail open (a rule that
151
178
  needs one never matches here; run `@camada/hono` on Cloudflare Workers for those).
179
+ - `await camada.stop()` in your own graceful shutdown stops polling and the event queue. It
180
+ returns a promise that settles once queued rows (a drain in flight, or rows held by an ingest
181
+ 429) are sent, or counted as dropped if the send fails; it never rejects.
152
182
  - Memory: ~5 MB per loaded snapshot.
153
183
 
154
184
  ## Develop
@@ -22,6 +22,9 @@ interface CamadaOptions {
22
22
  challenge?: boolean;
23
23
  challengePath?: string;
24
24
  snapshotVersion?: SnapshotVersion;
25
+ /** With CAMADA_SERVERLESS=1: handed every per-request event flush and snapshot refresh, so the
26
+ * platform keeps the invocation alive until they finish (e.g. `waitUntil` from `@vercel/functions`). */
27
+ waitUntil?: (p: Promise<unknown>) => void;
25
28
  }
26
29
  declare class Camada {
27
30
  readonly env: ResolvedEnv | null;
@@ -32,19 +35,39 @@ declare class Camada {
32
35
  private readonly fpPath;
33
36
  private readonly challengeOn;
34
37
  private readonly challengePath;
38
+ private readonly challengePathSet;
39
+ private readonly scriptPathSet;
35
40
  private readonly kit;
36
41
  private readonly envSource;
42
+ private readonly waitUntil?;
37
43
  constructor(opts?: CamadaOptions);
38
44
  get disabled(): boolean;
39
45
  private trustedProxy;
46
+ /** The client IP per the trusted-proxy config (team contract C6 v3). No socket at all, or a
47
+ * unix-socket listener (nginx/Passenger in front, where Node never has a peer): core's no-peer
48
+ * X-Forwarded-For walk, which trusts the local proxy. A TCP socket with no readable peer (the
49
+ * client reset the connection before camada ran, e.g. behind an async middleware) is
50
+ * unidentifiable: the walk would believe whatever the client wrote. So: null, the stance every
51
+ * ip-bound feature already takes (no ip rules, no challenge). */
52
+ private clientIp;
53
+ /** Where the challenge form posts: under the mount prefix, so it reaches camada, unless the
54
+ * path was configured explicitly. */
55
+ private challengeAction;
40
56
  private beaconEnabled;
57
+ /** Queues a row. With CAMADA_SERVERLESS=1 it also starts the POST now: the platform freezes the
58
+ * process once the response is out, so the 15 s interval would never run. Never throws. */
59
+ private enqueue;
60
+ /** Serverless: start the POST now and hand it to waitUntil. Never throws. */
61
+ private flushFor;
41
62
  /** True when camada fully handled the request. Synchronous; never throws. */
42
63
  handle(req: IncomingMessage, res: ServerResponse): boolean;
43
64
  private handleInner;
44
65
  private buildEvent;
45
66
  /** Reads the beacon POST (≤32 KB), answers 204 immediately, and queues the beacon as a `sig: 1`
46
67
  * row with the trusted-proxy-resolved client IP: it rides the next event batch, so the analyst
47
- * sees one request per flush instead of one per page view. Junk bodies are dropped, never shipped. */
68
+ * sees one request per flush instead of one per page view. Junk bodies are dropped, never shipped.
69
+ * A body parser mounted before camada (express.json(), always on Nest) has already drained the
70
+ * stream: its `req.body` is used instead, and the request is answered either way. */
48
71
  private relayBeacon;
49
72
  private challengePassed;
50
73
  /** 403 + the proof-of-work page (HTML navigations) or 403 JSON (everything else), plus the
@@ -79,7 +102,9 @@ declare class Camada {
79
102
  }): void;
80
103
  /** Express/Connect middleware (also what NestJS on the default platform consumes). */
81
104
  express(): (req: IncomingMessage, res: ServerResponse, next: (err?: unknown) => void) => void;
82
- stop(): void;
105
+ /** Stops polling and the event queue. The promise settles once rows still queued behind a drain
106
+ * in flight or a 429 hold are sent (or counted dropped); it never rejects. */
107
+ stop(): Promise<void>;
83
108
  }
84
109
  declare function createCamada(opts?: CamadaOptions): Camada;
85
110
 
@@ -22,6 +22,9 @@ interface CamadaOptions {
22
22
  challenge?: boolean;
23
23
  challengePath?: string;
24
24
  snapshotVersion?: SnapshotVersion;
25
+ /** With CAMADA_SERVERLESS=1: handed every per-request event flush and snapshot refresh, so the
26
+ * platform keeps the invocation alive until they finish (e.g. `waitUntil` from `@vercel/functions`). */
27
+ waitUntil?: (p: Promise<unknown>) => void;
25
28
  }
26
29
  declare class Camada {
27
30
  readonly env: ResolvedEnv | null;
@@ -32,19 +35,39 @@ declare class Camada {
32
35
  private readonly fpPath;
33
36
  private readonly challengeOn;
34
37
  private readonly challengePath;
38
+ private readonly challengePathSet;
39
+ private readonly scriptPathSet;
35
40
  private readonly kit;
36
41
  private readonly envSource;
42
+ private readonly waitUntil?;
37
43
  constructor(opts?: CamadaOptions);
38
44
  get disabled(): boolean;
39
45
  private trustedProxy;
46
+ /** The client IP per the trusted-proxy config (team contract C6 v3). No socket at all, or a
47
+ * unix-socket listener (nginx/Passenger in front, where Node never has a peer): core's no-peer
48
+ * X-Forwarded-For walk, which trusts the local proxy. A TCP socket with no readable peer (the
49
+ * client reset the connection before camada ran, e.g. behind an async middleware) is
50
+ * unidentifiable: the walk would believe whatever the client wrote. So: null, the stance every
51
+ * ip-bound feature already takes (no ip rules, no challenge). */
52
+ private clientIp;
53
+ /** Where the challenge form posts: under the mount prefix, so it reaches camada, unless the
54
+ * path was configured explicitly. */
55
+ private challengeAction;
40
56
  private beaconEnabled;
57
+ /** Queues a row. With CAMADA_SERVERLESS=1 it also starts the POST now: the platform freezes the
58
+ * process once the response is out, so the 15 s interval would never run. Never throws. */
59
+ private enqueue;
60
+ /** Serverless: start the POST now and hand it to waitUntil. Never throws. */
61
+ private flushFor;
41
62
  /** True when camada fully handled the request. Synchronous; never throws. */
42
63
  handle(req: IncomingMessage, res: ServerResponse): boolean;
43
64
  private handleInner;
44
65
  private buildEvent;
45
66
  /** Reads the beacon POST (≤32 KB), answers 204 immediately, and queues the beacon as a `sig: 1`
46
67
  * row with the trusted-proxy-resolved client IP: it rides the next event batch, so the analyst
47
- * sees one request per flush instead of one per page view. Junk bodies are dropped, never shipped. */
68
+ * sees one request per flush instead of one per page view. Junk bodies are dropped, never shipped.
69
+ * A body parser mounted before camada (express.json(), always on Nest) has already drained the
70
+ * stream: its `req.body` is used instead, and the request is answered either way. */
48
71
  private relayBeacon;
49
72
  private challengePassed;
50
73
  /** 403 + the proof-of-work page (HTML navigations) or 403 JSON (everything else), plus the
@@ -79,7 +102,9 @@ declare class Camada {
79
102
  }): void;
80
103
  /** Express/Connect middleware (also what NestJS on the default platform consumes). */
81
104
  express(): (req: IncomingMessage, res: ServerResponse, next: (err?: unknown) => void) => void;
82
- stop(): void;
105
+ /** Stops polling and the event queue. The promise settles once rows still queued behind a drain
106
+ * in flight or a 429 hold are sent (or counted dropped); it never rejects. */
107
+ stop(): Promise<void>;
83
108
  }
84
109
  declare function createCamada(opts?: CamadaOptions): Camada;
85
110
 
@@ -19,11 +19,9 @@ import {
19
19
  hashUserId,
20
20
  guarded,
21
21
  logRateLimited,
22
- challengePage,
23
22
  challengeCookie,
24
23
  safeReturnTo,
25
24
  wantsHtml,
26
- parseFormBody,
27
25
  CHALLENGE_COOKIE,
28
26
  TAP_NODE
29
27
  } from "@camada/core";
@@ -50,7 +48,7 @@ function resolveEnv(env = process.env) {
50
48
 
51
49
  // src/challenge.ts
52
50
  import { createHash, createHmac } from "crypto";
53
- import { createChallenge } from "@camada/core";
51
+ import { createChallenge, challengePage, challengePageCsp, parseFormBody } from "@camada/core";
54
52
  var CHALLENGE_PATH = "/__camada/challenge";
55
53
  var BODY_MAX = 4 * 1024;
56
54
  function nodeChallengeKit(secret) {
@@ -76,10 +74,41 @@ function readBody(req, done) {
76
74
  req.on("end", () => done(dead ? "" : Buffer.concat(chunks).toString("utf8")));
77
75
  req.on("error", () => done(""));
78
76
  }
77
+ var FORM_FIELDS = ["nonce", "solution", "to"];
78
+ function parsedBody(req) {
79
+ const b = req.body;
80
+ if (typeof b === "string") return b;
81
+ if (Buffer.isBuffer(b)) return b.toString("utf8");
82
+ return b && typeof b === "object" ? b : null;
83
+ }
84
+ function readForm(req, done) {
85
+ if (!req.readableEnded) {
86
+ readBody(req, (body) => done(parseFormBody(body)));
87
+ return;
88
+ }
89
+ const b = parsedBody(req);
90
+ if (typeof b === "string") {
91
+ done(b.length > BODY_MAX ? {} : parseFormBody(b));
92
+ return;
93
+ }
94
+ const form = /* @__PURE__ */ Object.create(null);
95
+ for (const k of FORM_FIELDS) {
96
+ const v = b?.[k];
97
+ if (typeof v === "string") form[k] = v;
98
+ }
99
+ done(form);
100
+ }
79
101
  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);
102
+ function writeChallengePage(res, o) {
103
+ res.removeHeader("content-security-policy");
104
+ res.removeHeader("content-security-policy-report-only");
105
+ res.writeHead(403, {
106
+ "content-type": "text/html; charset=utf-8",
107
+ "cache-control": "no-store",
108
+ "x-camada-challenge": "1",
109
+ "content-security-policy": challengePageCsp(o.bits)
110
+ });
111
+ res.end(challengePage(o));
83
112
  }
84
113
  function writeChallengeJson(res) {
85
114
  res.writeHead(403, { "content-type": "application/json", "cache-control": "no-store", "x-camada-challenge": "1" });
@@ -87,7 +116,7 @@ function writeChallengeJson(res) {
87
116
  }
88
117
 
89
118
  // package.json
90
- var version = "0.3.0";
119
+ var version = "0.3.2";
91
120
  var name = "@camada/node";
92
121
 
93
122
  // src/version.ts
@@ -103,6 +132,28 @@ var headerReader = (req) => (name2) => {
103
132
  const v = req.headers[name2];
104
133
  return v == null ? null : Array.isArray(v) ? v.join(", ") : v;
105
134
  };
135
+ var splitUrl = (raw) => {
136
+ const qi = raw.indexOf("?");
137
+ return qi === -1 ? { path: raw, query: "" } : { path: raw.slice(0, qi), query: raw.slice(qi) };
138
+ };
139
+ function pathAndQuery(req) {
140
+ return splitUrl(req.originalUrl || req.url || "/");
141
+ }
142
+ var atEndpoint = (req, path, endpoint) => path === endpoint || splitUrl(req.url || "/").path === endpoint;
143
+ function mountPrefix(req) {
144
+ if (!req.originalUrl) return "";
145
+ const full = splitUrl(req.originalUrl).path, rel = splitUrl(req.url || "/").path;
146
+ return rel !== "/" && full !== rel && full.endsWith(rel) ? full.slice(0, full.length - rel.length) : "";
147
+ }
148
+ function onUnixListener(socket) {
149
+ const srv = socket?.server;
150
+ if (!srv) return false;
151
+ try {
152
+ if (typeof srv.address?.() === "string") return true;
153
+ } catch {
154
+ }
155
+ return typeof srv._pipeName === "string";
156
+ }
106
157
  var cookieValue = (cookie, name2) => {
107
158
  const src = "; " + cookie;
108
159
  const i = src.indexOf("; " + name2 + "=");
@@ -131,15 +182,22 @@ var Camada = class {
131
182
  fpPath;
132
183
  challengeOn;
133
184
  challengePath;
185
+ challengePathSet;
186
+ // configured explicitly: used as given, never prefixed
187
+ scriptPathSet;
134
188
  kit = null;
135
189
  envSource;
190
+ waitUntil;
136
191
  constructor(opts = {}) {
137
192
  this.envSource = opts.env ?? process.env;
138
193
  this.fetchImpl = opts.fetchImpl ?? fetch;
194
+ this.waitUntil = opts.waitUntil;
139
195
  this.scriptPath = opts.scriptPath ?? SCRIPT_PATH;
140
196
  this.fpPath = opts.fpPath ?? FP_PATH;
141
197
  this.challengeOn = opts.challenge !== false && this.envSource.CAMADA_CHALLENGE !== "0";
142
198
  this.challengePath = opts.challengePath ?? CHALLENGE_PATH;
199
+ this.challengePathSet = opts.challengePath != null;
200
+ this.scriptPathSet = opts.scriptPath != null;
143
201
  this.env = resolveEnv(this.envSource);
144
202
  if (!this.env) return;
145
203
  if (this.envSource.CAMADA_DISABLED === "1") return;
@@ -163,9 +221,43 @@ var Camada = class {
163
221
  trustedProxy() {
164
222
  return this.env?.trustedProxy ?? this.snap?.config?.trusted_proxy ?? null;
165
223
  }
224
+ /** The client IP per the trusted-proxy config (team contract C6 v3). No socket at all, or a
225
+ * unix-socket listener (nginx/Passenger in front, where Node never has a peer): core's no-peer
226
+ * X-Forwarded-For walk, which trusts the local proxy. A TCP socket with no readable peer (the
227
+ * client reset the connection before camada ran, e.g. behind an async middleware) is
228
+ * unidentifiable: the walk would believe whatever the client wrote. So: null, the stance every
229
+ * ip-bound feature already takes (no ip rules, no challenge). */
230
+ clientIp(req) {
231
+ const peer = req.socket?.remoteAddress;
232
+ if (!peer && req.socket && !onUnixListener(req.socket)) return null;
233
+ return resolveClientIp(peer ?? null, req.headers["x-forwarded-for"], this.trustedProxy());
234
+ }
235
+ /** Where the challenge form posts: under the mount prefix, so it reaches camada, unless the
236
+ * path was configured explicitly. */
237
+ challengeAction(req) {
238
+ return this.challengePathSet ? this.challengePath : (req.camadaPrefix ?? mountPrefix(req)) + this.challengePath;
239
+ }
166
240
  beaconEnabled() {
167
241
  return this.snap?.config?.beacon !== false;
168
242
  }
243
+ /** Queues a row. With CAMADA_SERVERLESS=1 it also starts the POST now: the platform freezes the
244
+ * process once the response is out, so the 15 s interval would never run. Never throws. */
245
+ enqueue(row, req) {
246
+ const q = this.queue;
247
+ q.push(row);
248
+ if (!this.env?.serverless) return;
249
+ if (req?.camadaOpen) return;
250
+ this.flushFor(req);
251
+ }
252
+ /** Serverless: start the POST now and hand it to waitUntil. Never throws. */
253
+ flushFor(req) {
254
+ const q = this.queue;
255
+ guarded(() => {
256
+ const tracks = req?.camadaTracks ?? [];
257
+ const p = Promise.allSettled(tracks).then(() => q.flush()).then(() => q.size > 0 ? q.flush() : void 0);
258
+ this.waitUntil?.(p);
259
+ }, void 0);
260
+ }
169
261
  /** True when camada fully handled the request. Synchronous; never throws. */
170
262
  handle(req, res) {
171
263
  return guarded(() => this.handleInner(req, res), false);
@@ -173,15 +265,13 @@ var Camada = class {
173
265
  handleInner(req, res) {
174
266
  if (this.disabled || !this.snap || !this.queue || !this.env) return false;
175
267
  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());
268
+ this.snap.ensureFresh(this.env.serverless ? this.waitUntil : void 0);
269
+ const { path, query } = pathAndQuery(req);
270
+ const ip = this.clientIp(req);
271
+ req.camadaPrefix = mountPrefix(req);
182
272
  const v = this.snap.verdict({ ip, path, ua: req.headers["user-agent"], header: headerReader(req) });
183
273
  if (v.block) {
184
- const headers = { "x-block-reason": v.reason ?? "", "x-block-version": v.version ?? "", "content-type": "text/plain" };
274
+ const headers = { "x-block-reason": v.reason ?? "", "x-block-version": v.version ?? "", "content-type": "text/plain", "cache-control": "no-store" };
185
275
  if (v.rule) headers["x-block-rule"] = v.rule;
186
276
  res.writeHead(403, headers);
187
277
  res.end("Forbidden");
@@ -189,11 +279,11 @@ var Camada = class {
189
279
  ev.st = 403;
190
280
  ev.blk = v.reason;
191
281
  if (v.rule) ev.rl = v.rule;
192
- this.queue.push(ev);
282
+ this.enqueue(ev);
193
283
  return true;
194
284
  }
195
285
  if (this.challengeOn && this.kit && ip) {
196
- if (req.method === "POST" && path === this.challengePath) {
286
+ if (req.method === "POST" && atEndpoint(req, path, this.challengePath)) {
197
287
  this.verifyChallenge(req, res, ip);
198
288
  return true;
199
289
  }
@@ -203,12 +293,12 @@ var Camada = class {
203
293
  }
204
294
  }
205
295
  if (this.beaconEnabled()) {
206
- if (req.method === "GET" && path === this.scriptPath) {
296
+ if (req.method === "GET" && atEndpoint(req, path, this.scriptPath)) {
207
297
  res.writeHead(200, { "content-type": "application/javascript", "cache-control": "public, max-age=3600" });
208
298
  res.end(iife);
209
299
  return true;
210
300
  }
211
- if (req.method === "POST" && path === this.fpPath) {
301
+ if (req.method === "POST" && atEndpoint(req, path, this.fpPath)) {
212
302
  this.relayBeacon(req, res, ip);
213
303
  return true;
214
304
  }
@@ -227,16 +317,22 @@ var Camada = class {
227
317
  const excluded = (cfg?.exclude || []).some((x) => path.startsWith(x));
228
318
  if (!excluded && Math.random() < (cfg?.sample ?? 1)) {
229
319
  let shipped = false;
320
+ req.camadaOpen = true;
230
321
  const ship = () => guarded(() => {
231
- if (shipped || req.camadaChallenged) return;
322
+ req.camadaOpen = false;
323
+ if (shipped) return;
232
324
  shipped = true;
325
+ if (req.camadaChallenged) {
326
+ if (this.env?.serverless) this.flushFor(req);
327
+ return;
328
+ }
233
329
  const ev = this.buildEvent(req, path, query, ip, { rid, sid, newSession });
234
330
  ev.ts = t0;
235
331
  ev.st = res.statusCode;
236
332
  ev.dur = Date.now() - t0;
237
333
  if (req.route?.path) ev.rt = String(req.route.path);
238
334
  if (v.warn && v.rule) ev.wrn = v.rule;
239
- this.queue.push(ev);
335
+ this.enqueue(ev, req);
240
336
  }, void 0);
241
337
  res.once("finish", ship);
242
338
  res.once("close", ship);
@@ -254,8 +350,32 @@ var Camada = class {
254
350
  }
255
351
  /** Reads the beacon POST (≤32 KB), answers 204 immediately, and queues the beacon as a `sig: 1`
256
352
  * 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. */
353
+ * sees one request per flush instead of one per page view. Junk bodies are dropped, never shipped.
354
+ * A body parser mounted before camada (express.json(), always on Nest) has already drained the
355
+ * stream: its `req.body` is used instead, and the request is answered either way. */
258
356
  relayBeacon(req, res, ip) {
357
+ const accept = (parsed) => {
358
+ res.writeHead(204, { "cache-control": "no-store" });
359
+ res.end();
360
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed) || Buffer.isBuffer(parsed)) return;
361
+ this.enqueue({ ...parsed, sig: 1, ip, tap: TAP_NODE });
362
+ };
363
+ const fromText = (text) => {
364
+ try {
365
+ return JSON.parse(text);
366
+ } catch {
367
+ return null;
368
+ }
369
+ };
370
+ if (req.readableEnded) {
371
+ const b = parsedBody(req);
372
+ if ((typeof b === "string" ? b : b ? JSON.stringify(b) : "").length > FP_MAX) {
373
+ res.writeHead(413).end();
374
+ return;
375
+ }
376
+ accept(typeof b === "string" ? fromText(b) : b);
377
+ return;
378
+ }
259
379
  const chunks = [];
260
380
  let size = 0, dead = false;
261
381
  req.on("data", (c) => {
@@ -270,16 +390,7 @@ var Camada = class {
270
390
  });
271
391
  req.on("end", () => guarded(() => {
272
392
  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 });
393
+ accept(fromText(Buffer.concat(chunks).toString("utf8")));
283
394
  }, void 0));
284
395
  req.on("error", () => {
285
396
  try {
@@ -296,7 +407,7 @@ var Camada = class {
296
407
  serveChallengeInner(req, res, ip, target) {
297
408
  const to = safeReturnTo(target);
298
409
  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 }));
410
+ writeChallengePage(res, { nonce: this.kit.nonce(ip, Date.now()), action: this.challengeAction(req), to });
300
411
  } else {
301
412
  writeChallengeJson(res);
302
413
  }
@@ -311,7 +422,7 @@ var Camada = class {
311
422
  );
312
423
  ev.st = 403;
313
424
  ev.blk = "challenge";
314
- this.queue.push(ev);
425
+ this.enqueue(ev);
315
426
  }, void 0);
316
427
  }
317
428
  /** The request's existing session, so challenge rows join the session that produced them. */
@@ -321,12 +432,11 @@ var Camada = class {
321
432
  /** POST from the challenge page: validate the nonce and the proof of work, set `_cch`, 302
322
433
  * back to the (sanitised, same-site) original URL, and ship `{ st: 200, ch: 1 }`. */
323
434
  verifyChallenge(req, res, ip) {
324
- readBody(req, (body) => guarded(() => {
325
- const form = parseFormBody(body);
435
+ readForm(req, (form) => guarded(() => {
326
436
  const to = safeReturnTo(form.to);
327
437
  const now = Date.now();
328
438
  if (!this.kit.verify(ip, now, form.nonce, form.solution)) {
329
- writeChallengePage(res, challengePage({ nonce: this.kit.nonce(ip, now), action: this.challengePath, to }));
439
+ writeChallengePage(res, { nonce: this.kit.nonce(ip, now), action: this.challengeAction(req), to });
330
440
  return;
331
441
  }
332
442
  res.writeHead(302, {
@@ -335,10 +445,10 @@ var Camada = class {
335
445
  "cache-control": "no-store"
336
446
  });
337
447
  res.end();
338
- const ev = this.buildEvent(req, this.challengePath, "", ip, { rid: randomUUID(), sid: this.sessionOf(req), newSession: false });
448
+ const ev = this.buildEvent(req, this.challengeAction(req), "", ip, { rid: randomUUID(), sid: this.sessionOf(req), newSession: false });
339
449
  ev.st = 200;
340
450
  ev.ch = 1;
341
- this.queue.push(ev);
451
+ this.enqueue(ev);
342
452
  }, void 0));
343
453
  }
344
454
  /**
@@ -370,17 +480,15 @@ var Camada = class {
370
480
  }
371
481
  shipUpgrade(req, t0) {
372
482
  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);
483
+ const { path, query } = pathAndQuery(req);
376
484
  const cfg = this.snap?.config;
377
485
  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 });
486
+ const ip = this.clientIp(req);
487
+ const ev = this.buildEvent(req, path, query, ip, { rid: randomUUID(), sid: this.sessionOf(req), newSession: false });
380
488
  ev.ts = t0;
381
489
  ev.st = 101;
382
490
  ev.dur = Date.now() - t0;
383
- this.queue.push(ev);
491
+ this.enqueue(ev);
384
492
  }
385
493
  /** Serve the challenge for this request on demand — for a route the app wants to gate itself
386
494
  * (the example's /challenge-me). Returns false when the client already holds a valid `_cch`,
@@ -388,10 +496,11 @@ var Camada = class {
388
496
  serveChallenge(req, res) {
389
497
  return guarded(() => {
390
498
  if (this.disabled || !this.kit || !this.queue) return false;
391
- const ip = resolveClientIp(req.socket?.remoteAddress, req.headers["x-forwarded-for"], this.trustedProxy());
499
+ const ip = this.clientIp(req);
392
500
  if (!ip || this.challengePassed(req, ip)) return false;
393
501
  req.camadaChallenged = true;
394
- this.serveChallengeInner(req, res, ip, req.url || "/");
502
+ const { path, query } = pathAndQuery(req);
503
+ this.serveChallengeInner(req, res, ip, path + query);
395
504
  return true;
396
505
  }, false);
397
506
  }
@@ -399,7 +508,9 @@ var Camada = class {
399
508
  scriptTag(req) {
400
509
  if (this.disabled || !this.beaconEnabled()) return "";
401
510
  const rid = req.camada?.rid;
402
- return `<script src="${this.scriptPath}${rid ? `?r=${rid}` : ""}" async></script>`;
511
+ const r = req;
512
+ const src = this.scriptPathSet ? this.scriptPath : (r.camadaPrefix ?? mountPrefix(r)) + this.scriptPath;
513
+ return `<script src="${src}${rid ? `?r=${rid}` : ""}" async></script>`;
403
514
  }
404
515
  /** App-context outcome events (login failed, signup, …). The identifier is HMAC-hashed
405
516
  * in-process; the raw value never reaches the queue. */
@@ -407,10 +518,12 @@ var Camada = class {
407
518
  guarded(() => {
408
519
  if (this.disabled || !this.queue || !this.env) return;
409
520
  const ctx = req.camada;
410
- void (async () => {
521
+ const r = req;
522
+ const row = (async () => {
411
523
  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() });
524
+ 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
525
  })().catch(logRateLimited);
526
+ if (r.camadaOpen) (r.camadaTracks ??= []).push(row);
414
527
  }, void 0);
415
528
  }
416
529
  /** Express/Connect middleware (also what NestJS on the default platform consumes). */
@@ -419,9 +532,11 @@ var Camada = class {
419
532
  if (!this.handle(req, res)) next();
420
533
  };
421
534
  }
535
+ /** Stops polling and the event queue. The promise settles once rows still queued behind a drain
536
+ * in flight or a 429 hold are sent (or counted dropped); it never rejects. */
422
537
  stop() {
423
538
  this.snap?.stop();
424
- this.queue?.stop();
539
+ return this.queue?.stop() ?? Promise.resolve();
425
540
  }
426
541
  };
427
542
  function createCamada(opts = {}) {
@@ -462,4 +577,4 @@ export {
462
577
  configure,
463
578
  index_default
464
579
  };
465
- //# sourceMappingURL=chunk-CR3UHMBJ.js.map
580
+ //# sourceMappingURL=chunk-V3UWO5OT.js.map