@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,328 @@
1
+ /**
2
+ * launchd-management.ts — "is this instance actually under launchd?", and
3
+ * "why did launchd refuse to run it?" (flair#1022).
4
+ *
5
+ * `flair upgrade` on macOS restarts through launchd and falls back to a plain
6
+ * detached spawn when a launchd operation fails. The fallback is the right
7
+ * behaviour — a running instance beats a down one — but it changes a load
8
+ * bearing property of the install that nothing was measuring: the process is no
9
+ * longer owned by a service manager, so it does not come back after a reboot.
10
+ * The reported incident ended in `verified: healthy, authenticated, running
11
+ * <new version>`, every word of which was true, while the instance had just
12
+ * been orphaned. **Healthy and managed are different claims** and only the
13
+ * first was being made.
14
+ *
15
+ * Two things live here, and the split matters:
16
+ *
17
+ * 1. `assessLaunchdManagement()` — an OBSERVATION taken after the fact.
18
+ * Deliberately not a flag threaded down from whichever function did the
19
+ * falling back: `flair upgrade` hands its restart to the NEWLY INSTALLED
20
+ * CLI in a child process (flair#905), so no in-process bookkeeping
21
+ * survives the boundary. Asking launchd directly, at verification time,
22
+ * is the only form of this check that works on both the delegated and
23
+ * the in-process path — and it also catches a detachment this run did
24
+ * not cause.
25
+ *
26
+ * 2. `diagnoseLaunchdPlistPaths()` — a PRE-FLIGHT taken before the fact, and
27
+ * the reason the incident took two minutes to produce no information.
28
+ * **`launchctl load` and `launchctl start` both exit 0 for a job whose
29
+ * program does not exist.** Measured on macOS 15, not assumed: loading a
30
+ * plist whose ProgramArguments[0] points at a deleted path succeeds,
31
+ * `start` succeeds, and the only evidence of the failure is a nonzero
32
+ * `LastExitStatus` and a missing `PID` in `launchctl list <label>` after
33
+ * the fact. So the CLI's launchd path cannot learn anything from
34
+ * launchctl's exit codes; it waits the full startup budget for a port
35
+ * that was never going to open, and then falls back.
36
+ *
37
+ * A plist records absolute paths — the node binary (`process.execPath` at
38
+ * `flair init` time), Harper's entrypoint under that same install's
39
+ * `node_modules`, and the package working directory. Switch Node runtimes
40
+ * with a version manager and every one of them can move. That is knowable
41
+ * from a `readFileSync` and an `existsSync`, in microseconds, and it names
42
+ * both the stale path and the fix.
43
+ *
44
+ * Never logs plist CONTENTS. The plist embeds HDB_ADMIN_PASSWORD; only
45
+ * extracted program/working-directory paths ever reach a message, and the
46
+ * extractor below reads exactly those keys rather than returning the document.
47
+ */
48
+ import { existsSync, readFileSync } from "node:fs";
49
+ import { unescapeXml } from "./xml-escape.js";
50
+ /**
51
+ * Ceiling on the `launchctl list` spawn. A status query answers instantly when
52
+ * launchd is reachable; this only exists so an unreachable service manager
53
+ * cannot turn a post-upgrade summary line into a hang. Same reasoning as
54
+ * scheduler-platform's STATUS_CHECK_TIMEOUT_MS.
55
+ */
56
+ export const LAUNCHCTL_QUERY_TIMEOUT_MS = 5_000;
57
+ /**
58
+ * Extract ONLY the exec-related paths from a plist: ProgramArguments and
59
+ * WorkingDirectory.
60
+ *
61
+ * Regex rather than a plist parser for the same reason `readPlistRootPath`
62
+ * uses one — this reads documents `buildLaunchdPlist` wrote, the shape is
63
+ * fixed, and a parser would pull the whole `EnvironmentVariables` dict
64
+ * (including the admin password) into memory to answer a question about two
65
+ * keys. Values are XML-escaped on the way in, so they are unescaped on the way
66
+ * out; a path containing `&` is stored as `&amp;` and returned as `&`.
67
+ *
68
+ * Returns null when the file cannot be read at all. A readable plist with
69
+ * neither key returns an empty/null refs object, which callers treat as "no
70
+ * evidence" rather than "broken" — a hand-written plist that uses `Program`
71
+ * instead of `ProgramArguments` is not ours to judge.
72
+ */
73
+ export function readPlistProgramRefs(plistPath, read = (p) => readFileSync(p, "utf-8")) {
74
+ let raw;
75
+ try {
76
+ raw = read(plistPath);
77
+ }
78
+ catch {
79
+ return null;
80
+ }
81
+ const programArguments = [];
82
+ const argsBlock = raw.match(/<key>ProgramArguments<\/key>\s*<array>([\s\S]*?)<\/array>/);
83
+ if (argsBlock) {
84
+ for (const m of argsBlock[1].matchAll(/<string>([^<]*)<\/string>/g)) {
85
+ programArguments.push(unescapeXml(m[1]));
86
+ }
87
+ }
88
+ const wd = raw.match(/<key>WorkingDirectory<\/key>\s*<string>([^<]*)<\/string>/);
89
+ return { programArguments, workingDirectory: wd ? unescapeXml(wd[1]) : null };
90
+ }
91
+ /**
92
+ * Does this plist still point at things that exist?
93
+ *
94
+ * Only ABSOLUTE paths are checked. `ProgramArguments` for a Flair service is
95
+ * `[<node>, <harper entrypoint>, "run", "."]` — the trailing literals are
96
+ * arguments, not paths, and an install whose plist was hand-edited to use a
97
+ * relative program is resolved by launchd against WorkingDirectory in a way
98
+ * this check has no business second-guessing. A missing absolute path, by
99
+ * contrast, is not ambiguous: launchd cannot exec it, and will not say so.
100
+ *
101
+ * The remedy is `flair init` because init unconditionally rewrites the plist
102
+ * from `process.execPath` and the currently-resolved Harper entrypoint — it is
103
+ * what re-points a service at the Node the operator is actually using now —
104
+ * followed by a restart to bring the job up under the rewritten plist.
105
+ */
106
+ export function diagnoseLaunchdPlistPaths(plistPath, deps = {}) {
107
+ const exists = deps.exists ?? existsSync;
108
+ const refs = readPlistProgramRefs(plistPath, deps.read);
109
+ if (!refs)
110
+ return null;
111
+ const remedy = ["flair init", "flair restart"];
112
+ const rewriteNote = "`flair init` rewrites the plist against the Node runtime in use now, and `flair restart` " +
113
+ "brings the job back up under it.";
114
+ for (const arg of refs.programArguments) {
115
+ if (!arg.startsWith("/"))
116
+ continue;
117
+ if (exists(arg))
118
+ continue;
119
+ return {
120
+ kind: "ProgramArguments",
121
+ stalePath: arg,
122
+ message: `the launchd plist at ${plistPath} runs ${arg}, which no longer exists. ` +
123
+ `launchd cannot exec a missing program and reports no error for it — ` +
124
+ `load and start both succeed and the service never comes up. ` +
125
+ `A plist commonly goes stale like this after switching Node runtimes, ` +
126
+ `which moves both the node binary and the globally installed package tree. ` +
127
+ rewriteNote,
128
+ remedy,
129
+ };
130
+ }
131
+ const wd = refs.workingDirectory;
132
+ if (wd && wd.startsWith("/") && !exists(wd)) {
133
+ return {
134
+ kind: "WorkingDirectory",
135
+ stalePath: wd,
136
+ message: `the launchd plist at ${plistPath} sets WorkingDirectory to ${wd}, which no longer exists. ` +
137
+ `launchd refuses to spawn a job whose working directory is missing, and reports no error for it — ` +
138
+ `load and start both succeed and the service never comes up. ` +
139
+ rewriteNote,
140
+ remedy,
141
+ };
142
+ }
143
+ return null;
144
+ }
145
+ /**
146
+ * Parse `launchctl list <label>` output.
147
+ *
148
+ * The output is a plist-ish dict of `"Key" = value;` lines. The two that
149
+ * matter: `"PID"` is present ONLY while the job is running, and
150
+ * `"LastExitStatus"` records how the last run ended. A job whose program is
151
+ * missing has no PID and a nonzero LastExitStatus — that combination is the
152
+ * signature of the failure this module exists to name.
153
+ */
154
+ export function parseLaunchctlList(output) {
155
+ const pidMatch = output.match(/"PID"\s*=\s*(\d+)\s*;/);
156
+ const exitMatch = output.match(/"LastExitStatus"\s*=\s*(-?\d+)\s*;/);
157
+ return {
158
+ pid: pidMatch ? Number(pidMatch[1]) : null,
159
+ lastExitStatus: exitMatch ? Number(exitMatch[1]) : null,
160
+ };
161
+ }
162
+ export function readLaunchctlJobState(label, list) {
163
+ let res;
164
+ try {
165
+ res = list(label);
166
+ }
167
+ catch {
168
+ return { registered: false, pid: null, lastExitStatus: null };
169
+ }
170
+ if (res.code !== 0)
171
+ return { registered: false, pid: null, lastExitStatus: null };
172
+ const { pid, lastExitStatus } = parseLaunchctlList(res.stdout);
173
+ return { registered: true, pid, lastExitStatus };
174
+ }
175
+ /**
176
+ * Which PID to treat as "the process serving this instance".
177
+ *
178
+ * Harper's own `hdb.pid` is preferred — it is written by the serving process on
179
+ * every boot regardless of who spawned it, so it is the same number on the
180
+ * launchd path and on the direct-spawn fallback, which is what makes comparing
181
+ * it against launchd's reported PID a real comparison.
182
+ *
183
+ * The liveness check is the part that is easy to leave out and expensive to
184
+ * omit. A `hdb.pid` left behind by a process that is gone names a PID that
185
+ * matches nothing, and a mismatch is what this module reports as DETACHED — so
186
+ * a stale file would produce a loud, wrong warning on a perfectly healthy
187
+ * launchd install. A check that cries wolf on healthy installs is worse than no
188
+ * check at all, because it is the reason the real warning gets skipped. A dead
189
+ * PID is no evidence, so it is discarded and the port listener answers instead.
190
+ */
191
+ export function pickInstancePid(input) {
192
+ const { pidFilePid, isAlive, listeningPids } = input;
193
+ if (pidFilePid !== null && isAlive(pidFilePid))
194
+ return pidFilePid;
195
+ return listeningPids.length > 0 ? listeningPids[0] : null;
196
+ }
197
+ /** True when the instance is running outside the service manager that is registered to own it. */
198
+ export function isDetached(m) {
199
+ return m.state === "detached";
200
+ }
201
+ /**
202
+ * Is this instance under launchd right now?
203
+ *
204
+ * The evidence, in the order it is weighed:
205
+ *
206
+ * - Not darwin, or no plist for this data dir ⇒ nothing claims to manage it,
207
+ * and there is no degradation to report. `no-service` is deliberately NOT
208
+ * an alarm: an instance that was never registered has not lost anything,
209
+ * and warning about it on every run is how a real warning gets ignored.
210
+ * - `launchctl list <label>` cannot find the label, although the plist is on
211
+ * disk ⇒ **detached**. The service exists but is not loaded.
212
+ * - launchd reports no PID for the label ⇒ **detached**. The registered job
213
+ * is not running, so whatever is serving the port is not launchd's.
214
+ * `LastExitStatus` is carried into the detail because it is the difference
215
+ * between "never started" and "started and died".
216
+ * - launchd reports a PID that is not the instance's PID ⇒ **detached**, and
217
+ * this is the exact shape the incident produced: launchd holds a job that
218
+ * is failing, while a directly-spawned process answers on the port.
219
+ * - launchd reports a PID and we cannot read the instance's own ⇒ **managed**.
220
+ * A live job under this instance's label is positive evidence; refusing to
221
+ * believe it because `hdb.pid` was unreadable would warn on healthy
222
+ * installs, which is its own defect.
223
+ *
224
+ * A parent-process check is NOT used, and that is worth stating because it is
225
+ * the obvious first idea: the direct-start fallback spawns `detached: true` and
226
+ * `unref()`s, so once the CLI exits its child is reparented to PID 1 — exactly
227
+ * like a launchd-managed job. Both paths look identical from the parent PID,
228
+ * so the parent PID cannot distinguish them.
229
+ */
230
+ export function assessLaunchdManagement(input) {
231
+ const { platform, label, plistPath, instancePid, plistExists, list } = input;
232
+ if (platform !== "darwin") {
233
+ return { state: "not-applicable", detail: `${platform} does not use launchd` };
234
+ }
235
+ if (!plistExists(plistPath)) {
236
+ return { state: "no-service", detail: `no launchd service is registered for this instance (${plistPath})` };
237
+ }
238
+ const job = readLaunchctlJobState(label, list);
239
+ const diagnose = input.diagnose ?? ((p) => diagnoseLaunchdPlistPaths(p));
240
+ const detachedRemedy = () => {
241
+ const stale = diagnose(plistPath);
242
+ if (!stale) {
243
+ return {
244
+ remedy: ["flair restart"],
245
+ because: "",
246
+ };
247
+ }
248
+ return { remedy: stale.remedy, because: ` Cause: ${stale.message}` };
249
+ };
250
+ if (!job.registered) {
251
+ const { remedy, because } = detachedRemedy();
252
+ return {
253
+ state: "detached",
254
+ label,
255
+ detail: `the launchd service ${label} is registered on disk but not loaded, so launchd is not managing this instance.${because}`,
256
+ remedy,
257
+ };
258
+ }
259
+ if (job.pid === null) {
260
+ const { remedy, because } = detachedRemedy();
261
+ const exit = job.lastExitStatus === null
262
+ ? ""
263
+ : ` launchd's last run of it exited ${job.lastExitStatus}.`;
264
+ return {
265
+ state: "detached",
266
+ label,
267
+ detail: `the launchd job ${label} is loaded but not running, so whatever is serving this instance was not started by launchd.${exit}${because}`,
268
+ remedy,
269
+ };
270
+ }
271
+ if (instancePid !== null && instancePid !== job.pid) {
272
+ const { remedy, because } = detachedRemedy();
273
+ return {
274
+ state: "detached",
275
+ label,
276
+ detail: `this instance is served by process ${instancePid}, but launchd's job ${label} is process ${job.pid} — ` +
277
+ `the running instance is not the one launchd manages.${because}`,
278
+ remedy,
279
+ };
280
+ }
281
+ return { state: "managed", label, detail: `launchd job ${label} is running as process ${job.pid}` };
282
+ }
283
+ /**
284
+ * The lines to print for a degraded (detached) outcome.
285
+ *
286
+ * Kept here rather than at the two call sites so `flair restart` and `flair
287
+ * upgrade` cannot drift into saying different things about the same condition,
288
+ * and so the wording is assertable in a unit test without running either
289
+ * command. Deliberately says what is WRONG (not managed), what it COSTS (no
290
+ * restart after reboot), and what to DO — an operator who reads only the first
291
+ * line still knows they have to act.
292
+ */
293
+ export function renderDetachedWarning(m, headline) {
294
+ const lines = [`⚠️ ${headline}`, ` ${m.detail}`];
295
+ lines.push(" This instance will NOT come back after a reboot until launchd manages it again.");
296
+ // One paste-able line, not one command per line: an operator copying a fix
297
+ // out of a warning copies a line, and a two-step fix pasted as one step is
298
+ // how half a remedy gets applied.
299
+ if (m.remedy?.length)
300
+ lines.push(` Fix: ${m.remedy.join(" && ")}`);
301
+ return lines;
302
+ }
303
+ /**
304
+ * The final status line of a successful `flair upgrade`, given what the probe
305
+ * found AND what launchd is doing.
306
+ *
307
+ * This is the reported defect reduced to a function, on purpose. The bug was
308
+ * never in the probe — `healthy, authenticated, running <version>` was true —
309
+ * it was that those three facts were rendered as an unqualified ✅ while a
310
+ * fourth, unmeasured fact (the instance had been dropped out of its process
311
+ * manager) was the one that mattered. Deciding it here rather than inline in
312
+ * the command means the decision is testable without performing an upgrade,
313
+ * which on the darwin path nothing else can do: no CI lane runs it.
314
+ *
315
+ * The verified facts are still reported in the degraded case. The upgrade DID
316
+ * land, and hiding that would swap one misleading summary for another; what
317
+ * changes is the marker and the sentence around it.
318
+ */
319
+ export function renderVerifiedSummary(version, m) {
320
+ const facts = `healthy, authenticated${version ? `, running ${version}` : ""}`;
321
+ if (!isDetached(m)) {
322
+ return { degraded: false, lines: [`✅ verified: ${facts}`] };
323
+ }
324
+ return {
325
+ degraded: true,
326
+ lines: renderDetachedWarning(m, `upgrade landed (${facts}) but the instance is NOT running under launchd.`),
327
+ };
328
+ }
@@ -559,6 +559,25 @@ export async function selfVerifyMcpMetadata(issuer, deps = {}) {
559
559
  detail: `${url} responded but the metadata shape is unexpected (issuer/registration_endpoint/token_endpoint) — got issuer=${JSON.stringify(body?.issuer)}`,
560
560
  };
