@camada/hono 0.3.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 +215 -0
- package/dist/index.cjs +341 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +46 -0
- package/dist/index.d.ts +46 -0
- package/dist/index.js +317 -0
- package/dist/index.js.map +1 -0
- package/package.json +40 -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,215 @@
|
|
|
1
|
+
# @camada/hono
|
|
2
|
+
|
|
3
|
+
camada for [Hono](https://hono.dev) on Cloudflare Workers: enforces the tenant snapshot inline
|
|
4
|
+
(your ordered custom rules, then block, allow, challenge), serves a first-party proof-of-work
|
|
5
|
+
challenge page and beacon, records the outcomes your handlers know (`track()`),
|
|
6
|
+
and ships wire events through `waitUntil` so nothing is on the response path.
|
|
7
|
+
Fails open by design — a camada outage or bug never 5xxes your app.
|
|
8
|
+
|
|
9
|
+
Not yet on npm — consumed via a `file:` dependency from a sibling checkout.
|
|
10
|
+
|
|
11
|
+
## Quickstart
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
import { Hono } from 'hono';
|
|
15
|
+
import { camada } from '@camada/hono';
|
|
16
|
+
|
|
17
|
+
const app = new Hono();
|
|
18
|
+
app.use('*', camada()); // reads CAMADA_KEY / CAMADA_INGEST_URL / CAMADA_SNAPSHOT_URL from the Worker env
|
|
19
|
+
app.get('/', (c) => c.text('hello'));
|
|
20
|
+
export default app;
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Env (printed by camada onboarding / `npm run seed` in dev):
|
|
24
|
+
|
|
25
|
+
```
|
|
26
|
+
CAMADA_KEY=<ingest_token>.<snap_token>
|
|
27
|
+
CAMADA_INGEST_URL=http://localhost:8787 # dev only; defaults to production ingest
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Workers hand env vars to the request, not to a process, so `camada()` builds its engine from
|
|
31
|
+
`c.env` on the first request. An app that reads its own config can pass the values instead:
|
|
32
|
+
|
|
33
|
+
```ts
|
|
34
|
+
app.use('*', camada({ key: MY_KEY, ingestUrl: MY_INGEST }));
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## camada-backend (the dogfood mount — contracts §H, SEC-07)
|
|
38
|
+
|
|
39
|
+
Mount it in front of the API, and never in front of health or the Stripe webhook: those must
|
|
40
|
+
answer whatever the snapshot says.
|
|
41
|
+
|
|
42
|
+
```ts
|
|
43
|
+
import { camada } from '@camada/hono';
|
|
44
|
+
|
|
45
|
+
const guard = camada(); // key, ingest and snapshot URLs come from c.env
|
|
46
|
+
const OPEN = new Set(['/api/health', '/api/stripe/webhook']);
|
|
47
|
+
|
|
48
|
+
app.use('/api/*', (c, next) => (OPEN.has(c.req.path) ? next() : guard(c, next)));
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Call `camada()` once and reuse the handler. Engines are cached per resolved configuration, so
|
|
52
|
+
mounting the same config twice shares one snapshot poller and one event queue, while two mounts
|
|
53
|
+
with different keys or URLs each get their own — a Workers isolate hosting several Hono apps
|
|
54
|
+
never enforces one tenant's snapshot on another, nor signs its cookies with another's secret.
|
|
55
|
+
|
|
56
|
+
Without `CAMADA_KEY` the middleware is inert (one log line, no requests, no enforcement), so an
|
|
57
|
+
unprovisioned environment behaves exactly as if camada were not installed.
|
|
58
|
+
|
|
59
|
+
## What it does per request
|
|
60
|
+
|
|
61
|
+
1. Refreshes the snapshot off-path through `waitUntil` (lazy mode — no interval timers on an
|
|
62
|
+
edge runtime). Every poll and event batch carries `x-camada-sdk: @camada/hono/<version>`,
|
|
63
|
+
and polls ask for snapshot v5 (`x-camada-snapshot: 5`) — the container that carries your
|
|
64
|
+
ordered custom rules.
|
|
65
|
+
2. Resolves the client from `cf-connecting-ip`, falling back to `X-Forwarded-For` under your
|
|
66
|
+
tenant's trusted-proxy config. Neither header is ever trusted blindly: `cf-connecting-ip`
|
|
67
|
+
counts only on Workers (where Cloudflare sets it and a client cannot forge it, and where
|
|
68
|
+
`request.cf` proves it), and `X-Forwarded-For` counts only under a trusted-proxy config. On
|
|
69
|
+
`@hono/node-server`, Bun or Deno the CF header is ignored — there it is just another header
|
|
70
|
+
the caller controls.
|
|
71
|
+
3. Runs your ordered custom rules (see below), then the allow, block and challenge lists.
|
|
72
|
+
4. **Block** → `403` with `x-block-reason` before your handler; the event still ships, with
|
|
73
|
+
`st: 403` and `blk: <reason>` so the analyst counts SDK blocks apart from your own 403s.
|
|
74
|
+
5. **Skip** → a skip rule or the allow list wins over a wider block (an allow-listed IP
|
|
75
|
+
inside a blocked CIDR or ASN goes through).
|
|
76
|
+
6. **Challenge** → a `403` proof-of-work page (see below).
|
|
77
|
+
7. Otherwise your handler runs, and the settled response ships one batched, redacted event with
|
|
78
|
+
its real status (Authorization and Cookie values never leave the isolate; credential-looking
|
|
79
|
+
query values are scrubbed — see `@camada/core`).
|
|
80
|
+
|
|
81
|
+
## Custom rules
|
|
82
|
+
|
|
83
|
+
Your Rules page holds one ordered list per project, and this middleware walks it before the
|
|
84
|
+
allow, block and challenge lists. First match wins — the order *is* the precedence — and each
|
|
85
|
+
rule carries one of four actions:
|
|
86
|
+
|
|
87
|
+
| action | what the middleware does | on the event |
|
|
88
|
+
|---|---|---|
|
|
89
|
+
| `skip` | passes the request | nothing |
|
|
90
|
+
| `block` | `403` before your handler | `blk: "rule"`, `rl: "<rule id>"` |
|
|
91
|
+
| `challenge` | serves the proof-of-work page (`challenge: false` opts out) | `blk: "challenge"` |
|
|
92
|
+
| `warn` | passes the request and marks it for the analyst | `wrn: "<rule id>"` |
|
|
93
|
+
|
|
94
|
+
A skip rule also carries a *record matches* flag, which only the analyst reads: a recorded skip
|
|
95
|
+
is still scored and shows on your dashboard as Allowed, an unrecorded one is dropped before
|
|
96
|
+
scoring. Either way the request passes here, unstamped — the built-in Allow-list is a skip rule
|
|
97
|
+
with recording on.
|
|
98
|
+
|
|
99
|
+
A rule block also names the row that decided, so the response says which rule to edit:
|
|
100
|
+
|
|
101
|
+
```
|
|
102
|
+
HTTP/1.1 403 Forbidden
|
|
103
|
+
x-block-reason: rule
|
|
104
|
+
x-block-rule: cr_4f2a9c1b7e03
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Every condition type enforces at this tap: `ip`, `path`, `ua` and `header` like everywhere else,
|
|
108
|
+
plus the `asn`, `country` and `tlsx` conditions only `request.cf` can judge. A `header`
|
|
109
|
+
condition (`is`, `contains`, `matches`) reads the name case-insensitively, and headers are the
|
|
110
|
+
request plane's alone — the analyst never sees them, so a header rule is enforced by a v5 SDK
|
|
111
|
+
like this one or not at all. A project that has not published the v5 container is answered with
|
|
112
|
+
v4 or v3, and the lists in it keep enforcing.
|
|
113
|
+
|
|
114
|
+
## The challenge (SDK-04)
|
|
115
|
+
|
|
116
|
+
A `challenge` verdict serves a self-contained 403 page: no external assets, `no-store`, and an
|
|
117
|
+
inline SHA-256 solver that finds a counter whose digest starts with 16 zero bits (tens of
|
|
118
|
+
milliseconds) and posts it to `POST /__camada/challenge`. That endpoint checks the nonce and the
|
|
119
|
+
work, sets `_cch` (an HMAC token, 1 h, `HttpOnly; SameSite=Lax`) and 302s back to the original
|
|
120
|
+
URL. Events: `{ st: 403, blk: "challenge" }` when served, `{ st: 200, ch: 1 }` when passed.
|
|
121
|
+
|
|
122
|
+
Requests that are not HTML navigations (no `text/html` in `Accept`, or a `sec-fetch-dest` other
|
|
123
|
+
than `document`) get `403 {"error":"challenge_required"}` instead — an API client should see a
|
|
124
|
+
status it can act on, not a page it cannot solve.
|
|
125
|
+
|
|
126
|
+
The nonce and the cookie are bound to the client IP, so a request camada cannot identify is
|
|
127
|
+
never challenged (it would mint a cookie any other unidentified client could present).
|
|
128
|
+
|
|
129
|
+
## Options
|
|
130
|
+
|
|
131
|
+
| option | default | meaning |
|
|
132
|
+
|---|---|---|
|
|
133
|
+
| `key` | `env.CAMADA_KEY` | `<ingest_token>.<snap_token>`; without it the middleware is inert |
|
|
134
|
+
| `ingestUrl` | `env.CAMADA_INGEST_URL` | ingest base; batches go to `<ingestUrl>/e` |
|
|
135
|
+
| `snapshotUrl` | `<ingestUrl>/snapshot` | snapshot endpoint |
|
|
136
|
+
| `trustedProxy` | server config | `none` / `vercel` / `hops:N` / `cidrs:a,b`, or the parsed object |
|
|
137
|
+
| `challenge` | `true` | serve the proof-of-work page for `challenge` verdicts |
|
|
138
|
+
| `challengePath` | `/__camada/challenge` | where that page posts its solution |
|
|
139
|
+
| `snapshotVersion` | `5` | `4` drops the custom rules, `3` the allow/challenge sides too |
|
|
140
|
+
| `scriptPath` | `/_cam/b.js` | where the first-party beacon script is served |
|
|
141
|
+
| `fpPath` | `/_cam/fp` | where that script posts the beacon; keep it in `scriptPath`'s directory |
|
|
142
|
+
| `env` | `c.env` | overrides the Worker env (tests) |
|
|
143
|
+
|
|
144
|
+
`CAMADA_CHALLENGE=0` in the Worker env switches the challenge off without a code change.
|
|
145
|
+
|
|
146
|
+
`CAMADA_DISABLED=1` in the Worker env switches everything off, checked per request.
|
|
147
|
+
|
|
148
|
+
## The first-party beacon
|
|
149
|
+
|
|
150
|
+
Bots that never run JavaScript are the cheapest to catch. Put the tag in the `<head>` of the
|
|
151
|
+
pages you render and the middleware does the rest:
|
|
152
|
+
|
|
153
|
+
```ts
|
|
154
|
+
import { camada, scriptTag } from '@camada/hono';
|
|
155
|
+
|
|
156
|
+
app.use('*', camada());
|
|
157
|
+
app.get('/', (c) => c.html(`<html><head>${scriptTag(c)}</head><body>…</body></html>`));
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
`scriptTag(c)` returns `<script src="/_cam/b.js?r=<rid>" async></script>` — the `rid` is this
|
|
161
|
+
request's event id, so the analyst joins the beacon to the page view. The middleware serves the
|
|
162
|
+
script at `GET /_cam/b.js` (cacheable, 1 h) and relays `POST /_cam/fp` (≤ 32 KB, answers 204)
|
|
163
|
+
onto the event batch as a `sig: 1` row stamped with the client ip camada resolved — never the
|
|
164
|
+
one the body claims. Both endpoints sit behind the verdict: a blocked client gets 403 there
|
|
165
|
+
too. The tag is `''` when camada is off for the request or the project turned the beacon off
|
|
166
|
+
in its settings, and the endpoints stand down with it.
|
|
167
|
+
|
|
168
|
+
Mounting on a prefix (`app.use('/api/*', camada())`) means `/_cam/*` never reaches the
|
|
169
|
+
middleware; move both paths under it — `camada({ scriptPath: '/api/_cam/b.js', fpPath:
|
|
170
|
+
'/api/_cam/fp' })` — the script derives the post path from its own `src`, so the two must
|
|
171
|
+
share a directory.
|
|
172
|
+
|
|
173
|
+
## App-context events
|
|
174
|
+
|
|
175
|
+
The wire shows a `POST /login`; only your handler knows whether it failed. Tell camada:
|
|
176
|
+
|
|
177
|
+
```ts
|
|
178
|
+
import { camada, track } from '@camada/hono';
|
|
179
|
+
|
|
180
|
+
app.post('/login', async (c) => {
|
|
181
|
+
const ok = await signIn(c);
|
|
182
|
+
if (!ok) track(c, 'login_failed', { user: email }); // await optional — the flush rides waitUntil
|
|
183
|
+
return ok ? c.redirect('/') : c.text('Invalid credentials', 401);
|
|
184
|
+
});
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
`track(c, event, { user? })` ships `{ et, uid, rid, sid, ip, ts }` joined to this request's event.
|
|
188
|
+
The user identifier is HMAC-hashed in-process with the ingest token — the raw value never leaves
|
|
189
|
+
the isolate. It never throws and is a no-op where the middleware did not run. The event name is
|
|
190
|
+
free-form; the analyst's rules read this vocabulary:
|
|
191
|
+
|
|
192
|
+
| event | when |
|
|
193
|
+
|---|---|
|
|
194
|
+
| `login_failed` / `login_succeeded` | a credential check settled |
|
|
195
|
+
| `signup` | an account was created |
|
|
196
|
+
| `password_reset` | a reset was requested |
|
|
197
|
+
| `mfa_failed` | a second factor was rejected |
|
|
198
|
+
| `payment_failed` / `payment_succeeded` | a charge settled |
|
|
199
|
+
| `coupon_failed` | a promo code was rejected |
|
|
200
|
+
|
|
201
|
+
## What this tap can see
|
|
202
|
+
|
|
203
|
+
`request.cf` gives Workers the client's `asn`, `country`, `tlsClientExtensionsSha1` and
|
|
204
|
+
`httpProtocol`, so ASN, country and TLS-fingerprint rules genuinely enforce here — unlike a bare
|
|
205
|
+
Node app, which sees none of them. The connection terminates at Cloudflare, so the protocol on
|
|
206
|
+
the event is the visitor's own, not a proxy hop's (camada never reads a forwarded protocol
|
|
207
|
+
header for it). With the beacon and `track()` it is the full in-app position. workerd
|
|
208
|
+
normalises header order, so the raw-wire-order signal is the one thing not available here; the
|
|
209
|
+
analyst knows that from the tap's capability mask (`sdk-hono`) and never scores its absence as
|
|
210
|
+
evidence.
|
|
211
|
+
|
|
212
|
+
## Fail open
|
|
213
|
+
|
|
214
|
+
Every entry point runs inside camada's guard. A dead ingest, a corrupt snapshot, a bug in this
|
|
215
|
+
package: telemetry is lost, the request is not.
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __create = Object.create;
|
|
3
|
+
var __defProp = Object.defineProperty;
|
|
4
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
5
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
6
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
7
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
8
|
+
var __export = (target, all) => {
|
|
9
|
+
for (var name2 in all)
|
|
10
|
+
__defProp(target, name2, { get: all[name2], enumerable: true });
|
|
11
|
+
};
|
|
12
|
+
var __copyProps = (to, from, except, desc) => {
|
|
13
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
14
|
+
for (let key of __getOwnPropNames(from))
|
|
15
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
16
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
17
|
+
}
|
|
18
|
+
return to;
|
|
19
|
+
};
|
|
20
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
21
|
+
// If the importer is in node compatibility mode or this is not an ESM
|
|
22
|
+
// file that has been converted to a CommonJS file using a Babel-
|
|
23
|
+
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
24
|
+
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
25
|
+
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
26
|
+
mod
|
|
27
|
+
));
|
|
28
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
29
|
+
|
|
30
|
+
// src/index.ts
|
|
31
|
+
var index_exports = {};
|
|
32
|
+
__export(index_exports, {
|
|
33
|
+
camada: () => camada,
|
|
34
|
+
resetCamada: () => resetCamada,
|
|
35
|
+
resolveEnv: () => resolveEnv,
|
|
36
|
+
scriptTag: () => scriptTag,
|
|
37
|
+
track: () => track
|
|
38
|
+
});
|
|
39
|
+
module.exports = __toCommonJS(index_exports);
|
|
40
|
+
|
|
41
|
+
// src/camada.ts
|
|
42
|
+
var import_iife_string = __toESM(require("@camada/browser/iife-string"), 1);
|
|
43
|
+
var import_core3 = require("@camada/core");
|
|
44
|
+
|
|
45
|
+
// src/env.ts
|
|
46
|
+
var import_core = require("@camada/core");
|
|
47
|
+
var asProxy = (v) => typeof v === "string" ? (0, import_core.parseTrustedProxyEnv)(v) : v ?? null;
|
|
48
|
+
function resolveEnv(opts, env) {
|
|
49
|
+
const raw = opts.key || env.CAMADA_KEY;
|
|
50
|
+
const key = (0, import_core.parseKey)(raw);
|
|
51
|
+
const ingestToken = key?.ingestToken ?? env.CAMADA_TOKEN;
|
|
52
|
+
const snapToken = key?.snapToken ?? env.CAMADA_SNAPSHOT_TOKEN;
|
|
53
|
+
if (!ingestToken || !snapToken) return null;
|
|
54
|
+
const ingestUrl = (opts.ingestUrl || env.CAMADA_INGEST_URL || "https://in.camada.dev").replace(/\/$/, "");
|
|
55
|
+
return {
|
|
56
|
+
ingestToken,
|
|
57
|
+
snapToken,
|
|
58
|
+
secret: raw || `${ingestToken}.${snapToken}`,
|
|
59
|
+
ingestUrl,
|
|
60
|
+
snapshotUrl: opts.snapshotUrl || env.CAMADA_SNAPSHOT_URL || `${ingestUrl}/snapshot`,
|
|
61
|
+
trustedProxy: asProxy(opts.trustedProxy) ?? (0, import_core.parseTrustedProxyEnv)(env.CAMADA_TRUSTED_PROXY)
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// src/context.ts
|
|
66
|
+
var import_core2 = require("@camada/core");
|
|
67
|
+
var VAR = "__camada";
|
|
68
|
+
var beaconEnabled = (e) => e.snap.config?.beacon !== false;
|
|
69
|
+
var readVars = (c) => c.get(VAR);
|
|
70
|
+
function track(c, event, data) {
|
|
71
|
+
const vars = (0, import_core2.guarded)(() => readVars(c), void 0);
|
|
72
|
+
if (!vars) return Promise.resolve();
|
|
73
|
+
const p = (0, import_core2.guardedAsync)(async () => {
|
|
74
|
+
const uid = data?.user ? await (0, import_core2.hashUserId)(data.user, vars.eng.env.ingestToken) : null;
|
|
75
|
+
ship(vars.eng, { tap: import_core2.TAP_HONO, et: event, uid, rid: vars.rid, sid: vars.sid, ip: vars.ip, ts: Date.now() }, vars.waitUntil);
|
|
76
|
+
}, void 0);
|
|
77
|
+
vars.waitUntil(p);
|
|
78
|
+
return p;
|
|
79
|
+
}
|
|
80
|
+
function scriptTag(c) {
|
|
81
|
+
return (0, import_core2.guarded)(() => {
|
|
82
|
+
const vars = readVars(c);
|
|
83
|
+
if (!vars || !beaconEnabled(vars.eng)) return "";
|
|
84
|
+
return `<script src="${vars.scriptPath}?r=${vars.rid}" async></script>`;
|
|
85
|
+
}, "");
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// package.json
|
|
89
|
+
var name = "@camada/hono";
|
|
90
|
+
var version = "0.3.0";
|
|
91
|
+
|
|
92
|
+
// src/version.ts
|
|
93
|
+
var SDK_ID = `${name}/${version}`;
|
|
94
|
+
|
|
95
|
+
// src/camada.ts
|
|
96
|
+
var DEFAULT_CHALLENGE_PATH = "/__camada/challenge";
|
|
97
|
+
var SCRIPT_PATH = "/_cam/b.js";
|
|
98
|
+
var FP_PATH = "/_cam/fp";
|
|
99
|
+
var FP_MAX = 32 * 1024;
|
|
100
|
+
var SESSION_COOKIE = "_sfp";
|
|
101
|
+
var SESSION_MAX_AGE = 2592e3;
|
|
102
|
+
var BODY_MAX = 4 * 1024;
|
|
103
|
+
var encoder = new TextEncoder();
|
|
104
|
+
var engines = /* @__PURE__ */ new Map();
|
|
105
|
+
function resetCamada() {
|
|
106
|
+
for (const e of engines.values()) {
|
|
107
|
+
e.snap.stop();
|
|
108
|
+
e.queue.stop();
|
|
109
|
+
}
|
|
110
|
+
engines.clear();
|
|
111
|
+
}
|
|
112
|
+
function ensure(opts, ctxEnv) {
|
|
113
|
+
const env = resolveEnv(opts, ctxEnv);
|
|
114
|
+
if (!env) {
|
|
115
|
+
(0, import_core3.logRateLimited)(new Error("CAMADA_KEY not set \u2014 camada is inactive"));
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
const snapshotVersion = opts.snapshotVersion ?? import_core3.DEFAULT_SNAPSHOT_VERSION;
|
|
119
|
+
const id = `${env.ingestToken}|${env.ingestUrl}|${env.snapshotUrl}|${snapshotVersion}`;
|
|
120
|
+
const cached = engines.get(id);
|
|
121
|
+
if (cached) return cached;
|
|
122
|
+
const injected = opts.fetchImpl ? { fetchImpl: opts.fetchImpl } : {};
|
|
123
|
+
const engine = {
|
|
124
|
+
env,
|
|
125
|
+
snap: new import_core3.SnapshotClient({
|
|
126
|
+
url: env.snapshotUrl,
|
|
127
|
+
token: env.snapToken,
|
|
128
|
+
mode: "lazy",
|
|
129
|
+
sdk: SDK_ID,
|
|
130
|
+
snapshotVersion,
|
|
131
|
+
...injected
|
|
132
|
+
}),
|
|
133
|
+
queue: new import_core3.EventQueue({ url: env.ingestUrl, token: env.ingestToken, sdk: SDK_ID, ...injected }),
|
|
134
|
+
kit: (0, import_core3.createChallengeAsync)({ secret: env.secret })
|
|
135
|
+
};
|
|
136
|
+
engines.set(id, engine);
|
|
137
|
+
return engine;
|
|
138
|
+
}
|
|
139
|
+
var cookieValue = (cookie, name2) => {
|
|
140
|
+
const src = "; " + cookie;
|
|
141
|
+
const i = src.indexOf("; " + name2 + "=");
|
|
142
|
+
if (i === -1) return null;
|
|
143
|
+
const start = i + name2.length + 3;
|
|
144
|
+
const j = src.indexOf(";", start);
|
|
145
|
+
return src.slice(start, j === -1 ? void 0 : j);
|
|
146
|
+
};
|
|
147
|
+
var cfOf = (req) => req.cf ?? {};
|
|
148
|
+
var trustedProxy = (e) => e.env.trustedProxy ?? e.snap.config?.trusted_proxy ?? null;
|
|
149
|
+
var clientIp = (e, req) => {
|
|
150
|
+
const cf = req.cf;
|
|
151
|
+
const direct = cf ? req.headers.get("cf-connecting-ip") : null;
|
|
152
|
+
return direct || (0, import_core3.resolveClientIp)(null, req.headers.get("x-forwarded-for"), trustedProxy(e));
|
|
153
|
+
};
|
|
154
|
+
function describeRequest(e, req) {
|
|
155
|
+
const url = new URL(req.url);
|
|
156
|
+
const cookies = req.headers.get("cookie") || "";
|
|
157
|
+
return { url, path: url.pathname, cf: cfOf(req), ip: clientIp(e, req), sid: cookieValue(cookies, SESSION_COOKIE) };
|
|
158
|
+
}
|
|
159
|
+
function buildEvent(req, path, query, ip, sid, rid = crypto.randomUUID(), newSession = false) {
|
|
160
|
+
const headers = [];
|
|
161
|
+
req.headers.forEach((v, k) => headers.push([k, v]));
|
|
162
|
+
const cf = cfOf(req);
|
|
163
|
+
const ev = (0, import_core3.buildWireEvent)(
|
|
164
|
+
{
|
|
165
|
+
method: req.method,
|
|
166
|
+
host: req.headers.get("host") ?? new URL(req.url).host,
|
|
167
|
+
path,
|
|
168
|
+
query,
|
|
169
|
+
headers,
|
|
170
|
+
ip,
|
|
171
|
+
// The client's connection terminates at Cloudflare, so `cf.httpProtocol` really is the
|
|
172
|
+
// visitor's protocol (ea's TAP_CAPS grants this tap TRUE_PROTO on that basis). Never read
|
|
173
|
+
// a forwarded header for this — that would be the proxy's hop, not the client's.
|
|
174
|
+
httpVersion: cf.httpProtocol ? cf.httpProtocol.replace(/^HTTP\//i, "") : null
|
|
175
|
+
},
|
|
176
|
+
{ tap: import_core3.TAP_HONO, rid, sid, newSession }
|
|
177
|
+
);
|
|
178
|
+
if (cf.asn) ev.asn = cf.asn;
|
|
179
|
+
if (cf.country) ev.cc = cf.country;
|
|
180
|
+
if (cf.tlsClientExtensionsSha1) ev.tlsx = cf.tlsClientExtensionsSha1;
|
|
181
|
+
return ev;
|
|
182
|
+
}
|
|
183
|
+
function ship(e, ev, waitUntil) {
|
|
184
|
+
e.queue.push(ev);
|
|
185
|
+
e.queue.flush(waitUntil);
|
|
186
|
+
}
|
|
187
|
+
async function relayBeacon(eng, req, ip, waitUntil) {
|
|
188
|
+
const noContent = () => new Response(null, { status: 204, headers: { "cache-control": "no-store" } });
|
|
189
|
+
if (Number(req.headers.get("content-length")) > FP_MAX) return new Response(null, { status: 413 });
|
|
190
|
+
const body = await req.text();
|
|
191
|
+
if (encoder.encode(body).byteLength > FP_MAX) return new Response(null, { status: 413 });
|
|
192
|
+
let parsed;
|
|
193
|
+
try {
|
|
194
|
+
parsed = JSON.parse(body);
|
|
195
|
+
} catch {
|
|
196
|
+
return noContent();
|
|
197
|
+
}
|
|
198
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return noContent();
|
|
199
|
+
ship(eng, { ...parsed, sig: 1, ip, tap: import_core3.TAP_HONO }, waitUntil);
|
|
200
|
+
return noContent();
|
|
201
|
+
}
|
|
202
|
+
function camada(opts = {}) {
|
|
203
|
+
const challengePath = opts.challengePath ?? DEFAULT_CHALLENGE_PATH;
|
|
204
|
+
const scriptPath = opts.scriptPath ?? SCRIPT_PATH;
|
|
205
|
+
const fpPath = opts.fpPath ?? FP_PATH;
|
|
206
|
+
return async function camadaHono(c, next) {
|
|
207
|
+
const env = (0, import_core3.guarded)(() => ({ ...c.env, ...opts.env }), {});
|
|
208
|
+
const eng = (0, import_core3.guarded)(() => env.CAMADA_DISABLED === "1" ? null : ensure(opts, env), null);
|
|
209
|
+
if (!eng) return next();
|
|
210
|
+
const challengeOn = opts.challenge !== false && env.CAMADA_CHALLENGE !== "0";
|
|
211
|
+
const waitUntil = (p) => {
|
|
212
|
+
try {
|
|
213
|
+
c.executionCtx?.waitUntil(p);
|
|
214
|
+
} catch {
|
|
215
|
+
}
|
|
216
|
+
};
|
|
217
|
+
let warnRule = null;
|
|
218
|
+
let vars = null;
|
|
219
|
+
let newSession = false;
|
|
220
|
+
const answer = await (0, import_core3.guardedAsync)(async () => {
|
|
221
|
+
eng.snap.ensureFresh(waitUntil);
|
|
222
|
+
const req = c.req.raw;
|
|
223
|
+
const { url, path, cf, ip, sid } = describeRequest(eng, req);
|
|
224
|
+
const v = eng.snap.verdict({
|
|
225
|
+
ip,
|
|
226
|
+
path,
|
|
227
|
+
ua: req.headers.get("user-agent"),
|
|
228
|
+
// §D3: without ua every `ua` rule is false
|
|
229
|
+
// `header` conditions read the request through this getter; `Headers.get` is
|
|
230
|
+
// case-insensitive, so the lower-cased name the matcher asks with finds whatever
|
|
231
|
+
// spelling the client sent. Without it every `header` rule is false.
|
|
232
|
+
header: (n) => req.headers.get(n),
|
|
233
|
+
asn: cf.asn ?? null,
|
|
234
|
+
country: cf.country ?? null,
|
|
235
|
+
tlsx: cf.tlsClientExtensionsSha1 ?? null
|
|
236
|
+
});
|
|
237
|
+
warnRule = v.warn ? v.rule ?? null : null;
|
|
238
|
+
if (v.block) {
|
|
239
|
+
const ev = buildEvent(req, path, url.search, ip, sid);
|
|
240
|
+
ev.st = 403;
|
|
241
|
+
ev.blk = v.reason;
|
|
242
|
+
if (v.rule) ev.rl = v.rule;
|
|
243
|
+
ship(eng, ev, waitUntil);
|
|
244
|
+
const headers = {
|
|
245
|
+
"content-type": "text/plain",
|
|
246
|
+
"x-block-reason": String(v.reason ?? ""),
|
|
247
|
+
"x-block-version": v.version ?? ""
|
|
248
|
+
};
|
|
249
|
+
if (v.rule) headers["x-block-rule"] = v.rule;
|
|
250
|
+
return new Response("Forbidden", { status: 403, headers });
|
|
251
|
+
}
|
|
252
|
+
if (challengeOn && ip) {
|
|
253
|
+
if (req.method === "POST" && path === challengePath) return verify(eng, req, ip, sid, challengePath, waitUntil);
|
|
254
|
+
const token = cookieValue(req.headers.get("cookie") || "", import_core3.CHALLENGE_COOKIE);
|
|
255
|
+
if (v.challenge && !await eng.kit.tokenValid(ip, Date.now(), token)) {
|
|
256
|
+
return serve(eng, req, ip, sid, challengePath, path + url.search, waitUntil);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
if (beaconEnabled(eng)) {
|
|
260
|
+
if (req.method === "GET" && path === scriptPath) {
|
|
261
|
+
return new Response(import_iife_string.default, { status: 200, headers: { "content-type": "application/javascript", "cache-control": "public, max-age=3600" } });
|
|
262
|
+
}
|
|
263
|
+
if (req.method === "POST" && path === fpPath) return relayBeacon(eng, req, ip, waitUntil);
|
|
264
|
+
}
|
|
265
|
+
newSession = !sid;
|
|
266
|
+
vars = { eng, rid: crypto.randomUUID(), sid: sid ?? crypto.randomUUID(), ip, waitUntil, scriptPath };
|
|
267
|
+
c.set(VAR, vars);
|
|
268
|
+
return null;
|
|
269
|
+
}, null);
|
|
270
|
+
if (answer) return answer;
|
|
271
|
+
await next();
|
|
272
|
+
(0, import_core3.guarded)(() => {
|
|
273
|
+
if (!vars || !newSession) return;
|
|
274
|
+
const secure = new URL(c.req.raw.url).protocol === "https:" ? "; Secure" : "";
|
|
275
|
+
c.res.headers.append("set-cookie", `${SESSION_COOKIE}=${vars.sid}; Path=/; Max-Age=${SESSION_MAX_AGE}; HttpOnly; SameSite=Lax${secure}`);
|
|
276
|
+
}, void 0);
|
|
277
|
+
void (0, import_core3.guardedAsync)(async () => {
|
|
278
|
+
const req = c.req.raw;
|
|
279
|
+
const { url, path, ip } = describeRequest(eng, req);
|
|
280
|
+
const cfg = eng.snap.config;
|
|
281
|
+
if ((cfg?.exclude || []).some((x) => path.startsWith(x))) return;
|
|
282
|
+
if (Math.random() >= (cfg?.sample ?? 1)) return;
|
|
283
|
+
const ev = buildEvent(req, path, url.search, ip, vars?.sid ?? null, vars?.rid, newSession);
|
|
284
|
+
ev.st = c.res.status;
|
|
285
|
+
if (warnRule) ev.wrn = warnRule;
|
|
286
|
+
ship(eng, ev, waitUntil);
|
|
287
|
+
}, void 0);
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
async function serve(eng, req, ip, sid, action, target, waitUntil) {
|
|
291
|
+
const url = new URL(req.url);
|
|
292
|
+
const ev = buildEvent(req, url.pathname, url.search, ip, sid);
|
|
293
|
+
ev.st = 403;
|
|
294
|
+
ev.blk = "challenge";
|
|
295
|
+
ship(eng, ev, waitUntil);
|
|
296
|
+
if (!(0, import_core3.wantsHtml)(req.headers.get("accept"), req.headers.get("sec-fetch-dest"))) {
|
|
297
|
+
return new Response('{"error":"challenge_required"}', {
|
|
298
|
+
status: 403,
|
|
299
|
+
headers: { "content-type": "application/json", "cache-control": "no-store", "x-camada-challenge": "1" }
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
const html = (0, import_core3.challengePage)({ nonce: await eng.kit.nonce(ip, Date.now()), action, to: (0, import_core3.safeReturnTo)(target) });
|
|
303
|
+
return new Response(html, {
|
|
304
|
+
status: 403,
|
|
305
|
+
headers: { "content-type": "text/html; charset=utf-8", "cache-control": "no-store", "x-camada-challenge": "1" }
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
async function verify(eng, req, ip, sid, action, waitUntil) {
|
|
309
|
+
const url = new URL(req.url);
|
|
310
|
+
if (Number(req.headers.get("content-length")) > BODY_MAX) return new Response(null, { status: 413 });
|
|
311
|
+
const body = await req.text();
|
|
312
|
+
if (body.length > BODY_MAX) return new Response(null, { status: 413 });
|
|
313
|
+
const form = (0, import_core3.parseFormBody)(body);
|
|
314
|
+
const to = (0, import_core3.safeReturnTo)(form.to);
|
|
315
|
+
const now = Date.now();
|
|
316
|
+
if (!await eng.kit.verify(ip, now, form.nonce, form.solution)) {
|
|
317
|
+
const html = (0, import_core3.challengePage)({ nonce: await eng.kit.nonce(ip, now), action, to });
|
|
318
|
+
return new Response(html, { status: 403, headers: { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" } });
|
|
319
|
+
}
|
|
320
|
+
const ev = buildEvent(req, url.pathname, "", ip, sid);
|
|
321
|
+
ev.st = 200;
|
|
322
|
+
ev.ch = 1;
|
|
323
|
+
ship(eng, ev, waitUntil);
|
|
324
|
+
return new Response(null, {
|
|
325
|
+
status: 302,
|
|
326
|
+
headers: {
|
|
327
|
+
location: to,
|
|
328
|
+
"set-cookie": (0, import_core3.challengeCookie)(await eng.kit.issue(ip, now), url.protocol === "https:"),
|
|
329
|
+
"cache-control": "no-store"
|
|
330
|
+
}
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
334
|
+
0 && (module.exports = {
|
|
335
|
+
camada,
|
|
336
|
+
resetCamada,
|
|
337
|
+
resolveEnv,
|
|
338
|
+
scriptTag,
|
|
339
|
+
track
|
|
340
|
+
});
|
|
341
|
+
//# sourceMappingURL=index.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/index.ts","../src/camada.ts","../src/env.ts","../src/context.ts","../package.json","../src/version.ts"],"sourcesContent":["// @camada/hono — the one-line install for a Hono app on Cloudflare Workers:\n// app.use('*', camada()); // env: CAMADA_KEY (+ CAMADA_INGEST_URL / CAMADA_SNAPSHOT_URL in dev)\n// c.html(`<head>${scriptTag(c)}</head>…`) // the first-party beacon\n// track(c, 'login_failed', { user }) // an outcome the wire cannot show\nexport { camada, resetCamada } from './camada.js';\nexport { track, scriptTag } from './context.js';\nexport { resolveEnv, type CamadaHonoOptions, type ResolvedEnv } from './env.js';\n","// @camada/hono — the Hono middleware for Cloudflare Workers (SEC-07, D33).\n// app.use('*', camada());\n// Workers-native: `cf-connecting-ip` for the client address, `request.cf` for asn / country /\n// TLS fingerprint (so ASN and country rules really enforce at this tap), and\n// `c.executionCtx.waitUntil` so the snapshot poll and the event flush outlive the response.\n// Everything runs inside camada's fail-open envelope: a camada bug, a dead ingest or a corrupt\n// snapshot costs telemetry, never the app's response.\nimport type { Context, MiddlewareHandler, Next } from 'hono';\nimport iife from '@camada/browser/iife-string';\nimport {\n SnapshotClient, EventQueue, buildWireEvent, resolveClientIp, logRateLimited, guardedAsync,\n guarded, createChallengeAsync, challengePage, challengeCookie, safeReturnTo, wantsHtml, parseFormBody,\n CHALLENGE_COOKIE, DEFAULT_SNAPSHOT_VERSION, TAP_HONO,\n type AsyncChallengeKit, type TrustedProxyConfig, type WireEvent,\n} from '@camada/core';\nimport { resolveEnv, type CamadaHonoOptions, type ResolvedEnv } from './env.js';\nimport { VAR, beaconEnabled, type CamadaVars } from './context.js';\nimport { SDK_ID } from './version.js';\n\nconst DEFAULT_CHALLENGE_PATH = '/__camada/challenge';\nconst SCRIPT_PATH = '/_cam/b.js'; // the beacon IIFE; its auto-init posts to the sibling `fp` and reads the rid from ?r=\nconst FP_PATH = '/_cam/fp';\nconst FP_MAX = 32 * 1024; // matches the analyst's /fp cap: never accept what ingest will 413\nconst SESSION_COOKIE = '_sfp'; // the same session cookie as every other tap: sid/ns stay comparable\nconst SESSION_MAX_AGE = 2592000;\nconst BODY_MAX = 4 * 1024; // the verify form is ~120 bytes; anything larger is not ours\nconst encoder = new TextEncoder();\n\nexport interface Engine {\n env: ResolvedEnv;\n snap: SnapshotClient;\n queue: EventQueue;\n kit: AsyncChallengeKit;\n}\n\nexport type WaitUntil = (p: Promise<unknown>) => void;\n\n// One engine per resolved configuration, not per module: a Workers isolate can host several\n// Hono apps, and a shared singleton would enforce the first tenant's snapshot on the second\n// and sign its `_cch` cookies with the first tenant's secret. An unconfigured request is never\n// cached — a warm-up or a differently-keyed mount must not leave the isolate inert for good.\nconst engines = new Map<string, Engine>();\n\n/** Test/reset hook: drops every cached engine. */\nexport function resetCamada(): void {\n for (const e of engines.values()) { e.snap.stop(); e.queue.stop(); }\n engines.clear();\n}\n\nfunction ensure(opts: CamadaHonoOptions, ctxEnv: Record<string, string | undefined>): Engine | null {\n const env = resolveEnv(opts, ctxEnv);\n if (!env) {\n logRateLimited(new Error('CAMADA_KEY not set — camada is inactive'));\n return null;\n }\n const snapshotVersion = opts.snapshotVersion ?? DEFAULT_SNAPSHOT_VERSION;\n const id = `${env.ingestToken}|${env.ingestUrl}|${env.snapshotUrl}|${snapshotVersion}`;\n const cached = engines.get(id);\n if (cached) return cached;\n const injected = opts.fetchImpl ? { fetchImpl: opts.fetchImpl } : {}; // never pass an explicit undefined key\n const engine: Engine = {\n env,\n snap: new SnapshotClient({\n url: env.snapshotUrl, token: env.snapToken, mode: 'lazy', sdk: SDK_ID,\n snapshotVersion, ...injected,\n }),\n queue: new EventQueue({ url: env.ingestUrl, token: env.ingestToken, sdk: SDK_ID, ...injected }),\n kit: createChallengeAsync({ secret: env.secret }),\n };\n engines.set(id, engine);\n return engine;\n}\n\nconst cookieValue = (cookie: string, name: string): string | null => {\n const src = '; ' + cookie;\n const i = src.indexOf('; ' + name + '=');\n if (i === -1) return null;\n const start = i + name.length + 3;\n const j = src.indexOf(';', start);\n return src.slice(start, j === -1 ? undefined : j);\n};\n\ninterface CfProps { asn?: number; country?: string; tlsClientExtensionsSha1?: string; httpProtocol?: string }\nconst cfOf = (req: Request): CfProps => ((req as Request & { cf?: CfProps }).cf ?? {});\n\nconst trustedProxy = (e: Engine): TrustedProxyConfig | null =>\n e.env.trustedProxy ?? e.snap.config?.trusted_proxy ?? null;\n\n/** Cloudflare sets `cf-connecting-ip` itself and a client cannot forge it — but only on\n * Workers, which is also the only place `request.cf` exists. On any other Hono runtime\n * (node-server, Bun, Deno) that header is attacker-controlled, so it is ignored there and the\n * trusted-proxy rules decide, exactly as they do for X-Forwarded-For. */\nconst clientIp = (e: Engine, req: Request): string | null => {\n const cf = (req as Request & { cf?: unknown }).cf;\n const direct = cf ? req.headers.get('cf-connecting-ip') : null;\n return direct || resolveClientIp(null, req.headers.get('x-forwarded-for'), trustedProxy(e));\n};\n\ninterface RequestFacts {\n url: URL;\n path: string;\n cf: CfProps;\n ip: string | null;\n sid: string | null;\n}\n\nfunction describeRequest(e: Engine, req: Request): RequestFacts {\n const url = new URL(req.url);\n const cookies = req.headers.get('cookie') || '';\n return { url, path: url.pathname, cf: cfOf(req), ip: clientIp(e, req), sid: cookieValue(cookies, SESSION_COOKIE) };\n}\n\nfunction buildEvent(\n req: Request, path: string, query: string, ip: string | null, sid: string | null,\n rid: string = crypto.randomUUID(), newSession = false, // the capture path passes the ids it shared with the app; camada's own answers need none\n): WireEvent {\n const headers: Array<[string, string]> = [];\n req.headers.forEach((v, k) => headers.push([k, v])); // workerd normalises header order: no HEADER_ORDER signal here\n const cf = cfOf(req);\n const ev = buildWireEvent(\n {\n method: req.method, host: req.headers.get('host') ?? new URL(req.url).host, path, query, headers, ip,\n // The client's connection terminates at Cloudflare, so `cf.httpProtocol` really is the\n // visitor's protocol (ea's TAP_CAPS grants this tap TRUE_PROTO on that basis). Never read\n // a forwarded header for this — that would be the proxy's hop, not the client's.\n httpVersion: cf.httpProtocol ? cf.httpProtocol.replace(/^HTTP\\//i, '') : null,\n },\n { tap: TAP_HONO, rid, sid, newSession },\n );\n if (cf.asn) ev.asn = cf.asn;\n if (cf.country) ev.cc = cf.country;\n if (cf.tlsClientExtensionsSha1) ev.tlsx = cf.tlsClientExtensionsSha1;\n return ev;\n}\n\n/** Push then flush through `waitUntil` — the one rule every row (wire event, beacon, track()) follows. */\nexport function ship(e: Engine, ev: unknown, waitUntil: WaitUntil): void {\n e.queue.push(ev);\n e.queue.flush(waitUntil);\n}\n\n/** 204 and a `sig: 1` row on the event batch — one request per flush at the analyst, not one per page view. */\nasync function relayBeacon(eng: Engine, req: Request, ip: string | null, waitUntil: WaitUntil): Promise<Response> {\n const noContent = () => new Response(null, { status: 204, headers: { 'cache-control': 'no-store' } });\n if (Number(req.headers.get('content-length')) > FP_MAX) return new Response(null, { status: 413 });\n const body = await req.text();\n if (encoder.encode(body).byteLength > FP_MAX) return new Response(null, { status: 413 });\n let parsed: unknown;\n try { parsed = JSON.parse(body); } catch { return noContent(); } // not a beacon: drop it, never ship junk\n if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return noContent();\n // Spread first: the ip and the tap are the server's to say, whatever the body claimed.\n ship(eng, { ...(parsed as Record<string, unknown>), sig: 1, ip, tap: TAP_HONO }, waitUntil);\n return noContent();\n}\n\nexport function camada(opts: CamadaHonoOptions = {}): MiddlewareHandler {\n const challengePath = opts.challengePath ?? DEFAULT_CHALLENGE_PATH;\n const scriptPath = opts.scriptPath ?? SCRIPT_PATH;\n const fpPath = opts.fpPath ?? FP_PATH;\n\n return async function camadaHono(c: Context, next: Next): Promise<Response | void> {\n // Reading c.env and building the engine are inside the guard too: a Workers env carries\n // non-string bindings, and a throw here would 5xx the app on its very first request.\n const env = guarded(() => ({ ...(c.env as Record<string, string | undefined> | undefined), ...opts.env }), {} as Record<string, string | undefined>);\n const eng = guarded(() => (env.CAMADA_DISABLED === '1' ? null : ensure(opts, env)), null);\n if (!eng) return next();\n // Same opt-out as @camada/next: the code option, or CAMADA_CHALLENGE=0 in the Worker env.\n const challengeOn = opts.challenge !== false && env.CAMADA_CHALLENGE !== '0';\n\n const waitUntil: WaitUntil = (p) => {\n try { c.executionCtx?.waitUntil(p); } catch { /* no execution context outside a fetch handler */ }\n };\n\n // The id of the `warn` rule that let this request through, read again after next() when the\n // response event is built (§D3). Every other action settles before the app ever runs.\n let warnRule: string | null = null;\n // Set on the capture path only: the ids this request shares with the app (scriptTag, track)\n // and with its own post-response event. Null when camada answered the request itself.\n let vars: CamadaVars | null = null;\n let newSession = false;\n\n const answer = await guardedAsync(async (): Promise<Response | null> => {\n eng.snap.ensureFresh(waitUntil);\n\n const req = c.req.raw;\n const { url, path, cf, ip, sid } = describeRequest(eng, req);\n\n const v = eng.snap.verdict({\n ip, path, ua: req.headers.get('user-agent'), // §D3: without ua every `ua` rule is false\n // `header` conditions read the request through this getter; `Headers.get` is\n // case-insensitive, so the lower-cased name the matcher asks with finds whatever\n // spelling the client sent. Without it every `header` rule is false.\n header: (n) => req.headers.get(n),\n asn: cf.asn ?? null, country: cf.country ?? null, tlsx: cf.tlsClientExtensionsSha1 ?? null,\n });\n warnRule = v.warn ? v.rule ?? null : null;\n\n if (v.block) {\n const ev = buildEvent(req, path, url.search, ip, sid);\n ev.st = 403;\n ev.blk = v.reason; // SDK-01: the analyst counts SDK blocks apart from the app's own 403s ('rule' when a rule decided)\n if (v.rule) ev.rl = v.rule;\n ship(eng, ev, waitUntil);\n const headers: Record<string, string> = {\n 'content-type': 'text/plain', 'x-block-reason': String(v.reason ?? ''), 'x-block-version': v.version ?? '',\n };\n if (v.rule) headers['x-block-rule'] = v.rule; // a custom rule blocked: name it, so the customer knows which row to edit\n return new Response('Forbidden', { status: 403, headers });\n }\n\n // A challenge needs a resolved client ip: the nonce and the `_cch` cookie are bound to it.\n // Without one, fail open rather than mint a cookie every unidentified client could use.\n if (challengeOn && ip) {\n // The verify endpoint answers first — a challenged client must be able to reach it.\n if (req.method === 'POST' && path === challengePath) return verify(eng, req, ip, sid, challengePath, waitUntil);\n const token = cookieValue(req.headers.get('cookie') || '', CHALLENGE_COOKIE);\n if (v.challenge && !(await eng.kit.tokenValid(ip, Date.now(), token))) {\n return serve(eng, req, ip, sid, challengePath, path + url.search, waitUntil);\n }\n }\n\n // The beacon endpoints come after enforcement (a blocked or challenged client gets neither\n // the script nor a relay) and before the app (they are camada's, not its) — @camada/node's order.\n if (beaconEnabled(eng)) {\n if (req.method === 'GET' && path === scriptPath) {\n return new Response(iife, { status: 200, headers: { 'content-type': 'application/javascript', 'cache-control': 'public, max-age=3600' } });\n }\n if (req.method === 'POST' && path === fpPath) return relayBeacon(eng, req, ip, waitUntil);\n }\n\n // Capture: mint the ids the app, the beacon and the post-response event all share. The\n // session is decided here rather than after next() so track() inside the handler and the\n // event both carry the sid a first visit is about to be given.\n newSession = !sid;\n vars = { eng, rid: crypto.randomUUID(), sid: sid ?? crypto.randomUUID(), ip, waitUntil, scriptPath };\n c.set(VAR, vars);\n return null;\n }, null);\n\n if (answer) return answer;\n\n await next();\n\n // Mint the shared session cookie the other taps use, so `sid`/`ns` are real here too\n // (ea's capability mask for sdk-hono claims SESSION). Never overwrite an existing one.\n guarded(() => {\n if (!vars || !newSession) return;\n const secure = new URL(c.req.raw.url).protocol === 'https:' ? '; Secure' : '';\n c.res.headers.append('set-cookie', `${SESSION_COOKIE}=${vars.sid}; Path=/; Max-Age=${SESSION_MAX_AGE}; HttpOnly; SameSite=Lax${secure}`);\n }, undefined);\n\n // The response has settled, so this tap ships the real status — unlike @camada/next's\n // middleware position, which can only report pre-response.\n void guardedAsync(async () => {\n const req = c.req.raw;\n const { url, path, ip } = describeRequest(eng, req);\n const cfg = eng.snap.config;\n if ((cfg?.exclude || []).some((x) => path.startsWith(x))) return;\n if (Math.random() >= (cfg?.sample ?? 1)) return;\n // `vars` is null only when the guard above threw before setting it — then the event still ships, with fresh ids.\n const ev = buildEvent(req, path, url.search, ip, vars?.sid ?? null, vars?.rid, newSession);\n ev.st = c.res.status;\n if (warnRule) ev.wrn = warnRule; // §D3: the warn rule that let this request through\n ship(eng, ev, waitUntil);\n }, undefined);\n };\n}\n\n/** 403 + the proof-of-work page (HTML navigations) or 403 JSON, plus the `blk: \"challenge\"` event. */\nasync function serve(eng: Engine, req: Request, ip: string, sid: string | null, action: string, target: string, waitUntil: WaitUntil): Promise<Response> {\n const url = new URL(req.url);\n const ev = buildEvent(req, url.pathname, url.search, ip, sid);\n ev.st = 403;\n ev.blk = 'challenge';\n ship(eng, ev, waitUntil);\n\n if (!wantsHtml(req.headers.get('accept'), req.headers.get('sec-fetch-dest'))) {\n return new Response('{\"error\":\"challenge_required\"}', {\n status: 403,\n headers: { 'content-type': 'application/json', 'cache-control': 'no-store', 'x-camada-challenge': '1' },\n });\n }\n const html = challengePage({ nonce: await eng.kit.nonce(ip, Date.now()), action, to: safeReturnTo(target) });\n return new Response(html, {\n status: 403,\n headers: { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store', 'x-camada-challenge': '1' },\n });\n}\n\n/** POST from the page: validate, set `_cch`, 302 back, ship `{ st: 200, ch: 1 }`. */\nasync function verify(eng: Engine, req: Request, ip: string, sid: string | null, action: string, waitUntil: WaitUntil): Promise<Response> {\n const url = new URL(req.url);\n // camada answers this path before the app runs, so it must not become a place to post\n // hundreds of megabytes at an unauthenticated endpoint.\n if (Number(req.headers.get('content-length')) > BODY_MAX) return new Response(null, { status: 413 });\n const body = await req.text();\n if (body.length > BODY_MAX) return new Response(null, { status: 413 });\n const form = parseFormBody(body);\n const to = safeReturnTo(form.to);\n const now = Date.now();\n if (!(await eng.kit.verify(ip, now, form.nonce, form.solution))) {\n const html = challengePage({ nonce: await eng.kit.nonce(ip, now), action, to });\n return new Response(html, { status: 403, headers: { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' } });\n }\n const ev = buildEvent(req, url.pathname, '', ip, sid);\n ev.st = 200;\n ev.ch = 1; // challenge passed (contract §A3 ingest field)\n ship(eng, ev, waitUntil);\n return new Response(null, {\n status: 302,\n headers: {\n location: to,\n 'set-cookie': challengeCookie(await eng.kit.issue(ip, now), url.protocol === 'https:'),\n 'cache-control': 'no-store',\n },\n });\n}\n","// Workers hand env vars per request on `c.env`, not on a process — so the engine is built from\n// whatever the first request carries, merged with the options the app passed in code.\nimport { parseKey, parseTrustedProxyEnv, type SnapshotVersion, type TrustedProxyConfig } from '@camada/core';\n\nexport interface ResolvedEnv {\n ingestToken: string;\n snapToken: string;\n secret: string; // HMAC key for the challenge nonce/cookie — never leaves the isolate\n ingestUrl: string;\n snapshotUrl: string;\n trustedProxy: TrustedProxyConfig | null; // null = defer to server-delivered config\n}\n\nexport interface CamadaHonoOptions {\n key?: string;\n ingestUrl?: string;\n snapshotUrl?: string;\n trustedProxy?: TrustedProxyConfig | string | null;\n challenge?: boolean; // enforce `challenge` verdicts with the first-party page (default true)\n challengePath?: string; // where that page posts its solution (default /__camada/challenge)\n snapshotVersion?: SnapshotVersion; // 5 (default) also carries the tenant's ordered custom rules; 4 the allow/challenge sides only; 3 opts out of both\n scriptPath?: string; // where the first-party beacon script is served (default /_cam/b.js)\n fpPath?: string; // where that script posts the beacon (default /_cam/fp; must share scriptPath's directory — the script derives it)\n env?: Record<string, string | undefined>; // overrides c.env (tests, and apps that read config themselves)\n fetchImpl?: typeof fetch;\n}\n\nconst asProxy = (v: CamadaHonoOptions['trustedProxy']): TrustedProxyConfig | null =>\n typeof v === 'string' ? parseTrustedProxyEnv(v) : v ?? null;\n\n/** Returns null (the middleware stays inert, one log line) rather than throwing on bad config. */\nexport function resolveEnv(opts: CamadaHonoOptions, env: Record<string, string | undefined>): ResolvedEnv | null {\n const raw = opts.key || env.CAMADA_KEY;\n const key = parseKey(raw);\n const ingestToken = key?.ingestToken ?? env.CAMADA_TOKEN;\n const snapToken = key?.snapToken ?? env.CAMADA_SNAPSHOT_TOKEN;\n if (!ingestToken || !snapToken) return null;\n const ingestUrl = (opts.ingestUrl || env.CAMADA_INGEST_URL || 'https://in.camada.dev').replace(/\\/$/, ''); // PLACEHOLDER default — confirm the production ingest domain before any npm publish\n return {\n ingestToken, snapToken,\n secret: raw || `${ingestToken}.${snapToken}`,\n ingestUrl,\n snapshotUrl: opts.snapshotUrl || env.CAMADA_SNAPSHOT_URL || `${ingestUrl}/snapshot`,\n trustedProxy: asProxy(opts.trustedProxy) ?? parseTrustedProxyEnv(env.CAMADA_TRUSTED_PROXY),\n };\n}\n","// Per-request state the middleware leaves on the Hono context for the two helpers an app calls\n// from its own handlers: `scriptTag(c)` (the beacon tag) and `track(c, event, data)` (an\n// app-context outcome). The key is private — the helpers are the API, not `c.get`. When the\n// middleware did not run for this request (a path it is not mounted on, no key, CAMADA_DISABLED,\n// or a request it answered itself) there is nothing on the context and both helpers stand down.\nimport type { Context } from 'hono';\nimport { guarded, guardedAsync, hashUserId, TAP_HONO } from '@camada/core';\nimport { ship, type Engine, type WaitUntil } from './camada.js';\n\nexport const VAR = '__camada';\n\nexport interface CamadaVars {\n eng: Engine;\n rid: string; // the request id the page event carries; the beacon and track() join on it\n sid: string; // the `_sfp` session — the one just minted when the request arrived without it\n ip: string | null;\n waitUntil: WaitUntil;\n scriptPath: string;\n}\n\nexport const beaconEnabled = (e: Engine): boolean => e.snap.config?.beacon !== false; // tenant switch; a cold engine serves\n\nconst readVars = (c: Context): CamadaVars | undefined => c.get(VAR) as CamadaVars | undefined;\n\n/**\n * Records an outcome the app knows and the wire cannot show: `login_failed`, `login_succeeded`,\n * `signup`, `password_reset`, `mfa_failed`, `payment_failed`, `payment_succeeded`, `coupon_failed`\n * (free-form; that vocabulary is what the analyst's rules read). Joined to this request's event\n * through its rid and session. The user identifier is HMAC-hashed in-process with the ingest\n * token — the raw value never reaches the queue. Never throws, never rejects; awaiting it is\n * optional (the flush rides `waitUntil`), so a handler may fire and forget.\n */\nexport function track(c: Context, event: string, data?: { user?: string }): Promise<void> {\n const vars = guarded(() => readVars(c), undefined);\n if (!vars) return Promise.resolve();\n const p = guardedAsync(async () => {\n const uid = data?.user ? await hashUserId(data.user, vars.eng.env.ingestToken) : null;\n ship(vars.eng, { tap: TAP_HONO, et: event, uid, rid: vars.rid, sid: vars.sid, ip: vars.ip, ts: Date.now() }, vars.waitUntil);\n }, undefined);\n vars.waitUntil(p); // the isolate may freeze right after the response: hold it open for the flush\n return p;\n}\n\n/** The `<script>` tag for an HTML response — `''` when camada is off for this request or the tenant turned the beacon off. */\nexport function scriptTag(c: Context): string {\n return guarded(() => {\n const vars = readVars(c);\n if (!vars || !beaconEnabled(vars.eng)) return '';\n return `<script src=\"${vars.scriptPath}?r=${vars.rid}\" async></script>`;\n }, '');\n}\n","{\n \"name\": \"@camada/hono\",\n \"version\": \"0.3.0\",\n \"license\": \"MIT\",\n \"type\": \"module\",\n \"engines\": {\n \"node\": \">=18\"\n },\n \"files\": [\n \"dist\",\n \"README.md\",\n \"LICENSE\"\n ],\n \"scripts\": {\n \"build\": \"tsup\",\n \"dev\": \"tsup --watch\",\n \"test\": \"vitest run\",\n \"check\": \"tsc --noEmit\"\n },\n \"description\": \"camada SDK for Hono on Cloudflare Workers: snapshot enforcement, first-party challenge and beacon, track() outcomes, waitUntil-flushed event shipping\",\n \"exports\": {\n \".\": {\n \"types\": \"./dist/index.d.ts\",\n \"import\": \"./dist/index.js\",\n \"require\": \"./dist/index.cjs\"\n }\n },\n \"main\": \"./dist/index.cjs\",\n \"module\": \"./dist/index.js\",\n \"types\": \"./dist/index.d.ts\",\n \"dependencies\": {\n \"@camada/core\": \"file:../camada-core\",\n \"@camada/browser\": \"file:../camada-browser\"\n },\n \"peerDependencies\": {\n \"hono\": \">=4\"\n },\n \"devDependencies\": {\n \"@cloudflare/workers-types\": \"^4.20250109.0\",\n \"@types/node\": \"^22.10.0\",\n \"hono\": \"^4.6.0\",\n \"tsup\": \"^8.3.5\",\n \"typescript\": \"^5.7.2\",\n \"vitest\": \"^3.0.0\"\n }\n}\n","// The SDK's wire identity (SDK-03): x-camada-sdk: <package>/<version>. Named JSON imports are\n// inlined and tree-shaken by tsup at build, so dist carries two literals — no runtime fs read.\nimport { name, version } from '../package.json';\nexport const SDK_ID = `${name}/${version}`;\n"],"mappings":"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","names":["import_core","import_core","name","iife"]}
|
package/dist/index.d.cts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { MiddlewareHandler, Context } from 'hono';
|
|
2
|
+
import { TrustedProxyConfig, SnapshotVersion } from '@camada/core';
|
|
3
|
+
|
|
4
|
+
interface ResolvedEnv {
|
|
5
|
+
ingestToken: string;
|
|
6
|
+
snapToken: string;
|
|
7
|
+
secret: string;
|
|
8
|
+
ingestUrl: string;
|
|
9
|
+
snapshotUrl: string;
|
|
10
|
+
trustedProxy: TrustedProxyConfig | null;
|
|
11
|
+
}
|
|
12
|
+
interface CamadaHonoOptions {
|
|
13
|
+
key?: string;
|
|
14
|
+
ingestUrl?: string;
|
|
15
|
+
snapshotUrl?: string;
|
|
16
|
+
trustedProxy?: TrustedProxyConfig | string | null;
|
|
17
|
+
challenge?: boolean;
|
|
18
|
+
challengePath?: string;
|
|
19
|
+
snapshotVersion?: SnapshotVersion;
|
|
20
|
+
scriptPath?: string;
|
|
21
|
+
fpPath?: string;
|
|
22
|
+
env?: Record<string, string | undefined>;
|
|
23
|
+
fetchImpl?: typeof fetch;
|
|
24
|
+
}
|
|
25
|
+
/** Returns null (the middleware stays inert, one log line) rather than throwing on bad config. */
|
|
26
|
+
declare function resolveEnv(opts: CamadaHonoOptions, env: Record<string, string | undefined>): ResolvedEnv | null;
|
|
27
|
+
|
|
28
|
+
/** Test/reset hook: drops every cached engine. */
|
|
29
|
+
declare function resetCamada(): void;
|
|
30
|
+
declare function camada(opts?: CamadaHonoOptions): MiddlewareHandler;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Records an outcome the app knows and the wire cannot show: `login_failed`, `login_succeeded`,
|
|
34
|
+
* `signup`, `password_reset`, `mfa_failed`, `payment_failed`, `payment_succeeded`, `coupon_failed`
|
|
35
|
+
* (free-form; that vocabulary is what the analyst's rules read). Joined to this request's event
|
|
36
|
+
* through its rid and session. The user identifier is HMAC-hashed in-process with the ingest
|
|
37
|
+
* token — the raw value never reaches the queue. Never throws, never rejects; awaiting it is
|
|
38
|
+
* optional (the flush rides `waitUntil`), so a handler may fire and forget.
|
|
39
|
+
*/
|
|
40
|
+
declare function track(c: Context, event: string, data?: {
|
|
41
|
+
user?: string;
|
|
42
|
+
}): Promise<void>;
|
|
43
|
+
/** The `<script>` tag for an HTML response — `''` when camada is off for this request or the tenant turned the beacon off. */
|
|
44
|
+
declare function scriptTag(c: Context): string;
|
|
45
|
+
|
|
46
|
+
export { type CamadaHonoOptions, type ResolvedEnv, camada, resetCamada, resolveEnv, scriptTag, track };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { MiddlewareHandler, Context } from 'hono';
|
|
2
|
+
import { TrustedProxyConfig, SnapshotVersion } from '@camada/core';
|
|
3
|
+
|
|
4
|
+
interface ResolvedEnv {
|
|
5
|
+
ingestToken: string;
|
|
6
|
+
snapToken: string;
|
|
7
|
+
secret: string;
|
|
8
|
+
ingestUrl: string;
|
|
9
|
+
snapshotUrl: string;
|
|
10
|
+
trustedProxy: TrustedProxyConfig | null;
|
|
11
|
+
}
|
|
12
|
+
interface CamadaHonoOptions {
|
|
13
|
+
key?: string;
|
|
14
|
+
ingestUrl?: string;
|
|
15
|
+
snapshotUrl?: string;
|
|
16
|
+
trustedProxy?: TrustedProxyConfig | string | null;
|
|
17
|
+
challenge?: boolean;
|
|
18
|
+
challengePath?: string;
|
|
19
|
+
snapshotVersion?: SnapshotVersion;
|
|
20
|
+
scriptPath?: string;
|
|
21
|
+
fpPath?: string;
|
|
22
|
+
env?: Record<string, string | undefined>;
|
|
23
|
+
fetchImpl?: typeof fetch;
|
|
24
|
+
}
|
|
25
|
+
/** Returns null (the middleware stays inert, one log line) rather than throwing on bad config. */
|
|
26
|
+
declare function resolveEnv(opts: CamadaHonoOptions, env: Record<string, string | undefined>): ResolvedEnv | null;
|
|
27
|
+
|
|
28
|
+
/** Test/reset hook: drops every cached engine. */
|
|
29
|
+
declare function resetCamada(): void;
|
|
30
|
+
declare function camada(opts?: CamadaHonoOptions): MiddlewareHandler;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Records an outcome the app knows and the wire cannot show: `login_failed`, `login_succeeded`,
|
|
34
|
+
* `signup`, `password_reset`, `mfa_failed`, `payment_failed`, `payment_succeeded`, `coupon_failed`
|
|
35
|
+
* (free-form; that vocabulary is what the analyst's rules read). Joined to this request's event
|
|
36
|
+
* through its rid and session. The user identifier is HMAC-hashed in-process with the ingest
|
|
37
|
+
* token — the raw value never reaches the queue. Never throws, never rejects; awaiting it is
|
|
38
|
+
* optional (the flush rides `waitUntil`), so a handler may fire and forget.
|
|
39
|
+
*/
|
|
40
|
+
declare function track(c: Context, event: string, data?: {
|
|
41
|
+
user?: string;
|
|
42
|
+
}): Promise<void>;
|
|
43
|
+
/** The `<script>` tag for an HTML response — `''` when camada is off for this request or the tenant turned the beacon off. */
|
|
44
|
+
declare function scriptTag(c: Context): string;
|
|
45
|
+
|
|
46
|
+
export { type CamadaHonoOptions, type ResolvedEnv, camada, resetCamada, resolveEnv, scriptTag, track };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
// src/camada.ts
|
|
2
|
+
import iife from "@camada/browser/iife-string";
|
|
3
|
+
import {
|
|
4
|
+
SnapshotClient,
|
|
5
|
+
EventQueue,
|
|
6
|
+
buildWireEvent,
|
|
7
|
+
resolveClientIp,
|
|
8
|
+
logRateLimited,
|
|
9
|
+
guardedAsync as guardedAsync2,
|
|
10
|
+
guarded as guarded2,
|
|
11
|
+
createChallengeAsync,
|
|
12
|
+
challengePage,
|
|
13
|
+
challengeCookie,
|
|
14
|
+
safeReturnTo,
|
|
15
|
+
wantsHtml,
|
|
16
|
+
parseFormBody,
|
|
17
|
+
CHALLENGE_COOKIE,
|
|
18
|
+
DEFAULT_SNAPSHOT_VERSION,
|
|
19
|
+
TAP_HONO as TAP_HONO2
|
|
20
|
+
} from "@camada/core";
|
|
21
|
+
|
|
22
|
+
// src/env.ts
|
|
23
|
+
import { parseKey, parseTrustedProxyEnv } from "@camada/core";
|
|
24
|
+
var asProxy = (v) => typeof v === "string" ? parseTrustedProxyEnv(v) : v ?? null;
|
|
25
|
+
function resolveEnv(opts, env) {
|
|
26
|
+
const raw = opts.key || env.CAMADA_KEY;
|
|
27
|
+
const key = parseKey(raw);
|
|
28
|
+
const ingestToken = key?.ingestToken ?? env.CAMADA_TOKEN;
|
|
29
|
+
const snapToken = key?.snapToken ?? env.CAMADA_SNAPSHOT_TOKEN;
|
|
30
|
+
if (!ingestToken || !snapToken) return null;
|
|
31
|
+
const ingestUrl = (opts.ingestUrl || env.CAMADA_INGEST_URL || "https://in.camada.dev").replace(/\/$/, "");
|
|
32
|
+
return {
|
|
33
|
+
ingestToken,
|
|
34
|
+
snapToken,
|
|
35
|
+
secret: raw || `${ingestToken}.${snapToken}`,
|
|
36
|
+
ingestUrl,
|
|
37
|
+
snapshotUrl: opts.snapshotUrl || env.CAMADA_SNAPSHOT_URL || `${ingestUrl}/snapshot`,
|
|
38
|
+
trustedProxy: asProxy(opts.trustedProxy) ?? parseTrustedProxyEnv(env.CAMADA_TRUSTED_PROXY)
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// src/context.ts
|
|
43
|
+
import { guarded, guardedAsync, hashUserId, TAP_HONO } from "@camada/core";
|
|
44
|
+
var VAR = "__camada";
|
|
45
|
+
var beaconEnabled = (e) => e.snap.config?.beacon !== false;
|
|
46
|
+
var readVars = (c) => c.get(VAR);
|
|
47
|
+
function track(c, event, data) {
|
|
48
|
+
const vars = guarded(() => readVars(c), void 0);
|
|
49
|
+
if (!vars) return Promise.resolve();
|
|
50
|
+
const p = guardedAsync(async () => {
|
|
51
|
+
const uid = data?.user ? await hashUserId(data.user, vars.eng.env.ingestToken) : null;
|
|
52
|
+
ship(vars.eng, { tap: TAP_HONO, et: event, uid, rid: vars.rid, sid: vars.sid, ip: vars.ip, ts: Date.now() }, vars.waitUntil);
|
|
53
|
+
}, void 0);
|
|
54
|
+
vars.waitUntil(p);
|
|
55
|
+
return p;
|
|
56
|
+
}
|
|
57
|
+
function scriptTag(c) {
|
|
58
|
+
return guarded(() => {
|
|
59
|
+
const vars = readVars(c);
|
|
60
|
+
if (!vars || !beaconEnabled(vars.eng)) return "";
|
|
61
|
+
return `<script src="${vars.scriptPath}?r=${vars.rid}" async></script>`;
|
|
62
|
+
}, "");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// package.json
|
|
66
|
+
var name = "@camada/hono";
|
|
67
|
+
var version = "0.3.0";
|
|
68
|
+
|
|
69
|
+
// src/version.ts
|
|
70
|
+
var SDK_ID = `${name}/${version}`;
|
|
71
|
+
|
|
72
|
+
// src/camada.ts
|
|
73
|
+
var DEFAULT_CHALLENGE_PATH = "/__camada/challenge";
|
|
74
|
+
var SCRIPT_PATH = "/_cam/b.js";
|
|
75
|
+
var FP_PATH = "/_cam/fp";
|
|
76
|
+
var FP_MAX = 32 * 1024;
|
|
77
|
+
var SESSION_COOKIE = "_sfp";
|
|
78
|
+
var SESSION_MAX_AGE = 2592e3;
|
|
79
|
+
var BODY_MAX = 4 * 1024;
|
|
80
|
+
var encoder = new TextEncoder();
|
|
81
|
+
var engines = /* @__PURE__ */ new Map();
|
|
82
|
+
function resetCamada() {
|
|
83
|
+
for (const e of engines.values()) {
|
|
84
|
+
e.snap.stop();
|
|
85
|
+
e.queue.stop();
|
|
86
|
+
}
|
|
87
|
+
engines.clear();
|
|
88
|
+
}
|
|
89
|
+
function ensure(opts, ctxEnv) {
|
|
90
|
+
const env = resolveEnv(opts, ctxEnv);
|
|
91
|
+
if (!env) {
|
|
92
|
+
logRateLimited(new Error("CAMADA_KEY not set \u2014 camada is inactive"));
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
const snapshotVersion = opts.snapshotVersion ?? DEFAULT_SNAPSHOT_VERSION;
|
|
96
|
+
const id = `${env.ingestToken}|${env.ingestUrl}|${env.snapshotUrl}|${snapshotVersion}`;
|
|
97
|
+
const cached = engines.get(id);
|
|
98
|
+
if (cached) return cached;
|
|
99
|
+
const injected = opts.fetchImpl ? { fetchImpl: opts.fetchImpl } : {};
|
|
100
|
+
const engine = {
|
|
101
|
+
env,
|
|
102
|
+
snap: new SnapshotClient({
|
|
103
|
+
url: env.snapshotUrl,
|
|
104
|
+
token: env.snapToken,
|
|
105
|
+
mode: "lazy",
|
|
106
|
+
sdk: SDK_ID,
|
|
107
|
+
snapshotVersion,
|
|
108
|
+
...injected
|
|
109
|
+
}),
|
|
110
|
+
queue: new EventQueue({ url: env.ingestUrl, token: env.ingestToken, sdk: SDK_ID, ...injected }),
|
|
111
|
+
kit: createChallengeAsync({ secret: env.secret })
|
|
112
|
+
};
|
|
113
|
+
engines.set(id, engine);
|
|
114
|
+
return engine;
|
|
115
|
+
}
|
|
116
|
+
var cookieValue = (cookie, name2) => {
|
|
117
|
+
const src = "; " + cookie;
|
|
118
|
+
const i = src.indexOf("; " + name2 + "=");
|
|
119
|
+
if (i === -1) return null;
|
|
120
|
+
const start = i + name2.length + 3;
|
|
121
|
+
const j = src.indexOf(";", start);
|
|
122
|
+
return src.slice(start, j === -1 ? void 0 : j);
|
|
123
|
+
};
|
|
124
|
+
var cfOf = (req) => req.cf ?? {};
|
|
125
|
+
var trustedProxy = (e) => e.env.trustedProxy ?? e.snap.config?.trusted_proxy ?? null;
|
|
126
|
+
var clientIp = (e, req) => {
|
|
127
|
+
const cf = req.cf;
|
|
128
|
+
const direct = cf ? req.headers.get("cf-connecting-ip") : null;
|
|
129
|
+
return direct || resolveClientIp(null, req.headers.get("x-forwarded-for"), trustedProxy(e));
|
|
130
|
+
};
|
|
131
|
+
function describeRequest(e, req) {
|
|
132
|
+
const url = new URL(req.url);
|
|
133
|
+
const cookies = req.headers.get("cookie") || "";
|
|
134
|
+
return { url, path: url.pathname, cf: cfOf(req), ip: clientIp(e, req), sid: cookieValue(cookies, SESSION_COOKIE) };
|
|
135
|
+
}
|
|
136
|
+
function buildEvent(req, path, query, ip, sid, rid = crypto.randomUUID(), newSession = false) {
|
|
137
|
+
const headers = [];
|
|
138
|
+
req.headers.forEach((v, k) => headers.push([k, v]));
|
|
139
|
+
const cf = cfOf(req);
|
|
140
|
+
const ev = buildWireEvent(
|
|
141
|
+
{
|
|
142
|
+
method: req.method,
|
|
143
|
+
host: req.headers.get("host") ?? new URL(req.url).host,
|
|
144
|
+
path,
|
|
145
|
+
query,
|
|
146
|
+
headers,
|
|
147
|
+
ip,
|
|
148
|
+
// The client's connection terminates at Cloudflare, so `cf.httpProtocol` really is the
|
|
149
|
+
// visitor's protocol (ea's TAP_CAPS grants this tap TRUE_PROTO on that basis). Never read
|
|
150
|
+
// a forwarded header for this — that would be the proxy's hop, not the client's.
|
|
151
|
+
httpVersion: cf.httpProtocol ? cf.httpProtocol.replace(/^HTTP\//i, "") : null
|
|
152
|
+
},
|
|
153
|
+
{ tap: TAP_HONO2, rid, sid, newSession }
|
|
154
|
+
);
|
|
155
|
+
if (cf.asn) ev.asn = cf.asn;
|
|
156
|
+
if (cf.country) ev.cc = cf.country;
|
|
157
|
+
if (cf.tlsClientExtensionsSha1) ev.tlsx = cf.tlsClientExtensionsSha1;
|
|
158
|
+
return ev;
|
|
159
|
+
}
|
|
160
|
+
function ship(e, ev, waitUntil) {
|
|
161
|
+
e.queue.push(ev);
|
|
162
|
+
e.queue.flush(waitUntil);
|
|
163
|
+
}
|
|
164
|
+
async function relayBeacon(eng, req, ip, waitUntil) {
|
|
165
|
+
const noContent = () => new Response(null, { status: 204, headers: { "cache-control": "no-store" } });
|
|
166
|
+
if (Number(req.headers.get("content-length")) > FP_MAX) return new Response(null, { status: 413 });
|
|
167
|
+
const body = await req.text();
|
|
168
|
+
if (encoder.encode(body).byteLength > FP_MAX) return new Response(null, { status: 413 });
|
|
169
|
+
let parsed;
|
|
170
|
+
try {
|
|
171
|
+
parsed = JSON.parse(body);
|
|
172
|
+
} catch {
|
|
173
|
+
return noContent();
|
|
174
|
+
}
|
|
175
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return noContent();
|
|
176
|
+
ship(eng, { ...parsed, sig: 1, ip, tap: TAP_HONO2 }, waitUntil);
|
|
177
|
+
return noContent();
|
|
178
|
+
}
|
|
179
|
+
function camada(opts = {}) {
|
|
180
|
+
const challengePath = opts.challengePath ?? DEFAULT_CHALLENGE_PATH;
|
|
181
|
+
const scriptPath = opts.scriptPath ?? SCRIPT_PATH;
|
|
182
|
+
const fpPath = opts.fpPath ?? FP_PATH;
|
|
183
|
+
return async function camadaHono(c, next) {
|
|
184
|
+
const env = guarded2(() => ({ ...c.env, ...opts.env }), {});
|
|
185
|
+
const eng = guarded2(() => env.CAMADA_DISABLED === "1" ? null : ensure(opts, env), null);
|
|
186
|
+
if (!eng) return next();
|
|
187
|
+
const challengeOn = opts.challenge !== false && env.CAMADA_CHALLENGE !== "0";
|
|
188
|
+
const waitUntil = (p) => {
|
|
189
|
+
try {
|
|
190
|
+
c.executionCtx?.waitUntil(p);
|
|
191
|
+
} catch {
|
|
192
|
+
}
|
|
193
|
+
};
|
|
194
|
+
let warnRule = null;
|
|
195
|
+
let vars = null;
|
|
196
|
+
let newSession = false;
|
|
197
|
+
const answer = await guardedAsync2(async () => {
|
|
198
|
+
eng.snap.ensureFresh(waitUntil);
|
|
199
|
+
const req = c.req.raw;
|
|
200
|
+
const { url, path, cf, ip, sid } = describeRequest(eng, req);
|
|
201
|
+
const v = eng.snap.verdict({
|
|
202
|
+
ip,
|
|
203
|
+
path,
|
|
204
|
+
ua: req.headers.get("user-agent"),
|
|
205
|
+
// §D3: without ua every `ua` rule is false
|
|
206
|
+
// `header` conditions read the request through this getter; `Headers.get` is
|
|
207
|
+
// case-insensitive, so the lower-cased name the matcher asks with finds whatever
|
|
208
|
+
// spelling the client sent. Without it every `header` rule is false.
|
|
209
|
+
header: (n) => req.headers.get(n),
|
|
210
|
+
asn: cf.asn ?? null,
|
|
211
|
+
country: cf.country ?? null,
|
|
212
|
+
tlsx: cf.tlsClientExtensionsSha1 ?? null
|
|
213
|
+
});
|
|
214
|
+
warnRule = v.warn ? v.rule ?? null : null;
|
|
215
|
+
if (v.block) {
|
|
216
|
+
const ev = buildEvent(req, path, url.search, ip, sid);
|
|
217
|
+
ev.st = 403;
|
|
218
|
+
ev.blk = v.reason;
|
|
219
|
+
if (v.rule) ev.rl = v.rule;
|
|
220
|
+
ship(eng, ev, waitUntil);
|
|
221
|
+
const headers = {
|
|
222
|
+
"content-type": "text/plain",
|
|
223
|
+
"x-block-reason": String(v.reason ?? ""),
|
|
224
|
+
"x-block-version": v.version ?? ""
|
|
225
|
+
};
|
|
226
|
+
if (v.rule) headers["x-block-rule"] = v.rule;
|
|
227
|
+
return new Response("Forbidden", { status: 403, headers });
|
|
228
|
+
}
|
|
229
|
+
if (challengeOn && ip) {
|
|
230
|
+
if (req.method === "POST" && path === challengePath) return verify(eng, req, ip, sid, challengePath, waitUntil);
|
|
231
|
+
const token = cookieValue(req.headers.get("cookie") || "", CHALLENGE_COOKIE);
|
|
232
|
+
if (v.challenge && !await eng.kit.tokenValid(ip, Date.now(), token)) {
|
|
233
|
+
return serve(eng, req, ip, sid, challengePath, path + url.search, waitUntil);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
if (beaconEnabled(eng)) {
|
|
237
|
+
if (req.method === "GET" && path === scriptPath) {
|
|
238
|
+
return new Response(iife, { status: 200, headers: { "content-type": "application/javascript", "cache-control": "public, max-age=3600" } });
|
|
239
|
+
}
|
|
240
|
+
if (req.method === "POST" && path === fpPath) return relayBeacon(eng, req, ip, waitUntil);
|
|
241
|
+
}
|
|
242
|
+
newSession = !sid;
|
|
243
|
+
vars = { eng, rid: crypto.randomUUID(), sid: sid ?? crypto.randomUUID(), ip, waitUntil, scriptPath };
|
|
244
|
+
c.set(VAR, vars);
|
|
245
|
+
return null;
|
|
246
|
+
}, null);
|
|
247
|
+
if (answer) return answer;
|
|
248
|
+
await next();
|
|
249
|
+
guarded2(() => {
|
|
250
|
+
if (!vars || !newSession) return;
|
|
251
|
+
const secure = new URL(c.req.raw.url).protocol === "https:" ? "; Secure" : "";
|
|
252
|
+
c.res.headers.append("set-cookie", `${SESSION_COOKIE}=${vars.sid}; Path=/; Max-Age=${SESSION_MAX_AGE}; HttpOnly; SameSite=Lax${secure}`);
|
|
253
|
+
}, void 0);
|
|
254
|
+
void guardedAsync2(async () => {
|
|
255
|
+
const req = c.req.raw;
|
|
256
|
+
const { url, path, ip } = describeRequest(eng, req);
|
|
257
|
+
const cfg = eng.snap.config;
|
|
258
|
+
if ((cfg?.exclude || []).some((x) => path.startsWith(x))) return;
|
|
259
|
+
if (Math.random() >= (cfg?.sample ?? 1)) return;
|
|
260
|
+
const ev = buildEvent(req, path, url.search, ip, vars?.sid ?? null, vars?.rid, newSession);
|
|
261
|
+
ev.st = c.res.status;
|
|
262
|
+
if (warnRule) ev.wrn = warnRule;
|
|
263
|
+
ship(eng, ev, waitUntil);
|
|
264
|
+
}, void 0);
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
async function serve(eng, req, ip, sid, action, target, waitUntil) {
|
|
268
|
+
const url = new URL(req.url);
|
|
269
|
+
const ev = buildEvent(req, url.pathname, url.search, ip, sid);
|
|
270
|
+
ev.st = 403;
|
|
271
|
+
ev.blk = "challenge";
|
|
272
|
+
ship(eng, ev, waitUntil);
|
|
273
|
+
if (!wantsHtml(req.headers.get("accept"), req.headers.get("sec-fetch-dest"))) {
|
|
274
|
+
return new Response('{"error":"challenge_required"}', {
|
|
275
|
+
status: 403,
|
|
276
|
+
headers: { "content-type": "application/json", "cache-control": "no-store", "x-camada-challenge": "1" }
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
const html = challengePage({ nonce: await eng.kit.nonce(ip, Date.now()), action, to: safeReturnTo(target) });
|
|
280
|
+
return new Response(html, {
|
|
281
|
+
status: 403,
|
|
282
|
+
headers: { "content-type": "text/html; charset=utf-8", "cache-control": "no-store", "x-camada-challenge": "1" }
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
async function verify(eng, req, ip, sid, action, waitUntil) {
|
|
286
|
+
const url = new URL(req.url);
|
|
287
|
+
if (Number(req.headers.get("content-length")) > BODY_MAX) return new Response(null, { status: 413 });
|
|
288
|
+
const body = await req.text();
|
|
289
|
+
if (body.length > BODY_MAX) return new Response(null, { status: 413 });
|
|
290
|
+
const form = parseFormBody(body);
|
|
291
|
+
const to = safeReturnTo(form.to);
|
|
292
|
+
const now = Date.now();
|
|
293
|
+
if (!await eng.kit.verify(ip, now, form.nonce, form.solution)) {
|
|
294
|
+
const html = challengePage({ nonce: await eng.kit.nonce(ip, now), action, to });
|
|
295
|
+
return new Response(html, { status: 403, headers: { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" } });
|
|
296
|
+
}
|
|
297
|
+
const ev = buildEvent(req, url.pathname, "", ip, sid);
|
|
298
|
+
ev.st = 200;
|
|
299
|
+
ev.ch = 1;
|
|
300
|
+
ship(eng, ev, waitUntil);
|
|
301
|
+
return new Response(null, {
|
|
302
|
+
status: 302,
|
|
303
|
+
headers: {
|
|
304
|
+
location: to,
|
|
305
|
+
"set-cookie": challengeCookie(await eng.kit.issue(ip, now), url.protocol === "https:"),
|
|
306
|
+
"cache-control": "no-store"
|
|
307
|
+
}
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
export {
|
|
311
|
+
camada,
|
|
312
|
+
resetCamada,
|
|
313
|
+
resolveEnv,
|
|
314
|
+
scriptTag,
|
|
315
|
+
track
|
|
316
|
+
};
|
|
317
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/camada.ts","../src/env.ts","../src/context.ts","../package.json","../src/version.ts"],"sourcesContent":["// @camada/hono — the Hono middleware for Cloudflare Workers (SEC-07, D33).\n// app.use('*', camada());\n// Workers-native: `cf-connecting-ip` for the client address, `request.cf` for asn / country /\n// TLS fingerprint (so ASN and country rules really enforce at this tap), and\n// `c.executionCtx.waitUntil` so the snapshot poll and the event flush outlive the response.\n// Everything runs inside camada's fail-open envelope: a camada bug, a dead ingest or a corrupt\n// snapshot costs telemetry, never the app's response.\nimport type { Context, MiddlewareHandler, Next } from 'hono';\nimport iife from '@camada/browser/iife-string';\nimport {\n SnapshotClient, EventQueue, buildWireEvent, resolveClientIp, logRateLimited, guardedAsync,\n guarded, createChallengeAsync, challengePage, challengeCookie, safeReturnTo, wantsHtml, parseFormBody,\n CHALLENGE_COOKIE, DEFAULT_SNAPSHOT_VERSION, TAP_HONO,\n type AsyncChallengeKit, type TrustedProxyConfig, type WireEvent,\n} from '@camada/core';\nimport { resolveEnv, type CamadaHonoOptions, type ResolvedEnv } from './env.js';\nimport { VAR, beaconEnabled, type CamadaVars } from './context.js';\nimport { SDK_ID } from './version.js';\n\nconst DEFAULT_CHALLENGE_PATH = '/__camada/challenge';\nconst SCRIPT_PATH = '/_cam/b.js'; // the beacon IIFE; its auto-init posts to the sibling `fp` and reads the rid from ?r=\nconst FP_PATH = '/_cam/fp';\nconst FP_MAX = 32 * 1024; // matches the analyst's /fp cap: never accept what ingest will 413\nconst SESSION_COOKIE = '_sfp'; // the same session cookie as every other tap: sid/ns stay comparable\nconst SESSION_MAX_AGE = 2592000;\nconst BODY_MAX = 4 * 1024; // the verify form is ~120 bytes; anything larger is not ours\nconst encoder = new TextEncoder();\n\nexport interface Engine {\n env: ResolvedEnv;\n snap: SnapshotClient;\n queue: EventQueue;\n kit: AsyncChallengeKit;\n}\n\nexport type WaitUntil = (p: Promise<unknown>) => void;\n\n// One engine per resolved configuration, not per module: a Workers isolate can host several\n// Hono apps, and a shared singleton would enforce the first tenant's snapshot on the second\n// and sign its `_cch` cookies with the first tenant's secret. An unconfigured request is never\n// cached — a warm-up or a differently-keyed mount must not leave the isolate inert for good.\nconst engines = new Map<string, Engine>();\n\n/** Test/reset hook: drops every cached engine. */\nexport function resetCamada(): void {\n for (const e of engines.values()) { e.snap.stop(); e.queue.stop(); }\n engines.clear();\n}\n\nfunction ensure(opts: CamadaHonoOptions, ctxEnv: Record<string, string | undefined>): Engine | null {\n const env = resolveEnv(opts, ctxEnv);\n if (!env) {\n logRateLimited(new Error('CAMADA_KEY not set — camada is inactive'));\n return null;\n }\n const snapshotVersion = opts.snapshotVersion ?? DEFAULT_SNAPSHOT_VERSION;\n const id = `${env.ingestToken}|${env.ingestUrl}|${env.snapshotUrl}|${snapshotVersion}`;\n const cached = engines.get(id);\n if (cached) return cached;\n const injected = opts.fetchImpl ? { fetchImpl: opts.fetchImpl } : {}; // never pass an explicit undefined key\n const engine: Engine = {\n env,\n snap: new SnapshotClient({\n url: env.snapshotUrl, token: env.snapToken, mode: 'lazy', sdk: SDK_ID,\n snapshotVersion, ...injected,\n }),\n queue: new EventQueue({ url: env.ingestUrl, token: env.ingestToken, sdk: SDK_ID, ...injected }),\n kit: createChallengeAsync({ secret: env.secret }),\n };\n engines.set(id, engine);\n return engine;\n}\n\nconst cookieValue = (cookie: string, name: string): string | null => {\n const src = '; ' + cookie;\n const i = src.indexOf('; ' + name + '=');\n if (i === -1) return null;\n const start = i + name.length + 3;\n const j = src.indexOf(';', start);\n return src.slice(start, j === -1 ? undefined : j);\n};\n\ninterface CfProps { asn?: number; country?: string; tlsClientExtensionsSha1?: string; httpProtocol?: string }\nconst cfOf = (req: Request): CfProps => ((req as Request & { cf?: CfProps }).cf ?? {});\n\nconst trustedProxy = (e: Engine): TrustedProxyConfig | null =>\n e.env.trustedProxy ?? e.snap.config?.trusted_proxy ?? null;\n\n/** Cloudflare sets `cf-connecting-ip` itself and a client cannot forge it — but only on\n * Workers, which is also the only place `request.cf` exists. On any other Hono runtime\n * (node-server, Bun, Deno) that header is attacker-controlled, so it is ignored there and the\n * trusted-proxy rules decide, exactly as they do for X-Forwarded-For. */\nconst clientIp = (e: Engine, req: Request): string | null => {\n const cf = (req as Request & { cf?: unknown }).cf;\n const direct = cf ? req.headers.get('cf-connecting-ip') : null;\n return direct || resolveClientIp(null, req.headers.get('x-forwarded-for'), trustedProxy(e));\n};\n\ninterface RequestFacts {\n url: URL;\n path: string;\n cf: CfProps;\n ip: string | null;\n sid: string | null;\n}\n\nfunction describeRequest(e: Engine, req: Request): RequestFacts {\n const url = new URL(req.url);\n const cookies = req.headers.get('cookie') || '';\n return { url, path: url.pathname, cf: cfOf(req), ip: clientIp(e, req), sid: cookieValue(cookies, SESSION_COOKIE) };\n}\n\nfunction buildEvent(\n req: Request, path: string, query: string, ip: string | null, sid: string | null,\n rid: string = crypto.randomUUID(), newSession = false, // the capture path passes the ids it shared with the app; camada's own answers need none\n): WireEvent {\n const headers: Array<[string, string]> = [];\n req.headers.forEach((v, k) => headers.push([k, v])); // workerd normalises header order: no HEADER_ORDER signal here\n const cf = cfOf(req);\n const ev = buildWireEvent(\n {\n method: req.method, host: req.headers.get('host') ?? new URL(req.url).host, path, query, headers, ip,\n // The client's connection terminates at Cloudflare, so `cf.httpProtocol` really is the\n // visitor's protocol (ea's TAP_CAPS grants this tap TRUE_PROTO on that basis). Never read\n // a forwarded header for this — that would be the proxy's hop, not the client's.\n httpVersion: cf.httpProtocol ? cf.httpProtocol.replace(/^HTTP\\//i, '') : null,\n },\n { tap: TAP_HONO, rid, sid, newSession },\n );\n if (cf.asn) ev.asn = cf.asn;\n if (cf.country) ev.cc = cf.country;\n if (cf.tlsClientExtensionsSha1) ev.tlsx = cf.tlsClientExtensionsSha1;\n return ev;\n}\n\n/** Push then flush through `waitUntil` — the one rule every row (wire event, beacon, track()) follows. */\nexport function ship(e: Engine, ev: unknown, waitUntil: WaitUntil): void {\n e.queue.push(ev);\n e.queue.flush(waitUntil);\n}\n\n/** 204 and a `sig: 1` row on the event batch — one request per flush at the analyst, not one per page view. */\nasync function relayBeacon(eng: Engine, req: Request, ip: string | null, waitUntil: WaitUntil): Promise<Response> {\n const noContent = () => new Response(null, { status: 204, headers: { 'cache-control': 'no-store' } });\n if (Number(req.headers.get('content-length')) > FP_MAX) return new Response(null, { status: 413 });\n const body = await req.text();\n if (encoder.encode(body).byteLength > FP_MAX) return new Response(null, { status: 413 });\n let parsed: unknown;\n try { parsed = JSON.parse(body); } catch { return noContent(); } // not a beacon: drop it, never ship junk\n if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return noContent();\n // Spread first: the ip and the tap are the server's to say, whatever the body claimed.\n ship(eng, { ...(parsed as Record<string, unknown>), sig: 1, ip, tap: TAP_HONO }, waitUntil);\n return noContent();\n}\n\nexport function camada(opts: CamadaHonoOptions = {}): MiddlewareHandler {\n const challengePath = opts.challengePath ?? DEFAULT_CHALLENGE_PATH;\n const scriptPath = opts.scriptPath ?? SCRIPT_PATH;\n const fpPath = opts.fpPath ?? FP_PATH;\n\n return async function camadaHono(c: Context, next: Next): Promise<Response | void> {\n // Reading c.env and building the engine are inside the guard too: a Workers env carries\n // non-string bindings, and a throw here would 5xx the app on its very first request.\n const env = guarded(() => ({ ...(c.env as Record<string, string | undefined> | undefined), ...opts.env }), {} as Record<string, string | undefined>);\n const eng = guarded(() => (env.CAMADA_DISABLED === '1' ? null : ensure(opts, env)), null);\n if (!eng) return next();\n // Same opt-out as @camada/next: the code option, or CAMADA_CHALLENGE=0 in the Worker env.\n const challengeOn = opts.challenge !== false && env.CAMADA_CHALLENGE !== '0';\n\n const waitUntil: WaitUntil = (p) => {\n try { c.executionCtx?.waitUntil(p); } catch { /* no execution context outside a fetch handler */ }\n };\n\n // The id of the `warn` rule that let this request through, read again after next() when the\n // response event is built (§D3). Every other action settles before the app ever runs.\n let warnRule: string | null = null;\n // Set on the capture path only: the ids this request shares with the app (scriptTag, track)\n // and with its own post-response event. Null when camada answered the request itself.\n let vars: CamadaVars | null = null;\n let newSession = false;\n\n const answer = await guardedAsync(async (): Promise<Response | null> => {\n eng.snap.ensureFresh(waitUntil);\n\n const req = c.req.raw;\n const { url, path, cf, ip, sid } = describeRequest(eng, req);\n\n const v = eng.snap.verdict({\n ip, path, ua: req.headers.get('user-agent'), // §D3: without ua every `ua` rule is false\n // `header` conditions read the request through this getter; `Headers.get` is\n // case-insensitive, so the lower-cased name the matcher asks with finds whatever\n // spelling the client sent. Without it every `header` rule is false.\n header: (n) => req.headers.get(n),\n asn: cf.asn ?? null, country: cf.country ?? null, tlsx: cf.tlsClientExtensionsSha1 ?? null,\n });\n warnRule = v.warn ? v.rule ?? null : null;\n\n if (v.block) {\n const ev = buildEvent(req, path, url.search, ip, sid);\n ev.st = 403;\n ev.blk = v.reason; // SDK-01: the analyst counts SDK blocks apart from the app's own 403s ('rule' when a rule decided)\n if (v.rule) ev.rl = v.rule;\n ship(eng, ev, waitUntil);\n const headers: Record<string, string> = {\n 'content-type': 'text/plain', 'x-block-reason': String(v.reason ?? ''), 'x-block-version': v.version ?? '',\n };\n if (v.rule) headers['x-block-rule'] = v.rule; // a custom rule blocked: name it, so the customer knows which row to edit\n return new Response('Forbidden', { status: 403, headers });\n }\n\n // A challenge needs a resolved client ip: the nonce and the `_cch` cookie are bound to it.\n // Without one, fail open rather than mint a cookie every unidentified client could use.\n if (challengeOn && ip) {\n // The verify endpoint answers first — a challenged client must be able to reach it.\n if (req.method === 'POST' && path === challengePath) return verify(eng, req, ip, sid, challengePath, waitUntil);\n const token = cookieValue(req.headers.get('cookie') || '', CHALLENGE_COOKIE);\n if (v.challenge && !(await eng.kit.tokenValid(ip, Date.now(), token))) {\n return serve(eng, req, ip, sid, challengePath, path + url.search, waitUntil);\n }\n }\n\n // The beacon endpoints come after enforcement (a blocked or challenged client gets neither\n // the script nor a relay) and before the app (they are camada's, not its) — @camada/node's order.\n if (beaconEnabled(eng)) {\n if (req.method === 'GET' && path === scriptPath) {\n return new Response(iife, { status: 200, headers: { 'content-type': 'application/javascript', 'cache-control': 'public, max-age=3600' } });\n }\n if (req.method === 'POST' && path === fpPath) return relayBeacon(eng, req, ip, waitUntil);\n }\n\n // Capture: mint the ids the app, the beacon and the post-response event all share. The\n // session is decided here rather than after next() so track() inside the handler and the\n // event both carry the sid a first visit is about to be given.\n newSession = !sid;\n vars = { eng, rid: crypto.randomUUID(), sid: sid ?? crypto.randomUUID(), ip, waitUntil, scriptPath };\n c.set(VAR, vars);\n return null;\n }, null);\n\n if (answer) return answer;\n\n await next();\n\n // Mint the shared session cookie the other taps use, so `sid`/`ns` are real here too\n // (ea's capability mask for sdk-hono claims SESSION). Never overwrite an existing one.\n guarded(() => {\n if (!vars || !newSession) return;\n const secure = new URL(c.req.raw.url).protocol === 'https:' ? '; Secure' : '';\n c.res.headers.append('set-cookie', `${SESSION_COOKIE}=${vars.sid}; Path=/; Max-Age=${SESSION_MAX_AGE}; HttpOnly; SameSite=Lax${secure}`);\n }, undefined);\n\n // The response has settled, so this tap ships the real status — unlike @camada/next's\n // middleware position, which can only report pre-response.\n void guardedAsync(async () => {\n const req = c.req.raw;\n const { url, path, ip } = describeRequest(eng, req);\n const cfg = eng.snap.config;\n if ((cfg?.exclude || []).some((x) => path.startsWith(x))) return;\n if (Math.random() >= (cfg?.sample ?? 1)) return;\n // `vars` is null only when the guard above threw before setting it — then the event still ships, with fresh ids.\n const ev = buildEvent(req, path, url.search, ip, vars?.sid ?? null, vars?.rid, newSession);\n ev.st = c.res.status;\n if (warnRule) ev.wrn = warnRule; // §D3: the warn rule that let this request through\n ship(eng, ev, waitUntil);\n }, undefined);\n };\n}\n\n/** 403 + the proof-of-work page (HTML navigations) or 403 JSON, plus the `blk: \"challenge\"` event. */\nasync function serve(eng: Engine, req: Request, ip: string, sid: string | null, action: string, target: string, waitUntil: WaitUntil): Promise<Response> {\n const url = new URL(req.url);\n const ev = buildEvent(req, url.pathname, url.search, ip, sid);\n ev.st = 403;\n ev.blk = 'challenge';\n ship(eng, ev, waitUntil);\n\n if (!wantsHtml(req.headers.get('accept'), req.headers.get('sec-fetch-dest'))) {\n return new Response('{\"error\":\"challenge_required\"}', {\n status: 403,\n headers: { 'content-type': 'application/json', 'cache-control': 'no-store', 'x-camada-challenge': '1' },\n });\n }\n const html = challengePage({ nonce: await eng.kit.nonce(ip, Date.now()), action, to: safeReturnTo(target) });\n return new Response(html, {\n status: 403,\n headers: { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store', 'x-camada-challenge': '1' },\n });\n}\n\n/** POST from the page: validate, set `_cch`, 302 back, ship `{ st: 200, ch: 1 }`. */\nasync function verify(eng: Engine, req: Request, ip: string, sid: string | null, action: string, waitUntil: WaitUntil): Promise<Response> {\n const url = new URL(req.url);\n // camada answers this path before the app runs, so it must not become a place to post\n // hundreds of megabytes at an unauthenticated endpoint.\n if (Number(req.headers.get('content-length')) > BODY_MAX) return new Response(null, { status: 413 });\n const body = await req.text();\n if (body.length > BODY_MAX) return new Response(null, { status: 413 });\n const form = parseFormBody(body);\n const to = safeReturnTo(form.to);\n const now = Date.now();\n if (!(await eng.kit.verify(ip, now, form.nonce, form.solution))) {\n const html = challengePage({ nonce: await eng.kit.nonce(ip, now), action, to });\n return new Response(html, { status: 403, headers: { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' } });\n }\n const ev = buildEvent(req, url.pathname, '', ip, sid);\n ev.st = 200;\n ev.ch = 1; // challenge passed (contract §A3 ingest field)\n ship(eng, ev, waitUntil);\n return new Response(null, {\n status: 302,\n headers: {\n location: to,\n 'set-cookie': challengeCookie(await eng.kit.issue(ip, now), url.protocol === 'https:'),\n 'cache-control': 'no-store',\n },\n });\n}\n","// Workers hand env vars per request on `c.env`, not on a process — so the engine is built from\n// whatever the first request carries, merged with the options the app passed in code.\nimport { parseKey, parseTrustedProxyEnv, type SnapshotVersion, type TrustedProxyConfig } from '@camada/core';\n\nexport interface ResolvedEnv {\n ingestToken: string;\n snapToken: string;\n secret: string; // HMAC key for the challenge nonce/cookie — never leaves the isolate\n ingestUrl: string;\n snapshotUrl: string;\n trustedProxy: TrustedProxyConfig | null; // null = defer to server-delivered config\n}\n\nexport interface CamadaHonoOptions {\n key?: string;\n ingestUrl?: string;\n snapshotUrl?: string;\n trustedProxy?: TrustedProxyConfig | string | null;\n challenge?: boolean; // enforce `challenge` verdicts with the first-party page (default true)\n challengePath?: string; // where that page posts its solution (default /__camada/challenge)\n snapshotVersion?: SnapshotVersion; // 5 (default) also carries the tenant's ordered custom rules; 4 the allow/challenge sides only; 3 opts out of both\n scriptPath?: string; // where the first-party beacon script is served (default /_cam/b.js)\n fpPath?: string; // where that script posts the beacon (default /_cam/fp; must share scriptPath's directory — the script derives it)\n env?: Record<string, string | undefined>; // overrides c.env (tests, and apps that read config themselves)\n fetchImpl?: typeof fetch;\n}\n\nconst asProxy = (v: CamadaHonoOptions['trustedProxy']): TrustedProxyConfig | null =>\n typeof v === 'string' ? parseTrustedProxyEnv(v) : v ?? null;\n\n/** Returns null (the middleware stays inert, one log line) rather than throwing on bad config. */\nexport function resolveEnv(opts: CamadaHonoOptions, env: Record<string, string | undefined>): ResolvedEnv | null {\n const raw = opts.key || env.CAMADA_KEY;\n const key = parseKey(raw);\n const ingestToken = key?.ingestToken ?? env.CAMADA_TOKEN;\n const snapToken = key?.snapToken ?? env.CAMADA_SNAPSHOT_TOKEN;\n if (!ingestToken || !snapToken) return null;\n const ingestUrl = (opts.ingestUrl || env.CAMADA_INGEST_URL || 'https://in.camada.dev').replace(/\\/$/, ''); // PLACEHOLDER default — confirm the production ingest domain before any npm publish\n return {\n ingestToken, snapToken,\n secret: raw || `${ingestToken}.${snapToken}`,\n ingestUrl,\n snapshotUrl: opts.snapshotUrl || env.CAMADA_SNAPSHOT_URL || `${ingestUrl}/snapshot`,\n trustedProxy: asProxy(opts.trustedProxy) ?? parseTrustedProxyEnv(env.CAMADA_TRUSTED_PROXY),\n };\n}\n","// Per-request state the middleware leaves on the Hono context for the two helpers an app calls\n// from its own handlers: `scriptTag(c)` (the beacon tag) and `track(c, event, data)` (an\n// app-context outcome). The key is private — the helpers are the API, not `c.get`. When the\n// middleware did not run for this request (a path it is not mounted on, no key, CAMADA_DISABLED,\n// or a request it answered itself) there is nothing on the context and both helpers stand down.\nimport type { Context } from 'hono';\nimport { guarded, guardedAsync, hashUserId, TAP_HONO } from '@camada/core';\nimport { ship, type Engine, type WaitUntil } from './camada.js';\n\nexport const VAR = '__camada';\n\nexport interface CamadaVars {\n eng: Engine;\n rid: string; // the request id the page event carries; the beacon and track() join on it\n sid: string; // the `_sfp` session — the one just minted when the request arrived without it\n ip: string | null;\n waitUntil: WaitUntil;\n scriptPath: string;\n}\n\nexport const beaconEnabled = (e: Engine): boolean => e.snap.config?.beacon !== false; // tenant switch; a cold engine serves\n\nconst readVars = (c: Context): CamadaVars | undefined => c.get(VAR) as CamadaVars | undefined;\n\n/**\n * Records an outcome the app knows and the wire cannot show: `login_failed`, `login_succeeded`,\n * `signup`, `password_reset`, `mfa_failed`, `payment_failed`, `payment_succeeded`, `coupon_failed`\n * (free-form; that vocabulary is what the analyst's rules read). Joined to this request's event\n * through its rid and session. The user identifier is HMAC-hashed in-process with the ingest\n * token — the raw value never reaches the queue. Never throws, never rejects; awaiting it is\n * optional (the flush rides `waitUntil`), so a handler may fire and forget.\n */\nexport function track(c: Context, event: string, data?: { user?: string }): Promise<void> {\n const vars = guarded(() => readVars(c), undefined);\n if (!vars) return Promise.resolve();\n const p = guardedAsync(async () => {\n const uid = data?.user ? await hashUserId(data.user, vars.eng.env.ingestToken) : null;\n ship(vars.eng, { tap: TAP_HONO, et: event, uid, rid: vars.rid, sid: vars.sid, ip: vars.ip, ts: Date.now() }, vars.waitUntil);\n }, undefined);\n vars.waitUntil(p); // the isolate may freeze right after the response: hold it open for the flush\n return p;\n}\n\n/** The `<script>` tag for an HTML response — `''` when camada is off for this request or the tenant turned the beacon off. */\nexport function scriptTag(c: Context): string {\n return guarded(() => {\n const vars = readVars(c);\n if (!vars || !beaconEnabled(vars.eng)) return '';\n return `<script src=\"${vars.scriptPath}?r=${vars.rid}\" async></script>`;\n }, '');\n}\n","{\n \"name\": \"@camada/hono\",\n \"version\": \"0.3.0\",\n \"license\": \"MIT\",\n \"type\": \"module\",\n \"engines\": {\n \"node\": \">=18\"\n },\n \"files\": [\n \"dist\",\n \"README.md\",\n \"LICENSE\"\n ],\n \"scripts\": {\n \"build\": \"tsup\",\n \"dev\": \"tsup --watch\",\n \"test\": \"vitest run\",\n \"check\": \"tsc --noEmit\"\n },\n \"description\": \"camada SDK for Hono on Cloudflare Workers: snapshot enforcement, first-party challenge and beacon, track() outcomes, waitUntil-flushed event shipping\",\n \"exports\": {\n \".\": {\n \"types\": \"./dist/index.d.ts\",\n \"import\": \"./dist/index.js\",\n \"require\": \"./dist/index.cjs\"\n }\n },\n \"main\": \"./dist/index.cjs\",\n \"module\": \"./dist/index.js\",\n \"types\": \"./dist/index.d.ts\",\n \"dependencies\": {\n \"@camada/core\": \"file:../camada-core\",\n \"@camada/browser\": \"file:../camada-browser\"\n },\n \"peerDependencies\": {\n \"hono\": \">=4\"\n },\n \"devDependencies\": {\n \"@cloudflare/workers-types\": \"^4.20250109.0\",\n \"@types/node\": \"^22.10.0\",\n \"hono\": \"^4.6.0\",\n \"tsup\": \"^8.3.5\",\n \"typescript\": \"^5.7.2\",\n \"vitest\": \"^3.0.0\"\n }\n}\n","// The SDK's wire identity (SDK-03): x-camada-sdk: <package>/<version>. Named JSON imports are\n// inlined and tree-shaken by tsup at build, so dist carries two literals — no runtime fs read.\nimport { name, version } from '../package.json';\nexport const SDK_ID = `${name}/${version}`;\n"],"mappings":"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","names":["guardedAsync","guarded","TAP_HONO","name","TAP_HONO","guarded","guardedAsync"]}
|
package/package.json
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@camada/hono",
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"engines": {
|
|
7
|
+
"node": ">=18"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist",
|
|
11
|
+
"README.md",
|
|
12
|
+
"LICENSE"
|
|
13
|
+
],
|
|
14
|
+
"description": "camada SDK for Hono on Cloudflare Workers: snapshot enforcement, first-party challenge and beacon, track() outcomes, waitUntil-flushed event shipping",
|
|
15
|
+
"exports": {
|
|
16
|
+
".": {
|
|
17
|
+
"types": "./dist/index.d.ts",
|
|
18
|
+
"import": "./dist/index.js",
|
|
19
|
+
"require": "./dist/index.cjs"
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
"main": "./dist/index.cjs",
|
|
23
|
+
"module": "./dist/index.js",
|
|
24
|
+
"types": "./dist/index.d.ts",
|
|
25
|
+
"dependencies": {
|
|
26
|
+
"@camada/core": "^0.3.0",
|
|
27
|
+
"@camada/browser": "^0.2.0"
|
|
28
|
+
},
|
|
29
|
+
"peerDependencies": {
|
|
30
|
+
"hono": ">=4"
|
|
31
|
+
},
|
|
32
|
+
"repository": {
|
|
33
|
+
"type": "git",
|
|
34
|
+
"url": "git+https://github.com/camada-app/camada-hono.git"
|
|
35
|
+
},
|
|
36
|
+
"homepage": "https://github.com/camada-app/camada-hono#readme",
|
|
37
|
+
"bugs": {
|
|
38
|
+
"url": "https://github.com/camada-app/camada-hono/issues"
|
|
39
|
+
}
|
|
40
|
+
}
|