@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,194 @@
1
+ /**
2
+ * dcr-gate.ts — who may register an OAuth client (RFC 7591 Dynamic Client
3
+ * Registration) on flair's own authorization server.
4
+ *
5
+ * ── The state this replaces ─────────────────────────────────────────────────
6
+ * `OAuthRegister.allowCreate()` returned `true` unconditionally and the only
7
+ * gate in `post()` was a redirect-URI host match. On a publicly-reachable
8
+ * instance that means anyone can create rows in `OAuthClient` — a durable,
9
+ * replicated table — as fast as they can send requests, and every one of those
10
+ * rows is a client_id that will subsequently be honoured by `/OAuthAuthorize`.
11
+ *
12
+ * ── Registration is now OFF unless an operator turns it on ──────────────────
13
+ * `FLAIR_OAUTH_DCR_TOKEN` is the whole interface. Absent — which is every
14
+ * install that has not deliberately opted in — `POST /OAuthRegister` refuses,
15
+ * and `/OAuthMetadata` and `/.well-known/oauth-authorization-server` stop
16
+ * advertising a `registration_endpoint`, because advertising one that refuses
17
+ * everything is a discovery document that lies.
18
+ *
19
+ * ── Why ONE variable and not a mode enum ────────────────────────────────────
20
+ * There is deliberately no `FLAIR_OAUTH_DCR=open`, and no way to enable
21
+ * registration without also supplying the credential that guards it. Enabling
22
+ * and crededentialling are the SAME ACT, so the "on, and open to the internet"
23
+ * state is not reachable by forgetting an argument — it does not exist in the
24
+ * configuration space at all. A mode enum plus an optional token would put that
25
+ * state one typo away, and the difference between the two designs is only
26
+ * visible on the day someone makes the typo.
27
+ *
28
+ * That is also why a token shorter than `MIN_INITIAL_ACCESS_TOKEN_LEN` disables
29
+ * registration rather than enabling it weakly. A four-character shared secret on
30
+ * an unauthenticated public endpoint is nearer to open than to closed, and a
31
+ * control that silently degrades to almost-off is worse than one that is off,
32
+ * because only one of the two is visible. The refusal is logged, by variable
33
+ * NAME and minimum length — never the value.
34
+ *
35
+ * ── Why a header and not `Authorization: Bearer` ────────────────────────────
36
+ * RFC 7591 s3.1 presents the initial access token as a Bearer token. That
37
+ * channel does not work here, and this is measured rather than assumed: Harper's
38
+ * own auth layer claims every `Authorization: Bearer ...` header for itself and
39
+ * validates it as a Harper OPERATION token, so a Bearer-carrying request to
40
+ * `/OAuthRegister` is answered `401 {"error":"invalid token"}` before any code
41
+ * in resources/ runs. (Probed against a live instance: no header -> 200,
42
+ * `Bearer <anything>` -> 401, custom header -> 200. Same mechanism documented in
43
+ * resources/oauth-wellknown.ts's header for the REST surface.) So the token
44
+ * arrives in `X-Flair-Initial-Access-Token`, which does reach the handler.
45
+ *
46
+ * ── This is not the same surface as flair#756's DCR removal ─────────────────
47
+ * flair#756 disabled Dynamic Client Registration on the `@harperfast/oauth`
48
+ * plugin's authorization server (`/oauth/mcp/register`) in favour of CIMD, and
49
+ * deleted the gate-token machinery there. This module is about flair's OWN
50
+ * OAuth 2.1 AS in resources/OAuth.ts, a separate endpoint that was left open.
51
+ * The posture is the same one #756 settled on — registration is closed unless
52
+ * an operator deliberately opens it — arrived at through the mechanism this
53
+ * endpoint can actually support.
54
+ */
55
+ import { timingSafeEqual } from "node:crypto";
56
+ /** The header the initial access token arrives in. See the module header. */
57
+ export const INITIAL_ACCESS_TOKEN_HEADER = "x-flair-initial-access-token";
58
+ /**
59
+ * Shortest initial access token that will enable registration.
60
+ *
61
+ * The token guards an unauthenticated, publicly-reachable endpoint, so its only
62
+ * defence is length. 32 characters of anything reasonable clears any offline
63
+ * guessing concern; the per-IP rate limit on `/OAuthRegister` covers online
64
+ * guessing. Anything shorter is refused outright rather than accepted weakly.
65
+ */
66
+ export const MIN_INITIAL_ACCESS_TOKEN_LEN = 32;
67
+ /**
68
+ * Longest accepted initial access token.
69
+ *
70
+ * A bound is needed because the constant-time comparison below works over
71
+ * fixed-width buffers; it is set far above any plausible token (508 bytes is
72
+ * ~15x a 32-byte base64 secret) so it constrains nothing real. A configured
73
+ * value outside the range disables registration and says so, rather than
74
+ * failing every comparison silently for a reason no operator could deduce.
75
+ */
76
+ export const MAX_INITIAL_ACCESS_TOKEN_LEN = 508;
77
+ let warnedBadLength = false;
78
+ /** Test-only: forget the one-shot bad-length warning. */
79
+ export function __resetDcrWarningForTest() {
80
+ warnedBadLength = false;
81
+ }
82
+ /**
83
+ * The configured initial access token, or undefined when registration is off.
84
+ *
85
+ * Undefined covers all three of: variable unset, variable empty, and variable
86
+ * too short to be a credential. Callers therefore cannot accidentally treat
87
+ * "misconfigured" as "open" — there is one value that means enabled and it is a
88
+ * usable token.
89
+ */
90
+ export function initialAccessToken() {
91
+ const raw = (process.env.FLAIR_OAUTH_DCR_TOKEN ?? "").trim();
92
+ if (raw === "")
93
+ return undefined;
94
+ if (raw.length < MIN_INITIAL_ACCESS_TOKEN_LEN || Buffer.byteLength(raw, "utf8") > MAX_INITIAL_ACCESS_TOKEN_LEN) {
95
+ if (!warnedBadLength) {
96
+ warnedBadLength = true;
97
+ console.error(`[oauth-dcr] FLAIR_OAUTH_DCR_TOKEN must be between ${MIN_INITIAL_ACCESS_TOKEN_LEN} and ` +
98
+ `${MAX_INITIAL_ACCESS_TOKEN_LEN} characters — dynamic client registration stays DISABLED.`);
99
+ }
100
+ return undefined;
101
+ }
102
+ return raw;
103
+ }
104
+ /** Is `POST /OAuthRegister` open for business on this instance? */
105
+ export function dcrEnabled() {
106
+ return initialAccessToken() !== undefined;
107
+ }
108
+ /** Width of the comparison buffers: a 4-byte length prefix plus the token bytes. */
109
+ const COMPARE_WIDTH = MAX_INITIAL_ACCESS_TOKEN_LEN + 4;
110
+ /**
111
+ * Encode a value into a fixed-width buffer as `<uint32 byte length><bytes><zero
112
+ * padding>`, or null if it does not fit.
113
+ *
114
+ * The length prefix is what makes zero-padding safe. Padding alone would make
115
+ * `"abc"` and `"abc\0"` compare equal, because both pad to the same bytes;
116
+ * prefixing the length means two buffers are equal exactly when the two inputs
117
+ * are the same length AND the same bytes.
118
+ */
119
+ function fixedWidth(value) {
120
+ const bytes = Buffer.from(value, "utf8");
121
+ if (bytes.length > MAX_INITIAL_ACCESS_TOKEN_LEN)
122
+ return null;
123
+ const buf = Buffer.alloc(COMPARE_WIDTH);
124
+ buf.writeUInt32BE(bytes.length, 0);
125
+ bytes.copy(buf, 4);
126
+ return buf;
127
+ }
128
+ /**
129
+ * Does the presented token match the configured one?
130
+ *
131
+ * Both values are widened to the same fixed-size buffer and compared with
132
+ * `timingSafeEqual`. Equal width is not a convenience — it is the whole point:
133
+ *
134
+ * - `timingSafeEqual` THROWS on a length mismatch, so handing it the raw values
135
+ * would turn the configured token's length into an oracle (throw vs. false).
136
+ * - `===` on strings short-circuits at the first differing byte, so it leaks a
137
+ * prefix match through timing.
138
+ *
139
+ * Fixed-width buffers remove both, and the comparison touches every byte of the
140
+ * secret regardless of the input.
141
+ *
142
+ * DELIBERATELY NOT HASHED. An earlier revision digested both sides to get equal
143
+ * lengths. That works, but a hash of a credential sitting in an equality check
144
+ * reads — to a human and to a static analyser alike — as password-at-rest
145
+ * storage, which invites the obvious "why isn't this a KDF" question. The honest
146
+ * answer is that a KDF is the wrong primitive here: this is an equality check on
147
+ * a high-entropy machine credential on an unauthenticated request path, so
148
+ * deliberate per-request computational cost would be handing an attacker a
149
+ * cheap amplification lever on the exact endpoint the rate limiter exists to
150
+ * protect. Padding gets the same constant-time property with no hash to explain
151
+ * and no cost to exploit.
152
+ *
153
+ * Returns false whenever registration is disabled, so a caller cannot reach the
154
+ * comparison at all.
155
+ */
156
+ export function initialAccessTokenMatches(presented) {
157
+ const expected = initialAccessToken();
158
+ if (expected === undefined)
159
+ return false;
160
+ if (typeof presented !== "string" || presented === "")
161
+ return false;
162
+ const a = fixedWidth(presented);
163
+ const b = fixedWidth(expected);
164
+ // `b` cannot be null — initialAccessToken() already bounds the configured
165
+ // value — but null-checking both keeps this total rather than relying on that
166
+ // invariant holding in a future edit.
167
+ if (a === null || b === null)
168
+ return false;
169
+ return timingSafeEqual(a, b);
170
+ }
171
+ /** Read the initial access token off a Harper request. Never logs it. */
172
+ export function presentedInitialAccessToken(request) {
173
+ const raw = request?.headers?.get?.(INITIAL_ACCESS_TOKEN_HEADER) ??
174
+ request?.headers?.asObject?.[INITIAL_ACCESS_TOKEN_HEADER] ??
175
+ undefined;
176
+ if (typeof raw !== "string")
177
+ return undefined;
178
+ const trimmed = raw.trim();
179
+ return trimmed === "" ? undefined : trimmed;
180
+ }
181
+ /**
182
+ * The single decision point. `resources/OAuth.ts` calls this before it reads
183
+ * ANYTHING else off the request — before the redirect-URI check, before any
184
+ * write — so a caller who is not allowed to register learns nothing about the
185
+ * server's registration policy beyond the fact that they may not.
186
+ */
187
+ export function decideRegistration(request) {
188
+ if (!dcrEnabled())
189
+ return { allowed: false, reason: "disabled" };
190
+ if (!initialAccessTokenMatches(presentedInitialAccessToken(request))) {
191
+ return { allowed: false, reason: "invalid_token" };
192
+ }
193
+ return { allowed: true };
194
+ }
@@ -327,6 +327,7 @@ class InternalAgentTable {
327
327
  */
328
328
  export class Flair {
329
329
  #server;
330
+ #adminHandle;
330
331
  constructor(server) {
331
332
  this.#server = server;
332
333
  }
@@ -350,7 +351,10 @@ export class Flair {
350
351
  // AdminHandle requires an agentId for attribution. We use a sentinel
351
352
  // that makes the admin identity visible in audit logs. The caller
352
353
  // should use a real admin agent id when possible.
353
- return new AdminHandle(this.#server, "_admin");
354
+ // Cached: the getter returns the same handle on every access so
355
+ // flair.admin === flair.admin is true (flair#981).
356
+ this.#adminHandle ??= new AdminHandle(this.#server, "_admin");
357
+ return this.#adminHandle;
354
358
  }
355
359
  /**
356
360
  * Internal operations — trusted, unattributed, unfiltered.
@@ -27,9 +27,92 @@ import { databases } from "harper";
27
27
  import { randomBytes } from "node:crypto";
28
28
  import { TOOLS, listToolDefs } from "./mcp-tools.js";
29
29
  import { agentRecordIsAdmin } from "./agent-admin.js";
30
+ import { resolveVersion } from "./version.js";
30
31
  // The MCP protocol revision we implement (initialize handshake).
31
32
  const PROTOCOL_VERSION = "2025-06-18";
33
+ // ─── Body size cap ───────────────────────────────────────────────────────────
34
+ //
35
+ // flair#1033 — the /mcp handler reads the entire request body into memory with
36
+ // no size limit. Harper's HTTP layer imposes no cap on this path (the handler
37
+ // is registered via srv.http() which goes through Harper's own HTTP chain, not
38
+ // Fastify's 1 GB bodyLimit or the contentTypes handler's configurable 10 MB
39
+ // default). /mcp is the first surface reachable by an open population of OAuth
40
+ // clients rather than by Ed25519 agents we provisioned, so the reachability
41
+ // story is materially different from the identical pattern on any other route.
42
+ //
43
+ // 256 KB is ~100x headroom over any legitimate MCP JSON-RPC request (a
44
+ // memory_store with a large content field is a few KB; even a batch of 100
45
+ // would be well under this). It is small enough that an attacker cannot
46
+ // meaningfully consume memory through this path.
47
+ const MAX_MCP_BODY_SIZE = 256 * 1024; // 256 KB
32
48
  const JSON_HEADERS = { "content-type": "application/json" };
49
+ // ─── Body size cap helpers ───────────────────────────────────────────────────
50
+ /**
51
+ * Read the request body into a string, enforcing a hard byte cap.
52
+ *
53
+ * Two-phase enforcement:
54
+ * 1. Content-Length check (reject before reading a single byte when the
55
+ * client declares an oversized body).
56
+ * 2. Streaming read with a cap (catches chunked transfer encoding where
57
+ * Content-Length is absent, and a client that lies about its declared
58
+ * length).
59
+ *
60
+ * Handles three request shapes:
61
+ * - Production: Harper Request with async-iterable `request.body` (RequestBody
62
+ * wrapping Node IncomingMessage).
63
+ * - Test doubles: `request.text()` as a function (returns pre-built string),
64
+ * or `request.body` as a plain string.
65
+ *
66
+ * Throws an error with `code: "BODY_TOO_LARGE"` when the cap is exceeded, so
67
+ * the caller can distinguish a size rejection from a parse failure.
68
+ */
69
+ async function readBodyCapped(request, maxBytes) {
70
+ // Phase 1: trust-but-verify the declared Content-Length.
71
+ const contentLength = request?.headers?.get?.("content-length");
72
+ if (contentLength != null) {
73
+ const declared = Number(contentLength);
74
+ if (!Number.isFinite(declared) || declared < 0) {
75
+ throw Object.assign(new Error(`invalid Content-Length: ${contentLength}`), { code: "BODY_TOO_LARGE" });
76
+ }
77
+ if (declared > maxBytes) {
78
+ throw Object.assign(new Error(`request body too large: ${declared} bytes exceeds ${maxBytes}-byte limit`), { code: "BODY_TOO_LARGE" });
79
+ }
80
+ }
81
+ // Phase 2: read with a cap.
82
+ const body = request.body;
83
+ // Test double: body is already a plain string.
84
+ if (typeof body === "string") {
85
+ if (Buffer.byteLength(body) > maxBytes) {
86
+ throw Object.assign(new Error(`request body too large: exceeds ${maxBytes}-byte limit`), { code: "BODY_TOO_LARGE" });
87
+ }
88
+ return body;
89
+ }
90
+ // Test double: request.text() is provided as a function (returns a string).
91
+ if (typeof request.text === "function") {
92
+ const text = await request.text();
93
+ if (typeof text === "string" && Buffer.byteLength(text) > maxBytes) {
94
+ throw Object.assign(new Error(`request body too large: exceeds ${maxBytes}-byte limit`), { code: "BODY_TOO_LARGE" });
95
+ }
96
+ return text;
97
+ }
98
+ // Production: Harper Request.body is a RequestBody (async-iterable, wraps
99
+ // Node IncomingMessage). Read chunk by chunk with a running cap.
100
+ if (body && typeof body[Symbol.asyncIterator] === "function") {
101
+ const chunks = [];
102
+ let total = 0;
103
+ for await (const chunk of body) {
104
+ const buf = typeof chunk === "string" ? Buffer.from(chunk) : chunk;
105
+ total += buf.length;
106
+ if (total > maxBytes) {
107
+ throw Object.assign(new Error(`request body too large: exceeds ${maxBytes}-byte limit`), { code: "BODY_TOO_LARGE" });
108
+ }
109
+ chunks.push(buf);
110
+ }
111
+ return Buffer.concat(chunks).toString("utf-8");
112
+ }
113
+ // Fallback: body is absent or an unrecognised shape.
114
+ return String(body ?? "");
115
+ }
33
116
  // ─── JSON-RPC helpers ────────────────────────────────────────────────────────
34
117
  function rpcResult(id, result) {
35
118
  return { status: 200, headers: JSON_HEADERS, body: JSON.stringify({ jsonrpc: "2.0", id, result }) };
@@ -195,13 +278,17 @@ export async function mcpHandler(request) {
195
278
  if (method !== "POST") {
196
279
  return rpcError(null, -32600, "method not allowed: /mcp accepts JSON-RPC POST only", 405);
197
280
  }
198
- // Parse the JSON-RPC body. Harper's Request wraps a Node stream — read text.
281
+ // Parse the JSON-RPC body with a size cap. Harper's Request wraps a Node
282
+ // stream — read text, but never unbounded.
199
283
  let msg;
200
284
  try {
201
- const text = typeof request.text === "function" ? await request.text() : request.body;
285
+ const text = await readBodyCapped(request, MAX_MCP_BODY_SIZE);
202
286
  msg = typeof text === "string" ? JSON.parse(text) : text;
203
287
  }
204
- catch {
288
+ catch (err) {
289
+ if (err?.code === "BODY_TOO_LARGE") {
290
+ return rpcError(null, -32000, err.message, 413);
291
+ }
205
292
  return rpcError(null, -32700, "parse error: invalid JSON");
206
293
  }
207
294
  if (!msg || typeof msg !== "object" || msg.jsonrpc !== "2.0" || typeof msg.method !== "string") {
@@ -213,7 +300,7 @@ export async function mcpHandler(request) {
213
300
  return rpcResult(id, {
214
301
  protocolVersion: PROTOCOL_VERSION,
215
302
  capabilities: { tools: {} },
216
- serverInfo: { name: "flair", version: "0.1.0" },
303
+ serverInfo: { name: "flair", version: resolveVersion() },
217
304
  });
218
305
  // Notifications (no id) — acknowledge with 202-ish empty 200; MCP clients send
219
306
  // `notifications/initialized` after initialize.
@@ -19,14 +19,73 @@
19
19
  */
20
20
  import * as harper from "harper";
21
21
  import { mcpOAuthEnabled, mcpAuthConfig } from "./mcp-oauth-flag.js";
22
+ import { checkMcpRateLimit } from "./rate-limit.js";
23
+ /**
24
+ * Initial value: no route has been registered yet, which is literally true until
25
+ * `registerMcpOAuthRoute` runs — a reader between module load and registration
26
+ * would get a 404 from `/mcp`, so reporting "not mounted" is accurate rather
27
+ * than merely safe. It also stays correct for an embedder that sets
28
+ * FLAIR_MCP_NO_AUTOSTART and never calls the registration function.
29
+ */
30
+ let routeState = {
31
+ mounted: false,
32
+ status: "Not mounted",
33
+ reason: "MCP route registration has not run.",
34
+ };
35
+ /** Read the mount decision the router recorded. */
36
+ export function mcpRouteState() {
37
+ return routeState;
38
+ }
39
+ /**
40
+ * Record a decision and return its `mounted` value. Every `return` in
41
+ * `registerMcpOAuthRoute` goes through here, so a branch cannot decide the
42
+ * route's fate without publishing that decision.
43
+ */
44
+ function decide(state) {
45
+ routeState = state;
46
+ return state.mounted;
47
+ }
22
48
  async function defaultLoadWithMCPAuth() {
23
49
  // Dynamic import so the dep is only required when the surface is enabled.
24
50
  const mod = (await import("@harperfast/oauth"));
25
51
  return mod.withMCPAuth;
26
52
  }
53
+ /**
54
+ * Wrap the /mcp handler in the per-subject rate limit.
55
+ *
56
+ * Placed INSIDE `withMCPAuth` — i.e. the guard runs first and this runs second —
57
+ * so the key is the RS256-verified `sub` from the token rather than a network
58
+ * address. That is the identity worth limiting on for an authenticated surface:
59
+ * it is the authorization server's own assertion, it survives the caller
60
+ * changing address, and it is exactly the thing "a valid token can hammer the
61
+ * tools" is about. Authentication is not a rate limit.
62
+ *
63
+ * The consequence of that placement, stated rather than left implicit: requests
64
+ * bearing an INVALID token are rejected by `withMCPAuth` before this runs and so
65
+ * are not counted here. Those cost one JWT verification against a locally-cached
66
+ * key and touch no flair table or tool, which is a materially cheaper path than
67
+ * a tool call — but it is not zero, and it is not throttled at this layer.
68
+ *
69
+ * Exported for tests: the limiter's behaviour is asserted directly on this
70
+ * wrapper, without needing the plugin present.
71
+ */
72
+ export function rateLimitedMcpHandler(handler) {
73
+ return async (request) => {
74
+ const limited = checkMcpRateLimit(request);
75
+ if (limited)
76
+ return limited;
77
+ return handler(request);
78
+ };
79
+ }
27
80
  export async function registerMcpOAuthRoute(deps = {}) {
28
- if (!mcpOAuthEnabled())
29
- return false; // OFF → no route, no import, no side effects.
81
+ if (!mcpOAuthEnabled()) {
82
+ // OFF → no route, no import, no side effects.
83
+ return decide({
84
+ mounted: false,
85
+ status: "Not enabled",
86
+ reason: "Set FLAIR_MCP_OAUTH=1 (and an issuer) to serve MCP over HTTP.",
87
+ });
88
+ }
30
89
  const config = mcpAuthConfig();
31
90
  if (!config) {
32
91
  // Flag on but issuer unset → we cannot safely pin iss/aud. Do NOT mount an
@@ -34,7 +93,11 @@ export async function registerMcpOAuthRoute(deps = {}) {
34
93
  // is the clearer signal). Log and bail — the operator must set FLAIR_MCP_ISSUER.
35
94
  console.error("[mcp-oauth] FLAIR_MCP_OAUTH is on but no issuer configured " +
36
95
  "(set FLAIR_MCP_ISSUER or FLAIR_PUBLIC_URL) — /mcp NOT mounted.");
37
- return false;
96
+ return decide({
97
+ mounted: false,
98
+ status: "Not mounted",
99
+ reason: "FLAIR_MCP_OAUTH is on but no issuer is configured — set FLAIR_MCP_ISSUER (or FLAIR_PUBLIC_URL).",
100
+ });
38
101
  }
39
102
  let withMCPAuth;
40
103
  try {
@@ -42,11 +105,22 @@ export async function registerMcpOAuthRoute(deps = {}) {
42
105
  }
43
106
  catch (err) {
44
107
  console.error("[mcp-oauth] @harperfast/oauth not available — /mcp NOT mounted: " + (err?.message ?? err));
45
- return false;
108
+ // The underlying error text stays in the log rather than being carried into
109
+ // an operator-facing string: it is arbitrary text from a dependency, and the
110
+ // admin page is HTML.
111
+ return decide({
112
+ mounted: false,
113
+ status: "Not mounted",
114
+ reason: "The @harperfast/oauth plugin could not be loaded — see the server log.",
115
+ });
46
116
  }
47
117
  if (typeof withMCPAuth !== "function") {
48
118
  console.error("[mcp-oauth] @harperfast/oauth has no withMCPAuth export — /mcp NOT mounted.");
49
- return false;
119
+ return decide({
120
+ mounted: false,
121
+ status: "Not mounted",
122
+ reason: "The @harperfast/oauth plugin has no withMCPAuth export — see the server log.",
123
+ });
50
124
  }
51
125
  // Resolve the handler lazily (injected in tests; real module otherwise) — see
52
126
  // the top-of-file note on why it isn't a static import.
@@ -60,11 +134,11 @@ export async function registerMcpOAuthRoute(deps = {}) {
60
134
  // wrapper's iss/aud checks match the minted tokens even if this component
61
135
  // resolves a different node_modules copy of the plugin (docs/mcp-oauth.md
62
136
  // §"Using withMCPAuth from a different component").
63
- srv.http(withMCPAuth(handler, {
137
+ srv.http(withMCPAuth(rateLimitedMcpHandler(handler), {
64
138
  getConfig: () => mcpAuthConfig(),
65
139
  }), { urlPath: "/mcp" });
66
140
  console.error(`[mcp-oauth] /mcp mounted (OAuth-guarded); issuer=${config.issuer}`);
67
- return true;
141
+ return decide({ mounted: true });
68
142
  }
69
143
  // Fire-and-forget at module load. Any failure is contained inside
70
144
  // registerMcpOAuthRoute (it logs and returns) so it can never crash flair boot.
@@ -81,6 +155,14 @@ export async function registerMcpOAuthRoute(deps = {}) {
81
155
  // deployment.)
82
156
  if (process.env.FLAIR_MCP_NO_AUTOSTART == null) {
83
157
  void registerMcpOAuthRoute().catch((err) => {
158
+ // A throw escaping registerMcpOAuthRoute means the mount never happened, so
159
+ // record that too — otherwise mcpRouteState() would keep reporting whatever
160
+ // the last completed decision was.
161
+ decide({
162
+ mounted: false,
163
+ status: "Not mounted",
164
+ reason: "MCP route registration failed — see the server log.",
165
+ });
84
166
  console.error("[mcp-oauth] route registration failed (surface not mounted): " + (err?.message ?? err));
85
167
  });
86
168
  }
@@ -159,6 +159,37 @@ async function memoryStore(agent, args) {
159
159
  // id post-commit through the shared usage ledger).
160
160
  if (Array.isArray(args?.usedMemoryIds))
161
161
  body.usedMemoryIds = args.usedMemoryIds;
162
+ // flair#991 writer-controlled sharing intent. Forwarded ONLY when the caller
163
+ // actually supplied it, so an omitted visibility delegates a byte-identical
164
+ // body and Memory.post() applies its durability-keyed default.
165
+ //
166
+ // ── Why an unrecognized value is REJECTED, not dropped and not passed on ──
167
+ // `visibility` is a free-form String in schemas/memory.graphql, and the read
168
+ // scope asks `isPrivateVisibility()` — an exact match on the literal
169
+ // "private" — so EVERY other string, typos included, reads as non-private
170
+ // and is returned to every agent on the instance. Both of the softer
171
+ // options therefore fail in the unsafe direction:
172
+ // - forwarding it: `visibility: "prvate"` persists a row the caller
173
+ // believes is owner-only and that every agent can in fact read;
174
+ // - silently dropping it: falls back to the durability-keyed default,
175
+ // which for a permanent/persistent write is `shared` — same outcome,
176
+ // with no argument left in the record to explain it.
177
+ // A misspelled argument must never widen who can read a memory, so the tool
178
+ // call fails and says so. The allowlist is deliberately not derived from
179
+ // isPrivateVisibility(): that predicate must stay "is it exactly private"
180
+ // for the no-visibility-field migration invariant (see
181
+ // resources/memory-visibility.ts), which is a READ-side rule and cannot
182
+ // double as a WRITE-side allowlist.
183
+ if (args?.visibility !== undefined && args?.visibility !== null) {
184
+ if (args.visibility !== "private" && args.visibility !== "shared") {
185
+ return {
186
+ error: "invalid_visibility",
187
+ status: 400,
188
+ message: `visibility must be "private" or "shared" (got: ${JSON.stringify(args.visibility)}). Omit it to use the durability-keyed default: permanent/persistent -> shared, standard/ephemeral -> private.`,
189
+ };
190
+ }
191
+ body.visibility = args.visibility;
192
+ }
162
193
  return unwrap(await h.post(body));
163
194
  }
164
195
  /**
@@ -407,6 +438,15 @@ export const TOOLS = {
407
438
  type: { type: "string", enum: ["session", "lesson", "decision", "preference", "fact", "goal"], description: "Memory type (default session)" },
408
439
  durability: { type: "string", enum: ["permanent", "persistent", "standard", "ephemeral"], description: "permanent > persistent > standard > ephemeral (default standard)" },
409
440
  tags: { type: "array", items: { type: "string" }, description: "Tag strings" },
441
+ visibility: {
442
+ type: "string",
443
+ enum: ["private", "shared"],
444
+ description: "Writer-controlled sharing intent. Omit to use the server's durability-keyed default: " +
445
+ "permanent/persistent -> shared, standard/ephemeral -> private. " +
446
+ "private — owner-only, never visible to another agent, even one holding a memory grant. " +
447
+ "shared — visible to the owner and every other agent on this instance. " +
448
+ "The visibility the write actually landed on is returned in the result.",
449
+ },
410
450
  usedMemoryIds: { type: "array", items: { type: "string" }, description: "IDs of memories that informed this write (citation-on-write). Credited via the same deduped usage ledger as record_usage. Optional." },
411
451
  },
412
452
  required: ["content"],