561
561
  }
562
+ // flair#1000: this path is now served by flair ITSELF when FLAIR_MCP_OAUTH is
563
+ // off, so a 200 no longer proves the plugin answered. flair's own document
564
+ // (resources/oauth-discovery.ts) is identifiable by its token endpoint —
565
+ // `<issuer>/OAuthToken`, where the plugin's is `<issuer>/oauth/mcp/token`.
566
+ // Name the real cause here: before this existed the operator got a 404 whose
567
+ // message already asked the right question, and falling through to the CIMD
568
+ // branch would send them to a plugin knob that is not the problem.
569
+ if (body.token_endpoint === `${normalizedIssuer}/OAuthToken`) {
570
+ return {
571
+ ok: false,
572
+ issuer: body.issuer,
573
+ registrationEndpoint: body.registration_endpoint,
574
+ tokenEndpoint: body.token_endpoint,
575
+ detail: `${url} answered with flair's OWN OAuth 2.1 authorization server, not the MCP one ` +
576
+ `(token_endpoint=${body.token_endpoint}) — the /mcp surface is NOT enabled on that instance. ` +
577
+ `Is FLAIR_MCP_OAUTH actually set on the restarted instance, and is the '@harperfast/oauth' ` +
578
+ `component declared in its config.yaml?`,
579
+ };
580
+ }
562
581
  // flair#756: confirm CIMD is actually advertised (node_modules/@harperfast/
