@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/dist/index.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { IncomingMessage, ServerResponse, Server } from 'node:http';
2
- import { a as CamadaOptions, C as Camada } from './camada-Dh_9D6Sx.js';
3
- export { R as ResolvedEnv, c as createCamada, r as resolveEnv } from './camada-Dh_9D6Sx.js';
2
+ import { a as CamadaOptions, C as Camada } from './camada-CFmJTruP.js';
3
+ export { R as ResolvedEnv, c as createCamada, r as resolveEnv } from './camada-CFmJTruP.js';
4
4
  export { parseTrustedProxyEnv } from '@camada/core';
5
5
 
6
6
  declare function getDefault(): Camada;
package/dist/index.js CHANGED
@@ -6,7 +6,7 @@ import {
6
6
  index_default,
7
7
  parseTrustedProxyEnv,
8
8
  resolveEnv
9
- } from "./chunk-CR3UHMBJ.js";
9
+ } from "./chunk-V3UWO5OT.js";
10
10
  export {
11
11
  Camada,
12
12
  configure,
package/dist/koa.cjs CHANGED
@@ -86,10 +86,41 @@ function readBody(req, done) {
86
86
  req.on("end", () => done(dead ? "" : Buffer.concat(chunks).toString("utf8")));
87
87
  req.on("error", () => done(""));
88
88
  }
89
+ var FORM_FIELDS = ["nonce", "solution", "to"];
90
+ function parsedBody(req) {
91
+ const b = req.body;
92
+ if (typeof b === "string") return b;
93
+ if (Buffer.isBuffer(b)) return b.toString("utf8");
94
+ return b && typeof b === "object" ? b : null;
95
+ }
96
+ function readForm(req, done) {
97
+ if (!req.readableEnded) {
98
+ readBody(req, (body) => done((0, import_core2.parseFormBody)(body)));
99
+ return;
100
+ }
101
+ const b = parsedBody(req);
102
+ if (typeof b === "string") {
103
+ done(b.length > BODY_MAX ? {} : (0, import_core2.parseFormBody)(b));
104
+ return;
105
+ }
106
+ const form = /* @__PURE__ */ Object.create(null);
107
+ for (const k of FORM_FIELDS) {
108
+ const v = b?.[k];
109
+ if (typeof v === "string") form[k] = v;
110
+ }
111
+ done(form);
112
+ }
89
113
  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);
114
+ function writeChallengePage(res, o) {
115
+ res.removeHeader("content-security-policy");
116
+ res.removeHeader("content-security-policy-report-only");
117
+ res.writeHead(403, {
118
+ "content-type": "text/html; charset=utf-8",
119
+ "cache-control": "no-store",
120
+ "x-camada-challenge": "1",
121
+ "content-security-policy": (0, import_core2.challengePageCsp)(o.bits)
122
+ });
123
+ res.end((0, import_core2.challengePage)(o));
93
124
  }
