@tpsdev-ai/flair 0.35.0 → 0.37.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 CHANGED
@@ -13,7 +13,7 @@ import { keystore } from "./keystore.js";
13
13
  import { deploy as deployToFabric, validateOptions as validateDeployOptions, buildTargetUrl as buildDeployUrl, resolveDeployPublicUrl } from "./deploy.js";
14
14
  import { COMPONENT_ENV_FILENAME, PUBLIC_URL_KEY, assertNoSecretKeysAdded, describePublicUrlFinding, planComponentEnv, readEnvValue, } from "./component-env.js";
15
15
  import { fabricUpgrade } from "./fabric-upgrade.js";
16
- import { checkVersion, formatVersionNudge, primeVersionCheckCache, FLAIR_PKG_NAME } from "./version-check.js";
16
+ import { checkVersion, formatVersionNudge, primeVersionCheckCache, probeInstanceVersion, FLAIR_PKG_NAME } from "./version-check.js";
17
17
  import { readInstalledHarperVersion, fetchDeclaredHarperVersion, writeEngineVersionStamp, checkEngineVersionBackwards, UPGRADE_SNAPSHOT_ROOT, } from "./engine-version.js";
18
18
  import { checkServerHandshake, formatHandshakeNudge, invalidateHandshakeCache } from "./version-handshake.js";
19
19
  import { probeInstance } from "./probe.js";
@@ -4777,7 +4777,7 @@ mcp
4777
4777
  .option("--secrets-path <path>", "Override the secrets staging file path")
4778
4778
  .option("--cimd-allowed-hosts <hosts>", "Comma-separated clientIdMetadataDocuments.allowedHosts override (else claude.ai,claude.com)")
4779
4779
  .option("--signing-key-file <path>", "RS256 signing key PEM file (else ~/.flair/mcp-signing-key.pem)")
4780
- .option("--admin-pass <pass>", "Admin password for the target instance (or FLAIR_ADMIN_PASS)")
4780
+ .option("--admin-pass <pass>", "Admin password for the TARGET instance. Required explicitly for a remote target — FLAIR_ADMIN_PASS and ~/.flair/admin-pass are this machine's local credentials and are never sent to a remote instance")
4781
4781
  .option("--confirm-secrets-applied", "Confirm the staged secrets are already live on the target instance's environment (skips the interactive confirm)")
4782
4782
  .option("--dry-run", "Generate keys/tokens/config and validate inputs; skip every remote call")
4783
4783
  .option("--json", "Print machine-readable JSON instead of a human summary")
@@ -4802,8 +4802,11 @@ mcp
4802
4802
  // against someone else's instance (see resolveLocalAdminPass's doc comment).
4803
4803
  const adminPass = dryRun ? (opts.adminPass ?? process.env.FLAIR_ADMIN_PASS ?? "") : resolveLocalAdminPass(opts.adminPass, /* isRemoteTarget */ true);
4804
4804
  if (!dryRun && !adminPass) {
4805
- console.error("Error: --admin-pass or FLAIR_ADMIN_PASS required (the operations API on the target instance needs it " +
4806
- "for identity mapping + set_configuration + restart).");
4805
+ console.error("Error: --admin-pass <pass> or --admin-pass-file <path> is required for a REMOTE target " +
4806
+ "(the operations API on the target instance needs it for identity mapping + set_configuration + restart).\n" +
4807
+ " FLAIR_ADMIN_PASS and ~/.flair/admin-pass are deliberately NOT used here: they are THIS machine's " +
4808
+ "local admin credentials, and sending them to another instance is how a local secret ends up on someone " +
4809
+ "else's Harper. Pass the target's own admin password explicitly.");
4807
4810
  process.exit(1);
4808
4811
  }
4809
4812
  let idpClientId = opts.idpClientId;
@@ -9954,14 +9957,17 @@ program
9954
9957
  process.exit(1);
9955
9958
  }
9956
9959
  // flair#1047: refuse to boot if the store was written by a newer engine.