563
582
  // oauth/dist/lib/mcp/wellKnown.js:129-165's buildAuthorizationServerMetadata:
564
583
  // `client_id_metadata_document_supported` is set only when
@@ -1,5 +1,6 @@
1
1
  import { Resource } from "harper";
2
- import { layout, htmlResponse } from "./admin-layout.js";
2
+ import { layout, htmlResponse, esc } from "./admin-layout.js";
3
+ import { mcpRouteState } from "./mcp-oauth.js";
3
4
  import { existsSync, readFileSync } from "node:fs";
4
5
  import { join, dirname } from "node:path";
5
6
  import { homedir } from "node:os";
@@ -98,6 +99,23 @@ export class AdminInstance extends Resource {
98
99
  const ctx = this.getContext?.() ?? {};
99
100
  const request = ctx.request ?? ctx;
100
101
  const publicUrl = resolvePublicUrl(request);
102
+ // The MCP row (flair#1001). Every other row in the Endpoints table is a
103
+ // route this component always registers; `/mcp` is the one that may not be
104
+ // there — it is default-OFF, and even with FLAIR_MCP_OAUTH on it does not
105
+ // mount without an issuer. This page used to print the URL unconditionally,
106
+ // so a default install advertised an endpoint that 404s.
107
+ //
108
+ // The state comes from the router itself (mcp-oauth.ts records the outcome
109
+ // of its own mount decision) rather than from a second read of the flag
110
+ // here — a second read is a second source of truth, which is this bug one
111
+ // level up. The row is kept, not dropped: a missing row tells an operator
112
+ // nothing, while a named status and the variable that changes it is
113
+ // actionable and still teaches them the surface exists.
114
+ const mcp = mcpRouteState();
115
+ const mcpCell = mcp.mounted
116
+ ? `<code>${publicUrl}/mcp</code>`
117
+ : `<span class="badge badge-gray">${esc(mcp.status)}</span>` +
118
+ `<div style="margin-top:4px;color:#666;font-size:0.9em">${esc(mcp.reason)}</div>`;
101
119
  // Try to read instance public key
102
120
  let publicKey = "—";
103
121
  const keyDir = join(homedir(), ".flair", "keys");
@@ -139,7 +157,7 @@ export class AdminInstance extends Resource {
139
157
  <h3>Endpoints</h3>
140
158
  <table style="box-shadow:none">
141
159
  <tr><td>API</td><td><code>${publicUrl}/</code></td></tr>
142
- <tr><td>MCP</td><td><code>${publicUrl}/mcp</code></td></tr>
160
+ <tr><td>MCP</td><td>${mcpCell}</td></tr>
143
161
  <tr><td>OAuth Discovery</td><td><code>${publicUrl}/OAuthMetadata</code></td></tr>
144
162
  <tr><td>OAuth Authorize</td><td><code>${publicUrl}/OAuthAuthorize</code></td></tr>
145
163
  <tr><td>OAuth Token</td><td><code>${publicUrl}/OAuthToken</code></td></tr>
@@ -243,8 +243,27 @@ async function runDedupGate(ctx, content) {
243
243
  return findConservativeDedupMatch(ctx, content.agentId, content.content, embedding, cosineThreshold, lexicalThreshold);
244
244
  }
245
245
  /** Build the final write response: always `written: true`, always includes
246
- * `id`, and layers the dedup collision signal on top when present. Never a
247
- * code path where a match suppresses these base fields. */
246
+ * `id`, `visibility`, and layers the dedup collision signal on top when
247
+ * present. Never a code path where a match suppresses these base fields.
248
+ *
249
+ * ── Why `visibility` is in the write response (flair#991) ──────────────────
250
+ * Visibility is the one field on a memory the caller most often does NOT
251
+ * set and yet most needs to know: the durability-keyed default above stamps
252
+ * `private` for a bare write and `shared` for a permanent/persistent one, so
253
+ * "who can read this" is decided by a rule the writer never typed. Returning
254
+ * it makes the landed value observable on EVERY write surface at once —
255
+ * `flair memory add`'s printed JSON, the REST response, the native /mcp
256
+ * `memory_store` result, and packages/flair-mcp's `effectiveVisibility` line
257
+ * (which read this field all along and had nothing to read, so it always
258
+ * rendered "(server default)").
259
+ *
260
+ * Read from `content`, not from `base`: `content.visibility` is the value
261
+ * that was actually persisted a few lines earlier, and assigning after the
262
+ * `...base` spread means the persisted value wins over anything the storage
263
+ * layer echoes back. Omitted (not `null`) when unset, which happens only on
264
+ * the put()-over-an-existing-record path where a partial merge carried no
265
+ * visibility — reporting `null` there would read as "no one but the owner",
266
+ * the opposite of what an absent field means to `isPrivateVisibility()`. */
248
267
  function buildWriteResponse(content, result, dedupMatch) {
249
268
  const base = result && typeof result === "object" && !Array.isArray(result) ? result : {};
250
269
  const response = {
@@ -253,6 +272,9 @@ function buildWriteResponse(content, result, dedupMatch) {
253
272
  written: true,
254
273
  deduplicated: !!dedupMatch,
255
274
  };
275
+ if (content.visibility !== undefined && content.visibility !== null) {
276
+ response.visibility = content.visibility;
277
+ }
256
278
  if (dedupMatch) {
257
279
  response.matchedId = dedupMatch.matchedId;
258
280
  response.matchConfidence = { cosine: dedupMatch.cosine, lexical: dedupMatch.lexical };
@@ -2,11 +2,13 @@ import { Resource, databases } from "harper";
2
2
  import { createHash, randomBytes } from "node:crypto";
3
3
  import { handleJwtBearerGrant } from "./XAA.js";
4
4
  import { resolveAgentAuth } from "./agent-auth.js";
5
+ import { decideRegistration } from "./dcr-gate.js";
6
+ import { buildAuthorizationServerMetadata } from "./oauth-discovery.js";
5
7
  /**
6
8
  * OAuth 2.1 Authorization Server for Flair.
7
9
  *
8
10
  * Endpoints (all mapped via Harper's resource routing):
9
- * GET /OAuthMetadata → /.well-known/oauth-authorization-server
11
+ * GET /OAuthMetadata → alias of /.well-known/oauth-authorization-server
10
12
  * POST /OAuthRegister → /oauth/register (DCR)
11
13
  * GET /OAuthAuthorize → /oauth/authorize (consent screen)
12
14
  * POST /OAuthToken → /oauth/token (token exchange)
@@ -57,6 +59,19 @@ function redirectTo(url, status = 302) {
57
59
  return new Response(null, { status, headers: { Location: url } });
58
60
  }
59
61
  // ─── Discovery metadata ──────────────────────────────────────────────────────
62
+ /**
63
+ * `/OAuthMetadata` — an ALIAS of `/.well-known/oauth-authorization-server`
64
+ * (flair#1000), not a second implementation.
65
+ *
66
+ * The document body moved verbatim to `buildAuthorizationServerMetadata()` in
67
+ * resources/oauth-discovery.ts and BOTH paths now call it. The well-known path
68
+ * is the one RFC 8414 defines and the one every client probes; this path is
69
+ * kept because it is what shipped, is referenced by docs/auth.md and by
70
+ * deployed callers, and costs one delegating line. Keeping it as an alias
71
+ * rather than as an independent handler is the whole point: two endpoints that
72
+ * can drift apart is the failure mode, and after this change there is no code
73
+ * path that can produce one document without producing the other.
74
+ */
60
75
  export class OAuthMetadata extends Resource {
61
76
  // OAuth discovery metadata is intentionally public — RFC 8414 § 3 requires
62
77
  // it be accessible without authentication so clients can bootstrap their
@@ -65,30 +80,7 @@ export class OAuthMetadata extends Resource {
65
80
  // of a 401 from Harper's intrinsic auth layer. Same pattern as Health (#386).
66
81
  allowRead() { return true; }
67
82
  async get() {
68
- const baseUrl = process.env.FLAIR_PUBLIC_URL || `http://127.0.0.1:${process.env.HTTP_PORT || 19926}`;
69
- return {
70
- issuer: baseUrl,
71
- authorization_endpoint: `${baseUrl}/OAuthAuthorize`,
72
- token_endpoint: `${baseUrl}/OAuthToken`,
73
- registration_endpoint: `${baseUrl}/OAuthRegister`,
74
- revocation_endpoint: `${baseUrl}/OAuthRevoke`,
75
- response_types_supported: ["code"],
76
- grant_types_supported: [
77
- "authorization_code",
78
- "refresh_token",
79
- "urn:ietf:params:oauth:grant-type:jwt-bearer",
80
- ],
81
- token_endpoint_auth_methods_supported: ["none", "client_secret_basic"],
82
- code_challenge_methods_supported: ["S256"],
83
- scopes_supported: [
84
- "memory:read", "memory:write", "memory:admin",
85
- "principal:read", "principal:admin",
86
- "connector:read", "connector:admin",
87
- ],
88
- extensions_supported: [
89
- "io.modelcontextprotocol/enterprise-managed-authorization",
90
- ],
91
- };
83
+ return buildAuthorizationServerMetadata();
92
84
  }
93
85
  }
94
86
  // ─── Dynamic Client Registration (RFC 7591) ──────────────────────────────────
@@ -96,8 +88,32 @@ export class OAuthRegister extends Resource {
96
88
  // Dynamic Client Registration (RFC 7591) — clients must be able to register
97
89
  // anonymously to bootstrap. Validation (redirect_uri allow-list, etc.)
98
90
  // happens in post(). Pattern matches FederationPair.allowCreate (#299).
91
+ //
92
+ // This stays `true` — it is Harper's ROLE gate, and returning false here would
93
+ // answer with Harper's own 403 body instead of an RFC 7591-shaped error. WHO
94
+ // may register is decided in post() via resources/dcr-gate.ts, in one place.
95
+ // Deliberately not enforced in both: one condition in two places is how the
96
+ // two drift apart.
99
97
  allowCreate() { return true; }
100
98
  async post(data) {
99
+ // Registration gate FIRST — before the redirect-URI policy is applied,
100
+ // before anything is read off the body, and before any write. A caller who
101
+ // may not register learns only that; running the redirect-URI check first
102
+ // would turn a closed endpoint into a probe for what this server accepts.
103
+ const decision = decideRegistration(this.getContext?.()?.request ?? this.getContext?.());
104
+ if (!decision.allowed) {
105
+ if (decision.reason === "disabled") {
106
+ return new Response(JSON.stringify({
107
+ error: "access_denied",
108
+ error_description: "dynamic client registration is not enabled on this server " +
109
+ "(set FLAIR_OAUTH_DCR_TOKEN to enable it)",
110
+ }), { status: 403, headers: { "content-type": "application/json" } });
111
+ }
112
+ return new Response(JSON.stringify({
113
+ error: "invalid_token",
114
+ error_description: "a valid initial access token is required to register a client",
115
+ }), { status: 401, headers: { "content-type": "application/json" } });
116
+ }
101
117
  const redirectUris = data?.redirect_uris ?? [];
102
118
  const clientName = data?.client_name ?? "Unknown Client";
103
119
  // 1.0: only claude.com redirect URI permitted
@@ -5,6 +5,7 @@ import { isAdmin, FLAIR_AGENT_USERNAME } from "./agent-auth.js";
5
5
  import { WINDOW_MS, isNonceReplay, recordNonce, importEd25519Key, b64ToArrayBuffer, parseTpsEd25519Header } from "./ed25519-auth.js";
6
6
  import { resolveReadScope } from "./memory-read-scope.js";
7
7
  import { isForbiddenOwnerMutation, resolveGuardedRecord } from "./record-owner-guard.js";
8
+ import { checkHttpRateLimit } from "./rate-limit.js";
8
9
  // --- Admin credentials ---
9
10
  // Admin auth is sourced exclusively from Harper's own environment variables
10
11
  // (HDB_ADMIN_PASSWORD / FLAIR_ADMIN_PASSWORD). No filesystem token file.
@@ -65,6 +66,26 @@ async function backfillEmbedding(memoryId) {
65
66
  // ─── HTTP middleware ──────────────────────────────────────────────────────────
66
67
  server.http(async (request, nextLayer) => {
67
68
  const url = new URL(request.url, "http://" + (request.headers.get("host") || "localhost"));
69
+ // ── Rate limiting, FIRST ───────────────────────────────────────────────────
70
+ // Before the public-path passthrough below (the OAuth endpoints all sit on it,
71
+ // so a hook placed after it would never run for them), and before anything
72
+ // reads a credential.
73
+ //
74
+ // Ordering is a security property, not tidiness. The counter is consumed for
75
+ // every request to a throttled endpoint whether or not the credential that
76
+ // came with it was any good — if only failures were counted, "did this consume
77
+ // budget" would answer "was that credential valid", which is a cleaner
78
+ // enumeration oracle than the 400 the endpoint already returns. Because the
79
+ // decision is made here, a limited request carries no information about what
80
+ // it was carrying: a valid authorization code and a garbage one get the same
81
+ // 429 and the same body.
82
+ //
83
+ // Only the OAuth endpoints named in rate-limit.ts's PATH_POLICY are affected.
84
+ // Every other path — /Memory, /Presence, /FederationSync, everything agents
85
+ // actually use — returns null here and is untouched.
86
+ const limited = checkHttpRateLimit(request, url.pathname);
87
+ if (limited)
88
+ return limited;
68
89
  // A2A discovery endpoints: GET returns public agent-card metadata (per
69
90
  // A2A spec, cards are intentionally public). POST invokes JSON-RPC
70
91
  // actions (message/send writes OrgEvents on behalf of agents,
@@ -91,6 +112,11 @@ server.http(async (request, nextLayer) => {
91
112
  url.pathname === "/OAuthAuthorize" ||
92
113
  url.pathname === "/OAuthToken" ||
93
114
  url.pathname === "/OAuthRevoke" ||
115
+ // Belt-and-braces since flair#1000: `/.well-known/oauth-authorization-server`
116
+ // is served from its OWN urlPath mount (resources/oauth-wellknown.ts), which
117
+ // gets its own dispatch chain — this middleware never sees a request for it.
118
+ // The entry stays so the path is still public if that mount ever moves back
119
+ // onto the default chain.
94
120
  url.pathname === "/.well-known/oauth-authorization-server" ||
95
121
  url.pathname === "/OAuthMetadata" ||
96
122
  // Presence roster is public-safe (field-allowlisted); GET serves the