@tpsdev-ai/flair 0.23.0 → 0.24.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,787 @@
1
+ /**
2
+ * mcp-enable.ts — flair#719: `flair mcp enable/disable/status`, the last
3
+ * piece of the paved-paths command family. Automates the operator checklist
4
+ * documented in docs/notes/mcp-oauth-model2.md into one command.
5
+ *
6
+ * ── flair#756 (2026-07-19): CIMD-only, DCR removed entirely ──────────────────
7
+ * #754 shipped `enable`'s default flow pre-registering claude.ai via DCR
8
+ * (RFC 7591 Dynamic Client Registration) + provisioning a DCR gate token.
9
+ * That contradicted the strategic direction (Nathan, on the record,
10
+ * 2026-07-19): CIMD-only looking forward, DCR is not the path — and the
11
+ * scope was amended same-day from "CIMD-first with a --with-dcr legacy
12
+ * hatch" to full removal: DCR is UNSUPPORTED on this surface, not legacy.
13
+ * There is no `--with-dcr` flag, no gate-token machinery, and
14
+ * `src/lib/dcr-client.ts` (the module that used to own the gate-token
15
+ * contract + the RFC 7591 HTTP client) is deleted.
16
+ *
17
+ * Ground truth (verified in installed @harperfast/oauth@2.2.0 source): the
18
+ * plugin fully supports CIMD for the interactive authorization_code flow —
19
+ * `authorize.js` resolves URL-shaped client_ids via `cimd.js`'s
20
+ * `resolveClient` (metadata-document fetch, rate-limited,
21
+ * `clientIdMetadataDocuments.allowedHosts` gate, redirect-URI-host
22
+ * validation baked into the fetched document itself). A CIMD-capable client
23
+ * like claude.ai needs ZERO pre-registration — there is no client_id for
24
+ * `enable` to hand back, because Claude presents its OWN CIMD document URL
25
+ * as its client_id (Anthropic docs: claude.com/docs/connectors/building/
26
+ * authentication — "Claude uses an HTTPS URL as its client_id, and your
27
+ * authorization server fetches the metadata document from that URL").
28
+ *
29
+ * **Leaving `dynamicClientRegistration` unset does NOT disable DCR** — this
30
+ * is the load-bearing ground-truth fact this rewrite is built on. Read
31
+ * directly from the installed package:
32
+ * - `node_modules/@harperfast/oauth/dist/types.d.ts:131-144` (the
33
+ * `MCPDynamicClientRegistrationConfig` doc comment): "Defaults to
34
+ * enabled because Claude Desktop, Cursor, and mcp-remote all register at
35
+ * runtime with no pre-baked client_id. Restricting registration is
36
+ * opt-in via initialAccessToken or allowedRedirectUriHosts."
37
+ * - `node_modules/@harperfast/oauth/dist/lib/mcp/dcr.js:161-167`
38
+ * (`handleRegister`): `if (dcrConfig?.enabled === false) return 404`.
39
+ * An ABSENT `dynamicClientRegistration` block leaves `dcrConfig`
40
+ * `undefined`, so `dcrConfig?.enabled === false` is `false` — the
41
+ * endpoint stays live.
42
+ * - `dcr.js:16-24` (`checkInitialAccessToken`): "Returns null when no
43
+ * token is configured (open registration per RFC 7591)." — an absent
44
+ * `initialAccessToken` means the endpoint accepts ANY registration,
45
+ * unauthenticated.
46
+ * So simply never writing the block would leave `/oauth/mcp/register`
47
+ * OPEN, not inert — the opposite of "DCR removed." `buildMcpOAuthConfigBlock`
48
+ * below therefore writes an EXPLICIT `dynamicClientRegistration: { enabled:
49
+ * false }` — the one config shape that is verifiably fail-closed
50
+ * (dcr.js:165-167's 404 branch) — and never writes `initialAccessToken` or
51
+ * `allowedRedirectUriHosts` (there is no gate-token machinery to configure
52
+ * them with). A structural test in test/unit/mcp-enable.test.ts asserts
53
+ * this shape directly.
54
+ *
55
+ * ── K&S conditions from #719, still honored ──────────────────────────────────
56
+ * - Sherlock: `accessTokenTtl` is explicitly 900 in the written config
57
+ * block, never left at the plugin's 1h default (see
58
+ * `buildMcpOAuthConfigBlock`).
59
+ * - Sherlock: the RS256 keypair comes from `crypto.generateKeyPairSync`
60
+ * (see `generateRsaSigningKeyPair`), never a PRNG shortcut.
61
+ * - Sherlock (the #741 lesson): self-verification is the exit criterion.
62
+ * On failure, the result names which step to re-run — never reports
63
+ * success on hope (see `EnableMcpResult.failedStep`). flair#756 extends
64
+ * this: self-verify now also confirms the metadata endpoint advertises
65
+ * CIMD support (the exact pair Claude's client checks — see
66
+ * `selfVerifyMcpMetadata` below), not just that the endpoint answers.
67
+ * - Secrets provisioning is shape-aware but never silent: every result
68
+ * names the mechanism chosen and where the material lives (paths only —
69
+ * values never appear in `EnableMcpResult` or on stdout).
70
+ *
71
+ * ── Ground truth used to design the remote "existing ops paths" step ────────
72
+ * Verified against the ACTUALLY INSTALLED packages, not assumed:
73
+ * - `@harperfast/harper`'s Operations API has a genuine `set_configuration`
74
+ * operation (writes harperdb-config.yaml for all workers, requires a
75
+ * restart to take effect — node_modules/@harperfast/harper/dist/config/
76
+ * configUtils.js's `setConfiguration`) and a genuine `restart` operation
77
+ * (whole-process restart — see .../components/mcp/tools/schemas/
78
+ * operationDescriptions.js's operation catalog). Both are called the
79
+ * SAME way `grantMcpClient`/`revokeMcpClient` (src/cli.ts) already call
80
+ * the ops API: Basic admin auth, POST a JSON operation body, targeting
81
+ * either a local port or a remote URL — genuinely "the existing remote
82
+ * ops paths" the design addendum names, not a new mechanism invented for
83
+ * this slice.
84
+ * - `FLAIR_MCP_OAUTH` (resources/mcp-oauth-flag.ts) is read from
85
+ * `process.env` ONLY — never YAML config — so it (and the OAuth secrets:
86
+ * the signing key PEM, the IdP client secret) cannot be set via
87
+ * `set_configuration`. Those are delivered through the shape-aware
88
+ * secrets-provisioning step below (a 0600 staging file the operator
89
+ * applies via Fabric Studio's environment panel, or their own
90
+ * process-manager env). `enable` requires the operator to confirm
91
+ * application (`confirmSecretsApplied`, or an interactive prompt) before
92
+ * it calls `restart` — otherwise the restart would just bounce back to
93
+ * the flag-OFF byte-identical boot with the new config.yaml block inert.
94
+ * - `@harperfast/oauth`'s config field names (`mcp.issuer`, `mcp.resource`,
95
+ * `mcp.accessTokenTtl`, `mcp.dynamicClientRegistration.enabled`,
96
+ * `mcp.clientIdMetadataDocuments.allowedHosts`, `mcp.signingKeyPem`) are
97
+ * confirmed against the installed 2.2.0 package's source
98
+ * (dist/types.d.ts:38-229, dist/lib/mcp/{dcr,cimd,keyStore,token}.js).
99
+ * - The self-verification target, `${issuer}/.well-known/oauth-
100
+ * authorization-server` (RFC 8414), is served by
101
+ * `dist/lib/mcp/wellKnown.js`'s `buildAuthorizationServerMetadata`
102
+ * (lines 129-166), which advertises `registration_endpoint`/
103
+ * `token_endpoint` unconditionally (NOTE: `registration_endpoint` is
104
+ * advertised even though DCR is disabled — the plugin doesn't condition
105
+ * that field on `dynamicClientRegistration.enabled`; a POST there still
106
+ * 404s per dcr.js:165-167, this is just a metadata-completeness quirk of
107
+ * the installed package, not a gap in our config) and
108
+ * `client_id_metadata_document_supported: true` whenever
109
+ * `clientIdMetadataDocuments.enabled !== false` (wellKnown.js:164 — true
110
+ * by default, which is what our config relies on), and
111
+ * `token_endpoint_auth_methods_supported` always includes `"none"`
112
+ * (wellKnown.js:148-149) — together the exact pair Anthropic's docs say
113
+ * Claude checks before it will use CIMD instead of DCR (claude.com/docs/
114
+ * connectors/building/authentication: "Claude selects CIMD only when
115
+ * your authorization server metadata advertises both
116
+ * client_id_metadata_document_supported: true and none in
117
+ * token_endpoint_auth_methods_supported").
118
+ * - The GitHub OAuth-app callback URL, `${issuer}/oauth/github/callback`,
119
+ * is the plugin's own README "Configure OAuth Callback" convention
120
+ * (`https://your-domain/oauth/<provider>/callback`).
121
+ * - The claude.ai CIMD/redirect-URI allowlist hosts: Anthropic's current
122
+ * docs (claude.com/docs/connectors/building/authentication, "Callback
123
+ * URLs" — fetched 2026-07-19) name `https://claude.ai/api/mcp/
124
+ * auth_callback` as the redirect URI for the hosted Claude surfaces
125
+ * (Claude.ai web, Desktop, mobile, Cowork), and the lazy-authentication
126
+ * doc's CIMD section notes "the listed redirect_uris should be required
127
+ * to be same-origin with the client_id URL" — so claude.ai is the
128
+ * confirmed CIMD client_id host for that surface. `claude.com` is kept
129
+ * alongside it because it's this repo's own pre-existing allowlist value
130
+ * (`resources/OAuth.ts:24`'s `ALLOWED_REDIRECT_URI` for the 1.0
131
+ * opaque-token AS, and this module's own prior `DEFAULT_REDIRECT_URI_HOSTS`
132
+ * constant) — carried forward defensively, not newly invented; CIMD
133
+ * `allowedHosts` only widens which hosts MAY present a client_id URL,
134
+ * every resolution still runs the full SSRF/document-validation pipeline
135
+ * in `cimd.js`, so listing an extra host is not a meaningful risk
136
+ * expansion.
137
+ */
138
+ import { existsSync, mkdirSync, writeFileSync, chmodSync, readFileSync } from "node:fs";
139
+ import { homedir } from "node:os";
140
+ import { join, dirname } from "node:path";
141
+ import { generateKeyPairSync, randomBytes } from "node:crypto";
142
+ // ─── CIMD constants ──────────────────────────────────────────────────────────
143
+ /** Default `clientIdMetadataDocuments.allowedHosts` allowlist — see the
144
+ * module header's "claude.ai CIMD/redirect-URI allowlist hosts" note for
145
+ * the citation trail. Schema: node_modules/@harperfast/oauth/dist/
146
+ * types.d.ts:211-229 (`MCPClientIdMetadataDocumentsConfig.allowedHosts`). */
147
+ export const DEFAULT_CIMD_ALLOWED_HOSTS = ["claude.ai", "claude.com"];
148
+ /** Required TTL per Sherlock's Model-2 requirement 1 — never the plugin's 1h default. */
149
+ export const REQUIRED_ACCESS_TOKEN_TTL = 900;
150
+ // ─── Local-origin detection (scenario addendum, binding) ───────────────────
151
+ const LOCAL_ORIGIN_REFUSAL = "claude.ai connectors need a public HTTPS origin; this instance is local. See the hosted-shape docs.";
152
+ /**
153
+ * Is `url`'s host a local/private origin claude.ai's servers could never
154
+ * dial into? Covers localhost, loopback, RFC1918 private ranges, link-local,
155
+ * and `.local` mDNS. An unparseable URL is treated as local (refuse rather
156
+ * than proceed against an origin we can't even parse).
157
+ */
158
+ export function isLocalOrigin(url) {
159
+ let hostname;
160
+ try {
161
+ hostname = new URL(url).hostname.toLowerCase();
162
+ }
163
+ catch {
164
+ return true;
165
+ }
166
+ if (hostname === "localhost" || hostname.endsWith(".localhost"))
167
+ return true;
168
+ // WHATWG URL keeps IPv6 hosts bracketed in `.hostname` (e.g. "[::1]").
169
+ if (hostname === "::1" || hostname === "[::1]")
170
+ return true;
171
+ if (hostname.endsWith(".local"))
172
+ return true;
173
+ const ipv4 = hostname.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
174
+ if (ipv4) {
175
+ const a = Number(ipv4[1]);
176
+ const b = Number(ipv4[2]);
177
+ if (a === 127)
178
+ return true; // loopback
179
+ if (a === 10)
180
+ return true; // RFC1918
181
+ if (a === 172 && b >= 16 && b <= 31)
182
+ return true; // RFC1918
183
+ if (a === 192 && b === 168)
184
+ return true; // RFC1918
185
+ if (a === 169 && b === 254)
186
+ return true; // link-local
187
+ if (a === 0)
188
+ return true;
189
+ }
190
+ return false;
191
+ }
192
+ /** Structural refusal check + the exact operator-facing message (scenario addendum). */
193
+ export function checkLocalOriginRefusal(url) {
194
+ if (isLocalOrigin(url))
195
+ return { refused: true, message: LOCAL_ORIGIN_REFUSAL };
196
+ return { refused: false };
197
+ }
198
+ // ─── Fabric-shape detection (secrets-mechanism default) ────────────────────
199
+ /** Is this a Harper Fabric-hosted origin? (`*.harperfabric.com`.) Used only
200
+ * to pick the secrets-provisioning mechanism's DEFAULT — always overridable
201
+ * via `--secrets-mechanism`. */
202
+ export function isFabricOrigin(url) {
203
+ try {
204
+ return new URL(url).hostname.toLowerCase().endsWith(".harperfabric.com");
205
+ }
206
+ catch {
207
+ return false;
208
+ }
209
+ }
210
+ /**
211
+ * Which secrets-delivery mechanism should `enable` use? The remote path is
212
+ * primary per the scenario addendum: a recognized Fabric origin defaults to
213
+ * `fabric-env-secrets` (Harper's encrypted env-secrets, 5.2-alpha as of this
214
+ * writing — see the module header on why this is a DOCUMENTED procedure, not
215
+ * an automated push: no confirmed ops-API operation for it exists in the
216
+ * installed 5.1.17 SDK). Anything else defaults to `env-file` — the
217
+ * documented, universally-supported fallback. Always overridable.
218
+ */
219
+ export function selectSecretsMechanism(instanceUrl, override) {
220
+ if (override)
221
+ return override;
222
+ return isFabricOrigin(instanceUrl) ? "fabric-env-secrets" : "env-file";
223
+ }
224
+ /** RS256 signing keypair for `mcp.signingKeyPem` — `crypto.generateKeyPairSync`,
225
+ * never a PRNG shortcut (Sherlock's Model-2 requirement 2 implementation note). */
226
+ export function generateRsaSigningKeyPair() {
227
+ const { publicKey, privateKey } = generateKeyPairSync("rsa", {
228
+ modulusLength: 2048,
229
+ publicKeyEncoding: { type: "spki", format: "pem" },
230
+ privateKeyEncoding: { type: "pkcs8", format: "pem" },
231
+ });
232
+ return { publicKey, privateKey };
233
+ }
234
+ export function defaultSigningKeyFilePath() {
235
+ return join(homedir(), ".flair", "mcp-signing-key.pem");
236
+ }
237
+ /** Write the RS256 private key PEM to a 0600 file (idempotent — reuses an
238
+ * existing file rather than silently rotating the signing key). */
239
+ export function ensureSigningKeyFile(filePath, deps = {}) {
240
+ const path = filePath ?? defaultSigningKeyFilePath();
241
+ if (existsSync(path)) {
242
+ return { path, reused: true };
243
+ }
244
+ const generate = deps.generate ?? generateRsaSigningKeyPair;
245
+ const { privateKey } = generate();
246
+ mkdirSync(dirname(path), { recursive: true });
247
+ writeFileSync(path, privateKey, { mode: 0o600 });
248
+ chmodSync(path, 0o600);
249
+ return { path, reused: false };
250
+ }
251
+ /** Read back a signing key file's PEM contents (used to fold it into the
252
+ * secrets bundle — never logged, never returned in an `EnableMcpResult`). */
253
+ export function readSigningKeyFile(path) {
254
+ return readFileSync(path, "utf-8");
255
+ }
256
+ /**
257
+ * The `@harperfast/oauth` config block, matching the installed 2.2.0
258
+ * package's field names (node_modules/@harperfast/oauth/dist/types.d.ts).
259
+ * Secrets are `${ENV_VAR}` placeholders — never literal values — so this
260
+ * block is safe to write to harperdb-config.yaml via `set_configuration`
261
+ * (the config file itself carries no secret material; see the
262
+ * secrets-provisioning step for how the referenced env vars land).
263
+ *
264
+ * flair#756: `dynamicClientRegistration: { enabled: false }` is written
265
+ * EXPLICITLY — never omitted. See the module header's "Leaving
266
+ * `dynamicClientRegistration` unset does NOT disable DCR" note: an absent
267
+ * block leaves the plugin's own default (OPEN, ungated registration) live
268
+ * (dcr.js:161-167, types.d.ts:131-144). `enabled: false` is the one shape
269
+ * that actually 404s the endpoint (dcr.js:165-167). No `initialAccessToken`
270
+ * / `allowedRedirectUriHosts` are ever written — there is no gate-token
271
+ * machinery left to populate them with.
272
+ */
273
+ export function buildMcpOAuthConfigBlock(params) {
274
+ const provider = params.idpProvider;
275
+ const envPrefix = `OAUTH_${provider.toUpperCase()}`;
276
+ const cimdAllowedHosts = params.cimdAllowedHosts ?? DEFAULT_CIMD_ALLOWED_HOSTS;
277
+ return {
278
+ "@harperfast/oauth": {
279
+ package: "@harperfast/oauth",
280
+ providers: {
281
+ [provider]: {
282
+ clientId: `\${${envPrefix}_CLIENT_ID}`,
283
+ clientSecret: `\${${envPrefix}_CLIENT_SECRET}`,
284
+ },
285
+ },
286
+ mcp: {
287
+ enabled: true,
288
+ issuer: "${FLAIR_MCP_ISSUER}",
289
+ resource: "${FLAIR_MCP_ISSUER}/mcp",
290
+ accessTokenTtl: REQUIRED_ACCESS_TOKEN_TTL,
291
+ // Explicit fail-closed disable — see the doc comment above and the
292
+ // module header for why an omitted block is NOT equivalent to this.
293
+ dynamicClientRegistration: { enabled: false },
294
+ // CIMD is the only supported client-registration path. `allowedHosts`
295
+ // restricts which hosts may present a CIMD client_id URL — schema at
296
+ // node_modules/@harperfast/oauth/dist/types.d.ts:211-229. Every
297
+ // resolution still runs cimd.js's full SSRF/document-validation
298
+ // pipeline regardless of this list.
299
+ clientIdMetadataDocuments: { allowedHosts: cimdAllowedHosts },
300
+ signingKeyPem: "${FLAIR_MCP_SIGNING_KEY_PEM}",
301
+ },
302
+ },
303
+ };
304
+ }
305
+ /** The exact callback URL to hand the operator when they create the IdP
306
+ * OAuth app ("with the exact GitHub callback URL printed"). */
307
+ export function idpCallbackUrl(issuer, idpProvider) {
308
+ return `${issuer.replace(/\/+$/, "")}/oauth/${idpProvider}/callback`;
309
+ }
310
+ /** The full set of env vars the restarted instance needs live. Contains
311
+ * secret VALUES — this is the one place they exist as a JS object; callers
312
+ * must never fold this into a CLI-printed / `EnableMcpResult` field. */
313
+ export function buildSecretsBundle(params) {
314
+ const envPrefix = `OAUTH_${params.idpProvider.toUpperCase()}`;
315
+ return {
316
+ FLAIR_MCP_OAUTH: "1",
317
+ FLAIR_MCP_ISSUER: params.issuer.replace(/\/+$/, ""),
318
+ FLAIR_MCP_SIGNING_KEY_PEM: params.signingKeyPem,
319
+ [`${envPrefix}_CLIENT_ID`]: params.idpClientId,
320
+ [`${envPrefix}_CLIENT_SECRET`]: params.idpClientSecret,
321
+ };
322
+ }
323
+ export function defaultSecretsStagingPath(issuer) {
324
+ let host = "instance";
325
+ try {
326
+ host = new URL(issuer).hostname;
327
+ }
328
+ catch {
329
+ /* fall through to the generic name */
330
+ }
331
+ const safe = host.replace(/[^a-zA-Z0-9.-]/g, "_");
332
+ return join(homedir(), ".flair", `mcp-enable-secrets-${safe}.env`);
333
+ }
334
+ /** Write the secrets bundle to a 0600 staging file, `KEY=VALUE` per line.
335
+ * This file legitimately carries secret material (like `grant`'s 0600 key
336
+ * files) — the "never print secret values" rule is about stdout/returned
337
+ * result objects, not this deliberately-created, permission-locked file. */
338
+ export function writeSecretsStagingFile(path, bundle) {
339
+ mkdirSync(dirname(path), { recursive: true });
340
+ const body = Object.entries(bundle).map(([k, v]) => `${k}=${v}`).join("\n") + "\n";
341
+ writeFileSync(path, body, { mode: 0o600 });
342
+ chmodSync(path, 0o600);
343
+ }
344
+ /** Provision secrets per the shape-aware mechanism, staging the bundle to a
345
+ * 0600 file and returning ONLY names/paths/instructions — never values. */
346
+ export function provisionSecrets(instanceUrl, bundle, opts = {}) {
347
+ const mechanism = selectSecretsMechanism(instanceUrl, opts.mechanism);
348
+ const path = opts.stagingPath ?? defaultSecretsStagingPath(instanceUrl);
349
+ writeSecretsStagingFile(path, bundle);
350
+ const varNames = Object.keys(bundle);
351
+ const instructions = mechanism === "fabric-env-secrets"
352
+ ? `Fabric env-secrets (enc:v1) push is alpha-only as of this writing — no confirmed ops-API operation exists in the installed SDK to automate it. ` +
353
+ `Apply the ${varNames.length} vars staged at ${path} via Fabric Studio → Cluster Settings → Environment, then re-run with --confirm-secrets-applied.`
354
+ : `Apply the ${varNames.length} vars staged at ${path} to the target instance's process environment (systemd/launchd unit, or your process manager), then re-run with --confirm-secrets-applied.`;
355
+ return { mechanism, path, varNames, instructions };
356
+ }
357
+ // ─── Identity mapping (Credential kind:idp) ─────────────────────────────────
358
+ function opsBaseUrl(opsPortOrUrl) {
359
+ return typeof opsPortOrUrl === "number" ? `http://127.0.0.1:${opsPortOrUrl}/` : `${opsPortOrUrl.replace(/\/$/, "")}/`;
360
+ }
361
+ function basicAuthHeader(adminUser, adminPass) {
362
+ return `Basic ${Buffer.from(`${adminUser}:${adminPass}`).toString("base64")}`;
363
+ }
364
+ /**
365
+ * Map the operator's IdP subject to their principal via `Credential(kind:
366
+ * "idp")` — the SAME credential surface resources/mcp-handler.ts's
367
+ * `resolveAgentFromSub` reads at request time. Idempotent: an existing
368
+ * mapping for (provider, subject) is reused (lastUsedAt bumped) rather than
369
+ * duplicated; the principal Agent is created only if missing.
370
+ */
371
+ export async function provisionIdpIdentityMapping(params, deps = {}) {
372
+ const fetchImpl = deps.fetchImpl ?? fetch;
373
+ const now = (deps.now ?? (() => new Date().toISOString()))();
374
+ const opsUrl = opsBaseUrl(params.opsPortOrUrl);
375
+ const authHeader = basicAuthHeader(params.adminUser, params.adminPass);
376
+ // Ensure the principal Agent exists.
377
+ const findRes = await fetchImpl(opsUrl, {
378
+ method: "POST",
379
+ headers: { "Content-Type": "application/json", Authorization: authHeader },
380
+ body: JSON.stringify({
381
+ operation: "search_by_value",
382
+ database: "flair",
383
+ table: "Agent",
384
+ search_attribute: "id",
385
+ search_value: params.principal,
386
+ get_attributes: ["id"],
387
+ }),
388
+ });
389
+ if (!findRes.ok) {
390
+ const text = await findRes.text().catch(() => "");
391
+ throw new Error(`Identity mapping: failed to look up principal '${params.principal}' (HTTP ${findRes.status}): ${text}`);
392
+ }
393
+ const foundAgents = await findRes.json().catch(() => []);
394
+ let principalCreated = false;
395
+ if (!Array.isArray(foundAgents) || foundAgents.length === 0) {
396
+ const insertRes = await fetchImpl(opsUrl, {
397
+ method: "POST",
398
+ headers: { "Content-Type": "application/json", Authorization: authHeader },
399
+ body: JSON.stringify({
400
+ operation: "insert",
401
+ database: "flair",
402
+ table: "Agent",
403
+ records: [
404
+ {
405
+ id: params.principal,
406
+ name: params.principal,
407
+ displayName: params.principal,
408
+ kind: params.principalKind,
409
+ type: params.principalKind,
410
+ status: "active",
411
+ admin: false,
412
+ defaultTrustTier: "endorsed",
413
+ createdAt: now,
414
+ updatedAt: now,
415
+ },
416
+ ],
417
+ }),
418
+ });
419
+ if (!insertRes.ok) {
420
+ const text = await insertRes.text().catch(() => "");
421
+ throw new Error(`Identity mapping: failed to create principal '${params.principal}' (HTTP ${insertRes.status}): ${text}`);
422
+ }
423
+ principalCreated = true;
424
+ }
425
+ // Reuse an existing Credential(kind:idp, provider, subject) mapping if present.
426
+ const searchCredRes = await fetchImpl(opsUrl, {
427
+ method: "POST",
428
+ headers: { "Content-Type": "application/json", Authorization: authHeader },
429
+ body: JSON.stringify({
430
+ operation: "search_by_conditions",
431
+ database: "flair",
432
+ table: "Credential",
433
+ operator: "and",
434
+ conditions: [
435
+ { search_attribute: "kind", search_type: "equals", search_value: "idp" },
436
+ { search_attribute: "idpProvider", search_type: "equals", search_value: params.idpProvider },
437
+ { search_attribute: "idpSubject", search_type: "equals", search_value: params.idpSubject },
438
+ ],
439
+ get_attributes: ["id", "principalId"],
440
+ }),
441
+ });
442
+ const existingCreds = searchCredRes.ok ? await searchCredRes.json().catch(() => []) : [];
443
+ const existing = Array.isArray(existingCreds) ? existingCreds[0] : undefined;
444
+ const credentialId = existing?.id ?? `cred_idp_${params.idpProvider}_${randomBytes(6).toString("hex")}`;
445
+ const upsertRes = await fetchImpl(opsUrl, {
446
+ method: "POST",
447
+ headers: { "Content-Type": "application/json", Authorization: authHeader },
448
+ body: JSON.stringify({
449
+ operation: "upsert",
450
+ database: "flair",
451
+ table: "Credential",
452
+ records: [
453
+ {
454
+ id: credentialId,
455
+ principalId: params.principal,
456
+ kind: "idp",
457
+ label: `MCP OAuth (${params.idpProvider})`,
458
+ status: "active",
459
+ idpProvider: params.idpProvider,
460
+ idpSubject: params.idpSubject,
461
+ createdAt: existing ? undefined : now,
462
+ lastUsedAt: now,
463
+ },
464
+ ],
465
+ }),
466
+ });
467
+ if (!upsertRes.ok) {
468
+ const text = await upsertRes.text().catch(() => "");
469
+ throw new Error(`Identity mapping: failed to write Credential(kind:idp) mapping (HTTP ${upsertRes.status}): ${text}`);
470
+ }
471
+ return { principalCreated, credentialId, credentialReused: Boolean(existing) };
472
+ }
473
+ /** `set_configuration` (writes harperdb-config.yaml) then `restart`
474
+ * (whole-process restart) — the genuine Harper Operations API operations
475
+ * this module's header documents. Throws on either non-2xx response. */
476
+ export async function applyRemoteConfigAndRestart(params, deps = {}) {
477
+ const fetchImpl = deps.fetchImpl ?? fetch;
478
+ const opsUrl = opsBaseUrl(params.opsPortOrUrl);
479
+ const authHeader = basicAuthHeader(params.adminUser, params.adminPass);
480
+ const setRes = await fetchImpl(opsUrl, {
481
+ method: "POST",
482
+ headers: { "Content-Type": "application/json", Authorization: authHeader },
483
+ body: JSON.stringify({ operation: "set_configuration", ...params.configBlock }),
484
+ });
485
+ if (!setRes.ok) {
486
+ const text = await setRes.text().catch(() => "");
487
+ throw new Error(`set_configuration failed (HTTP ${setRes.status}): ${text}`);
488
+ }
489
+ const restartRes = await fetchImpl(opsUrl, {
490
+ method: "POST",
491
+ headers: { "Content-Type": "application/json", Authorization: authHeader },
492
+ body: JSON.stringify({ operation: "restart" }),
493
+ });
494
+ if (!restartRes.ok) {
495
+ const text = await restartRes.text().catch(() => "");
496
+ throw new Error(`restart failed (HTTP ${restartRes.status}): ${text}`);
497
+ }
498
+ }
499
+ /** `restart` only — used by `disableMcp` (flag off + restart, no config
500
+ * rewrite: the `@harperfast/oauth` config block is left in place; it is
501
+ * inert whenever `FLAIR_MCP_OAUTH` is unset, per the byte-identical-boot
502
+ * contract). */
503
+ export async function triggerRemoteRestart(opsPortOrUrl, adminUser, adminPass, deps = {}) {
504
+ const fetchImpl = deps.fetchImpl ?? fetch;
505
+ const opsUrl = opsBaseUrl(opsPortOrUrl);
506
+ const authHeader = basicAuthHeader(adminUser, adminPass);
507
+ const restartRes = await fetchImpl(opsUrl, {
508
+ method: "POST",
509
+ headers: { "Content-Type": "application/json", Authorization: authHeader },
510
+ body: JSON.stringify({ operation: "restart" }),
511
+ });
512
+ if (!restartRes.ok) {
513
+ const text = await restartRes.text().catch(() => "");
514
+ throw new Error(`restart failed (HTTP ${restartRes.status}): ${text}`);
515
+ }
516
+ }
517
+ /**
518
+ * Hit the OAuth metadata endpoint from the operator's machine against the
519
+ * PUBLIC origin — the verification that matters is the one claude.ai's
520
+ * perspective sees (scenario addendum). Never reports success on hope: any
521
+ * unreachable/malformed/mismatched response, OR a response that doesn't
522
+ * advertise CIMD support, is `ok: false` with a specific `detail`.
523
+ *
524
+ * flair#756: since CIMD is the only supported client-registration path now,
525
+ * "the /mcp OAuth surface is properly enabled" means "and a CIMD client can
526
+ * actually use it" — this single check is reused by `enable`'s own
527
+ * self-verify step, `grant`/`revoke`'s workflow gate (src/cli.ts), and
528
+ * `flair mcp status`, so all four commands agree on what "enabled" means.
529
+ */
530
+ export async function selfVerifyMcpMetadata(issuer, deps = {}) {
531
+ const fetchImpl = deps.fetchImpl ?? fetch;
532
+ const normalizedIssuer = issuer.replace(/\/+$/, "");
533
+ const url = `${normalizedIssuer}/.well-known/oauth-authorization-server`;
534
+ let res;
535
+ try {
536
+ res = await fetchImpl(url, { signal: AbortSignal.timeout(15_000) });
537
+ }
538
+ catch (err) {
539
+ return { ok: false, detail: `could not reach ${url}: ${err?.message ?? err}` };
540
+ }
541
+ if (!res.ok) {
542
+ return {
543
+ ok: false,
544
+ detail: `${url} returned HTTP ${res.status} — is FLAIR_MCP_OAUTH actually set on the restarted instance?`,
545
+ };
546
+ }
547
+ let body;
548
+ try {
549
+ body = await res.json();
550
+ }
551
+ catch {
552
+ return { ok: false, detail: `${url} did not return JSON` };
553
+ }
554
+ if (body?.issuer !== normalizedIssuer ||
555
+ typeof body?.registration_endpoint !== "string" ||
556
+ typeof body?.token_endpoint !== "string") {
557
+ return {
558
+ ok: false,
559
+ detail: `${url} responded but the metadata shape is unexpected (issuer/registration_endpoint/token_endpoint) — got issuer=${JSON.stringify(body?.issuer)}`,
560
+ };
561
+ }
562
+ // flair#756: confirm CIMD is actually advertised (node_modules/@harperfast/
563
+ // oauth/dist/lib/mcp/wellKnown.js:129-165's buildAuthorizationServerMetadata:
564
+ // `client_id_metadata_document_supported` is set only when
565
+ // clientIdMetadataDocuments.enabled !== false; `token_endpoint_auth_methods_
566
+ // supported` always includes "none"). Both are required per Anthropic's docs
567
+ // before Claude will use CIMD (see module header).
568
+ const cimdSupported = body?.client_id_metadata_document_supported === true &&
569
+ Array.isArray(body?.token_endpoint_auth_methods_supported) &&
570
+ body.token_endpoint_auth_methods_supported.includes("none");
571
+ if (!cimdSupported) {
572
+ return {
573
+ ok: false,
574
+ issuer: body.issuer,
575
+ registrationEndpoint: body.registration_endpoint,
576
+ tokenEndpoint: body.token_endpoint,
577
+ cimdSupported: false,
578
+ detail: `${url} answered but does not advertise CIMD support (client_id_metadata_document_supported / "none" in token_endpoint_auth_methods_supported) — is clientIdMetadataDocuments.enabled explicitly false?`,
579
+ };
580
+ }
581
+ return {
582
+ ok: true,
583
+ issuer: body.issuer,
584
+ registrationEndpoint: body.registration_endpoint,
585
+ tokenEndpoint: body.token_endpoint,
586
+ cimdSupported: true,
587
+ detail: "OAuth metadata endpoint answering on the public origin, advertising CIMD support",
588
+ };
589
+ }
590
+ /** The exact block to paste into claude.ai → Settings → Connectors. No
591
+ * client ID to hand over — CIMD-based connectors have Claude present its
592
+ * OWN client_id (a URL it hosts), never one this server issues. */
593
+ export function buildClaudePasteBlock(resource) {
594
+ return [
595
+ "claude.ai → Settings → Connectors → Add custom connector",
596
+ ` URL: ${resource}`,
597
+ " (no client ID to enter — Claude presents its own Client ID Metadata Document URL automatically)",
598
+ ].join("\n");
599
+ }
600
+ /**
601
+ * Full `flair mcp enable` orchestration. No `process.exit`, no console
602
+ * output — directly unit-testable with a mocked fetch and temp dirs, same
603
+ * split as `grantMcpClient`/`revokeMcpClient`. Returns a step-by-step log so
604
+ * a failure names exactly which step to re-run (never reports success on
605
+ * hope).
606
+ *
607
+ * flair#756: no DCR step anywhere in this flow — CIMD needs no
608
+ * pre-registration, so there is nothing to do after the restart besides
609
+ * self-verify. `self-verify` is now the ONLY live call that happens after
610
+ * `apply-config-and-restart`.
611
+ */
612
+ export async function enableMcp(params, deps = {}) {
613
+ const steps = [];
614
+ const dryRun = Boolean(params.dryRun);
615
+ const push = (step, ok, detail) => steps.push({ step, ok, detail });
616
+ // ── Local-origin refusal (scenario addendum, binding) ─────────────────────
617
+ const localCheck = checkLocalOriginRefusal(params.instance);
618
+ if (localCheck.refused) {
619
+ push("local-origin-check", false, localCheck.message);
620
+ return { ok: false, dryRun, refused: { message: localCheck.message }, steps, failedStep: "local-origin-check" };
621
+ }
622
+ push("local-origin-check", true, `${params.instance} is a public-shaped origin`);
623
+ const issuer = (params.issuer ?? params.instance).replace(/\/+$/, "");
624
+ const idpProvider = params.idpProvider ?? "github";
625
+ const principal = params.principal ?? "self";
626
+ const principalKind = params.principalKind ?? "human";
627
+ try {
628
+ // ── RS256 signing keypair ─────────────────────────────────────────────────
629
+ const keyResult = ensureSigningKeyFile(params.signingKeyFilePath, { generate: deps.generateRsaKeyPair });
630
+ push("signing-key", true, `signing key ${keyResult.reused ? "reused" : "generated"} at ${keyResult.path} (0600)`);
631
+ // ── @harperfast/oauth config block (CIMD-only; DCR explicitly disabled) ──
632
+ const cimdAllowedHosts = params.cimdAllowedHosts ?? DEFAULT_CIMD_ALLOWED_HOSTS;
633
+ const configBlock = buildMcpOAuthConfigBlock({ idpProvider, cimdAllowedHosts });
634
+ push("config-block", true, `built the @harperfast/oauth mcp config block (accessTokenTtl=${REQUIRED_ACCESS_TOKEN_TTL}, ` +
635
+ `dynamicClientRegistration.enabled=false, clientIdMetadataDocuments.allowedHosts=${JSON.stringify(cimdAllowedHosts)})`);
636
+ // ── IdP OAuth-app credential intake ───────────────────────────────────────
637
+ const callbackUrl = idpCallbackUrl(issuer, idpProvider);
638
+ if (!params.idpClientId || !params.idpClientSecret || !params.idpSubject) {
639
+ const missing = [
640
+ !params.idpClientId && "--idp-client-id",
641
+ !params.idpClientSecret && "--idp-client-secret",
642
+ !params.idpSubject && "--idp-subject",
643
+ ].filter(Boolean).join(", ");
644
+ push("idp-credentials", false, `missing ${missing}. Create a ${idpProvider} OAuth app with callback URL ${callbackUrl}, then re-run with the credentials.`);
645
+ return { ok: false, dryRun, steps, failedStep: "idp-credentials", callbackUrl };
646
+ }
647
+ push("idp-credentials", true, `${idpProvider} OAuth app credentials present; callback URL: ${callbackUrl}`);
648
+ if (dryRun) {
649
+ // Dry-run stops here — everything above is pure/local generation; no
650
+ // remote mutation has happened, and nothing below this line would run
651
+ // without --dry-run either.
652
+ return {
653
+ ok: true,
654
+ dryRun: true,
655
+ steps,
656
+ issuer,
657
+ resource: `${issuer}/mcp`,
658
+ callbackUrl,
659
+ signingKeyFilePath: keyResult.path,
660
+ };
661
+ }
662
+ // ── Secrets provisioning (shape-aware, never silent) ──────────────────────
663
+ const signingKeyPem = readSigningKeyFile(keyResult.path);
664
+ const bundle = buildSecretsBundle({
665
+ issuer,
666
+ signingKeyPem,
667
+ idpProvider,
668
+ idpClientId: params.idpClientId,
669
+ idpClientSecret: params.idpClientSecret,
670
+ });
671
+ const secretsResult = provisionSecrets(params.instance, bundle, {
672
+ mechanism: params.secretsMechanism,
673
+ stagingPath: params.secretsStagingPath,
674
+ });
675
+ push("secrets-provisioning", true, `mechanism: ${secretsResult.mechanism}; ${secretsResult.varNames.length} vars staged at ${secretsResult.path} (0600). ${secretsResult.instructions}`);
676
+ // ── Identity mapping (Credential kind:idp) ────────────────────────────────
677
+ const mapping = await provisionIdpIdentityMapping({
678
+ opsPortOrUrl: params.instance,
679
+ adminUser: params.adminUser,
680
+ adminPass: params.adminPass,
681
+ principal,
682
+ principalKind,
683
+ idpProvider,
684
+ idpSubject: params.idpSubject,
685
+ }, { fetchImpl: deps.fetchImpl, now: deps.now });
686
+ push("identity-mapping", true, `principal '${principal}' ${mapping.principalCreated ? "created" : "already existed"}; ` +
687
+ `Credential(kind:idp) ${mapping.credentialReused ? "reused" : "created"} (${mapping.credentialId})`);
688
+ // ── Gate: confirm the staged secrets are actually live before restarting ─
689
+ let confirmed = Boolean(params.confirmSecretsApplied);
690
+ if (!confirmed && deps.confirmPrompt) {
691
+ confirmed = await deps.confirmPrompt(`Have you applied the ${secretsResult.varNames.length} vars staged at ${secretsResult.path} to ${params.instance}'s environment?`);
692
+ }
693
+ if (!confirmed) {
694
+ push("apply-config-and-restart", false, `not applied: pass --confirm-secrets-applied once the staged secrets are live on ${params.instance}, then re-run \`flair mcp enable\` (earlier steps are idempotent and will reuse what's already provisioned).`);
695
+ return { ok: false, dryRun, steps, failedStep: "apply-config-and-restart", secretsMechanism: secretsResult.mechanism, secretsPath: secretsResult.path };
696
+ }
697
+ // ── Apply config + restart ────────────────────────────────────────────────
698
+ await applyRemoteConfigAndRestart({ opsPortOrUrl: params.instance, adminUser: params.adminUser, adminPass: params.adminPass, configBlock }, { fetchImpl: deps.fetchImpl });
699
+ push("apply-config-and-restart", true, `set_configuration + restart succeeded against ${params.instance}`);
700
+ // ── Self-verify from the operator's machine, public origin, CIMD-inclusive
701
+ const verify = await selfVerifyMcpMetadata(issuer, { fetchImpl: deps.fetchImpl });
702
+ if (!verify.ok) {
703
+ push("self-verify", false, `${verify.detail} — re-run \`flair mcp status\` to check current state, or \`flair mcp enable\` to retry the apply-config-and-restart step.`);
704
+ return {
705
+ ok: false,
706
+ dryRun,
707
+ steps,
708
+ failedStep: "self-verify",
709
+ issuer,
710
+ resource: `${issuer}/mcp`,
711
+ };
712
+ }
713
+ push("self-verify", true, verify.detail);
714
+ const resource = `${issuer}/mcp`;
715
+ return {
716
+ ok: true,
717
+ dryRun: false,
718
+ steps,
719
+ issuer,
720
+ resource,
721
+ pasteBlock: buildClaudePasteBlock(resource),
722
+ secretsMechanism: secretsResult.mechanism,
723
+ secretsPath: secretsResult.path,
724
+ signingKeyFilePath: keyResult.path,
725
+ callbackUrl,
726
+ };
727
+ }
728
+ catch (err) {
729
+ const lastStep = steps.length > 0 ? steps[steps.length - 1].step : "signing-key";
730
+ push(lastStep, false, `unexpected error: ${err?.message ?? err}`);
731
+ return { ok: false, dryRun, steps, failedStep: lastStep };
732
+ }
733
+ }
734
+ /**
735
+ * Flag off + restart = byte-identical boot, per the Model-2 contract
736
+ * (resources/mcp-oauth.ts: the route is registered ONLY when
737
+ * `FLAIR_MCP_OAUTH` is truthy; when off, the module does nothing at load).
738
+ * `FLAIR_MCP_OAUTH` is a process env var (never YAML config —
739
+ * resources/mcp-oauth-flag.ts), so `disable` cannot flip it remotely by
740
+ * itself; it requires the same operator confirmation `enable` requires
741
+ * before it calls `restart`. The `@harperfast/oauth` config block written by
742
+ * `enable` is deliberately left in place — it's inert whenever the flag is
743
+ * off, so there is nothing to "undo" there.
744
+ */
745
+ export async function disableMcp(params, deps = {}) {
746
+ let confirmed = Boolean(params.confirmFlagOff);
747
+ if (!confirmed && deps.confirmPrompt) {
748
+ confirmed = await deps.confirmPrompt(`Have you unset FLAIR_MCP_OAUTH (or set it to 0) in ${params.instance}'s process environment?`);
749
+ }
750
+ if (!confirmed) {
751
+ return {
752
+ ok: false,
753
+ detail: `Unset FLAIR_MCP_OAUTH (or set it to 0) via the same mechanism \`flair mcp enable\` used to set it, ` +
754
+ `then re-run \`flair mcp disable --confirm-flag-off\` to restart.`,
755
+ };
756
+ }
757
+ try {
758
+ await triggerRemoteRestart(params.instance, params.adminUser, params.adminPass, { fetchImpl: deps.fetchImpl });
759
+ }
760
+ catch (err) {
761
+ return { ok: false, detail: `restart failed: ${err?.message ?? err}` };
762
+ }
763
+ return { ok: true, detail: `restarted ${params.instance} — /mcp route no longer mounts (byte-identical boot)` };
764
+ }
765
+ /**
766
+ * Surfaces LIVE state (not a stale local marker): hits the same well-known
767
+ * metadata endpoint `enable`'s self-verify step checks. A 200 with the
768
+ * expected shape AND CIMD advertised means the surface is enabled and
769
+ * usable by a CIMD client; anything else means disabled/unreachable/
770
+ * misconfigured — `status` never guesses from local files alone (this is
771
+ * the same "never report success on hope" posture as self-verify).
772
+ */
773
+ export async function mcpStatus(params, deps = {}) {
774
+ const verify = await selfVerifyMcpMetadata(params.instance, { fetchImpl: deps.fetchImpl });
775
+ const machineClientCount = deps.countMachineClients?.();
776
+ return {
777
+ instance: params.instance,
778
+ enabled: verify.ok,
779
+ metadataReachable: verify.ok,
780
+ issuer: verify.issuer,
781
+ registrationEndpoint: verify.registrationEndpoint,
782
+ tokenEndpoint: verify.tokenEndpoint,
783
+ cimdSupported: verify.cimdSupported,
784
+ detail: verify.detail,
785
+ machineClientCount,
786
+ };
787
+ }