@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 +21 -0
- package/README.md +135 -0
- package/dist/camada-Cn5SJVIG.d.cts +75 -0
- package/dist/camada-Cn5SJVIG.d.ts +75 -0
- package/dist/chunk-J7BQLOME.js +406 -0
- package/dist/chunk-J7BQLOME.js.map +1 -0
- package/dist/fastify.cjs +405 -0
- package/dist/fastify.cjs.map +1 -0
- package/dist/fastify.d.cts +17 -0
- package/dist/fastify.d.ts +17 -0
- package/dist/fastify.js +18 -0
- package/dist/fastify.js.map +1 -0
- package/dist/index.cjs +420 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +20 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.js +19 -0
- package/dist/index.js.map +1 -0
- package/dist/koa.cjs +405 -0
- package/dist/koa.cjs.map +1 -0
- package/dist/koa.d.cts +12 -0
- package/dist/koa.d.ts +12 -0
- package/dist/koa.js +18 -0
- package/dist/koa.js.map +1 -0
- package/dist/nest.cjs +420 -0
- package/dist/nest.cjs.map +1 -0
- package/dist/nest.d.cts +14 -0
- package/dist/nest.d.ts +14 -0
- package/dist/nest.js +26 -0
- package/dist/nest.js.map +1 -0
- package/package.json +60 -0
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 };
|