@camada/node 0.2.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 camada
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,135 @@
1
+ # @camada/node
2
+
3
+ camada's backend SDK for Node: captures request metadata on response-finish (real status,
4
+ latency, and the true wire header order no proxy position can see), enforces the tenant
5
+ blocklist inline before your app runs, and serves the fingerprint beacon first-party at
6
+ `/_cam/b.js` + `/_cam/fp`. Fails open by design: a camada outage or bug never 5xxes your app.
7
+
8
+ Not yet on npm — consumed via `file:` dependency from a sibling checkout.
9
+
10
+ ## Quickstart
11
+
12
+ Env (printed by camada onboarding / `npm run seed` in dev):
13
+
14
+ ```
15
+ CAMADA_KEY=<ingest_token>.<snap_token>
16
+ CAMADA_INGEST_URL=http://localhost:8787 # dev only; defaults to production ingest
17
+ ```
18
+
19
+ **Express / Connect**
20
+ ```js
21
+ import camada from '@camada/node';
22
+ app.use(camada.express());
23
+ ```
24
+
25
+ **Fastify**
26
+ ```js
27
+ import { camadaFastify } from '@camada/node/fastify';
28
+ await app.register(camadaFastify);
29
+ ```
30
+
31
+ **NestJS** (default Express platform; on the Fastify platform register the fastify plugin instead)
32
+ ```ts
33
+ import { CamadaModule } from '@camada/node/nest';
34
+ @Module({ imports: [CamadaModule.forRoot()] }) // Nest ≤10: CamadaModule.forRoot({ routes: '*' })
35
+ ```
36
+
37
+ **Koa**
38
+ ```js
39
+ import { camadaKoa } from '@camada/node/koa';
40
+ app.use(camadaKoa());
41
+ ```
42
+
43
+ ## What it does per request
44
+
45
+ 1. Refreshes the blocklist snapshot off-path (30 s poll, ETag; cold start fails open). Every
46
+ poll and event batch carries `x-camada-sdk: @camada/node/<version>`.
47
+ 2. Resolves the client IP per your tenant's trusted-proxy config — raw `X-Forwarded-For` is
48
+ never trusted without it (`CAMADA_TRUSTED_PROXY=hops:1|cidrs:…|vercel` overrides locally).
49
+ 3. Runs your ordered custom rules (see below), then the allow, block and challenge lists.
50
+ Blocked → `403` with `x-block-reason` before your app; the event still ships, with
51
+ `st: 403` and `blk: <reason>` (ip4|ip6|path|rule) so the analyst counts SDK blocks apart
52
+ from your app's own 403s.
53
+ 4. Serves `/_cam/b.js` (the beacon, first-party — no third-party domain for ad-blockers or CSP
54
+ to break) and relays `/_cam/fp` posts to ingest with the resolved client IP.
55
+ 5. Otherwise: sets `x-rid` + the `_sfp` session cookie, and on response-finish ships one
56
+ batched, redacted event (Authorization/Cookie values never leave the process; credential-
57
+ looking query values are scrubbed; see @camada/core).
58
+
59
+ ## Custom rules
60
+
61
+ Your Rules page holds one ordered list per project, and this SDK walks it before the allow,
62
+ block and challenge lists. First match wins — the order *is* the precedence — and each rule
63
+ carries one of four actions:
64
+
65
+ | action | what this SDK does | on the event |
66
+ |---|---|---|
67
+ | `skip` | passes the request | nothing |
68
+ | `block` | `403` before your app | `blk: "rule"`, `rl: "<rule id>"` |
69
+ | `challenge` | serves the proof-of-work page (`challenge: false` opts out) | `blk: "challenge"` |
70
+ | `warn` | passes the request and marks it for the analyst | `wrn: "<rule id>"` |
71
+
72
+ A skip rule also carries a *record matches* flag, which only the analyst reads: a recorded skip
73
+ is still scored and shows on your dashboard as Allowed, an unrecorded one is dropped before
74
+ scoring. Either way the request passes here, unstamped — the built-in Allow-list is a skip rule
75
+ with recording on.
76
+
77
+ A rule block also names the row that decided, so the response says which rule to edit:
78
+
79
+ ```
80
+ HTTP/1.1 403 Forbidden
81
+ x-block-reason: rule
82
+ x-block-rule: cr_4f2a9c1b7e03
83
+ ```
84
+
85
+ A rule may also test one request header (`is`, `contains` or `matches`), and the header name is
86
+ matched case-insensitively against what the client actually sent. Headers belong to the request
87
+ plane alone: the analyst never sees them, so it treats a header rule as not matching and only a
88
+ v5 SDK like this one enforces it.
89
+
90
+ The rules ride the v5 snapshot, which this SDK asks for by default. `snapshotVersion: 4`
91
+ pins the allow/challenge lists without the rules, `3` the block list alone; a project that
92
+ has not published the container you ask for is answered with the next one down, so asking
93
+ for the newest is always safe.
94
+
95
+ HTML templates add the beacon with the helper (or use `@camada/react`'s `<CamadaBeacon/>`):
96
+
97
+ ```js
98
+ res.send(`<head>${camada.scriptTag(req)}</head>…`);
99
+ ```
100
+
101
+ App-context outcomes (the signals no edge tap can see; identifiers are HMAC-hashed in-process):
102
+
103
+ ```js
104
+ camada.track(req, 'login_failed', { user: email });
105
+ ```
106
+
107
+ The event name is free-form, but the analyst's app-context rules read a fixed vocabulary — use these names
108
+ and the credential-stuffing, password-spray, account-aggregation, signup-velocity, carding and coupon rules
109
+ fire on your app's own truth instead of path heuristics:
110
+
111
+ | event | when |
112
+ |---|---|
113
+ | `login_failed` / `login_succeeded` | a password (or passwordless) login attempt settled; pass `{ user }` so attempts per account can be counted |
114
+ | `signup` | an account was created |
115
+ | `password_reset` | a reset was requested |
116
+ | `mfa_failed` | a second factor was rejected |
117
+ | `payment_failed` / `payment_succeeded` | a payment authorisation settled |
118
+ | `coupon_failed` | a promo/voucher code was rejected |
119
+
120
+ ## Operational notes
121
+
122
+ - `CAMADA_DISABLED=1` — kill switch, checked per request.
123
+ - `CAMADA_SERVERLESS=1` — lazy snapshot refresh (no interval timer); cold invocations fail
124
+ open and catch up asynchronously.
125
+ - Enforcement scope at this position: IP, path, user-agent and request-header conditions. ASN,
126
+ country and TLS-fingerprint conditions can't be evaluated in-app and fail open (a rule that
127
+ needs one never matches here; run `@camada/hono` on Cloudflare Workers for those).
128
+ - Memory: ~5 MB per loaded snapshot.
129
+
130
+ ## Develop
131
+
132
+ ```
133
+ npm install && npm run build && npm test && npm run check
134
+ ```
135
+ Sibling checkouts of `camada-core` and `camada-browser` must exist (file: deps).
@@ -0,0 +1,75 @@
1
+ import { IncomingMessage, ServerResponse } from 'node:http';
2
+ import { TrustedProxyConfig, SnapshotClient, EventQueue, SnapshotVersion } from '@camada/core';
3
+
4
+ interface ResolvedEnv {
5
+ ingestToken: string;
6
+ snapToken: string;
7
+ secret: string;
8
+ ingestUrl: string;
9
+ snapshotUrl: string;
10
+ serverless: boolean;
11
+ trustedProxy: TrustedProxyConfig | null;
12
+ }
13
+ /** Returns null (SDK stays disabled, one log line) rather than throwing on bad config. */
14
+ declare function resolveEnv(env?: Record<string, string | undefined>): ResolvedEnv | null;
15
+
16
+ interface CamadaOptions {
17
+ env?: Record<string, string | undefined>;
18
+ fetchImpl?: typeof fetch;
19
+ refreshMs?: number;
20
+ scriptPath?: string;
21
+ fpPath?: string;
22
+ challenge?: boolean;
23
+ challengePath?: string;
24
+ snapshotVersion?: SnapshotVersion;
25
+ }
26
+ declare class Camada {
27
+ readonly env: ResolvedEnv | null;
28
+ readonly snap: SnapshotClient | null;
29
+ readonly queue: EventQueue | null;
30
+ private readonly fetchImpl;
31
+ private readonly scriptPath;
32
+ private readonly fpPath;
33
+ private readonly challengeOn;
34
+ private readonly challengePath;
35
+ private readonly kit;
36
+ private readonly envSource;
37
+ constructor(opts?: CamadaOptions);
38
+ get disabled(): boolean;
39
+ private trustedProxy;
40
+ private beaconEnabled;
41
+ /** True when camada fully handled the request. Synchronous; never throws. */
42
+ handle(req: IncomingMessage, res: ServerResponse): boolean;
43
+ private handleInner;
44
+ private buildEvent;
45
+ /** Reads the beacon POST (≤32 KB), answers 204 immediately, and queues the beacon as a `sig: 1`
46
+ * 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. */
48
+ private relayBeacon;
49
+ private challengePassed;
50
+ /** 403 + the proof-of-work page (HTML navigations) or 403 JSON (everything else), plus the
51
+ * `blk: "challenge"` event — a served challenge is reported like a block (contract §D2). */
52
+ private serveChallengeInner;
53
+ /** The request's existing session, so challenge rows join the session that produced them. */
54
+ private sessionOf;
55
+ /** POST from the challenge page: validate the nonce and the proof of work, set `_cch`, 302
56
+ * back to the (sanitised, same-site) original URL, and ship `{ st: 200, ch: 1 }`. */
57
+ private verifyChallenge;
58
+ /** Serve the challenge for this request on demand — for a route the app wants to gate itself
59
+ * (the example's /challenge-me). Returns false when the client already holds a valid `_cch`,
60
+ * so the caller renders its own page. */
61
+ serveChallenge(req: IncomingMessage, res: ServerResponse): boolean;
62
+ /** For HTML templates: the first-party beacon tag with the request's rid. */
63
+ scriptTag(req: IncomingMessage): string;
64
+ /** App-context outcome events (login failed, signup, …). The identifier is HMAC-hashed
65
+ * in-process; the raw value never reaches the queue. */
66
+ track(req: IncomingMessage, event: string, data?: {
67
+ user?: string;
68
+ }): void;
69
+ /** Express/Connect middleware (also what NestJS on the default platform consumes). */
70
+ express(): (req: IncomingMessage, res: ServerResponse, next: (err?: unknown) => void) => void;
71
+ stop(): void;
72
+ }
73
+ declare function createCamada(opts?: CamadaOptions): Camada;
74
+
75
+ export { Camada as C, type ResolvedEnv as R, type CamadaOptions as a, createCamada as c, resolveEnv as r };
@@ -0,0 +1,75 @@
1
+ import { IncomingMessage, ServerResponse } from 'node:http';
2
+ import { TrustedProxyConfig, SnapshotClient, EventQueue, SnapshotVersion } from '@camada/core';
3
+
4
+ interface ResolvedEnv {
5
+ ingestToken: string;
6
+ snapToken: string;
7
+ secret: string;
8
+ ingestUrl: string;
9
+ snapshotUrl: string;
10
+ serverless: boolean;
11
+ trustedProxy: TrustedProxyConfig | null;
12
+ }
13
+ /** Returns null (SDK stays disabled, one log line) rather than throwing on bad config. */
14
+ declare function resolveEnv(env?: Record<string, string | undefined>): ResolvedEnv | null;
15
+
16
+ interface CamadaOptions {
17
+ env?: Record<string, string | undefined>;
18
+ fetchImpl?: typeof fetch;
19
+ refreshMs?: number;
20
+ scriptPath?: string;
21
+ fpPath?: string;
22
+ challenge?: boolean;
23
+ challengePath?: string;
24
+ snapshotVersion?: SnapshotVersion;
25
+ }
26
+ declare class Camada {
27
+ readonly env: ResolvedEnv | null;
28
+ readonly snap: SnapshotClient | null;
29
+ readonly queue: EventQueue | null;
30
+ private readonly fetchImpl;
31
+ private readonly scriptPath;
32
+ private readonly fpPath;
33
+ private readonly challengeOn;
34
+ private readonly challengePath;
35
+ private readonly kit;
36
+ private readonly envSource;
37
+ constructor(opts?: CamadaOptions);
38
+ get disabled(): boolean;
39
+ private trustedProxy;
40
+ private beaconEnabled;
41
+ /** True when camada fully handled the request. Synchronous; never throws. */
42
+ handle(req: IncomingMessage, res: ServerResponse): boolean;
43
+ private handleInner;
44
+ private buildEvent;
45
+ /** Reads the beacon POST (≤32 KB), answers 204 immediately, and queues the beacon as a `sig: 1`
46
+ * 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. */
48
+ private relayBeacon;
49
+ private challengePassed;
50
+ /** 403 + the proof-of-work page (HTML navigations) or 403 JSON (everything else), plus the
51
+ * `blk: "challenge"` event — a served challenge is reported like a block (contract §D2). */
52
+ private serveChallengeInner;
53
+ /** The request's existing session, so challenge rows join the session that produced them. */
54
+ private sessionOf;
55
+ /** POST from the challenge page: validate the nonce and the proof of work, set `_cch`, 302
56
+ * back to the (sanitised, same-site) original URL, and ship `{ st: 200, ch: 1 }`. */
57
+ private verifyChallenge;
58
+ /** Serve the challenge for this request on demand — for a route the app wants to gate itself
59
+ * (the example's /challenge-me). Returns false when the client already holds a valid `_cch`,
60
+ * so the caller renders its own page. */
61
+ serveChallenge(req: IncomingMessage, res: ServerResponse): boolean;
62
+ /** For HTML templates: the first-party beacon tag with the request's rid. */
63
+ scriptTag(req: IncomingMessage): string;
64
+ /** App-context outcome events (login failed, signup, …). The identifier is HMAC-hashed
65
+ * in-process; the raw value never reaches the queue. */
66
+ track(req: IncomingMessage, event: string, data?: {
67
+ user?: string;
68
+ }): void;
69
+ /** Express/Connect middleware (also what NestJS on the default platform consumes). */
70
+ express(): (req: IncomingMessage, res: ServerResponse, next: (err?: unknown) => void) => void;
71
+ stop(): void;
72
+ }
73
+ declare function createCamada(opts?: CamadaOptions): Camada;
74
+
75
+ export { Camada as C, type ResolvedEnv as R, type CamadaOptions as a, createCamada as c, resolveEnv as r };