@tpsdev-ai/flair 0.22.1 → 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.
- package/dist/cli.js +1836 -523
- package/dist/deploy.js +16 -1
- package/dist/doctor-client.js +176 -0
- package/dist/hook-install.js +324 -0
- package/dist/install/clients.js +46 -11
- package/dist/lib/auth-resolve.js +369 -0
- package/dist/lib/mcp-enable.js +787 -0
- package/dist/probe.js +15 -2
- package/dist/resources/Memory.js +98 -60
- package/dist/resources/OrgEvent.js +37 -26
- package/dist/resources/Presence.js +4 -4
- package/dist/resources/Relationship.js +52 -53
- package/dist/resources/Soul.js +16 -8
- package/dist/resources/WorkspaceState.js +58 -54
- package/dist/resources/agent-auth.js +4 -5
- package/dist/resources/auth-middleware.js +4 -4
- package/dist/resources/ed25519-auth.js +37 -0
- package/dist/resources/embeddings-boot.js +38 -1
- package/dist/resources/mcp-handler.js +29 -5
- package/dist/resources/mcp-tools.js +50 -3
- package/dist/resources/provenance.js +60 -8
- package/dist/resources/record-type-kit.js +253 -0
- package/dist/resources/record-types.js +363 -0
- package/package.json +3 -3
- package/schemas/memory.graphql +2 -2
|
@@ -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
|
+
}
|