@tpsdev-ai/flair 0.32.0 → 0.34.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/README.md +64 -64
- package/SECURITY.md +7 -0
- package/config.yaml +34 -0
- package/dist/cli.js +659 -111
- package/dist/component-env.js +286 -0
- package/dist/deploy.js +190 -3
- package/dist/doctor-client.js +357 -7
- package/dist/hook-install.js +39 -9
- package/dist/lib/auth-resolve.js +85 -2
- package/dist/lib/launchd-management.js +328 -0
- package/dist/lib/mcp-enable.js +19 -0
- package/dist/resources/AdminInstance.js +20 -2
- package/dist/resources/Memory.js +24 -2
- package/dist/resources/OAuth.js +41 -25
- package/dist/resources/auth-middleware.js +26 -0
- package/dist/resources/dcr-gate.js +194 -0
- package/dist/resources/in-process-api.js +5 -1
- package/dist/resources/mcp-handler.js +91 -4
- package/dist/resources/mcp-oauth.js +89 -7
- package/dist/resources/mcp-tools.js +40 -0
- package/dist/resources/oauth-discovery.js +242 -0
- package/dist/resources/oauth-wellknown.js +111 -0
- package/dist/resources/rate-limit.js +400 -0
- package/docs/auth.md +122 -5
- package/docs/deploying-on-fabric.md +35 -2
- package/docs/deployment.md +1 -1
- package/docs/embedding-in-a-harper-app.md +6 -1
- package/docs/hosted-on-fabric.md +1 -1
- package/docs/mcp-clients.md +28 -4
- package/docs/quickstart.md +29 -4
- package/docs/the-team.md +8 -4
- package/docs/troubleshooting.md +37 -1
- package/package.json +1 -1
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* rate-limit.ts — throttling for the OAuth authorization-server surface and the
|
|
3
|
+
* OAuth-guarded `/mcp` surface.
|
|
4
|
+
*
|
|
5
|
+
* ── Why this exists ─────────────────────────────────────────────────────────
|
|
6
|
+
* `/OAuthToken` accepts three grant types on one unauthenticated endpoint;
|
|
7
|
+
* `/OAuthRegister` creates a durable row; `/OAuthAuthorize` mints authorization
|
|
8
|
+
* codes; `/mcp` runs tools for anyone holding a valid token. None of them were
|
|
9
|
+
* throttled. Authentication is not a rate limit: a *valid* token can hammer the
|
|
10
|
+
* tool surface just as effectively as an invalid one can hammer the token
|
|
11
|
+
* endpoint.
|
|
12
|
+
*
|
|
13
|
+
* ── PER-NODE, IN-MEMORY. Say it out loud. ───────────────────────────────────
|
|
14
|
+
* The counter is a process-local Map. Flair runs on Harper Fabric with more than
|
|
15
|
+
* one node behind a GTM, and components hot-reload. So, precisely:
|
|
16
|
+
*
|
|
17
|
+
* - The EFFECTIVE ceiling is `limit x <number of nodes serving the origin>`,
|
|
18
|
+
* not `limit`. A caller cannot choose their node, but repeated attempts do
|
|
19
|
+
* spread across them.
|
|
20
|
+
* - The counter RESETS on component reload/restart. Reload is operator-
|
|
21
|
+
* initiated, so this is not something a caller can trigger — but a deploy
|
|
22
|
+
* does clear every bucket.
|
|
23
|
+
* - A fixed window admits up to `2 x limit` across a window boundary.
|
|
24
|
+
*
|
|
25
|
+
* The alternative — a Harper table-backed counter — is cluster-visible but is
|
|
26
|
+
* the wrong trade here, and not merely because of latency:
|
|
27
|
+
*
|
|
28
|
+
* 1. A flair table is REPLICATED. Every counted request would become a
|
|
29
|
+
* durable write fanned out to every node. An attacker sending N requests
|
|
30
|
+
* would generate N cluster-wide replicated writes. That is write
|
|
31
|
+
* amplification on the exact hot path the limiter exists to protect — a
|
|
32
|
+
* strictly worse DoS shape than the one being defended against.
|
|
33
|
+
* 2. Replication is eventually consistent, so the shared counter would not be
|
|
34
|
+
* accurate for a burst anyway. It would buy correctness at the timescale of
|
|
35
|
+
* replication lag, which is the timescale a burst has already finished in.
|
|
36
|
+
*
|
|
37
|
+
* And the security value does not hinge on the exact multiplier. This is a
|
|
38
|
+
* BRUTE-FORCE control, not a capacity control: what matters is that the number
|
|
39
|
+
* of guesses per unit time is bounded and small, against secrets with 256 bits
|
|
40
|
+
* of entropy (`randomBytes(32)` — resources/OAuth.ts). Whether the ceiling is
|
|
41
|
+
* 30/min or 60/min is immaterial to that; whether it is bounded at all is not.
|
|
42
|
+
*
|
|
43
|
+
* WHAT WOULD CHANGE THIS ANSWER: a guessable-secret space small enough that a
|
|
44
|
+
* small constant multiplier matters; a node count large enough that `limit x N`
|
|
45
|
+
* stops being meaningfully bounded; or Harper gaining a shared, NON-replicated
|
|
46
|
+
* cache primitive (a counter that is cluster-visible without becoming durable
|
|
47
|
+
* replicated storage), at which point the per-node argument stops being the
|
|
48
|
+
* honest one.
|
|
49
|
+
*
|
|
50
|
+
* WHAT THIS DOES NOT PROTECT AGAINST, stated plainly:
|
|
51
|
+
* - A distributed botnet. Per-IP keying is defeated by enough distinct source
|
|
52
|
+
* addresses; nothing here changes that.
|
|
53
|
+
* - Aggregate cluster capacity. This is a per-node, per-key control.
|
|
54
|
+
* - Anything at all if the operator sets `FLAIR_RATE_LIMIT=off`.
|
|
55
|
+
*
|
|
56
|
+
* ── The counter is consumed BEFORE any credential is looked at ──────────────
|
|
57
|
+
* `checkHttpRateLimit` runs at the top of auth-middleware, before the request
|
|
58
|
+
* body is parsed and before any grant, code, secret or token is evaluated. That
|
|
59
|
+
* is deliberate and it is a security property, not an implementation detail: if
|
|
60
|
+
* the limiter only counted FAILURES, then "did this attempt consume budget"
|
|
61
|
+
* would answer "was that credential valid" — an enumeration oracle strictly
|
|
62
|
+
* better than the 400 the endpoint already returns. Counting unconditionally
|
|
63
|
+
* means a 429 carries no information about the credential that accompanied it,
|
|
64
|
+
* and a valid credential and a garbage one are byte-identical once limited.
|
|
65
|
+
*
|
|
66
|
+
* For the same reason nothing here emits `RateLimit-Limit`/`RateLimit-Remaining`
|
|
67
|
+
* on a request that was ALLOWED. Those headers are conventional and harmless on
|
|
68
|
+
* an ordinary API; on a credential endpoint they hand a caller a free pacing
|
|
69
|
+
* oracle for staying just under the threshold. `Retry-After` on the 429 itself
|
|
70
|
+
* is the one hint we give, because a client that cannot back off correctly is a
|
|
71
|
+
* client that retries in a tight loop.
|
|
72
|
+
*/
|
|
73
|
+
// ─── Configuration ──────────────────────────────────────────────────────────
|
|
74
|
+
/** One-shot log guard — a warning per request would be its own amplification. */
|
|
75
|
+
const warnedOnce = new Set();
|
|
76
|
+
function warnOnce(tag, message) {
|
|
77
|
+
if (warnedOnce.has(tag))
|
|
78
|
+
return;
|
|
79
|
+
warnedOnce.add(tag);
|
|
80
|
+
console.error(`[rate-limit] ${message}`);
|
|
81
|
+
}
|
|
82
|
+
/** Test-only: forget which one-shot warnings have fired. */
|
|
83
|
+
export function __resetWarningsForTest() {
|
|
84
|
+
warnedOnce.clear();
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Master switch. Enabled unless `FLAIR_RATE_LIMIT` is exactly `off`/`0`/`false`
|
|
88
|
+
* (case-insensitive). Deliberately opt-OUT: a limiter that has to be discovered
|
|
89
|
+
* and switched on is a limiter that is off on every instance that most needs it.
|
|
90
|
+
*
|
|
91
|
+
* An unrecognised value is treated as ENABLED and warned about, so a typo
|
|
92
|
+
* (`FLAIR_RATE_LIMIT=disabled`) cannot silently disable the control.
|
|
93
|
+
*/
|
|
94
|
+
export function rateLimitEnabled() {
|
|
95
|
+
const raw = (process.env.FLAIR_RATE_LIMIT ?? "").trim().toLowerCase();
|
|
96
|
+
if (raw === "")
|
|
97
|
+
return true;
|
|
98
|
+
if (raw === "off" || raw === "0" || raw === "false" || raw === "no") {
|
|
99
|
+
warnOnce("disabled", "DISABLED by FLAIR_RATE_LIMIT — the OAuth and /mcp surfaces are unthrottled.");
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
if (raw === "on" || raw === "1" || raw === "true" || raw === "yes")
|
|
103
|
+
return true;
|
|
104
|
+
warnOnce("bad-master", `FLAIR_RATE_LIMIT is set to an unrecognised value — keeping rate limiting ENABLED. ` +
|
|
105
|
+
`Use FLAIR_RATE_LIMIT=off to disable it.`);
|
|
106
|
+
return true;
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Read a positive-integer limit from the environment, falling back to `dflt`.
|
|
110
|
+
*
|
|
111
|
+
* Zero and negatives are REJECTED rather than honoured. A limit of 0 would mean
|
|
112
|
+
* "reject everything", which is not a rate limit an operator arrives at by
|
|
113
|
+
* intent — it is what a shell that expanded an unset variable produces. Every
|
|
114
|
+
* rejection is warned about by NAME so a typo is visible rather than silently
|
|
115
|
+
* swapped for the default.
|
|
116
|
+
*/
|
|
117
|
+
export function envLimit(name, dflt) {
|
|
118
|
+
const raw = (process.env[name] ?? "").trim();
|
|
119
|
+
if (raw === "")
|
|
120
|
+
return dflt;
|
|
121
|
+
const n = Number(raw);
|
|
122
|
+
if (!Number.isInteger(n) || n <= 0) {
|
|
123
|
+
warnOnce(`bad-limit:${name}`, `${name} is not a positive integer — using the default of ${dflt}.`);
|
|
124
|
+
return dflt;
|
|
125
|
+
}
|
|
126
|
+
return n;
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Limits, and why each number.
|
|
130
|
+
*
|
|
131
|
+
* `/OAuthToken`, `/OAuthAuthorize`, `/OAuthRevoke` — 30 per minute per caller.
|
|
132
|
+
* A real authorization flow is a handful of requests per user per hour: one
|
|
133
|
+
* authorize, one code exchange, one refresh per access-token lifetime (1 hour,
|
|
134
|
+
* resources/OAuth.ts). 30/min leaves roughly two orders of magnitude of headroom
|
|
135
|
+
* over legitimate use while capping a guessing run at 1800/hour/node against a
|
|
136
|
+
* 256-bit space.
|
|
137
|
+
*
|
|
138
|
+
* `/OAuthRegister` — 5 per five minutes. Registration is a once-per-client
|
|
139
|
+
* event, and each one creates a durable, replicated row. This is the table-
|
|
140
|
+
* pollution ceiling. (Registration is additionally gated; see the DCR work.)
|
|
141
|
+
*
|
|
142
|
+
* `/mcp` — 120 per minute per verified token subject. An agent doing real work
|
|
143
|
+
* makes many tool calls in a burst; this is sized to be invisible to genuine use
|
|
144
|
+
* and to stop one token from monopolising the surface.
|
|
145
|
+
*/
|
|
146
|
+
export function policyFor(bucket) {
|
|
147
|
+
switch (bucket) {
|
|
148
|
+
case "register":
|
|
149
|
+
return { bucket: "register", limit: envLimit("FLAIR_OAUTH_REGISTER_RATE_LIMIT", 5), windowMs: 300_000 };
|
|
150
|
+
case "mcp":
|
|
151
|
+
return { bucket: "mcp", limit: envLimit("FLAIR_MCP_RATE_LIMIT", 120), windowMs: 60_000 };
|
|
152
|
+
case "oauth":
|
|
153
|
+
default:
|
|
154
|
+
return { bucket: "oauth", limit: envLimit("FLAIR_OAUTH_RATE_LIMIT", 30), windowMs: 60_000 };
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
/** Paths this module throttles, and which policy each uses. Nothing else is touched. */
|
|
158
|
+
const PATH_POLICY = {
|
|
159
|
+
"/OAuthToken": "oauth",
|
|
160
|
+
"/OAuthAuthorize": "oauth",
|
|
161
|
+
"/OAuthRevoke": "oauth",
|
|
162
|
+
"/OAuthRegister": "register",
|
|
163
|
+
};
|
|
164
|
+
/**
|
|
165
|
+
* Which policy applies to a pathname, or null for "not throttled".
|
|
166
|
+
*
|
|
167
|
+
* Exact match only. A prefix test would pull in unrelated sibling routes, and
|
|
168
|
+
* the OAuth endpoints are addressed by their exact resource path.
|
|
169
|
+
*/
|
|
170
|
+
export function policyForPath(pathname) {
|
|
171
|
+
const kind = PATH_POLICY[pathname];
|
|
172
|
+
return kind ? policyFor(kind) : null;
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Hard cap on tracked keys. Without one, a caller rotating source addresses
|
|
176
|
+
* turns the limiter itself into a memory-exhaustion primitive — the classic way
|
|
177
|
+
* a rate limiter becomes the DoS. 20 000 entries is far above any legitimate key
|
|
178
|
+
* count for a single instance and costs on the order of a megabyte.
|
|
179
|
+
*
|
|
180
|
+
* Eviction is LRU (see `consume`: a touched key is re-inserted at the back of
|
|
181
|
+
* the Map). That ordering matters and is the right way round: a caller hammering
|
|
182
|
+
* one key keeps it hot, so it is the LAST thing evicted and stays limited; the
|
|
183
|
+
* entries evicted under pressure are idle ones, which had unspent budget anyway.
|
|
184
|
+
*
|
|
185
|
+
* Named trade-off: at the cap, an evicted key gets a fresh budget. Bounded
|
|
186
|
+
* memory is worth more than a perfectly-retained counter, and a caller able to
|
|
187
|
+
* fill 20 000 distinct keys is already distributed across 20 000 sources, which
|
|
188
|
+
* per-key limiting was never the control for.
|
|
189
|
+
*/
|
|
190
|
+
export const MAX_TRACKED_KEYS = 20_000;
|
|
191
|
+
const buckets = new Map();
|
|
192
|
+
/** Test-only: drop all counters so cases don't inherit each other's state. */
|
|
193
|
+
export function __resetBucketsForTest() {
|
|
194
|
+
buckets.clear();
|
|
195
|
+
}
|
|
196
|
+
/** Test-only: how many keys are currently tracked. */
|
|
197
|
+
export function __trackedKeyCountForTest() {
|
|
198
|
+
return buckets.size;
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Count one request against `key` and decide.
|
|
202
|
+
*
|
|
203
|
+
* ALWAYS consumes, including when the answer is "limited" — a caller that keeps
|
|
204
|
+
* hammering keeps the window pinned rather than trickling through at exactly the
|
|
205
|
+
* limit. Callers must invoke this before evaluating any credential; see the
|
|
206
|
+
* module header for why that is a security property.
|
|
207
|
+
*/
|
|
208
|
+
export function consume(key, policy, now = Date.now()) {
|
|
209
|
+
const existing = buckets.get(key);
|
|
210
|
+
let bucket;
|
|
211
|
+
if (!existing || now - existing.windowStart >= policy.windowMs) {
|
|
212
|
+
bucket = { windowStart: now, count: 1 };
|
|
213
|
+
}
|
|
214
|
+
else {
|
|
215
|
+
bucket = { windowStart: existing.windowStart, count: existing.count + 1 };
|
|
216
|
+
}
|
|
217
|
+
// delete-then-set moves the key to the back of the Map's insertion order,
|
|
218
|
+
// which is what makes the eviction below LRU rather than first-seen.
|
|
219
|
+
buckets.delete(key);
|
|
220
|
+
buckets.set(key, bucket);
|
|
221
|
+
if (buckets.size > MAX_TRACKED_KEYS)
|
|
222
|
+
evictOldest(now, policy.windowMs);
|
|
223
|
+
const elapsed = now - bucket.windowStart;
|
|
224
|
+
return {
|
|
225
|
+
limited: bucket.count > policy.limit,
|
|
226
|
+
limit: policy.limit,
|
|
227
|
+
retryAfterSec: Math.max(1, Math.ceil((policy.windowMs - elapsed) / 1000)),
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* Bring the Map back under the cap: drop expired windows first (they carry no
|
|
232
|
+
* information), then the least-recently-used entries.
|
|
233
|
+
*
|
|
234
|
+
* Not a periodic sweep. A timer in a Harper component outlives nothing useful
|
|
235
|
+
* and has to be torn down on reload; doing the work only when the cap is
|
|
236
|
+
* actually reached keeps the steady state at zero cost.
|
|
237
|
+
*/
|
|
238
|
+
function evictOldest(now, windowMs) {
|
|
239
|
+
for (const [k, b] of buckets) {
|
|
240
|
+
if (buckets.size <= MAX_TRACKED_KEYS)
|
|
241
|
+
break;
|
|
242
|
+
if (now - b.windowStart >= windowMs)
|
|
243
|
+
buckets.delete(k);
|
|
244
|
+
}
|
|
245
|
+
for (const k of buckets.keys()) {
|
|
246
|
+
if (buckets.size <= MAX_TRACKED_KEYS)
|
|
247
|
+
break;
|
|
248
|
+
buckets.delete(k);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
// ─── Caller identity ────────────────────────────────────────────────────────
|
|
252
|
+
/**
|
|
253
|
+
* How many proxy hops in front of this instance are trusted, or 0 for "none".
|
|
254
|
+
*
|
|
255
|
+
* `FLAIR_TRUSTED_PROXY` — unset/`0` means the socket peer address is the only
|
|
256
|
+
* thing used. A positive integer N means the instance genuinely sits behind N
|
|
257
|
+
* trusted reverse proxies that append to `X-Forwarded-For`.
|
|
258
|
+
*
|
|
259
|
+
* DEFAULT OFF, and that direction is the point: `X-Forwarded-For` is a request
|
|
260
|
+
* header, so an instance that trusts it without a proxy in front lets any caller
|
|
261
|
+
* mint a fresh bucket per request by rotating the header — a limiter that is
|
|
262
|
+
* bypassable by anyone who has read this file. Trusting it has to be a decision
|
|
263
|
+
* an operator makes about their own topology.
|
|
264
|
+
*/
|
|
265
|
+
export function trustedProxyHops() {
|
|
266
|
+
const raw = (process.env.FLAIR_TRUSTED_PROXY ?? "").trim().toLowerCase();
|
|
267
|
+
if (raw === "" || raw === "0" || raw === "off" || raw === "false" || raw === "no")
|
|
268
|
+
return 0;
|
|
269
|
+
if (raw === "on" || raw === "true" || raw === "yes")
|
|
270
|
+
return 1;
|
|
271
|
+
const n = Number(raw);
|
|
272
|
+
if (!Number.isInteger(n) || n < 0) {
|
|
273
|
+
warnOnce("bad-proxy", `FLAIR_TRUSTED_PROXY is not a non-negative integer — treating it as 0 (no proxy trusted).`);
|
|
274
|
+
return 0;
|
|
275
|
+
}
|
|
276
|
+
return n;
|
|
277
|
+
}
|
|
278
|
+
/** Sentinel used when no caller address can be determined. See `callerKey`. */
|
|
279
|
+
export const UNKNOWN_CALLER = "addr:unknown";
|
|
280
|
+
/**
|
|
281
|
+
* The rate-limit key component identifying the caller.
|
|
282
|
+
*
|
|
283
|
+
* Sources, in order:
|
|
284
|
+
* 1. `X-Forwarded-For`, but ONLY when `FLAIR_TRUSTED_PROXY` is set, and then
|
|
285
|
+
* the entry `hops` from the RIGHT — never the leftmost. Each proxy appends
|
|
286
|
+
* the peer it saw, so the rightmost entries are the ones written by the
|
|
287
|
+
* trusted hops; the leftmost is whatever the original caller chose to send
|
|
288
|
+
* and is worth nothing. Taking the left entry is the classic way this
|
|
289
|
+
* control is made bypassable.
|
|
290
|
+
* 2. Harper's `request.ip` — the socket peer address. On a Fabric UDS listener
|
|
291
|
+
* Harper substitutes the real client address from the PROXY v1 header when
|
|
292
|
+
* one is present (harper/dist/server/http.js).
|
|
293
|
+
* 3. `UNKNOWN_CALLER`, warned about once.
|
|
294
|
+
*
|
|
295
|
+
* Step 3 collapses every caller into ONE bucket, which is deliberately the
|
|
296
|
+
* fail-SAFE direction — over-restrictive, never unthrottled. It is also loud:
|
|
297
|
+
* silently not limiting would be an unrun check that looks like a pass, and an
|
|
298
|
+
* operator who never learns the limiter degraded cannot fix it.
|
|
299
|
+
*/
|
|
300
|
+
export function callerKey(request) {
|
|
301
|
+
const hops = trustedProxyHops();
|
|
302
|
+
if (hops > 0) {
|
|
303
|
+
const raw = request?.headers?.get?.("x-forwarded-for") ??
|
|
304
|
+
request?.headers?.asObject?.["x-forwarded-for"] ??
|
|
305
|
+
"";
|
|
306
|
+
if (typeof raw === "string" && raw.trim() !== "") {
|
|
307
|
+
const parts = raw.split(",").map((s) => s.trim()).filter(Boolean);
|
|
308
|
+
// The trusted hops appended the last `hops` entries; the one the nearest
|
|
309
|
+
// trusted proxy observed as its own peer sits at length - hops.
|
|
310
|
+
const idx = parts.length - hops;
|
|
311
|
+
if (idx >= 0 && parts[idx])
|
|
312
|
+
return `xff:${parts[idx]}`;
|
|
313
|
+
// Fewer entries than trusted hops: the chain is shorter than configured, so
|
|
314
|
+
// no entry in it was written by a trusted hop. Fall through to the socket
|
|
315
|
+
// peer rather than trust a caller-supplied value.
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
const ip = request?.ip;
|
|
319
|
+
if (typeof ip === "string" && ip !== "")
|
|
320
|
+
return `addr:${ip}`;
|
|
321
|
+
warnOnce("no-addr", "no client address available on the request — every caller now shares ONE bucket per endpoint, " +
|
|
322
|
+
"which throttles more than intended. If this instance sits behind a reverse proxy that sets " +
|
|
323
|
+
"X-Forwarded-For, set FLAIR_TRUSTED_PROXY to the number of trusted hops.");
|
|
324
|
+
return UNKNOWN_CALLER;
|
|
325
|
+
}
|
|
326
|
+
// ─── Responses ──────────────────────────────────────────────────────────────
|
|
327
|
+
/**
|
|
328
|
+
* The 429 body. Identical for every endpoint, every key and every grant type,
|
|
329
|
+
* and it echoes nothing back — no client_id, no address, no grant type, no
|
|
330
|
+
* remaining count. `slow_down` is a real OAuth error code (RFC 8628 s3.5) and
|
|
331
|
+
* means exactly this, so a spec-aware client already knows what to do with it.
|
|
332
|
+
*/
|
|
333
|
+
export const LIMITED_BODY = JSON.stringify({
|
|
334
|
+
error: "slow_down",
|
|
335
|
+
error_description: "too many requests",
|
|
336
|
+
});
|
|
337
|
+
export function limitedHeaders(decision) {
|
|
338
|
+
return {
|
|
339
|
+
"content-type": "application/json",
|
|
340
|
+
"retry-after": String(decision.retryAfterSec),
|
|
341
|
+
// Do not add RateLimit-Limit/Remaining here or on the success path; see the
|
|
342
|
+
// module header on why a pacing oracle is not wanted on a credential endpoint.
|
|
343
|
+
"cache-control": "no-store",
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
/** A 429 as a `Response` — for the default dispatch chain (auth-middleware). */
|
|
347
|
+
export function limitedResponse(decision) {
|
|
348
|
+
return new Response(LIMITED_BODY, { status: 429, headers: limitedHeaders(decision) });
|
|
349
|
+
}
|
|
350
|
+
// ─── Entry points ───────────────────────────────────────────────────────────
|
|
351
|
+
/**
|
|
352
|
+
* The auth-middleware hook. Returns a 429 `Response` to short-circuit with, or
|
|
353
|
+
* null to continue.
|
|
354
|
+
*
|
|
355
|
+
* Must be called before the public-path passthrough and before anything reads
|
|
356
|
+
* the body: the three OAuth endpoints this covers all sit on that passthrough,
|
|
357
|
+
* so a hook placed after it would never run for them.
|
|
358
|
+
*/
|
|
359
|
+
export function checkHttpRateLimit(request, pathname) {
|
|
360
|
+
if (!rateLimitEnabled())
|
|
361
|
+
return null;
|
|
362
|
+
const policy = policyForPath(pathname);
|
|
363
|
+
if (!policy)
|
|
364
|
+
return null;
|
|
365
|
+
const decision = consume(`${policy.bucket}|${callerKey(request)}`, policy);
|
|
366
|
+
return decision.limited ? limitedResponse(decision) : null;
|
|
367
|
+
}
|
|
368
|
+
/**
|
|
369
|
+
* The `/mcp` hook. Keyed on the RS256-verified token subject (and `client_id`
|
|
370
|
+
* when present), never on an address: `/mcp` is authenticated, so the strongest
|
|
371
|
+
* available identity is the one the authorization server signed. That also makes
|
|
372
|
+
* this limit survive a caller changing address, which per-IP keying does not.
|
|
373
|
+
*
|
|
374
|
+
* Returns a Harper listener result (`{ status, body, headers }`) to short-circuit
|
|
375
|
+
* with, or null to continue. Runs INSIDE `withMCPAuth`, so `request.mcp` is
|
|
376
|
+
* populated; an unverified request never reaches here because the guard fails
|
|
377
|
+
* closed ahead of it.
|
|
378
|
+
*/
|
|
379
|
+
export function checkMcpRateLimit(request) {
|
|
380
|
+
if (!rateLimitEnabled())
|
|
381
|
+
return null;
|
|
382
|
+
const policy = policyFor("mcp");
|
|
383
|
+
const sub = typeof request?.mcp?.sub === "string" ? request.mcp.sub : "";
|
|
384
|
+
const clientId = typeof request?.mcp?.client_id === "string" ? request.mcp.client_id : "";
|
|
385
|
+
// No verified subject should be impossible here (withMCPAuth fails closed), but
|
|
386
|
+
// if it ever were, fall back to the caller address rather than to no limit.
|
|
387
|
+
const identity = sub ? `sub:${sub}|cid:${clientId}` : callerKey(request);
|
|
388
|
+
const decision = consume(`${policy.bucket}|${identity}`, policy);
|
|
389
|
+
if (!decision.limited)
|
|
390
|
+
return null;
|
|
391
|
+
return {
|
|
392
|
+
status: 429,
|
|
393
|
+
headers: limitedHeaders(decision),
|
|
394
|
+
body: JSON.stringify({
|
|
395
|
+
jsonrpc: "2.0",
|
|
396
|
+
id: null,
|
|
397
|
+
error: { code: -32029, message: "too many requests" },
|
|
398
|
+
}),
|
|
399
|
+
};
|
|
400
|
+
}
|
package/docs/auth.md
CHANGED
|
@@ -62,22 +62,72 @@ Flair includes a built-in OAuth 2.1 authorization server for client integrations
|
|
|
62
62
|
|
|
63
63
|
### Dynamic Client Registration
|
|
64
64
|
|
|
65
|
-
|
|
65
|
+
**Registration is off by default.** `POST /OAuthRegister` answers `403
|
|
66
|
+
access_denied`, and the discovery documents do not advertise a
|
|
67
|
+
`registration_endpoint`. Nothing needs doing to be in this state — it is what a
|
|
68
|
+
fresh install does, and it is what an internet-reachable instance should stay in
|
|
69
|
+
unless there is a reason otherwise.
|
|
70
|
+
|
|
71
|
+
`OAuthClient` rows are durable and replicated, and every one of them is a
|
|
72
|
+
`client_id` that `/OAuthAuthorize` will subsequently honour, so an open
|
|
73
|
+
registration endpoint on a public instance means anyone can fill that table.
|
|
74
|
+
|
|
75
|
+
To turn registration on, set an initial access token (RFC 7591 §3.1):
|
|
76
|
+
|
|
77
|
+
```sh
|
|
78
|
+
FLAIR_OAUTH_DCR_TOKEN=$(openssl rand -base64 32)
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
That one variable is the whole interface. There is no separate "enable" switch,
|
|
82
|
+
which is the point: enabling registration and supplying the credential that
|
|
83
|
+
guards it are the same act, so "on, and open to the internet" is not a state you
|
|
84
|
+
can reach by forgetting a setting — it does not exist in the configuration. A
|
|
85
|
+
token outside 32–508 characters is refused and registration stays **off**, with
|
|
86
|
+
a warning naming the variable; a weak shared secret on an unauthenticated public
|
|
87
|
+
endpoint is nearer to open than to closed.
|
|
88
|
+
|
|
89
|
+
Registration is also rate limited, and the limiter runs **in front of** this
|
|
90
|
+
gate: refused attempts spend budget too, so a flood against a closed endpoint is
|
|
91
|
+
answered `429`, not `403`. That is deliberate — the limiter is the cheaper check
|
|
92
|
+
and the one bounding volume — but it means a client retrying hard against an
|
|
93
|
+
instance that has not opted in sees `429` and should read it as "stop", not as a
|
|
94
|
+
different answer to the same question. The budget is 5 per five minutes by
|
|
95
|
+
default (`FLAIR_OAUTH_REGISTER_RATE_LIMIT`).
|
|
96
|
+
|
|
97
|
+
Keep the token in the process environment. Do not put it in a component `.env`
|
|
98
|
+
for `flair deploy` to ship — a deploy payload is stored in Harper's deployment
|
|
99
|
+
record and replicated to every node. `flair deploy` refuses to generate one
|
|
100
|
+
containing this key, and warns if your own file assigns it.
|
|
101
|
+
|
|
102
|
+
Clients then present it in a request header:
|
|
66
103
|
|
|
67
104
|
```
|
|
68
105
|
POST /OAuthRegister
|
|
69
106
|
Content-Type: application/json
|
|
107
|
+
X-Flair-Initial-Access-Token: <the token>
|
|
70
108
|
|
|
71
109
|
{
|
|
72
110
|
"client_name": "Claude Desktop",
|
|
73
|
-
"redirect_uris": ["
|
|
111
|
+
"redirect_uris": ["https://claude.com/api/mcp/auth_callback"],
|
|
74
112
|
"grant_types": ["authorization_code"],
|
|
75
113
|
"response_types": ["code"],
|
|
76
114
|
"token_endpoint_auth_method": "none"
|
|
77
115
|
}
|
|
78
116
|
```
|
|
79
117
|
|
|
80
|
-
Returns `client_id
|
|
118
|
+
Returns `client_id`. A missing or wrong token answers `401 invalid_token`.
|
|
119
|
+
|
|
120
|
+
**Why a header and not `Authorization: Bearer`,** which is what RFC 7591 §3.1
|
|
121
|
+
specifies: Harper's own auth layer claims every `Authorization: Bearer …` header
|
|
122
|
+
and validates it as a Harper operation token, so a Bearer-carrying request to
|
|
123
|
+
`/OAuthRegister` is answered `401 {"error":"invalid token"}` before any Flair
|
|
124
|
+
code runs. Measured, not assumed — no header returns 200, `Bearer <anything>`
|
|
125
|
+
returns 401, a custom header returns 200.
|
|
126
|
+
|
|
127
|
+
Registering clients ahead of time and leaving this off is the better shape where
|
|
128
|
+
it is workable — the surface exists to serve one known client shape, and the
|
|
129
|
+
`@harperfast/oauth` authorization server used by the `/mcp` surface takes CIMD
|
|
130
|
+
rather than registration.
|
|
81
131
|
|
|
82
132
|
### Authorization Code Flow with PKCE
|
|
83
133
|
|
|
@@ -93,10 +143,77 @@ Standard OAuth 2.1 authorization code flow:
|
|
|
93
143
|
|
|
94
144
|
| Endpoint | Method | Description |
|
|
95
145
|
|----------|--------|-------------|
|
|
96
|
-
| `/OAuthRegister` | POST | Dynamic client registration |
|
|
146
|
+
| `/OAuthRegister` | POST | Dynamic client registration — off unless `FLAIR_OAUTH_DCR_TOKEN` is set |
|
|
97
147
|
| `/OAuthAuthorize` | GET/POST | Authorization endpoint |
|
|
98
148
|
| `/OAuthToken` | POST | Token endpoint |
|
|
99
|
-
| `/.well-known/oauth-authorization-server` | GET |
|
|
149
|
+
| `/.well-known/oauth-authorization-server` | GET | Authorization server metadata (RFC 8414) |
|
|
150
|
+
| `/.well-known/oauth-protected-resource` | GET | Protected resource metadata (RFC 9728) |
|
|
151
|
+
| `/OAuthMetadata` | GET | Alias of `/.well-known/oauth-authorization-server` |
|
|
152
|
+
|
|
153
|
+
Both well-known documents are public — RFC 8414 §3 and RFC 9728 §3 require them
|
|
154
|
+
to be retrievable without authentication — and are served with
|
|
155
|
+
`Access-Control-Allow-Origin: *` so browser-based MCP clients can read them.
|
|
156
|
+
|
|
157
|
+
The protected-resource document is also served at the RFC 9728 §3.1
|
|
158
|
+
path-appended URL `/.well-known/oauth-protected-resource/mcp`, which is the form
|
|
159
|
+
MCP clients construct from the resource identifier.
|
|
160
|
+
|
|
161
|
+
### Rate limiting
|
|
162
|
+
|
|
163
|
+
The OAuth endpoints and the OAuth-guarded `/mcp` surface are rate limited. This
|
|
164
|
+
is on by default; nothing needs configuring for it to apply.
|
|
165
|
+
|
|
166
|
+
| Surface | Default | Window | Keyed on |
|
|
167
|
+
|---------|---------|--------|----------|
|
|
168
|
+
| `/OAuthToken`, `/OAuthAuthorize`, `/OAuthRevoke` (one shared budget) | 30 | 60s | Caller address |
|
|
169
|
+
| `/OAuthRegister` | 5 | 300s | Caller address |
|
|
170
|
+
| `/mcp` | 120 | 60s | The verified token subject |
|
|
171
|
+
|
|
172
|
+
A rejected request gets `429` with a `Retry-After`, and a body that echoes
|
|
173
|
+
nothing back. The counter is consumed *before* any credential is examined, so a
|
|
174
|
+
`429` says nothing about the credential that came with it — a valid
|
|
175
|
+
authorization code and a garbage one get byte-identical responses once a bucket
|
|
176
|
+
is spent. No `RateLimit-*` headers are emitted on requests that are allowed;
|
|
177
|
+
publishing a live remaining-count on a credential endpoint is a pacing aid for
|
|
178
|
+
exactly the caller you don't want to help.
|
|
179
|
+
|
|
180
|
+
| Variable | Default | Meaning |
|
|
181
|
+
|----------|---------|---------|
|
|
182
|
+
| `FLAIR_RATE_LIMIT` | on | Set to `off` to disable rate limiting entirely. An unrecognised value leaves it **enabled**. |
|
|
183
|
+
| `FLAIR_OAUTH_RATE_LIMIT` | `30` | Requests per 60s for the token/authorize/revoke budget. |
|
|
184
|
+
| `FLAIR_OAUTH_REGISTER_RATE_LIMIT` | `5` | Registrations per 300s. |
|
|
185
|
+
| `FLAIR_MCP_RATE_LIMIT` | `120` | `/mcp` calls per 60s per token subject. |
|
|
186
|
+
| `FLAIR_TRUSTED_PROXY` | `0` | Number of trusted reverse-proxy hops in front of this instance. |
|
|
187
|
+
|
|
188
|
+
A limit set to `0`, a negative number, or anything non-numeric is refused and the
|
|
189
|
+
default is used, with a warning naming the variable — so a shell that expanded an
|
|
190
|
+
unset variable cannot silently switch the control off.
|
|
191
|
+
|
|
192
|
+
**`FLAIR_TRUSTED_PROXY` and NAT.** By default the key is the socket peer address
|
|
193
|
+
and `X-Forwarded-For` is ignored completely, because that header is caller-supplied:
|
|
194
|
+
an instance that honours it with no proxy in front can be bypassed by anyone
|
|
195
|
+
willing to vary a header. Set `FLAIR_TRUSTED_PROXY` to the number of proxies that
|
|
196
|
+
genuinely sit in front and append to `X-Forwarded-For`, and the key becomes the
|
|
197
|
+
entry those hops wrote (counted from the right — never the leftmost entry, which
|
|
198
|
+
the original caller controls). Keying on an address means a busy NAT shares one
|
|
199
|
+
budget; the defaults leave roughly two orders of magnitude of headroom over real
|
|
200
|
+
usage, and raising them is a one-variable change.
|
|
201
|
+
|
|
202
|
+
**This limiter is per node.** The counter lives in the serving process. On a
|
|
203
|
+
multi-node deployment the effective ceiling is the configured limit times the
|
|
204
|
+
number of nodes, and the counters reset when a component reloads. That is a
|
|
205
|
+
deliberate trade: a cluster-shared counter in a Harper table would make every
|
|
206
|
+
counted request a durable replicated write on an authentication hot path, which
|
|
207
|
+
is a worse denial-of-service shape than the one being defended against. The
|
|
208
|
+
control here bounds how fast a caller can guess against 256-bit secrets, and a
|
|
209
|
+
small constant multiplier does not change that. It does **not** defend against a
|
|
210
|
+
distributed botnet, and it is not a capacity control.
|
|
211
|
+
|
|
212
|
+
Every URL in every one of these documents derives from `FLAIR_PUBLIC_URL`,
|
|
213
|
+
falling back to the loopback bind address. **Set `FLAIR_PUBLIC_URL` on any
|
|
214
|
+
deployment reachable at something other than localhost** — otherwise the
|
|
215
|
+
documents are well-formed and every URL in them points at the *client's* own
|
|
216
|
+
localhost. See [deploying-on-fabric.md](deploying-on-fabric.md).
|
|
100
217
|
|
|
101
218
|
## XAA (Enterprise-Managed Authorization)
|
|
102
219
|
|
|
@@ -78,8 +78,41 @@ export FLAIR_URL=https://<cluster>.<org>.harperfabric.com
|
|
|
78
78
|
flair agent add mybot --target "$FLAIR_URL" --ops-target <ops-url>
|
|
79
79
|
```
|
|
80
80
|
|
|
81
|
-
|
|
82
|
-
|
|
81
|
+
### `FLAIR_PUBLIC_URL` — set for you, and how to override it
|
|
82
|
+
|
|
83
|
+
OAuth metadata and A2A discovery advertise `FLAIR_PUBLIC_URL`; with it unset, every
|
|
84
|
+
URL a client is handed points at loopback and no remote client can authorize.
|
|
85
|
+
|
|
86
|
+
`flair deploy` ships it. The deploy already knows the URL — it is the target it
|
|
87
|
+
verifies the served API against immediately afterwards — so it writes
|
|
88
|
+
`FLAIR_PUBLIC_URL=<target>` into a `.env` in the component payload, and then checks
|
|
89
|
+
`GET <target>/OAuthMetadata` really does advertise a non-loopback issuer. That check
|
|
90
|
+
fails the deploy if it does not.
|
|
91
|
+
|
|
92
|
+
To advertise something other than the deploy target — a CDN, a reverse proxy, a
|
|
93
|
+
vanity domain — put your own `.env` in the package root:
|
|
94
|
+
|
|
95
|
+
```
|
|
96
|
+
FLAIR_PUBLIC_URL=https://flair.example.com
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
A value you set is never overwritten; the deploy prints the disagreement and keeps
|
|
100
|
+
yours. Any other keys in that file are carried through untouched.
|
|
101
|
+
|
|
102
|
+
Three things worth knowing about that file:
|
|
103
|
+
|
|
104
|
+
- Harper reads a component's `.env` **only** because flair's `config.yaml` declares
|
|
105
|
+
its `loadEnv` plugin, above `jsResource`. Without that declaration the file is
|
|
106
|
+
present and inert.
|
|
107
|
+
- A variable already set in the instance's **process environment** outranks the
|
|
108
|
+
file. Harper's `loadEnv` skips any key already present in `process.env` (and logs
|
|
109
|
+
an "Environment variable conflict" warning) unless `override` is declared, which
|
|
110
|
+
flair does not declare. So a value you set through Fabric's own environment
|
|
111
|
+
mechanism is what the instance uses, and a deploy cannot replace it.
|
|
112
|
+
- The deploy payload is stored in Harper's deployment record and replicated to every
|
|
113
|
+
node, so anything in `.env` is persisted cluster-wide. flair puts no credential
|
|
114
|
+
there. `HDB_ADMIN_PASSWORD` in particular cannot work from a component `.env` at
|
|
115
|
+
all — Harper composes its own configuration before component env files load.
|
|
83
116
|
|
|
84
117
|
---
|
|
85
118
|
|
package/docs/deployment.md
CHANGED
|
@@ -180,7 +180,7 @@ Set these in the Flair process environment (`~/Library/LaunchAgents/ai.tpsdev.fl
|
|
|
180
180
|
|
|
181
181
|
| Variable | What it does | When to set it |
|
|
182
182
|
|----------|--------------|----------------|
|
|
183
|
-
| `FLAIR_PUBLIC_URL` | The URL operators reach this Flair on (e.g. `https://flair.example.com`). Surfaced in the AdminInstance pane's Endpoints table and used by OAuth metadata + A2A discovery so external clients see a reachable URL. | **Always set on remote / Fabric
|
|
183
|
+
| `FLAIR_PUBLIC_URL` | The URL operators reach this Flair on (e.g. `https://flair.example.com`). Surfaced in the AdminInstance pane's Endpoints table and used by OAuth metadata + A2A discovery so external clients see a reachable URL. | **Always set on remote / VPS deployments** — unset means every URL a client is handed points at loopback. On Fabric, `flair deploy` sets it from the deploy target and verifies the result; see [deploying-on-fabric.md](deploying-on-fabric.md). Local-only installs can leave it unset. |
|
|
184
184
|
| `HDB_ADMIN_PASSWORD` | Bootstrap password for the embedded Harper. After first start, the persisted user record is the source of truth; rotate via the Harper ops API, not by changing this env var. | Set at install time. See [secrets-and-keys.md](secrets-and-keys.md) for rotation. |
|
|
185
185
|
| `FLAIR_KEY_PASSPHRASE` | Passphrase used to derive the AES-256-GCM key that wraps federation private-key seeds at rest. Auto-generated to `~/.flair/keys/.passphrase` if unset. | Set explicitly for production federation deployments so the passphrase isn't auto-generated and lost on disk wipe. |
|
|
186
186
|
| `HTTP_PORT` | Override the Harper HTTP port. Useful for sandboxes; production deployments should configure the port in `config.yaml` instead. | Rare. |
|
|
@@ -21,6 +21,7 @@ Embedding *adds* the in-process path; `rest: true` keeps serving MCP clients and
|
|
|
21
21
|
**2. Import the facade and write a memory.**
|
|
22
22
|
|
|
23
23
|
```javascript
|
|
24
|
+
import { server } from "harper";
|
|
24
25
|
import { Flair } from "@tpsdev-ai/flair";
|
|
25
26
|
|
|
26
27
|
const flair = new Flair(server);
|
|
@@ -61,6 +62,8 @@ console.log([...server.resources.keys()].sort()); // what Flair registered
|
|
|
61
62
|
|
|
62
63
|
One handle per Harper instance. Resolves resources lazily on first use — no lookup at construction time.
|
|
63
64
|
|
|
65
|
+
**The handle owns nothing.** It holds a reference to the Harper server the caller already owns and acquires no timers, connections, or file handles. There is no `close()` or `dispose()` method. If a future version acquires something releasable, that is a breaking change and will be versioned as one.
|
|
66
|
+
|
|
64
67
|
### `flair.as(agentId)`
|
|
65
68
|
|
|
66
69
|
Returns an `AgentHandle` scoped to that agent. The `agentId` is runtime-validated: missing, empty, blank, or non-string throws `InProcessContextError`.
|
|
@@ -85,6 +88,8 @@ planner.agentId; // "planner"
|
|
|
85
88
|
|
|
86
89
|
Admin operations — unfiltered reads, cross-agent writes. Every call site is greppable via `git grep "flair.admin"`.
|
|
87
90
|
|
|
91
|
+
**The handle is cached** — `flair.admin === flair.admin` is `true`. Access it once and reuse the reference, or access it inline; either is fine.
|
|
92
|
+
|
|
88
93
|
| Method | Description |
|
|
89
94
|
|---|---|
|
|
90
95
|
| `flair.admin.registerAgent(id, opts?)` | Register an agent through the Agent resource (full Principal shape). |
|
|
@@ -301,7 +306,7 @@ The facade wraps a lower-level API that is still available for callers who need
|
|
|
301
306
|
import { agentContext, adminContext, internalContext, collectionResource } from "@tpsdev-ai/flair/server";
|
|
302
307
|
```
|
|
303
308
|
|
|
304
|
-
This is the same seam Flair's own MCP handler and internal tooling use. You should not need it for ordinary agent operations — the facade covers those.
|
|
309
|
+
This is the same seam Flair's own MCP handler and internal tooling use. **You should not need it for ordinary agent operations** — the facade covers those. Reach for the primitives when you are building your own abstraction on top of Flair's resources, or when you need the context helpers (`agentContext`, `adminContext`, `internalContext`) to pass into a resource call directly.
|
|
305
310
|
|
|
306
311
|
### Resolving a resource
|
|
307
312
|
|
package/docs/hosted-on-fabric.md
CHANGED
|
@@ -53,7 +53,7 @@ On Fabric, configuration goes through the component's environment, not a local `
|
|
|
53
53
|
|
|
54
54
|
| Variable | What it does | When to set it |
|
|
55
55
|
|----------|--------------|----------------|
|
|
56
|
-
| `FLAIR_PUBLIC_URL` | The URL operators reach this Flair on. Surfaced in OAuth metadata and A2A discovery. |
|
|
56
|
+
| `FLAIR_PUBLIC_URL` | The URL operators reach this Flair on. Surfaced in OAuth metadata and A2A discovery. | **`flair deploy` sets it** to the deploy target, in the component's `.env`. Set it yourself only to advertise a different host (CDN / proxy / vanity domain) — a value you set is never overwritten. |
|
|
57
57
|
| `HDB_ADMIN_PASSWORD` | Bootstrap password for the embedded Harper. | Set at install time. |
|
|
58
58
|
| `FLAIR_KEY_PASSPHRASE` | Passphrase for federation key encryption. | Set for production federation deployments. |
|
|
59
59
|
|