@camada/remix 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 camada
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,228 @@
1
+ # @camada/remix
2
+
3
+ camada for [React Router](https://reactrouter.com) framework mode (the Remix lineage): enforces
4
+ the tenant snapshot in a root server middleware (your ordered custom rules, then block, allow,
5
+ challenge), serves a first-party proof-of-work challenge page and beacon, and records the
6
+ outcomes your actions know (`track()`). Fails open by design — a camada outage or bug never
7
+ 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
+ // app/root.tsx
15
+ import { camada } from '@camada/remix';
16
+
17
+ export const middleware = [camada()]; // reads CAMADA_KEY / CAMADA_INGEST_URL / CAMADA_SNAPSHOT_URL from process.env
18
+ ```
19
+
20
+ ```ts
21
+ // app/routes.ts
22
+ import { type RouteConfig, index, route } from '@react-router/dev/routes';
23
+
24
+ export default [
25
+ index('routes/home.tsx'),
26
+ route('/_cam/*', 'routes/cam.tsx'), // the beacon endpoints only run where a route exists
27
+ ] satisfies RouteConfig;
28
+ ```
29
+
30
+ ```ts
31
+ // app/routes/cam.tsx
32
+ import { camadaRoute } from '@camada/remix';
33
+
34
+ export const { loader, action } = camadaRoute();
35
+ ```
36
+
37
+ Env (printed by camada onboarding / `npm run seed` in dev):
38
+
39
+ ```
40
+ CAMADA_KEY=<ingest_token>.<snap_token>
41
+ CAMADA_INGEST_URL=http://localhost:8787 # dev only; defaults to production ingest
42
+ ```
43
+
44
+ An app that reads its own config can pass the values instead:
45
+
46
+ ```ts
47
+ export const middleware = [camada({ key: MY_KEY, ingestUrl: MY_INGEST })];
48
+ ```
49
+
50
+ Server middleware is stable in React Router 8. On 7.9+ it is behind a future flag:
51
+
52
+ ```ts
53
+ // react-router.config.ts
54
+ export default { future: { v8_middleware: true } };
55
+ ```
56
+
57
+ Without `CAMADA_KEY` the middleware is inert (one log line, no requests, no enforcement), so an
58
+ unprovisioned environment behaves exactly as if camada were not installed.
59
+
60
+ `camada()` and `camadaRoute()` share one engine per configuration — the same options object,
61
+ or two that spell the same options — so the middleware and the route poll one snapshot and
62
+ fill one event queue. Two mounts with different keys or URLs each get their own.
63
+
64
+ ## What it does per request
65
+
66
+ 1. Refreshes the snapshot off-path (lazy mode by default: a refresh is kicked when stale and
67
+ the request does not wait for it; `mode: 'timer'` polls on an unref'd interval and drains
68
+ the queue on exit for a long-lived Node server). Every poll and event batch carries
69
+ `x-camada-sdk: @camada/remix/<version>`, and polls ask for snapshot v5
70
+ (`x-camada-snapshot: 5`) — the container that carries your ordered custom rules.
71
+ 2. Resolves the client from the peer your server vouched for (see below), falling back to
72
+ `X-Forwarded-For` only under your tenant's trusted-proxy config. A bare header is never
73
+ trusted: without a vouched peer and a trusted-proxy config the ip is `null`, ip rules do not
74
+ fire, and the request is still captured.
75
+ 3. Runs your ordered custom rules, then the allow, block and challenge lists.
76
+ 4. **Block** → `403` with `x-block-reason` (and `x-block-rule` when a custom rule decided)
77
+ before `next()`; the event still ships, with `st: 403` and `blk: <reason>` so the analyst
78
+ counts SDK blocks apart from your own 403s.
79
+ 5. **Skip** → a skip rule or the allow list wins over a wider block.
80
+ 6. **Challenge** → a `403` proof-of-work page for HTML navigations, `403
81
+ {"error":"challenge_required"}` for anything else; `POST /__camada/challenge` verifies the
82
+ work, sets `_cch` and 302s back.
83
+ 7. Otherwise `next()` runs your loaders and actions, and the settled response ships one
84
+ batched, redacted event with its real status (Authorization and Cookie values never leave
85
+ the process; credential-looking query values are scrubbed — see `@camada/core`). A first
86
+ visit gets the `_sfp` session cookie, appended even to a `redirect()` whose headers are
87
+ immutable.
88
+
89
+ ## The peer
90
+
91
+ React Router hands the middleware a `Request` and nothing about the socket, so out of the box
92
+ this tap vouches for no address. A custom server knows the socket and can say so through
93
+ `getLoadContext`:
94
+
95
+ ```ts
96
+ // server.ts (Express)
97
+ import { createRequestHandler } from '@react-router/express';
98
+ import { RouterContextProvider } from 'react-router';
99
+ import { camadaPeerContext } from '@camada/remix';
100
+
101
+ app.all('*', createRequestHandler({
102
+ build,
103
+ getLoadContext(req) {
104
+ const context = new RouterContextProvider();
105
+ context.set(camadaPeerContext, req.socket.remoteAddress ?? null); // the address Node vouches for, never a header
106
+ return context;
107
+ },
108
+ }));
109
+ ```
110
+
111
+ Behind a load balancer, set `CAMADA_TRUSTED_PROXY=hops:1` (or `cidrs:…`) — or the
112
+ `trustedProxy` option — and `X-Forwarded-For` is read one hop behind that peer. Never put a
113
+ header value in `camadaPeerContext`: that is the one address camada takes on faith.
114
+
115
+ ## Options
116
+
117
+ | option | default | meaning |
118
+ |---|---|---|
119
+ | `key` | `env.CAMADA_KEY` | `<ingest_token>.<snap_token>`; without it the middleware is inert |
120
+ | `ingestUrl` | `env.CAMADA_INGEST_URL` | ingest base; batches go to `<ingestUrl>/e` |
121
+ | `snapshotUrl` | `<ingestUrl>/snapshot` | snapshot endpoint |
122
+ | `trustedProxy` | server config | `none` / `vercel` / `hops:N` / `cidrs:a,b`, or the parsed object |
123
+ | `challenge` | `true` | serve the proof-of-work page for `challenge` verdicts |
124
+ | `challengePath` | `/__camada/challenge` | where that page posts its solution |
125
+ | `snapshotVersion` | `5` | `4` drops the custom rules, `3` the allow/challenge sides too |
126
+ | `scriptPath` | `/_cam/b.js` | where the first-party beacon script is served |
127
+ | `fpPath` | `/_cam/fp` | where that script posts the beacon; keep it in `scriptPath`'s directory |
128
+ | `mode` | `lazy` (or `timer`) | `timer` polls the snapshot on an unref'd interval (long-lived process); `lazy` refreshes it per request off-path. `CAMADA_SERVERLESS=1` forces `lazy` |
129
+ | `env` | `process.env` | overrides the process env (tests, and apps that read config themselves) |
130
+
131
+ `CAMADA_CHALLENGE=0` in the env switches the challenge off without a code change.
132
+
133
+ `CAMADA_DISABLED=1` in the env switches everything off, checked per request.
134
+
135
+ ## The first-party beacon
136
+
137
+ Bots that never run JavaScript are the cheapest to catch. Put the tag in the pages you render
138
+ and the middleware does the rest. The helper returns markup, so render it through a wrapper
139
+ at the end of `<body>` — it is part of the server-rendered document, and the browser runs it
140
+ on parse (an element cannot be dangerously-set inside `<head>` next to `<Meta />`):
141
+
142
+ ```tsx
143
+ // app/root.tsx
144
+ import { Meta, Links, Scripts, useRouteLoaderData } from 'react-router';
145
+ import { camada, scriptTag } from '@camada/remix';
146
+ import type { Route } from './+types/root';
147
+
148
+ export const middleware = [camada()];
149
+ export const loader = ({ context }: Route.LoaderArgs) => ({ beacon: scriptTag(context) });
150
+
151
+ export function Layout({ children }: { children: React.ReactNode }) {
152
+ const { beacon } = useRouteLoaderData<typeof loader>('root') ?? { beacon: '' };
153
+ return (
154
+ <html>
155
+ <head><Meta /><Links /></head>
156
+ <body>
157
+ {children}
158
+ <Scripts />
159
+ <div hidden dangerouslySetInnerHTML={{ __html: beacon }} />
160
+ </body>
161
+ </html>
162
+ );
163
+ }
164
+ ```
165
+
166
+ `scriptTag(context)` returns `<script src="/_cam/b.js?r=<rid>" async></script>` — the `rid` is
167
+ this request's event id, so the analyst joins the beacon to the page view. The middleware
168
+ serves the script at `GET /_cam/b.js` (cacheable, 1 h) and relays `POST /_cam/fp` (≤ 32 KB,
169
+ answers 204) onto the event batch as a `sig: 1` row stamped with the client ip camada resolved —
170
+ never the one the body claims. Both endpoints sit behind the verdict: a blocked client gets 403
171
+ there too. The tag is `''` when camada is off for the request or the project turned the beacon
172
+ off in its settings, and the endpoints stand down with it.
173
+
174
+ React Router runs middleware only for matched routes, so `/_cam/*` needs a route to exist:
175
+ that is what `route('/_cam/*', 'routes/cam.tsx')` and `camadaRoute()` are for. With the root
176
+ middleware mounted it answers first and the route's loader and action are the fallback (a
177
+ middleware mounted lower, or none at all); they re-run the verdict, so a blocked client is
178
+ 403'd there as well. A custom `scriptPath` / `fpPath` must share a directory (the script derives
179
+ the post path from its own `src`) and the route must cover it.
180
+
181
+ ## App-context events
182
+
183
+ The wire shows a `POST /login`; only your action knows whether it failed. Tell camada:
184
+
185
+ ```ts
186
+ // app/routes/login.tsx
187
+ import { track } from '@camada/remix';
188
+ import type { Route } from './+types/login';
189
+
190
+ export async function action({ request, context }: Route.ActionArgs) {
191
+ const form = await request.formData();
192
+ const user = String(form.get('email'));
193
+ const ok = await signIn(user, String(form.get('password')));
194
+ if (!ok) track(context, 'login_failed', { user }); // await optional
195
+ return ok ? redirect('/') : data({ error: 'Invalid credentials' }, { status: 401 });
196
+ }
197
+ ```
198
+
199
+ `track(context, event, { user? })` ships `{ et, uid, rid, sid, ip, ts }` joined to this
200
+ request's event. The user identifier is HMAC-hashed in-process with the ingest token — the raw
201
+ value never leaves the process. It never throws and is a no-op where the middleware did not
202
+ run. The event name is free-form; the analyst's rules read this vocabulary:
203
+
204
+ | event | when |
205
+ |---|---|
206
+ | `login_failed` / `login_succeeded` | a credential check settled |
207
+ | `signup` | an account was created |
208
+ | `password_reset` | a reset was requested |
209
+ | `mfa_failed` | a second factor was rejected |
210
+ | `payment_failed` / `payment_succeeded` | a charge settled |
211
+ | `coupon_failed` | a promo code was rejected |
212
+
213
+ ## What this tap can see
214
+
215
+ The in-app position: the beacon, the client hints and headers the request carries, the real
216
+ response status, the `_sfp` session, and the outcomes your actions report through `track()`.
217
+ What it cannot see is the connection. React Router vouches for nothing below the `Request` —
218
+ no socket address, no ASN, country, TLS fingerprint or client protocol — so those come only
219
+ from what your server puts in `camadaPeerContext` (the address) and from a trusted-proxy
220
+ config (`X-Forwarded-For`). ASN, country and `tlsx` rules do not fire at this tap; the analyst
221
+ knows that from the tap's capability mask (`sdk-remix`) and never scores their absence as
222
+ evidence. Header order is normalised by the `Headers` API before camada sees it, so the
223
+ raw-wire-order signal is not available here either.
224
+
225
+ ## Fail open
226
+
227
+ Every entry point runs inside camada's guard. A dead ingest, a corrupt snapshot, a bug in this
228
+ package, a context that is not a `RouterContextProvider`: telemetry is lost, the request is not.
package/dist/index.cjs ADDED
@@ -0,0 +1,131 @@
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
+ camadaPeerContext: () => camadaPeerContext,
35
+ camadaRoute: () => camadaRoute,
36
+ resetCamada: () => resetCamada,
37
+ scriptTag: () => scriptTag,
38
+ track: () => track
39
+ });
40
+ module.exports = __toCommonJS(index_exports);
41
+
42
+ // src/camada.ts
43
+ var import_iife_string = __toESM(require("@camada/browser/iife-string"), 1);
44
+ var import_core2 = require("@camada/core");
45
+ var import_fetch2 = require("@camada/core/fetch");
46
+
47
+ // src/context.ts
48
+ var import_react_router = require("react-router");
49
+ var import_core = require("@camada/core");
50
+ var import_fetch = require("@camada/core/fetch");
51
+ var camadaContext = (0, import_react_router.createContext)(null);
52
+ var camadaPeerContext = (0, import_react_router.createContext)(null);
53
+ var readSlot = (context) => (0, import_core.guarded)(() => context.get(camadaContext) ?? void 0, void 0);
54
+ var track = (context, event, data) => (0, import_fetch.track)(readSlot(context), event, data);
55
+ var scriptTag = (context) => (0, import_fetch.scriptTag)(readSlot(context));
56
+
57
+ // package.json
58
+ var name = "@camada/remix";
59
+ var version = "0.1.0";
60
+
61
+ // src/version.ts
62
+ var SDK_ID = `${name}/${version}`;
63
+
64
+ // src/camada.ts
65
+ var instances = /* @__PURE__ */ new Set();
66
+ var byKey = /* @__PURE__ */ new Map();
67
+ var sortedJson = (v) => JSON.stringify(v, (_, x) => x && typeof x === "object" && !Array.isArray(x) ? Object.fromEntries(Object.entries(x).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)) : x);
68
+ function instanceFor(opts = {}) {
69
+ const { fetchImpl, ...serialisable } = opts;
70
+ const key = sortedJson(serialisable);
71
+ let bucket = byKey.get(fetchImpl);
72
+ if (!bucket) byKey.set(fetchImpl, bucket = /* @__PURE__ */ new Map());
73
+ let cam = bucket.get(key);
74
+ if (!cam) {
75
+ cam = (0, import_fetch2.createFetchCamada)({ tap: import_core2.TAP_REMIX, sdk: SDK_ID, iife: import_iife_string.default }, opts);
76
+ bucket.set(key, cam);
77
+ instances.add(cam);
78
+ }
79
+ return cam;
80
+ }
81
+ function resetCamada() {
82
+ for (const cam of instances) cam.reset();
83
+ }
84
+ function requestContext(context) {
85
+ return {
86
+ peer: (0, import_core2.guarded)(() => context.get(camadaPeerContext), null),
87
+ env: globalThis.process?.env
88
+ };
89
+ }
90
+ function camada(opts = {}) {
91
+ const cam = instanceFor(opts);
92
+ return async ({ request, context }, next) => {
93
+ const r = await cam.before(request, requestContext(context));
94
+ if (!r) return next();
95
+ if (r.response) return r.response;
96
+ (0, import_core2.guarded)(() => context.set(camadaContext, r.vars), void 0);
97
+ let res;
98
+ try {
99
+ res = await next();
100
+ } catch (err) {
101
+ if (err instanceof Response) {
102
+ cam.after(request, r.vars, err.status);
103
+ throw r.vars.sessionCookie ? (0, import_fetch2.withSetCookie)(err, r.vars.sessionCookie) : err;
104
+ }
105
+ cam.after(request, r.vars, 500);
106
+ throw err;
107
+ }
108
+ cam.after(request, r.vars, res.status);
109
+ return r.vars.sessionCookie ? (0, import_fetch2.withSetCookie)(res, r.vars.sessionCookie) : res;
110
+ };
111
+ }
112
+
113
+ // src/route.ts
114
+ function camadaRoute(opts = {}) {
115
+ const cam = instanceFor(opts);
116
+ const answer = async ({ request, context }) => {
117
+ const r = await cam.before(request, requestContext(context));
118
+ return r?.response ?? new Response(null, { status: 404 });
119
+ };
120
+ return { loader: answer, action: answer };
121
+ }
122
+ // Annotate the CommonJS export names for ESM import in node:
123
+ 0 && (module.exports = {
124
+ camada,
125
+ camadaPeerContext,
126
+ camadaRoute,
127
+ resetCamada,
128
+ scriptTag,
129
+ track
130
+ });
131
+ //# sourceMappingURL=index.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/index.ts","../src/camada.ts","../src/context.ts","../package.json","../src/version.ts","../src/route.ts"],"sourcesContent":["// @camada/remix — the install for a React Router framework-mode (Remix) app:\n// export const middleware = [camada()]; // app/root.tsx; env: CAMADA_KEY (+ CAMADA_INGEST_URL in dev)\n// route('/_cam/*', 'routes/cam.tsx') // routes.ts; the file re-exports camadaRoute()\n// scriptTag(context) // in a loader: the first-party beacon tag\n// track(context, 'login_failed', { user }) // in an action: an outcome the wire cannot show\nexport { camada, resetCamada, type CamadaRemixOptions, type CamadaRemixVars } from './camada.js';\nexport { camadaRoute } from './route.js';\nexport { track, scriptTag, camadaPeerContext } from './context.js';\n","// @camada/remix — the root server middleware for React Router framework mode (SDK-G04):\n// export const middleware = [camada()]; // app/root.tsx\n// A thin binding over @camada/core/fetch: the peer is whatever a custom server put in\n// `camadaPeerContext` (React Router itself vouches for no socket), the env is process.env, and\n// there is no waitUntil — the host is a long-lived Node server or an edge runtime that keeps\n// its own isolate open. A camada bug, a dead ingest or a corrupt snapshot costs telemetry,\n// never the app's response.\nimport iife from '@camada/browser/iife-string';\nimport { guarded, TAP_REMIX } from '@camada/core';\nimport { createFetchCamada, withSetCookie, type FetchCamada, type FetchCamadaOptions, type FetchRequestContext, type FetchVars } from '@camada/core/fetch';\nimport type { MiddlewareFunction } from 'react-router';\nimport { camadaContext, camadaPeerContext, type RouterContext } from './context.js';\nimport { SDK_ID } from './version.js';\n\nexport type CamadaRemixOptions = FetchCamadaOptions;\nexport type CamadaRemixVars = FetchVars;\n\n// camada() and camadaRoute() must see one engine for the same options, or the route would poll\n// and enforce a second copy of the tenant's snapshot: an instance is found by its serialisable\n// options (keys sorted, so two modules spelling them in a different order agree); fetchImpl\n// cannot be stringified, so it keys a bucket by identity.\nconst instances = new Set<FetchCamada>();\nconst byKey = new Map<typeof fetch | undefined, Map<string, FetchCamada>>();\n\n// Sorts the keys of every object on the way in, so key order never splits one configuration in two.\nconst sortedJson = (v: unknown): string =>\n JSON.stringify(v, (_, x: unknown) => (x && typeof x === 'object' && !Array.isArray(x) ? Object.fromEntries(Object.entries(x).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))) : x));\n\nexport function instanceFor(opts: CamadaRemixOptions = {}): FetchCamada {\n const { fetchImpl, ...serialisable } = opts;\n const key = sortedJson(serialisable);\n let bucket = byKey.get(fetchImpl);\n if (!bucket) byKey.set(fetchImpl, (bucket = new Map()));\n let cam = bucket.get(key);\n if (!cam) {\n cam = createFetchCamada({ tap: TAP_REMIX, sdk: SDK_ID, iife }, opts);\n bucket.set(key, cam);\n instances.add(cam);\n }\n return cam;\n}\n\n/** Test/reset hook: stops and drops every engine this module created; the instances stay wired and rebuild lazily. */\nexport function resetCamada(): void {\n for (const cam of instances) cam.reset();\n}\n\n/** What the host can vouch for: the peer a custom server set, and the process env. The peer read\n * is guarded — a 7.9 app without the middleware flag hands a plain object; an edge runtime has no process. */\nexport function requestContext(context: RouterContext): FetchRequestContext {\n return {\n peer: guarded(() => context.get(camadaPeerContext), null),\n env: globalThis.process?.env,\n };\n}\n\nexport function camada(opts: CamadaRemixOptions = {}): MiddlewareFunction<Response> {\n const cam = instanceFor(opts);\n return async ({ request, context }, next) => {\n const r = await cam.before(request, requestContext(context));\n if (!r) return next();\n if (r.response) return r.response; // block, challenge, verify, beacon: answered without the app\n guarded(() => context.set(camadaContext, r.vars), undefined);\n let res: Response;\n try {\n res = await next();\n } catch (err) {\n // A thrown Response (a redirect from a downstream middleware) is the answer, so it carries\n // the status and the cookie; anything else lands in the router's error boundary as a 500.\n if (err instanceof Response) {\n cam.after(request, r.vars, err.status);\n throw r.vars.sessionCookie ? withSetCookie(err, r.vars.sessionCookie) : err;\n }\n cam.after(request, r.vars, 500);\n throw err;\n }\n cam.after(request, r.vars, res.status);\n return r.vars.sessionCookie ? withSetCookie(res, r.vars.sessionCookie) : res;\n };\n}\n","// Per-request state the middleware leaves on React Router's context for the two helpers an app\n// calls from its own loaders and actions: `scriptTag(context)` (the beacon tag) and\n// `track(context, event, data)` (an app-context outcome). When the middleware did not run for\n// this request (no key, CAMADA_DISABLED, or a request it answered itself) the slot is empty and\n// both helpers stand down.\nimport { createContext, type RouterContextProvider } from 'react-router';\nimport { guarded } from '@camada/core';\nimport { track as coreTrack, scriptTag as coreScriptTag, type FetchVars } from '@camada/core/fetch';\n\n// Defaults are null, not undefined: React Router's `get()` throws on a context that has neither\n// a value nor a default, and the helpers must stand down silently where the middleware did not run.\n/** The vars the middleware kept for this request; null where it did not run. Private to the package: track() and scriptTag() are the API. */\nexport const camadaContext = createContext<FetchVars | null>(null);\n\n/** The socket address a custom server vouches for (set from `getLoadContext`); null = nothing vouched. */\nexport const camadaPeerContext = createContext<string | null>(null);\n\n/** Loaders, actions and middleware all receive the same read-only provider. */\nexport type RouterContext = Readonly<RouterContextProvider>;\n\nconst readSlot = (context: RouterContext): FetchVars | undefined => guarded(() => context.get(camadaContext) ?? undefined, 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. Never throws,\n * never rejects, and is a no-op where the middleware did not run.\n */\nexport const track = (context: RouterContext, event: string, data?: { user?: string }): Promise<void> => coreTrack(readSlot(context), event, data);\n\n/** The `<script>` tag for an HTML response — `''` when camada is off for this request or the tenant turned the beacon off. */\nexport const scriptTag = (context: RouterContext): string => coreScriptTag(readSlot(context));\n","{\n \"name\": \"@camada/remix\",\n \"version\": \"0.1.0\",\n \"license\": \"MIT\",\n \"type\": \"module\",\n \"engines\": {\n \"node\": \">=20\"\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 React Router framework mode (Remix): snapshot enforcement in a root middleware, first-party challenge and beacon route, track() outcomes\",\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 \"react-router\": \">=7.9\"\n },\n \"devDependencies\": {\n \"@types/node\": \"^22.10.0\",\n \"react-router\": \"^8.3.1\",\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","// The resource route for the beacon endpoints (routes.ts: route('/_cam/*', 'routes/cam.tsx')).\n// React Router runs middleware only for a matched route, so without a route under /_cam/* the\n// root middleware never sees the script or the relay and the router 404s them — the same reason\n// @camada/next has camadaRoute(). before() re-runs the verdict, so a blocked client gets its\n// 403 here too, and with the root middleware mounted it answers first and this never runs.\nimport type { ActionFunctionArgs, LoaderFunctionArgs } from 'react-router';\nimport { instanceFor, requestContext, type CamadaRemixOptions } from './camada.js';\n\ntype Handler<A> = (args: A) => Promise<Response>;\n\nexport function camadaRoute(opts: CamadaRemixOptions = {}): { loader: Handler<LoaderFunctionArgs>; action: Handler<ActionFunctionArgs> } {\n const cam = instanceFor(opts);\n const answer: Handler<LoaderFunctionArgs | ActionFunctionArgs> = async ({ request, context }) => {\n const r = await cam.before(request, requestContext(context));\n return r?.response ?? new Response(null, { status: 404 }); // not a beacon path, or camada inert: nothing to serve\n };\n return { loader: answer, action: answer };\n}\n"],"mappings":"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","names":["import_core","import_fetch","coreTrack","coreScriptTag","iife"]}
@@ -0,0 +1,34 @@
1
+ import { FetchCamadaOptions, FetchVars } from '@camada/core/fetch';
2
+ import * as react_router from 'react-router';
3
+ import { RouterContextProvider, MiddlewareFunction, LoaderFunctionArgs, ActionFunctionArgs } from 'react-router';
4
+
5
+ /** The socket address a custom server vouches for (set from `getLoadContext`); null = nothing vouched. */
6
+ declare const camadaPeerContext: react_router.RouterContext<string | null>;
7
+ /** Loaders, actions and middleware all receive the same read-only provider. */
8
+ type RouterContext = Readonly<RouterContextProvider>;
9
+ /**
10
+ * Records an outcome the app knows and the wire cannot show: `login_failed`, `login_succeeded`,
11
+ * `signup`, `password_reset`, `mfa_failed`, `payment_failed`, `payment_succeeded`, `coupon_failed`
12
+ * (free-form; that vocabulary is what the analyst's rules read). Joined to this request's event
13
+ * through its rid and session; the user identifier is HMAC-hashed in-process. Never throws,
14
+ * never rejects, and is a no-op where the middleware did not run.
15
+ */
16
+ declare const track: (context: RouterContext, event: string, data?: {
17
+ user?: string;
18
+ }) => Promise<void>;
19
+ /** The `<script>` tag for an HTML response — `''` when camada is off for this request or the tenant turned the beacon off. */
20
+ declare const scriptTag: (context: RouterContext) => string;
21
+
22
+ type CamadaRemixOptions = FetchCamadaOptions;
23
+ type CamadaRemixVars = FetchVars;
24
+ /** Test/reset hook: stops and drops every engine this module created; the instances stay wired and rebuild lazily. */
25
+ declare function resetCamada(): void;
26
+ declare function camada(opts?: CamadaRemixOptions): MiddlewareFunction<Response>;
27
+
28
+ type Handler<A> = (args: A) => Promise<Response>;
29
+ declare function camadaRoute(opts?: CamadaRemixOptions): {
30
+ loader: Handler<LoaderFunctionArgs>;
31
+ action: Handler<ActionFunctionArgs>;
32
+ };
33
+
34
+ export { type CamadaRemixOptions, type CamadaRemixVars, camada, camadaPeerContext, camadaRoute, resetCamada, scriptTag, track };
@@ -0,0 +1,34 @@
1
+ import { FetchCamadaOptions, FetchVars } from '@camada/core/fetch';
2
+ import * as react_router from 'react-router';
3
+ import { RouterContextProvider, MiddlewareFunction, LoaderFunctionArgs, ActionFunctionArgs } from 'react-router';
4
+
5
+ /** The socket address a custom server vouches for (set from `getLoadContext`); null = nothing vouched. */
6
+ declare const camadaPeerContext: react_router.RouterContext<string | null>;
7
+ /** Loaders, actions and middleware all receive the same read-only provider. */
8
+ type RouterContext = Readonly<RouterContextProvider>;
9
+ /**
10
+ * Records an outcome the app knows and the wire cannot show: `login_failed`, `login_succeeded`,
11
+ * `signup`, `password_reset`, `mfa_failed`, `payment_failed`, `payment_succeeded`, `coupon_failed`
12
+ * (free-form; that vocabulary is what the analyst's rules read). Joined to this request's event
13
+ * through its rid and session; the user identifier is HMAC-hashed in-process. Never throws,
14
+ * never rejects, and is a no-op where the middleware did not run.
15
+ */
16
+ declare const track: (context: RouterContext, event: string, data?: {
17
+ user?: string;
18
+ }) => Promise<void>;
19
+ /** The `<script>` tag for an HTML response — `''` when camada is off for this request or the tenant turned the beacon off. */
20
+ declare const scriptTag: (context: RouterContext) => string;
21
+
22
+ type CamadaRemixOptions = FetchCamadaOptions;
23
+ type CamadaRemixVars = FetchVars;
24
+ /** Test/reset hook: stops and drops every engine this module created; the instances stay wired and rebuild lazily. */
25
+ declare function resetCamada(): void;
26
+ declare function camada(opts?: CamadaRemixOptions): MiddlewareFunction<Response>;
27
+
28
+ type Handler<A> = (args: A) => Promise<Response>;
29
+ declare function camadaRoute(opts?: CamadaRemixOptions): {
30
+ loader: Handler<LoaderFunctionArgs>;
31
+ action: Handler<ActionFunctionArgs>;
32
+ };
33
+
34
+ export { type CamadaRemixOptions, type CamadaRemixVars, camada, camadaPeerContext, camadaRoute, resetCamada, scriptTag, track };
package/dist/index.js ADDED
@@ -0,0 +1,89 @@
1
+ // src/camada.ts
2
+ import iife from "@camada/browser/iife-string";
3
+ import { guarded as guarded2, TAP_REMIX } from "@camada/core";
4
+ import { createFetchCamada, withSetCookie } from "@camada/core/fetch";
5
+
6
+ // src/context.ts
7
+ import { createContext } from "react-router";
8
+ import { guarded } from "@camada/core";
9
+ import { track as coreTrack, scriptTag as coreScriptTag } from "@camada/core/fetch";
10
+ var camadaContext = createContext(null);
11
+ var camadaPeerContext = createContext(null);
12
+ var readSlot = (context) => guarded(() => context.get(camadaContext) ?? void 0, void 0);
13
+ var track = (context, event, data) => coreTrack(readSlot(context), event, data);
14
+ var scriptTag = (context) => coreScriptTag(readSlot(context));
15
+
16
+ // package.json
17
+ var name = "@camada/remix";
18
+ var version = "0.1.0";
19
+
20
+ // src/version.ts
21
+ var SDK_ID = `${name}/${version}`;
22
+
23
+ // src/camada.ts
24
+ var instances = /* @__PURE__ */ new Set();
25
+ var byKey = /* @__PURE__ */ new Map();
26
+ var sortedJson = (v) => JSON.stringify(v, (_, x) => x && typeof x === "object" && !Array.isArray(x) ? Object.fromEntries(Object.entries(x).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)) : x);
27
+ function instanceFor(opts = {}) {
28
+ const { fetchImpl, ...serialisable } = opts;
29
+ const key = sortedJson(serialisable);
30
+ let bucket = byKey.get(fetchImpl);
31
+ if (!bucket) byKey.set(fetchImpl, bucket = /* @__PURE__ */ new Map());
32
+ let cam = bucket.get(key);
33
+ if (!cam) {
34
+ cam = createFetchCamada({ tap: TAP_REMIX, sdk: SDK_ID, iife }, opts);
35
+ bucket.set(key, cam);
36
+ instances.add(cam);
37
+ }
38
+ return cam;
39
+ }
40
+ function resetCamada() {
41
+ for (const cam of instances) cam.reset();
42
+ }
43
+ function requestContext(context) {
44
+ return {
45
+ peer: guarded2(() => context.get(camadaPeerContext), null),
46
+ env: globalThis.process?.env
47
+ };
48
+ }
49
+ function camada(opts = {}) {
50
+ const cam = instanceFor(opts);
51
+ return async ({ request, context }, next) => {
52
+ const r = await cam.before(request, requestContext(context));
53
+ if (!r) return next();
54
+ if (r.response) return r.response;
55
+ guarded2(() => context.set(camadaContext, r.vars), void 0);
56
+ let res;
57
+ try {
58
+ res = await next();
59
+ } catch (err) {
60
+ if (err instanceof Response) {
61
+ cam.after(request, r.vars, err.status);
62
+ throw r.vars.sessionCookie ? withSetCookie(err, r.vars.sessionCookie) : err;
63
+ }
64
+ cam.after(request, r.vars, 500);
65
+ throw err;
66
+ }
67
+ cam.after(request, r.vars, res.status);
68
+ return r.vars.sessionCookie ? withSetCookie(res, r.vars.sessionCookie) : res;
69
+ };
70
+ }
71
+
72
+ // src/route.ts
73
+ function camadaRoute(opts = {}) {
74
+ const cam = instanceFor(opts);
75
+ const answer = async ({ request, context }) => {
76
+ const r = await cam.before(request, requestContext(context));
77
+ return r?.response ?? new Response(null, { status: 404 });
78
+ };
79
+ return { loader: answer, action: answer };
80
+ }
81
+ export {
82
+ camada,
83
+ camadaPeerContext,
84
+ camadaRoute,
85
+ resetCamada,
86
+ scriptTag,
87
+ track
88
+ };
89
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/camada.ts","../src/context.ts","../package.json","../src/version.ts","../src/route.ts"],"sourcesContent":["// @camada/remix — the root server middleware for React Router framework mode (SDK-G04):\n// export const middleware = [camada()]; // app/root.tsx\n// A thin binding over @camada/core/fetch: the peer is whatever a custom server put in\n// `camadaPeerContext` (React Router itself vouches for no socket), the env is process.env, and\n// there is no waitUntil — the host is a long-lived Node server or an edge runtime that keeps\n// its own isolate open. A camada bug, a dead ingest or a corrupt snapshot costs telemetry,\n// never the app's response.\nimport iife from '@camada/browser/iife-string';\nimport { guarded, TAP_REMIX } from '@camada/core';\nimport { createFetchCamada, withSetCookie, type FetchCamada, type FetchCamadaOptions, type FetchRequestContext, type FetchVars } from '@camada/core/fetch';\nimport type { MiddlewareFunction } from 'react-router';\nimport { camadaContext, camadaPeerContext, type RouterContext } from './context.js';\nimport { SDK_ID } from './version.js';\n\nexport type CamadaRemixOptions = FetchCamadaOptions;\nexport type CamadaRemixVars = FetchVars;\n\n// camada() and camadaRoute() must see one engine for the same options, or the route would poll\n// and enforce a second copy of the tenant's snapshot: an instance is found by its serialisable\n// options (keys sorted, so two modules spelling them in a different order agree); fetchImpl\n// cannot be stringified, so it keys a bucket by identity.\nconst instances = new Set<FetchCamada>();\nconst byKey = new Map<typeof fetch | undefined, Map<string, FetchCamada>>();\n\n// Sorts the keys of every object on the way in, so key order never splits one configuration in two.\nconst sortedJson = (v: unknown): string =>\n JSON.stringify(v, (_, x: unknown) => (x && typeof x === 'object' && !Array.isArray(x) ? Object.fromEntries(Object.entries(x).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))) : x));\n\nexport function instanceFor(opts: CamadaRemixOptions = {}): FetchCamada {\n const { fetchImpl, ...serialisable } = opts;\n const key = sortedJson(serialisable);\n let bucket = byKey.get(fetchImpl);\n if (!bucket) byKey.set(fetchImpl, (bucket = new Map()));\n let cam = bucket.get(key);\n if (!cam) {\n cam = createFetchCamada({ tap: TAP_REMIX, sdk: SDK_ID, iife }, opts);\n bucket.set(key, cam);\n instances.add(cam);\n }\n return cam;\n}\n\n/** Test/reset hook: stops and drops every engine this module created; the instances stay wired and rebuild lazily. */\nexport function resetCamada(): void {\n for (const cam of instances) cam.reset();\n}\n\n/** What the host can vouch for: the peer a custom server set, and the process env. The peer read\n * is guarded — a 7.9 app without the middleware flag hands a plain object; an edge runtime has no process. */\nexport function requestContext(context: RouterContext): FetchRequestContext {\n return {\n peer: guarded(() => context.get(camadaPeerContext), null),\n env: globalThis.process?.env,\n };\n}\n\nexport function camada(opts: CamadaRemixOptions = {}): MiddlewareFunction<Response> {\n const cam = instanceFor(opts);\n return async ({ request, context }, next) => {\n const r = await cam.before(request, requestContext(context));\n if (!r) return next();\n if (r.response) return r.response; // block, challenge, verify, beacon: answered without the app\n guarded(() => context.set(camadaContext, r.vars), undefined);\n let res: Response;\n try {\n res = await next();\n } catch (err) {\n // A thrown Response (a redirect from a downstream middleware) is the answer, so it carries\n // the status and the cookie; anything else lands in the router's error boundary as a 500.\n if (err instanceof Response) {\n cam.after(request, r.vars, err.status);\n throw r.vars.sessionCookie ? withSetCookie(err, r.vars.sessionCookie) : err;\n }\n cam.after(request, r.vars, 500);\n throw err;\n }\n cam.after(request, r.vars, res.status);\n return r.vars.sessionCookie ? withSetCookie(res, r.vars.sessionCookie) : res;\n };\n}\n","// Per-request state the middleware leaves on React Router's context for the two helpers an app\n// calls from its own loaders and actions: `scriptTag(context)` (the beacon tag) and\n// `track(context, event, data)` (an app-context outcome). When the middleware did not run for\n// this request (no key, CAMADA_DISABLED, or a request it answered itself) the slot is empty and\n// both helpers stand down.\nimport { createContext, type RouterContextProvider } from 'react-router';\nimport { guarded } from '@camada/core';\nimport { track as coreTrack, scriptTag as coreScriptTag, type FetchVars } from '@camada/core/fetch';\n\n// Defaults are null, not undefined: React Router's `get()` throws on a context that has neither\n// a value nor a default, and the helpers must stand down silently where the middleware did not run.\n/** The vars the middleware kept for this request; null where it did not run. Private to the package: track() and scriptTag() are the API. */\nexport const camadaContext = createContext<FetchVars | null>(null);\n\n/** The socket address a custom server vouches for (set from `getLoadContext`); null = nothing vouched. */\nexport const camadaPeerContext = createContext<string | null>(null);\n\n/** Loaders, actions and middleware all receive the same read-only provider. */\nexport type RouterContext = Readonly<RouterContextProvider>;\n\nconst readSlot = (context: RouterContext): FetchVars | undefined => guarded(() => context.get(camadaContext) ?? undefined, 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. Never throws,\n * never rejects, and is a no-op where the middleware did not run.\n */\nexport const track = (context: RouterContext, event: string, data?: { user?: string }): Promise<void> => coreTrack(readSlot(context), event, data);\n\n/** The `<script>` tag for an HTML response — `''` when camada is off for this request or the tenant turned the beacon off. */\nexport const scriptTag = (context: RouterContext): string => coreScriptTag(readSlot(context));\n","{\n \"name\": \"@camada/remix\",\n \"version\": \"0.1.0\",\n \"license\": \"MIT\",\n \"type\": \"module\",\n \"engines\": {\n \"node\": \">=20\"\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 React Router framework mode (Remix): snapshot enforcement in a root middleware, first-party challenge and beacon route, track() outcomes\",\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 \"react-router\": \">=7.9\"\n },\n \"devDependencies\": {\n \"@types/node\": \"^22.10.0\",\n \"react-router\": \"^8.3.1\",\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","// The resource route for the beacon endpoints (routes.ts: route('/_cam/*', 'routes/cam.tsx')).\n// React Router runs middleware only for a matched route, so without a route under /_cam/* the\n// root middleware never sees the script or the relay and the router 404s them — the same reason\n// @camada/next has camadaRoute(). before() re-runs the verdict, so a blocked client gets its\n// 403 here too, and with the root middleware mounted it answers first and this never runs.\nimport type { ActionFunctionArgs, LoaderFunctionArgs } from 'react-router';\nimport { instanceFor, requestContext, type CamadaRemixOptions } from './camada.js';\n\ntype Handler<A> = (args: A) => Promise<Response>;\n\nexport function camadaRoute(opts: CamadaRemixOptions = {}): { loader: Handler<LoaderFunctionArgs>; action: Handler<ActionFunctionArgs> } {\n const cam = instanceFor(opts);\n const answer: Handler<LoaderFunctionArgs | ActionFunctionArgs> = async ({ request, context }) => {\n const r = await cam.before(request, requestContext(context));\n return r?.response ?? new Response(null, { status: 404 }); // not a beacon path, or camada inert: nothing to serve\n };\n return { loader: answer, action: answer };\n}\n"],"mappings":"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","names":["guarded","guarded"]}
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "@camada/remix",
3
+ "version": "0.1.0",
4
+ "license": "MIT",
5
+ "type": "module",
6
+ "engines": {
7
+ "node": ">=20"
8
+ },
9
+ "files": [
10
+ "dist",
11
+ "README.md",
12
+ "LICENSE"
13
+ ],
14
+ "description": "camada SDK for React Router framework mode (Remix): snapshot enforcement in a root middleware, first-party challenge and beacon route, track() outcomes",
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
+ "react-router": ">=7.9"
31
+ },
32
+ "repository": {
33
+ "type": "git",
34
+ "url": "git+https://github.com/camada-app/camada-remix.git"
35
+ },
36
+ "homepage": "https://github.com/camada-app/camada-remix#readme",
37
+ "bugs": {
38
+ "url": "https://github.com/camada-app/camada-remix/issues"
39
+ }
40
+ }