@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 +21 -0
- package/README.md +228 -0
- package/dist/index.cjs +131 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +34 -0
- package/dist/index.d.ts +34 -0
- package/dist/index.js +89 -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,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"]}
|
package/dist/index.d.cts
ADDED
|
@@ -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.d.ts
ADDED
|
@@ -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
|
+
}
|