9957
- const runningHarperVersion = readInstalledHarperVersion(flairPackageDir());
9958
- if (runningHarperVersion) {
9959
- const backwardsError = checkEngineVersionBackwards(dataDir, runningHarperVersion);
9960
- if (backwardsError) {
9961
- console.error(`❌ Cannot start Flair — the data directory was written by a newer Harper engine.\n`);
9962
- console.error(backwardsError);
9963
- process.exit(1);
9964
- }
9960
+ // Same guard startFlairProcess runs, so restart/upgrade/snapshot cannot
9961
+ // reach a boot this command would refuse (flair#1093).
9962
+ try {
9963
+ guardEngineNotBackwards(dataDir);
9964
+ }
9965
+ catch (err) {
9966
+ if (!err?.engineBackwards)
9967
+ throw err;
9968
+ console.error(`❌ Cannot start Flair — the data directory was written by a newer Harper engine.\n`);
9969
+ console.error(err.message);
9970
+ process.exit(1);
9965
9971
  }
9966
9972
  const platform = process.platform;
9967
9973
  if (platform === "darwin") {
@@ -10329,7 +10335,45 @@ async function stopFlairProcess(port, dataDir) {
10329
10335
  * snapshot commands' restart leg brought the DEFAULT instance back up after
10330
10336
  * operating on a `--data-dir` elsewhere.
10331
10337
  */
10338
+ /**
10339
+ * flair#1047's refusal, at the point every boot passes through (flair#1093).
10340
+ *
10341
+ * It used to live inline in the `start` command's action and nowhere else, so
10342
+ * `flair restart`, `flair upgrade` (which restarts by spawning the new CLI with
10343
+ * `restart`) and the snapshot paths all booted Harper without it. The guard
10344
+ * covered one of the doors, and not the one an ENGINE SWAP comes through — so
10345
+ * an upgrade across a storage-format boundary came back as a dead port and a
10346
+ * bare exit 1 instead of a refusal naming actor, state and remedy.
10347
+ *
10348
+ * These same two functions had already drifted once, on the spawn environment:
10349
+ * see the note above buildDirectSpawnEnv about `start` setting a host-qualified
10350
+ * OPERATIONSAPI_NETWORK_PORT while startFlairProcess set none, silently
10351
+ * re-widening the ops API on every restart. Same pair, same shape. This is why
10352
+ * the check is a single function called from both rather than a second copy.
10353
+ *
10354
+ * Throws rather than exiting: `start` wants its own framing and an exit code,
10355
+ * while restart/upgrade need the message to travel up as an error. Nothing here
10356
+ * decides how it is presented.
10357
+ */
10358
+ function guardEngineNotBackwards(dataDir) {
10359
+ const runningHarperVersion = readInstalledHarperVersion(flairPackageDir());
10360
+ // No readable engine version means nothing to compare — the pre-stamp case
10361
+ // checkEngineVersionBackwards already treats as "not backwards". Refusing here
10362
+ // would brick every install written before the stamp existed.
10363
+ if (!runningHarperVersion)
10364
+ return;
10365
+ const backwardsError = checkEngineVersionBackwards(dataDir, runningHarperVersion);
10366
+ if (!backwardsError)
10367
+ return;
10368
+ const err = new Error(backwardsError);
10369
+ err.engineBackwards = true;
10370
+ throw err;
10371
+ }
10332
10372
  async function startFlairProcess(port, dataDir) {
10373
+ // Before anything is spawned or launchd is touched: an older engine opening a
10374
+ // newer store fails at the storage layer with an error about compression
10375
+ // internals, minutes later and nowhere near the cause.
10376
+ guardEngineNotBackwards(dataDir);
10333
10377
  if (process.platform === "darwin") {
10334
10378
  // resolveLaunchdLabel (flair#693) finds whichever label this data dir
10335
10379
  // is currently registered under before we attempt anything.
@@ -11310,17 +11354,46 @@ program
11310
11354
  // since we don't have advisory data, only the version gap. A red gap
11311
11355
  // counts as an issue (exit 1); a quieter yellow gap (one minor, or
11312
11356
  // patch-only) is printed but doesn't fail doctor.
11313
- const versionCheckResult = await checkVersion(__pkgVersion);
11314
- const versionNudge = formatVersionNudge(versionCheckResult);
11315
- if (versionNudge) {
11316
- const color = versionNudge.severity === "red" ? render.c.red : render.c.yellow;
11317
- const icon = versionNudge.severity === "red" ? render.wrap(render.c.red, "✗") : render.icons.warn;
11318
- console.log(` ${icon} ${render.wrap(color, versionNudge.message)}`);
11319
- if (versionNudge.severity === "red")
11320
- issues++;
11357
+ // ── flair#1072: the currency claim must be about the INSTANCE ─────────────
11358
+ //
11359
+ // This check used to run `checkVersion(__pkgVersion)` — the version of the
11360
+ // CLI you happen to have installed — and print "flair <x> is current". When
11361
+ // FLAIR_URL or --url points at a deployed instance, every other line doctor
11362
+ // prints is genuinely remote, so that sentence reads as a statement about
11363
+ // the thing you are talking to. It was a statement about your laptop.
11364
+ //
11365
+ // Reported against an instance five minors behind, where doctor said
11366
+ // "current". Telling you that is doctor's entire job.
11367
+ //
11368
+ // UNKNOWN MUST NOT FALL BACK TO THE LOCAL NUMBER. An older instance may not
11369
+ // expose its version at all, and the tempting fix is to use the one already
11370
+ // in hand — which is precisely how this bug reads today. If the instance
11371
+ // version cannot be determined, say so and count it as an issue rather than
11372
+ // answering from the wrong machine.
11373
+ const instanceVersion = await probeInstanceVersion(baseUrl);
11374
+ const versionSubject = instanceVersion ?? null;
11375
+ if (versionSubject === null) {
11376
+ console.log(` ${render.icons.warn} ${render.wrap(render.c.yellow, `could not determine the version running at ${baseUrl} — not reporting currency. ` +
11377
+ `(The local CLI is ${__pkgVersion}; that is NOT the instance.)`)}`);
11378
+ issues++;
11321
11379
  }
11322
- else if (versionCheckResult.latest) {
11323
- console.log(` ${render.icons.ok} flair ${__pkgVersion} is current`);
11380
+ else {
11381
+ const versionCheckResult = await checkVersion(versionSubject);
11382
+ const versionNudge = formatVersionNudge(versionCheckResult);
11383
+ if (versionNudge) {
11384
+ const color = versionNudge.severity === "red" ? render.c.red : render.c.yellow;
11385
+ const icon = versionNudge.severity === "red" ? render.wrap(render.c.red, "✗") : render.icons.warn;
11386
+ console.log(` ${icon} ${render.wrap(color, versionNudge.message)}`);
11387
+ if (versionNudge.severity === "red")
11388
+ issues++;
11389
+ }
11390
+ else if (versionCheckResult.latest) {
11391
+ console.log(` ${render.icons.ok} instance at ${baseUrl} runs flair ${versionSubject} — current`);
11392
+ }
11393
+ if (versionSubject !== __pkgVersion) {
11394
+ console.log(` ${render.icons.warn} ${render.wrap(render.c.yellow, `local CLI is ${__pkgVersion}, instance is ${versionSubject} — they differ. ` +
11395
+ `Commands run through the CLI; the instance serves the data.`)}`);
11396
+ }
11324
11397
  }
11325
11398
  // Helper: try to reach Harper on a given port.
11326
11399
  // Must return true ONLY when Harper's /Health endpoint returns 200 OK.
package/dist/deploy.js CHANGED
@@ -6,8 +6,19 @@ import { fileURLToPath } from "node:url";
6
6
  import { createRequire } from "node:module";
7
7
  import { COMPONENT_ENV_FILENAME, PUBLIC_URL_KEY, isLoopbackUrl, planComponentEnv, publicUrlRemedy, } from "./component-env.js";
8
8
  import { awaitOriginQuiescent, awaitReplicationConvergence, defaultConvergenceDeps, parseReplicationFailure, } from "./replication-convergence.js";
9
- // Files that must be present in a Flair package for deployment.
10
- // Mirrors the `files` array in package.json — keep in sync.
9
+ // Files that must be PRESENT for a deploy root to be usable at all — a
10
+ // preflight sanity check, not the payload definition.
11
+ //
12
+ // This used to say "Mirrors the `files` array in package.json — keep in sync."
13
+ // It did not, and nothing compared them. The payload was whatever sat in the
14
+ // deploy root, which for a git checkout meant .git, models/, test/, packages/
15
+ // and any stray file: 96 MB against a 1.3 MB published tarball, verified on a
16
+ // production Fabric component. A rule asserted in a comment with no mechanism
17
+ // behind it will drift, and this one drifted invisibly for as long as it existed.
18
+ //
19
+ // The payload is now derived from `files` directly, at pack time, by
20
+ // publishedEntryNames() — so there is one source of truth and nothing left to
21
+ // keep in sync by hand.
11
22
  export const REQUIRED_PACKAGE_FILES = [
12
23
  "dist",
13
24
  "schemas",
@@ -592,6 +603,73 @@ function isNodeModulesPath(root, path) {
592
603
  const rel = relative(root, path);
593
604
  return rel !== "" && rel.split(sep).includes("node_modules");
594
605
  }
606
+ /**
607
+ * The top-level entries the payload may contain: whatever `files` declares, plus
608
+ * the three npm always-includes. Read from the deploy root's own package.json so
609
+ * there is ONE source of truth — a hardcoded copy here is how `files` and the
610
+ * payload drifted apart in the first place.
611
+ */
612
+ export function publishedEntryNames(packageRoot) {
613
+ // `.env` is not in `files` and never ships to npm, but it MUST reach the
614
+ // component: config.yaml's `loadEnv` reads it, and shipping it is the whole
615
+ // point of flair#1005 and of stageDeployRoot itself. Filtering it out breaks
616
+ // FLAIR_PUBLIC_URL on every deploy — caught by the operator-value test rather
617
+ // than in production, which is the only reason this comment exists.
618
+ //
619
+ // What goes INSIDE that file is a separate concern with its own issue
620
+ // (flair#1011, HDB_ADMIN_PASSWORD must not be written into a deployed .env).
621
+ // This function decides what may ship, not what the file may contain.
622
+ const always = ["package.json", "README.md", "LICENSE", "LICENCE", COMPONENT_ENV_FILENAME];
623
+ let declared = [];
624
+ try {
625
+ const pkg = JSON.parse(readFileSync(join(packageRoot, "package.json"), "utf8"));
626
+ if (Array.isArray(pkg.files))
627
+ declared = pkg.files;
628
+ }
629
+ catch {
630
+ /* handled by the caller's emptiness check below */
631
+ }
632
+ // `files` entries are npm patterns; the ones flair uses are plain top-level
633
+ // names, optionally trailing-slashed ("dist/"). Normalise to the entry name.
634
+ const names = declared
635
+ .map((f) => String(f).replace(/^\.\//, "").replace(/\/+$/, ""))
636
+ .filter((f) => f !== "" && !f.includes("*") && !f.startsWith("!"));
637
+ return new Set([...names, ...always]);
638
+ }
639
+ /**
640
+ * Whether `packageRoot` declares a usable `files` array.
641
+ *
642
+ * Separate from `publishedEntryNames` deliberately. The refusal below used to
643
+ * ask whether the resulting set was larger than the always-includes, i.e. it
644
+ * compared against a hardcoded count — so adding one always-include silently
645
+ * stopped it firing. A check keyed to the length of a list that grows is a check
646
+ * that disables itself the next time someone edits that list.
647
+ */
648
+ export function hasDeclaredFiles(packageRoot) {
649
+ try {
650
+ const pkg = JSON.parse(readFileSync(join(packageRoot, "package.json"), "utf8"));
651
+ return Array.isArray(pkg.files) && pkg.files.length > 0;
652
+ }
653
+ catch {
654
+ return false;
655
+ }
656
+ }
657
+ /**
658
+ * True when `src` is a top-level entry the published package would not contain.
659
+ *
660
+ * Only TOP-LEVEL entries are filtered. Once a directory is admitted its whole
661
+ * subtree ships, which is what npm does and what makes the staged copy equal the
662
+ * published tarball rather than merely similar.
663
+ */
664
+ function isUnpublishedEntry(root, src, allowed) {
665
+ const rel = relative(root, src);
666
+ if (rel === "")
667
+ return false;
668
+ const parts = rel.split(sep);
669
+ if (parts.length > 1)
670
+ return false;
671
+ return !allowed.has(parts[0]);
672
+ }
595
673
  /**
596
674
  * Resolve the value `FLAIR_PUBLIC_URL` should carry for this deploy, or null.
597
675
  *
@@ -629,8 +707,40 @@ export function stageDeployRoot(packageRoot, publicUrl) {
629
707
  const envPath = join(packageRoot, COMPONENT_ENV_FILENAME);
630
708
  const existing = existsSync(envPath) ? readFileSync(envPath, "utf8") : null;
631
709
  const plan = planComponentEnv(existing, publicUrl);
632
- if (plan.text === null) {
633
- return { dir: packageRoot, plan, cleanup: () => { } };
710
+ // ── Why this ALWAYS stages now ─────────────────────────────────────────────
711
+ //
712
+ // harper packs its CWD wholesale, so the payload is whatever sits in the
713
+ // deploy root. The comment above assumes that root is an npm-installed
714
+ // package, where the tree already IS the published file set. That assumption
715
+ // is true for the intended path and silently false for the one our own
716
+ // deploy procedure prescribes: a git checkout.
717
+ //
718
+ // Measured on the production Fabric origin 2026-08-03, deploying from a
719
+ // checkout at v0.36.0: the deployed component held 36 top-level entries —
720
+ // `.git`, `.env`, `models/`, `test/`, `packages/`, `src/`, and a scratch
721
+ // `pr-body.md` left in the clone that afternoon. Payload 96 MB against the
722
+ // published tarball's 1.3 MB, a factor of 74. `models/` alone was 80 MB.
723
+ //
724
+ // Two consequences, both bad. An operator deploying from a checkout ships
725
+ // `.git` (every secret ever committed and later removed) and any `.env`
726
+ // sitting in the tree, into a component that is then PERSISTED and REPLICATED
727
+ // across the cluster. And a 96 MB payload is what puts the deploy inside
728
+ // HarperFast/harper#2062's aborted-transaction window, where the pre-saved
729
+ // blob is destroyed at the source.
730
+ //
731
+ // So the filter is not an optimisation. Staging unconditionally, restricted to
732
+ // the entries `files` declares, makes the payload equal the published package
733
+ // BY CONSTRUCTION rather than by an operator happening to run from the right
734
+ // directory. For an npm-installed root the result is byte-identical to before,
735
+ // because such a tree contains nothing else.
736
+ const allowed = publishedEntryNames(packageRoot);
737
+ // An empty/unreadable `files` would filter the payload down to the
738
+ // always-includes and deploy a component with no dist/. Refuse instead: a
739
+ // deploy that silently ships almost nothing is worse than one that does not run.
740
+ if (!hasDeclaredFiles(packageRoot)) {
741
+ throw new Error(`Cannot determine the published file set for ${packageRoot}: package.json has no usable "files" array. ` +
742
+ `Refusing to deploy rather than shipping an unfiltered payload (which would include .git and any .env) ` +
743
+ `or an empty one. Deploy from an npm-installed @tpsdev-ai/flair, or pass --package-root at one.`);
634
744
  }
635
745
  const dir = mkdtempSync(join(tmpdir(), STAGING_PREFIX));
636
746
  try {
@@ -638,12 +748,19 @@ export function stageDeployRoot(packageRoot, publicUrl) {
638
748
  recursive: true,
639
749
  dereference: false,
640
750
  verbatimSymlinks: true,
641
- filter: (src) => !isNodeModulesPath(packageRoot, src),
751
+ filter: (src) => !isNodeModulesPath(packageRoot, src) && !isUnpublishedEntry(packageRoot, src, allowed),
642
752
  });
643
753
  // 0600 even though the generated content is a public URL: an operator's own
644
754
  // keys may have been merged through, and the file's permissions should not
645
755
  // depend on what happens to be in it.
646
- writeFileSync(join(dir, COMPONENT_ENV_FILENAME), plan.text, { mode: 0o600 });
756
+ //
757
+ // `plan.text === null` means there is nothing to add — a loopback target, or
758
+ // an operator who already set the key. Staging still happened (the filter is
759
+ // the point now, not the `.env`), so any `.env` the root carried has been
760
+ // copied through unchanged and must be left alone.
761
+ if (plan.text !== null) {
762
+ writeFileSync(join(dir, COMPONENT_ENV_FILENAME), plan.text, { mode: 0o600 });
763
+ }
647
764
  }
648
765
  catch (err) {
649
766
  rmSync(dir, { recursive: true, force: true });
@@ -140,6 +140,25 @@ export function defaultKeysDir() {
140
140
  * local admin secret against someone else's Harper instance. Remote callers
141
141
  * keep requiring an explicit `--admin-pass`.
142
142
  *
143
+ * ── Both legs, not just the file (flair#1085) ────────────────────────────────
144
+ * An operator reported this as a bug: `--help` and the error both promised
145
+ * `FLAIR_ADMIN_PASS` would work, and for a remote target it silently did not.
146
+ * The promise was wrong, not the guard — but the codebase disagreed with itself,
147
+ * which is why it read as a defect. One call site's comment described the goal as
148
+ * blocking only the *file* fallback, this doc described blocking both, and the
149
+ * user-facing strings described neither.
150
+ *
151
+ * The env leg is skipped **deliberately**, and the reasoning is worth keeping:
152
+ * an exported `FLAIR_ADMIN_PASS` is not evidence of intent toward THIS target. It
153
+ * persists across a shell session, so an operator who set it for their local
154
+ * instance and later types `--instance https://someone-elses-host` would send a
155
+ * local admin credential to a third party — silently, and with no way to notice.
156
+ * "The operator exported it" and "the operator meant it for this host" are
157
+ * different claims, and only the explicit flag asserts the second.
158
+ *
159
+ * If that trade is ever revisited, revisit it here rather than at a call site:
160
+ * the guard is one line and the reasoning is not obvious from it.
161
+ *
143
162
  * Throws (via readAdminPassFileSecure) if the file exists but has unsafe
144
163
  * permissions, so a misconfigured file surfaces as an actionable chmod error
145
164
  * instead of a generic "admin pass required" message.
@@ -355,8 +355,50 @@ export function provisionSecrets(instanceUrl, bundle, opts = {}) {
355
355
  return { mechanism, path, varNames, instructions };
356
356
  }
357
357
  // ─── Identity mapping (Credential kind:idp) ─────────────────────────────────
358
+ /**
359
+ * The ops API is NOT the served origin, and its port is NOT derivable.
360
+ *
361
+ * flair#1072-adjacent, found while enabling MCP against a hosted instance: this
362
+ * function used a string target verbatim, so `flair mcp enable --instance
363
+ * https://flair.example.harperfabric.com` posted its ops calls to **port 443**,
364
+ * where the flair REST component owns `/` and answers `404 Not found`. Measured
365
+ * against a live Fabric instance, same request both ways:
366
+ *
367
+ * POST https://<host>/ -> HTTP 404 "Not found"
368
+ * POST https://<host>:9925/ -> HTTP 200 []
369
+ *
370
+ * The codebase elsewhere documents "ops port = HTTP port - 1", which derives 442
371
+ * for a 443-served instance. Also measured: 442 and 19925 are both dead on
372
+ * Fabric. **That convention does not hold, and no arithmetic on the served port
373
+ * can be trusted — an operator can put the ops API anywhere.**
374
+ *
375
+ * So: never derive silently. An explicit target wins; otherwise the conventional
376
+ * hosted ops port is *tried*, and a caller that cannot reach it is told to pass
377
+ * one rather than being handed a 404 about something else.
378
+ */
379
+ export const HOSTED_OPS_PORT = 9925;
380
+ export function resolveOpsUrl(target, explicitOpsUrl) {
381
+ if (explicitOpsUrl)
382
+ return `${explicitOpsUrl.replace(/\/+$/, "")}/`;
383
+ if (typeof target === "number")
384
+ return `http://127.0.0.1:${target}/`;
385
+ // A string target is the SERVED origin. Its own port serves the REST surface,
386
+ // not the ops API, so reuse the host and apply the hosted ops port.
387
+ try {
388
+ const u = new URL(target.includes("://") ? target : `https://${target}`);
389
+ u.port = String(HOSTED_OPS_PORT);
390
+ u.pathname = "/";
391
+ u.search = "";
392
+ return u.toString();
393
+ }
394
+ catch {
395
+ // Unparseable — preserve the old behaviour rather than inventing a URL, and
396
+ // let the caller's error path name the remedy.
397
+ return `${target.replace(/\/+$/, "")}/`;
398
+ }
399
+ }
358
400
  function opsBaseUrl(opsPortOrUrl) {
359
- return typeof opsPortOrUrl === "number" ? `http://127.0.0.1:${opsPortOrUrl}/` : `${opsPortOrUrl.replace(/\/$/, "")}/`;
401
+ return resolveOpsUrl(opsPortOrUrl);
360
402
  }
361
403
  function basicAuthHeader(adminUser, adminPass) {
362
404
  return `Basic ${Buffer.from(`${adminUser}:${adminPass}`).toString("base64")}`;
@@ -388,7 +430,19 @@ export async function provisionIdpIdentityMapping(params, deps = {}) {
388
430
  });
389
431
  if (!findRes.ok) {
390
432
  const text = await findRes.text().catch(() => "");
391
- throw new Error(`Identity mapping: failed to look up principal '${params.principal}' (HTTP ${findRes.status}): ${text}`);
433
+ // A MISSING principal is not this branch. The ops API answers an empty
434
+ // search with 200 and [], and the code below creates the principal when the
435
+ // list is empty. Reaching here means the ops CALL failed, not that the
436
+ // identity is absent — and saying "failed to look up principal 'x'" sends
437
+ // the reader to look at principals, which is where an evening goes.
438
+ //
439
+ // 404 in particular almost always means the request reached the SERVED
440
+ // origin instead of the ops API: the flair REST component owns `/` there and
441
+ // answers 404. Say that, and name the flag that fixes it.
442
+ const hint = findRes.status === 404
443
+ ? ` — a 404 here usually means ${opsUrl} is the served origin rather than the ops API (the REST component owns "/" and answers 404). The ops API is a DIFFERENT port (conventionally ${HOSTED_OPS_PORT} on hosted instances) and is not derivable from the served port. Pass --ops-url <url> to point at it explicitly.`
444
+ : "";
445
+ throw new Error(`Identity mapping: the ops API call to ${opsUrl} failed (HTTP ${findRes.status})${hint}${text ? `: ${text}` : ""}`);
392
446
  }
393
447
  const foundAgents = await findRes.json().catch(() => []);
394
448
  let principalCreated = false;
@@ -630,29 +684,46 @@ export function buildClaudePasteBlock(resource) {
630
684
  */
631
685
  export async function enableMcp(params, deps = {}) {
632
686
  const steps = [];
687
+ // The step currently executing, so a throw is attributed to IT rather than to
688
+ // the last step that succeeded (flair#1087).
689
+ //
690
+ // `push` deliberately takes NO step name: it reads this variable. A name passed
691
+ // per-call would be the same string typed twice (once here, once at the push),
692
+ // and the two drifting apart is precisely the misattribution #1087 is about —
693
+ // a rule that only a comment or a source scan could enforce. Deriving it makes
694
+ // a wrong name unrepresentable instead of merely discouraged, so there is
695
+ // nothing left for a reviewer to check.
696
+ //
697
+ // Initialised to the first step rather than left undefined so a throw before
698
+ // any assignment cannot be attributed to an arbitrary fallback name.
699
+ let currentStep = "local-origin-check";
633
700
  const dryRun = Boolean(params.dryRun);
634
- const push = (step, ok, detail) => steps.push({ step, ok, detail });
701
+ const push = (ok, detail) => steps.push({ step: currentStep, ok, detail });
635
702
  // ── Local-origin refusal (scenario addendum, binding) ─────────────────────
703
+ currentStep = "local-origin-check";
636
704
  const localCheck = checkLocalOriginRefusal(params.instance);
637
705
  if (localCheck.refused) {
638
- push("local-origin-check", false, localCheck.message);
706
+ push(false, localCheck.message);
639
707
  return { ok: false, dryRun, refused: { message: localCheck.message }, steps, failedStep: "local-origin-check" };
640
708
  }
641
- push("local-origin-check", true, `${params.instance} is a public-shaped origin`);
709
+ push(true, `${params.instance} is a public-shaped origin`);
642
710
  const issuer = (params.issuer ?? params.instance).replace(/\/+$/, "");
643
711
  const idpProvider = params.idpProvider ?? "github";
644
712
  const principal = params.principal ?? "self";
645
713
  const principalKind = params.principalKind ?? "human";
646
714
  try {
647
715
  // ── RS256 signing keypair ─────────────────────────────────────────────────
716
+ currentStep = "signing-key";
648
717
  const keyResult = ensureSigningKeyFile(params.signingKeyFilePath, { generate: deps.generateRsaKeyPair });
649
- push("signing-key", true, `signing key ${keyResult.reused ? "reused" : "generated"} at ${keyResult.path} (0600)`);
718
+ push(true, `signing key ${keyResult.reused ? "reused" : "generated"} at ${keyResult.path} (0600)`);
650
719
  // ── @harperfast/oauth config block (CIMD-only; DCR explicitly disabled) ──
651
720
  const cimdAllowedHosts = params.cimdAllowedHosts ?? DEFAULT_CIMD_ALLOWED_HOSTS;
721
+ currentStep = "config-block";
652
722
  const configBlock = buildMcpOAuthConfigBlock({ idpProvider, cimdAllowedHosts });
653
- push("config-block", true, `built the @harperfast/oauth mcp config block (accessTokenTtl=${REQUIRED_ACCESS_TOKEN_TTL}, ` +
723
+ push(true, `built the @harperfast/oauth mcp config block (accessTokenTtl=${REQUIRED_ACCESS_TOKEN_TTL}, ` +
654
724
  `dynamicClientRegistration.enabled=false, clientIdMetadataDocuments.allowedHosts=${JSON.stringify(cimdAllowedHosts)})`);
655
725
  // ── IdP OAuth-app credential intake ───────────────────────────────────────
726
+ currentStep = "idp-credentials";
656
727
  const callbackUrl = idpCallbackUrl(issuer, idpProvider);
657
728
  if (!params.idpClientId || !params.idpClientSecret || !params.idpSubject) {
658
729
  const missing = [
@@ -660,10 +731,10 @@ export async function enableMcp(params, deps = {}) {
660
731
  !params.idpClientSecret && "--idp-client-secret",
661
732
  !params.idpSubject && "--idp-subject",
662
733
  ].filter(Boolean).join(", ");
663
- push("idp-credentials", false, `missing ${missing}. Create a ${idpProvider} OAuth app with callback URL ${callbackUrl}, then re-run with the credentials.`);
734
+ push(false, `missing ${missing}. Create a ${idpProvider} OAuth app with callback URL ${callbackUrl}, then re-run with the credentials.`);
664
735
  return { ok: false, dryRun, steps, failedStep: "idp-credentials", callbackUrl };
665
736
  }
666
- push("idp-credentials", true, `${idpProvider} OAuth app credentials present; callback URL: ${callbackUrl}`);
737
+ push(true, `${idpProvider} OAuth app credentials present; callback URL: ${callbackUrl}`);
667
738
  if (dryRun) {
668
739
  // Dry-run stops here — everything above is pure/local generation; no
669
740
  // remote mutation has happened, and nothing below this line would run
@@ -687,12 +758,14 @@ export async function enableMcp(params, deps = {}) {
687
758
  idpClientId: params.idpClientId,
688
759
  idpClientSecret: params.idpClientSecret,
689
760
  });
761
+ currentStep = "secrets-provisioning";
690
762
  const secretsResult = provisionSecrets(params.instance, bundle, {
691
763
  mechanism: params.secretsMechanism,
692
764
  stagingPath: params.secretsStagingPath,
693
765
  });
694
- push("secrets-provisioning", true, `mechanism: ${secretsResult.mechanism}; ${secretsResult.varNames.length} vars staged at ${secretsResult.path} (0600). ${secretsResult.instructions}`);
766
+ push(true, `mechanism: ${secretsResult.mechanism}; ${secretsResult.varNames.length} vars staged at ${secretsResult.path} (0600). ${secretsResult.instructions}`);
695
767
  // ── Identity mapping (Credential kind:idp) ────────────────────────────────
768
+ currentStep = "identity-mapping";
696
769
  const mapping = await provisionIdpIdentityMapping({
697
770
  opsPortOrUrl: params.instance,
698
771
  adminUser: params.adminUser,
@@ -702,7 +775,7 @@ export async function enableMcp(params, deps = {}) {
702
775
  idpProvider,
703
776
  idpSubject: params.idpSubject,
704
777
  }, { fetchImpl: deps.fetchImpl, now: deps.now });
705
- push("identity-mapping", true, `principal '${principal}' ${mapping.principalCreated ? "created" : "already existed"}; ` +
778
+ push(true, `principal '${principal}' ${mapping.principalCreated ? "created" : "already existed"}; ` +
706
779
  `Credential(kind:idp) ${mapping.credentialReused ? "reused" : "created"} (${mapping.credentialId})`);
707
780
  // ── Gate: confirm the staged secrets are actually live before restarting ─
708
781
  let confirmed = Boolean(params.confirmSecretsApplied);
@@ -710,16 +783,18 @@ export async function enableMcp(params, deps = {}) {
710
783
  confirmed = await deps.confirmPrompt(`Have you applied the ${secretsResult.varNames.length} vars staged at ${secretsResult.path} to ${params.instance}'s environment?`);
711
784
  }
712
785
  if (!confirmed) {
713
- 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).`);
786
+ push(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).`);
714
787
  return { ok: false, dryRun, steps, failedStep: "apply-config-and-restart", secretsMechanism: secretsResult.mechanism, secretsPath: secretsResult.path };
715
788
  }
716
789
  // ── Apply config + restart ────────────────────────────────────────────────
790
+ currentStep = "apply-config-and-restart";
717
791
  await applyRemoteConfigAndRestart({ opsPortOrUrl: params.instance, adminUser: params.adminUser, adminPass: params.adminPass, configBlock }, { fetchImpl: deps.fetchImpl });
718
- push("apply-config-and-restart", true, `set_configuration + restart succeeded against ${params.instance}`);
792
+ push(true, `set_configuration + restart succeeded against ${params.instance}`);
719
793
  // ── Self-verify from the operator's machine, public origin, CIMD-inclusive
794
+ currentStep = "self-verify";
720
795
  const verify = await selfVerifyMcpMetadata(issuer, { fetchImpl: deps.fetchImpl });
721
796
  if (!verify.ok) {
722
- 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.`);
797
+ push(false, `${verify.detail} — re-run \`flair mcp status\` to check current state, or \`flair mcp enable\` to retry the apply-config-and-restart step.`);
723
798
  return {
724
799
  ok: false,
725
800
  dryRun,
@@ -729,7 +804,7 @@ export async function enableMcp(params, deps = {}) {
729
804
  resource: `${issuer}/mcp`,
730
805
  };
731
806
  }
732
- push("self-verify", true, verify.detail);
807
+ push(true, verify.detail);
733
808
  const resource = `${issuer}/mcp`;
734
809
  return {
735
810
  ok: true,
@@ -745,9 +820,23 @@ export async function enableMcp(params, deps = {}) {
745
820
  };
746
821
  }
747
822
  catch (err) {
748
- const lastStep = steps.length > 0 ? steps[steps.length - 1].step : "signing-key";
749
- push(lastStep, false, `unexpected error: ${err?.message ?? err}`);
750
- return { ok: false, dryRun, steps, failedStep: lastStep };
823
+ // flair#1087: blame the step that was RUNNING, never the last one that
824
+ // succeeded. This read steps[steps.length - 1] — the last COMPLETED step —
825
+ // so a throw inside identity-mapping was reported against
826
+ // secrets-provisioning, which had just succeeded. An operator saw:
827
+ //
828
+ // ✓ secrets-provisioning ...apply these 5 vars in Fabric Studio, then re-run
829
+ // ✗ secrets-provisioning unexpected error: Identity mapping: ...
830
+ //
831
+ // Two results for one step, and the ✓ instructs several minutes of manual
832
+ // work in a web UI that the ✗ makes pointless. Read in order, you do the
833
+ // work first.
834
+ // No `?? "signing-key"` fallback: currentStep is initialised to the first
835
+ // step, so there is no undefined case to invent a name for. A fallback here
836
+ // would attribute a throw to a step chosen for being a plausible default —
837
+ // the same misattribution this handler exists to prevent, one layer down.
838
+ push(false, `unexpected error: ${err?.message ?? err}`);
839
+ return { ok: false, dryRun, steps, failedStep: currentStep };
751
840
  }
752
841
  }
753
842
  /**