@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.
@@ -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
- Clients register automatically on first connection:
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": ["http://localhost:3000/callback"],
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` and `client_secret` (if applicable).
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 | Server metadata |
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
- Also set `FLAIR_PUBLIC_URL` to that URL in the component's environment OAuth metadata
82
- and A2A discovery advertise it, or clients see a loopback address.
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
 
@@ -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 / VPS deployments.** Local-only installs can leave it unset. |
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. Use the primitives when you are building your own abstraction on top of Flair's resources.
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
 
@@ -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. | **Always set** or clients see a loopback address. |
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