94
125
  function writeChallengeJson(res) {
95
126
  res.writeHead(403, { "content-type": "application/json", "cache-control": "no-store", "x-camada-challenge": "1" });
@@ -97,7 +128,7 @@ function writeChallengeJson(res) {
97
128
  }
98
129
 
99
130
  // package.json
100
- var version = "0.3.0";
131
+ var version = "0.3.2";
101
132
  var name = "@camada/node";
102
133
 
103
134
  // src/version.ts
@@ -113,6 +144,28 @@ var headerReader = (req) => (name2) => {
113
144
  const v = req.headers[name2];
114
145
  return v == null ? null : Array.isArray(v) ? v.join(", ") : v;
115
146
  };
147
+ var splitUrl = (raw) => {
148
+ const qi = raw.indexOf("?");
149
+ return qi === -1 ? { path: raw, query: "" } : { path: raw.slice(0, qi), query: raw.slice(qi) };
150
+ };
151
+ function pathAndQuery(req) {
152
+ return splitUrl(req.originalUrl || req.url || "/");
153
+ }
154
+ var atEndpoint = (req, path, endpoint) => path === endpoint || splitUrl(req.url || "/").path === endpoint;
155
+ function mountPrefix(req) {
156
+ if (!req.originalUrl) return "";
157
+ const full = splitUrl(req.originalUrl).path, rel = splitUrl(req.url || "/").path;
158
+ return rel !== "/" && full !== rel && full.endsWith(rel) ? full.slice(0, full.length - rel.length) : "";
159
+ }
160
+ function onUnixListener(socket) {
161
+ const srv = socket?.server;
162
+ if (!srv) return false;
163
+ try {
164
+ if (typeof srv.address?.() === "string") return true;
165
+ } catch {
166
+ }
167
+ return typeof srv._pipeName === "string";
168
+ }
116
169
  var cookieValue = (cookie, name2) => {
117
170
  const src = "; " + cookie;
118
171
  const i = src.indexOf("; " + name2 + "=");
@@ -141,15 +194,22 @@ var Camada = class {
141
194
  fpPath;
142
195
  challengeOn;
143
196
  challengePath;
197
+ challengePathSet;
198
+ // configured explicitly: used as given, never prefixed
199
+ scriptPathSet;
144
200
  kit = null;
145
201
  envSource;
202
+ waitUntil;
146
203
  constructor(opts = {}) {
147
204
  this.envSource = opts.env ?? process.env;
148
205
  this.fetchImpl = opts.fetchImpl ?? fetch;
206
+ this.waitUntil = opts.waitUntil;
149
207
  this.scriptPath = opts.scriptPath ?? SCRIPT_PATH;
150
208
  this.fpPath = opts.fpPath ?? FP_PATH;
151
209
  this.challengeOn = opts.challenge !== false && this.envSource.CAMADA_CHALLENGE !== "0";
152
210
  this.challengePath = opts.challengePath ?? CHALLENGE_PATH;
211
+ this.challengePathSet = opts.challengePath != null;
212
+ this.scriptPathSet = opts.scriptPath != null;
153
213
  this.env = resolveEnv(this.envSource);
154
214
  if (!this.env) return;
155
215
  if (this.envSource.CAMADA_DISABLED === "1") return;
@@ -173,9 +233,43 @@ var Camada = class {
173
233
  trustedProxy() {
174
234
  return this.env?.trustedProxy ?? this.snap?.config?.trusted_proxy ?? null;
175
235
  }
236
+ /** The client IP per the trusted-proxy config (team contract C6 v3). No socket at all, or a
237
+ * unix-socket listener (nginx/Passenger in front, where Node never has a peer): core's no-peer
238
+ * X-Forwarded-For walk, which trusts the local proxy. A TCP socket with no readable peer (the
239
+ * client reset the connection before camada ran, e.g. behind an async middleware) is
240
+ * unidentifiable: the walk would believe whatever the client wrote. So: null, the stance every
241
+ * ip-bound feature already takes (no ip rules, no challenge). */
242
+ clientIp(req) {
243
+ const peer = req.socket?.remoteAddress;
244
+ if (!peer && req.socket && !onUnixListener(req.socket)) return null;
245
+ return (0, import_core3.resolveClientIp)(peer ?? null, req.headers["x-forwarded-for"], this.trustedProxy());
246
+ }
247
+ /** Where the challenge form posts: under the mount prefix, so it reaches camada, unless the
248
+ * path was configured explicitly. */
249
+ challengeAction(req) {
250
+ return this.challengePathSet ? this.challengePath : (req.camadaPrefix ?? mountPrefix(req)) + this.challengePath;
251
+ }
176
252
  beaconEnabled() {
177
253
  return this.snap?.config?.beacon !== false;
178
254
  }
255
+ /** Queues a row. With CAMADA_SERVERLESS=1 it also starts the POST now: the platform freezes the
256
+ * process once the response is out, so the 15 s interval would never run. Never throws. */
257
+ enqueue(row, req) {
258
+ const q = this.queue;
259
+ q.push(row);
260
+ if (!this.env?.serverless) return;
261
+ if (req?.camadaOpen) return;
262
+ this.flushFor(req);
263
+ }
264
+ /** Serverless: start the POST now and hand it to waitUntil. Never throws. */
265
+ flushFor(req) {
266
+ const q = this.queue;
267
+ (0, import_core3.guarded)(() => {
268
+ const tracks = req?.camadaTracks ?? [];
269
+ const p = Promise.allSettled(tracks).then(() => q.flush()).then(() => q.size > 0 ? q.flush() : void 0);
270
+ this.waitUntil?.(p);
271
+ }, void 0);
272
+ }
179
273
  /** True when camada fully handled the request. Synchronous; never throws. */
180
274
  handle(req, res) {
181
275
  return (0, import_core3.guarded)(() => this.handleInner(req, res), false);
@@ -183,15 +277,13 @@ var Camada = class {
183
277
  handleInner(req, res) {
184
278
  if (this.disabled || !this.snap || !this.queue || !this.env) return false;
185
279
  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());
280
+ this.snap.ensureFresh(this.env.serverless ? this.waitUntil : void 0);
281
+ const { path, query } = pathAndQuery(req);
282
+ const ip = this.clientIp(req);
283
+ req.camadaPrefix = mountPrefix(req);
192
284
  const v = this.snap.verdict({ ip, path, ua: req.headers["user-agent"], header: headerReader(req) });
193
285
  if (v.block) {
194
- const headers = { "x-block-reason": v.reason ?? "", "x-block-version": v.version ?? "", "content-type": "text/plain" };
286
+ const headers = { "x-block-reason": v.reason ?? "", "x-block-version": v.version ?? "", "content-type": "text/plain", "cache-control": "no-store" };
195
287
  if (v.rule) headers["x-block-rule"] = v.rule;
196
288
  res.writeHead(403, headers);
197
289
  res.end("Forbidden");
@@ -199,11 +291,11 @@ var Camada = class {
199
291
  ev.st = 403;
200
292
  ev.blk = v.reason;
201
293
  if (v.rule) ev.rl = v.rule;
202
- this.queue.push(ev);
294
+ this.enqueue(ev);
203
295
  return true;
204
296
  }
205
297
  if (this.challengeOn && this.kit && ip) {
206
- if (req.method === "POST" && path === this.challengePath) {
298
+ if (req.method === "POST" && atEndpoint(req, path, this.challengePath)) {
207
299
  this.verifyChallenge(req, res, ip);
208
300
  return true;
209
301
  }
@@ -213,12 +305,12 @@ var Camada = class {
213
305
  }
214
306
  }
215
307
  if (this.beaconEnabled()) {
216
- if (req.method === "GET" && path === this.scriptPath) {
308
+ if (req.method === "GET" && atEndpoint(req, path, this.scriptPath)) {
217
309
  res.writeHead(200, { "content-type": "application/javascript", "cache-control": "public, max-age=3600" });
218
310
  res.end(import_iife_string.default);
219
311
  return true;
220
312
  }
221
- if (req.method === "POST" && path === this.fpPath) {
313
+ if (req.method === "POST" && atEndpoint(req, path, this.fpPath)) {
222
314
  this.relayBeacon(req, res, ip);
223
315
  return true;
224
316
  }
@@ -237,16 +329,22 @@ var Camada = class {
237
329
  const excluded = (cfg?.exclude || []).some((x) => path.startsWith(x));
238
330
  if (!excluded && Math.random() < (cfg?.sample ?? 1)) {
239
331
  let shipped = false;
332
+ req.camadaOpen = true;
240
333
  const ship = () => (0, import_core3.guarded)(() => {
241
- if (shipped || req.camadaChallenged) return;
334
+ req.camadaOpen = false;
335
+ if (shipped) return;
242
336
  shipped = true;
337
+ if (req.camadaChallenged) {
338
+ if (this.env?.serverless) this.flushFor(req);
339
+ return;
340
+ }
243
341
  const ev = this.buildEvent(req, path, query, ip, { rid, sid, newSession });
244
342
  ev.ts = t0;
245
343
  ev.st = res.statusCode;
246
344
  ev.dur = Date.now() - t0;
247
345
  if (req.route?.path) ev.rt = String(req.route.path);
248
346
  if (v.warn && v.rule) ev.wrn = v.rule;
249
- this.queue.push(ev);
347
+ this.enqueue(ev, req);
250
348
  }, void 0);
251
349
  res.once("finish", ship);
252
350
  res.once("close", ship);
@@ -264,8 +362,32 @@ var Camada = class {
264
362
  }
265
363
  /** Reads the beacon POST (≤32 KB), answers 204 immediately, and queues the beacon as a `sig: 1`
266
364
  * 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. */
365
+ * sees one request per flush instead of one per page view. Junk bodies are dropped, never shipped.
366
+ * A body parser mounted before camada (express.json(), always on Nest) has already drained the
367
+ * stream: its `req.body` is used instead, and the request is answered either way. */
268
368
  relayBeacon(req, res, ip) {
369
+ const accept = (parsed) => {
370
+ res.writeHead(204, { "cache-control": "no-store" });
371
+ res.end();
372
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed) || Buffer.isBuffer(parsed)) return;
373
+ this.enqueue({ ...parsed, sig: 1, ip, tap: import_core3.TAP_NODE });
374
+ };
375
+ const fromText = (text) => {
376
+ try {
377
+ return JSON.parse(text);
378
+ } catch {
379
+ return null;
380
+ }
381
+ };
382
+ if (req.readableEnded) {
383
+ const b = parsedBody(req);
384
+ if ((typeof b === "string" ? b : b ? JSON.stringify(b) : "").length > FP_MAX) {
385
+ res.writeHead(413).end();
386
+ return;
387
+ }
388
+ accept(typeof b === "string" ? fromText(b) : b);
389
+ return;
390
+ }
269
391
  const chunks = [];
270
392
  let size = 0, dead = false;
271
393
  req.on("data", (c) => {
@@ -280,16 +402,7 @@ var Camada = class {
280
402
  });
281
403
  req.on("end", () => (0, import_core3.guarded)(() => {
282
404
  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 });
405
+ accept(fromText(Buffer.concat(chunks).toString("utf8")));
293
406
  }, void 0));
294
407
  req.on("error", () => {
295
408
  try {
@@ -306,7 +419,7 @@ var Camada = class {
306
419
  serveChallengeInner(req, res, ip, target) {
307
420
  const to = (0, import_core3.safeReturnTo)(target);
308
421
  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 }));
422
+ writeChallengePage(res, { nonce: this.kit.nonce(ip, Date.now()), action: this.challengeAction(req), to });
310
423
  } else {
311
424
  writeChallengeJson(res);
312
425
  }
@@ -321,7 +434,7 @@ var Camada = class {
321
434
  );
322
435
  ev.st = 403;
323
436
  ev.blk = "challenge";
324
- this.queue.push(ev);
437
+ this.enqueue(ev);
325
438
  }, void 0);
326
439
  }
327
440
  /** The request's existing session, so challenge rows join the session that produced them. */
@@ -331,12 +444,11 @@ var Camada = class {
331
444
  /** POST from the challenge page: validate the nonce and the proof of work, set `_cch`, 302
332
445
  * back to the (sanitised, same-site) original URL, and ship `{ st: 200, ch: 1 }`. */
333
446
  verifyChallenge(req, res, ip) {
334
- readBody(req, (body) => (0, import_core3.guarded)(() => {
335
- const form = (0, import_core3.parseFormBody)(body);
447
+ readForm(req, (form) => (0, import_core3.guarded)(() => {
336
448
  const to = (0, import_core3.safeReturnTo)(form.to);
337
449
  const now = Date.now();
338
450
  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 }));
451
+ writeChallengePage(res, { nonce: this.kit.nonce(ip, now), action: this.challengeAction(req), to });
340
452
  return;
341
453
  }
342
454
  res.writeHead(302, {
@@ -345,10 +457,10 @@ var Camada = class {
345
457
  "cache-control": "no-store"
346
458
  });
347
459
  res.end();
348
- const ev = this.buildEvent(req, this.challengePath, "", ip, { rid: (0, import_node_crypto2.randomUUID)(), sid: this.sessionOf(req), newSession: false });
460
+ const ev = this.buildEvent(req, this.challengeAction(req), "", ip, { rid: (0, import_node_crypto2.randomUUID)(), sid: this.sessionOf(req), newSession: false });
349
461
  ev.st = 200;
350
462
  ev.ch = 1;
351
- this.queue.push(ev);
463
+ this.enqueue(ev);
352
464
  }, void 0));
353
465
  }
354
466
  /**
@@ -380,17 +492,15 @@ var Camada = class {
380
492
  }
381
493
  shipUpgrade(req, t0) {
382
494
  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);
495
+ const { path, query } = pathAndQuery(req);
386
496
  const cfg = this.snap?.config;
387
497
  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 });
498
+ const ip = this.clientIp(req);
499
+ const ev = this.buildEvent(req, path, query, ip, { rid: (0, import_node_crypto2.randomUUID)(), sid: this.sessionOf(req), newSession: false });
390
500
  ev.ts = t0;
391
501
  ev.st = 101;
392
502
  ev.dur = Date.now() - t0;
393
- this.queue.push(ev);
503
+ this.enqueue(ev);
394
504
  }
395
505
  /** Serve the challenge for this request on demand — for a route the app wants to gate itself
396
506
  * (the example's /challenge-me). Returns false when the client already holds a valid `_cch`,
@@ -398,10 +508,11 @@ var Camada = class {
398
508
  serveChallenge(req, res) {
399
509
  return (0, import_core3.guarded)(() => {
400
510
  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());
511
+ const ip = this.clientIp(req);
402
512
  if (!ip || this.challengePassed(req, ip)) return false;
403
513
  req.camadaChallenged = true;
404
- this.serveChallengeInner(req, res, ip, req.url || "/");
514
+ const { path, query } = pathAndQuery(req);
515
+ this.serveChallengeInner(req, res, ip, path + query);
405
516
  return true;
406
517
  }, false);
407
518
  }
@@ -409,7 +520,9 @@ var Camada = class {
409
520
  scriptTag(req) {
410
521
  if (this.disabled || !this.beaconEnabled()) return "";
411
522
  const rid = req.camada?.rid;
412
- return `<script src="${this.scriptPath}${rid ? `?r=${rid}` : ""}" async></script>`;
523
+ const r = req;
524
+ const src = this.scriptPathSet ? this.scriptPath : (r.camadaPrefix ?? mountPrefix(r)) + this.scriptPath;
525
+ return `<script src="${src}${rid ? `?r=${rid}` : ""}" async></script>`;
413
526
  }
414
527
  /** App-context outcome events (login failed, signup, …). The identifier is HMAC-hashed
415
528
  * in-process; the raw value never reaches the queue. */
@@ -417,10 +530,12 @@ var Camada = class {
417
530
  (0, import_core3.guarded)(() => {
418
531
  if (this.disabled || !this.queue || !this.env) return;
419
532
  const ctx = req.camada;
420
- void (async () => {
533
+ const r = req;
534
+ const row = (async () => {
421
535
  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() });
536
+ 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
537
  })().catch(import_core3.logRateLimited);
538
+ if (r.camadaOpen) (r.camadaTracks ??= []).push(row);
424
539
  }, void 0);
425
540
  }
426
541
  /** Express/Connect middleware (also what NestJS on the default platform consumes). */
@@ -429,9 +544,11 @@ var Camada = class {
429
544
  if (!this.handle(req, res)) next();
430
545
  };
431
546
  }
547
+ /** Stops polling and the event queue. The promise settles once rows still queued behind a drain
548
+ * in flight or a 429 hold are sent (or counted dropped); it never rejects. */
432
549
  stop() {
433
550
  this.snap?.stop();
434
- this.queue?.stop();
551
+ return this.queue?.stop() ?? Promise.resolve();
435
552
  }
436
553
  };
437
554
  function createCamada(opts = {}) {
@@ -449,6 +566,8 @@ function getDefault() {
449
566
  // src/koa.ts
450
567
  function camadaKoa(engine) {
451
568
  return async (ctx, next) => {
569
+ ctx.req.body ??= ctx.request?.body;
570
+ ctx.req.originalUrl ??= ctx.originalUrl;
452
571
  if ((engine ?? getDefault()).handle(ctx.req, ctx.res)) {
453
572
  ctx.respond = false;
454
573
  return;