@tpsdev-ai/flair 0.32.0 → 0.34.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,286 @@
1
+ // ─── The deployed component's `.env` (flair#1005 item 2, flair#1000, flair#1011) ──
2
+ //
3
+ // A publicly-reachable Flair served an OAuth discovery document whose issuer and
4
+ // every endpoint were `http://127.0.0.1:9980`, so no remote client could complete
5
+ // an authorization flow (flair#1000). The cause was `FLAIR_PUBLIC_URL` being unset
6
+ // on the instance: `resources/OAuth.ts` and `resources/AdminInstance.ts` fall back
7
+ // to the bind address when it is absent.
8
+ //
9
+ // The value has to arrive as an ENVIRONMENT VARIABLE in the component's process.
10
+ // The only channel a deploying client controls is a `.env` file inside the
11
+ // component payload, and Harper reads that file only because flair#1010 added
12
+ //
13
+ // loadEnv:
14
+ // files: '.env'
15
+ //
16
+ // to the shipped config.yaml, declared above `jsResource`. Without that block the
17
+ // file is inert — present on disk, never in `process.env`. Read config.yaml's own
18
+ // comment for the ordering constraint and for why `loadEnv` supplies APPLICATION
19
+ // variables only: it runs after Harper has already composed its own configuration.
20
+ //
21
+ // ── Why exactly one key ships, and no secret ever does ──────────────────────────
22
+ //
23
+ // The deploy payload is not transient. Harper's `deploy_component` ingests the
24
+ // whole tarball into an `hdb_deployment` row's `payload_blob`, and that row is the
25
+ // channel peers read the component from and the source for rollback
26
+ // (harper/dist/components/operations.js — "The row also holds the payload in a Blob
27
+ // attribute, which doubles as the source for peer replication and (later)
28
+ // rollback"). So anything in the payload is persisted on every node for as long as
29
+ // the deployment record lives. A public URL is fine there. A password is not.
30
+ //
31
+ // `HDB_ADMIN_PASSWORD` additionally cannot work from here even if that were
32
+ // acceptable: Harper composes its own configuration before component `.env` files
33
+ // load, so the name Harper itself consumes at startup is already resolved by the
34
+ // time `loadEnv` fires. Shipping it would put a credential in a component directory
35
+ // that only flair reads, fed from a different source than Harper's own copy, with
36
+ // nothing detecting divergence (flair#1011). `FLAIR_ADMIN_PASSWORD` is flair's own
37
+ // name for flair's own need and does reach its reader in time — but it is still a
38
+ // password, and the payload-persistence argument above applies to it unchanged.
39
+ //
40
+ // Hence: the file this module generates carries `FLAIR_PUBLIC_URL` and nothing
41
+ // else, and `assertNoSecretKeys` is a runtime guard, not a comment.
42
+ /** The file Harper's `loadEnv` plugin is pointed at by flair's config.yaml. */
43
+ export const COMPONENT_ENV_FILENAME = ".env";
44
+ /** The one key `flair deploy` supplies. */
45
+ export const PUBLIC_URL_KEY = "FLAIR_PUBLIC_URL";
46
+ /**
47
+ * Key names that must never appear in a `.env` flair GENERATES.
48
+ *
49
+ * An operator's own file is a different matter — see `planComponentEnv`, which
50
+ * preserves whatever they wrote and warns by key NAME (never value) rather than
51
+ * silently dropping or shipping it.
52
+ */
53
+ export const NEVER_GENERATED_SECRET_KEYS = [
54
+ "HDB_ADMIN_PASSWORD",
55
+ "FLAIR_ADMIN_PASSWORD",
56
+ "FLAIR_ADMIN_PASS",
57
+ "FABRIC_PASSWORD",
58
+ "FLAIR_CLUSTER_ADMIN_PASS",
59
+ "CLI_TARGET_PASSWORD",
60
+ // The DCR initial access token (resources/dcr-gate.ts). It is a bearer
61
+ // credential for an unauthenticated public endpoint, so it belongs in the
62
+ // process environment, never in a deploy payload — that payload is ingested
63
+ // into an hdb_deployment row and replicated to every node, per the argument
64
+ // at the top of this file.
65
+ "FLAIR_OAUTH_DCR_TOKEN",
66
+ ];
67
+ /**
68
+ * A key name that looks like it carries a credential. Used for the operator-facing
69
+ * notice only — matching is on the NAME, and the value is never read, printed, or
70
+ * compared.
71
+ */
72
+ export function looksLikeSecretKey(name) {
73
+ return /(PASSWORD|PASSWD|SECRET|TOKEN|_KEY|APIKEY|CREDENTIAL)/i.test(name);
74
+ }
75
+ /**
76
+ * Is this URL one only the machine serving it can reach?
77
+ *
78
+ * Deliberately a TEXT test, not a DNS lookup: this decides what gets baked into a
79
+ * deployed artifact, and a resolver answer at deploy time is not a property of the
80
+ * artifact. The IPv4 pattern is anchored end-to-end on purpose — a prefix test
81
+ * (`startsWith("127.")`) also matches hostnames like `127.0.0.1.example.com`, which
82
+ * are ordinary DNS names that merely happen to begin with those digits.
83
+ */
84
+ export function isLoopbackUrl(raw) {
85
+ if (!raw)
86
+ return false;
87
+ let host;
88
+ try {
89
+ host = new URL(raw).hostname;
90
+ }
91
+ catch {
92
+ return false;
93
+ }
94
+ return isLoopbackHost(host);
95
+ }
96
+ export function isLoopbackHost(host) {
97
+ const h = host.replace(/^\[|\]$/g, "").toLowerCase();
98
+ if (h === "localhost" || h.endsWith(".localhost"))
99
+ return true;
100
+ if (h === "::1" || h === "0:0:0:0:0:0:0:1")
101
+ return true;
102
+ return /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(h);
103
+ }
104
+ // A `.env` assignment line. `export ` is accepted because dotenv-style files
105
+ // commonly carry it and an operator who wrote `export FLAIR_PUBLIC_URL=...` has
106
+ // unambiguously set the key — treating that as "absent" and appending a second
107
+ // assignment would be the clobber this module exists to avoid.
108
+ const ASSIGNMENT_RE = /^[ \t]*(?:export[ \t]+)?([A-Za-z_][A-Za-z0-9_]*)[ \t]*=/;
109
+ /** Key NAMES present in a `.env`, in file order. Never returns values. */
110
+ export function envKeyNames(text) {
111
+ if (!text)
112
+ return [];
113
+ const names = [];
114
+ for (const line of text.split(/\r?\n/)) {
115
+ const m = ASSIGNMENT_RE.exec(line);
116
+ if (m && !names.includes(m[1]))
117
+ names.push(m[1]);
118
+ }
119
+ return names;
120
+ }
121
+ /**
122
+ * The value assigned to `key`, or null. The single value this module reads is
123
+ * `FLAIR_PUBLIC_URL`, which is not a secret; nothing here reads any other value.
124
+ */
125
+ export function readEnvValue(text, key) {
126
+ if (!text)
127
+ return null;
128
+ for (const line of text.split(/\r?\n/)) {
129
+ const m = ASSIGNMENT_RE.exec(line);
130
+ if (!m || m[1] !== key)
131
+ continue;
132
+ let v = line.slice(line.indexOf("=") + 1).trim();
133
+ if ((v.startsWith('"') && v.endsWith('"') && v.length >= 2) ||
134
+ (v.startsWith("'") && v.endsWith("'") && v.length >= 2)) {
135
+ v = v.slice(1, -1);
136
+ }
137
+ return v;
138
+ }
139
+ return null;
140
+ }
141
+ /**
142
+ * Throws if the transition from `existing` to `generated` INTRODUCES any key flair
143
+ * must never generate.
144
+ *
145
+ * Deliberately a diff and not "does the output contain a password": an operator's
146
+ * own `.env` may legitimately assign one, and refusing to deploy their file would
147
+ * break a deploy that works today. What must never happen is flair adding one. A
148
+ * runtime guard rather than a review convention, because the generator and this
149
+ * check are one edit apart forever — and `git grep NEVER_GENERATED_SECRET_KEYS`
150
+ * finds both ends of it.
151
+ */
152
+ export function assertNoSecretKeysAdded(existing, generated) {
153
+ const before = new Set(envKeyNames(existing));
154
+ const added = envKeyNames(generated).filter((n) => !before.has(n) && NEVER_GENERATED_SECRET_KEYS.includes(n));
155
+ if (added.length) {
156
+ throw new Error(`refusing to generate a component ${COMPONENT_ENV_FILENAME} that adds ${added.join(", ")}: ` +
157
+ `the deploy payload is persisted in Harper's hdb_deployment record and replicated to ` +
158
+ `every node, so flair must add no credential to it (flair#1011)`);
159
+ }
160
+ }
161
+ /**
162
+ * Decide what `.env` a deploy should ship.
163
+ *
164
+ * `existing` is the CONTENT of a `.env` already in the package root, or null. It is
165
+ * never modified in place and never overwritten on disk — the caller stages a copy.
166
+ * That is the answer to "do not silently clobber an operator's existing `.env`":
167
+ * flair MERGES, and merging here means "append the one key when it is absent, and
168
+ * change nothing at all when it is present".
169
+ *
170
+ * Why merge rather than refuse: a `.env` in the package root already ships today
171
+ * (Harper's packer includes every non-`node_modules` file under the deploy root), so
172
+ * an operator can legitimately be relying on one, and refusing would break a deploy
173
+ * that works. Why the operator's value wins rather than the deploy target: an
174
+ * instance is routinely fronted by a hostname that is not the URL the deploy is
175
+ * addressed to — a CDN, a reverse proxy, a vanity domain — and that hostname is
176
+ * exactly what OAuth clients must be told. Overwriting it with the Fabric URL would
177
+ * break the deployment the value exists to fix. The disagreement is printed, so the
178
+ * choice is visible rather than silent.
179
+ *
180
+ * `publicUrl` is null when the deploy has no non-loopback target to advertise; the
181
+ * plan is then "unchanged" — writing `http://127.0.0.1:...` into a shipped `.env` is
182
+ * the precise misconfiguration flair#1000 is about, so it is never generated.
183
+ */
184
+ export function planComponentEnv(existing, publicUrl) {
185
+ const notices = [];
186
+ // Whatever the operator wrote, say out loud which credential-shaped KEYS are
187
+ // about to be persisted in the replicated deployment record. Names only.
188
+ const secretish = envKeyNames(existing).filter(looksLikeSecretKey);
189
+ if (secretish.length) {
190
+ notices.push(`${COMPONENT_ENV_FILENAME} in the deploy root assigns ${secretish.join(", ")} — a deploy ` +
191
+ `payload is stored in Harper's deployment record and replicated to every node, so ` +
192
+ `those values travel with it. flair adds no credential of its own.`);
193
+ }
194
+ const operatorValue = readEnvValue(existing, PUBLIC_URL_KEY);
195
+ if (operatorValue !== null) {
196
+ if (publicUrl && operatorValue !== publicUrl) {
197
+ notices.push(`${PUBLIC_URL_KEY} is already set in ${COMPONENT_ENV_FILENAME} to ${operatorValue} — ` +
198
+ `keeping it. The deploy target is ${publicUrl}; if the instance is fronted by a ` +
199
+ `proxy or CDN the existing value is the correct one, otherwise edit that file.`);
200
+ }
201
+ if (isLoopbackUrl(operatorValue)) {
202
+ notices.push(`${PUBLIC_URL_KEY} in ${COMPONENT_ENV_FILENAME} is a loopback address ` +
203
+ `(${operatorValue}). OAuth discovery and A2A discovery advertise it verbatim, so ` +
204
+ `remote clients will be told to connect to their own machine (flair#1000).`);
205
+ }
206
+ return { action: "operator-value-kept", text: null, effectiveValue: operatorValue, notices };
207
+ }
208
+ if (!publicUrl) {
209
+ return { action: "unchanged", text: null, effectiveValue: null, notices };
210
+ }
211
+ const base = existing ?? "";
212
+ const separator = base.length === 0 || base.endsWith("\n") ? "" : "\n";
213
+ const text = `${base}${separator}${PUBLIC_URL_KEY}=${publicUrl}\n`;
214
+ assertNoSecretKeysAdded(existing, text);
215
+ return { action: "added", text, effectiveValue: publicUrl, notices };
216
+ }
217
+ /**
218
+ * The remedy string for a missing/loopback `FLAIR_PUBLIC_URL`. One definition so
219
+ * `flair deploy` and `flair doctor` cannot drift into naming different files.
220
+ *
221
+ * It names all three things an operator needs: the FILE, the KEY, and the fact that
222
+ * the file is only read because config.yaml declares Harper's `loadEnv` plugin —
223
+ * without which the file is present and inert, which is what made flair#1000 hard
224
+ * to see.
225
+ */
226
+ export function publicUrlRemedy(envPath, exampleUrl = "https://flair.example.com") {
227
+ return (`set ${PUBLIC_URL_KEY}=${exampleUrl} in ${envPath} (Harper reads a component's ` +
228
+ `${COMPONENT_ENV_FILENAME} only because flair's config.yaml declares the loadEnv plugin, ` +
229
+ `above jsResource), then restart the instance`);
230
+ }
231
+ /**
232
+ * What `flair doctor` should say about the instance's advertised public URL.
233
+ *
234
+ * Scope, stated rather than implied: doctor diagnoses the instance on THIS machine,
235
+ * reached over loopback. It cannot observe whether that instance is also reachable
236
+ * at a public address, so "unset, and the instance is public" is not a state it can
237
+ * detect — an unconditional finding there would fire on every laptop install, and a
238
+ * check that always fires is noise, not a gate. What it CAN detect without guessing
239
+ * is DRIFT: the value exists somewhere, and the running instance is still advertising
240
+ * loopback anyway. That is the exact shape of flair#1000 (a `.env` was placed and the
241
+ * issuer never changed, because no `loadEnv` declaration existed to read it), and it
242
+ * has no false positives.
243
+ *
244
+ * The unset-everywhere case is reported as information, not a finding, with the full
245
+ * remedy — so an operator who IS running this publicly is told precisely what to do.
246
+ */
247
+ export function describePublicUrlFinding(input) {
248
+ const { advertisedIssuer, componentEnvValue, processEnvValue, componentEnvPath } = input;
249
+ // Nothing to say if the instance could not be asked.
250
+ if (advertisedIssuer === null)
251
+ return null;
252
+ if (!isLoopbackUrl(advertisedIssuer)) {
253
+ return {
254
+ isIssue: false,
255
+ icon: "ok",
256
+ message: `OAuth/A2A discovery advertises ${advertisedIssuer}`,
257
+ };
258
+ }
259
+ if (componentEnvValue !== null && !isLoopbackUrl(componentEnvValue)) {
260
+ return {
261
+ isIssue: true,
262
+ icon: "error",
263
+ message: `${PUBLIC_URL_KEY} is set in ${componentEnvPath} but discovery still advertises ` +
264
+ `${advertisedIssuer} — the file is not reaching process.env, so every URL this ` +
265
+ `instance publishes points at its own loopback (flair#1000)`,
266
+ fixHint: `confirm config.yaml declares "loadEnv: files: '${COMPONENT_ENV_FILENAME}'" ABOVE ` +
267
+ `jsResource, then restart — a component .env is inert without that declaration`,
268
+ };
269
+ }
270
+ if (componentEnvValue === null && processEnvValue !== null && !isLoopbackUrl(processEnvValue)) {
271
+ return {
272
+ isIssue: true,
273
+ icon: "error",
274
+ message: `${PUBLIC_URL_KEY} is set in this shell but discovery advertises ${advertisedIssuer} — ` +
275
+ `the server reads its own environment, not yours`,
276
+ fixHint: publicUrlRemedy(componentEnvPath, processEnvValue),
277
+ };
278
+ }
279
+ return {
280
+ isIssue: false,
281
+ icon: "warn",
282
+ message: `${PUBLIC_URL_KEY} is not set — OAuth and A2A discovery advertise ${advertisedIssuer}, ` +
283
+ `which is correct for a local-only install and unusable for any remote client`,
284
+ fixHint: `if this instance is reachable at a public URL, ${publicUrlRemedy(componentEnvPath)}`,
285
+ };
286
+ }
package/dist/deploy.js CHANGED
@@ -1,8 +1,10 @@
1
1
  import { spawn } from "node:child_process";
2
- import { dirname, join, resolve } from "node:path";
3
- import { existsSync, readFileSync, readdirSync } from "node:fs";
2
+ import { dirname, join, relative, resolve, sep } from "node:path";
3
+ import { cpSync, existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
4
5
  import { fileURLToPath } from "node:url";
5
6
  import { createRequire } from "node:module";
7
+ import { COMPONENT_ENV_FILENAME, PUBLIC_URL_KEY, isLoopbackUrl, planComponentEnv, publicUrlRemedy, } from "./component-env.js";
6
8
  import { awaitOriginQuiescent, awaitReplicationConvergence, defaultConvergenceDeps, parseReplicationFailure, } from "./replication-convergence.js";
7
9
  // Files that must be present in a Flair package for deployment.
8
10
  // Mirrors the `files` array in package.json — keep in sync.
@@ -564,6 +566,159 @@ async function verifyResourcesServing(baseUrl, resources, fetchImpl) {
564
566
  `component is not serving; likely deployed the wrong package root`);
565
567
  }
566
568
  }
569
+ // ─── The component `.env` the deploy ships (flair#1005 item 2) ────────────────
570
+ //
571
+ // `harper deploy` packs its own CWD — every file under it except `node_modules`
572
+ // (harper/dist/bin/cliOperations.js sets `skip_node_modules` unless explicitly
573
+ // disabled, and harper/dist/components/packageComponent.js's `isExcluded` is the
574
+ // only other filter). So a `.env` sitting in the deploy root ships; there is no
575
+ // entries list to add it to, and no `.env` is special-cased away. Verified by
576
+ // running harper's own packer over a directory containing one.
577
+ //
578
+ // What this must NOT do is write into `packageRoot`. That directory is an
579
+ // npm-installed package — frequently not writable by the deploying user, shared by
580
+ // every deploy from this machine, and, when the operator has put their own `.env`
581
+ // there, theirs. So when flair has a key to add, it copies the deploy root to a
582
+ // temp directory, writes the merged file THERE, and points harper at the copy. The
583
+ // operator's tree is read and never written.
584
+ //
585
+ // The copy skips `node_modules` for the obvious reason (it can be gigabytes) and
586
+ // for the correctness one: harper excludes it at pack time regardless, so the
587
+ // resulting payload is identical. `deploy-staging.test.ts` asserts that identity
588
+ // with harper's real packer rather than trusting this paragraph.
589
+ const STAGING_PREFIX = "flair-deploy-";
590
+ /** True for any path inside a `node_modules` directory under `root`. */
591
+ function isNodeModulesPath(root, path) {
592
+ const rel = relative(root, path);
593
+ return rel !== "" && rel.split(sep).includes("node_modules");
594
+ }
595
+ /**
596
+ * Resolve the value `FLAIR_PUBLIC_URL` should carry for this deploy, or null.
597
+ *
598
+ * The deploy already knows this: `url` is the target it hands to harper AND the
599
+ * base URL it verifies the served API against immediately afterwards. A loopback
600
+ * target yields null — baking `http://127.0.0.1:...` into a shipped `.env` is
601
+ * precisely the misconfiguration flair#1000 is about, so it is never generated.
602
+ *
603
+ * Anything that is not an absolute http(s) URL also yields null. `--target` is
604
+ * operator-supplied and reaches here before harper has had a chance to reject it,
605
+ * and a value that is not a URL cannot be the base of a discovery document — so
606
+ * "cannot be determined" is answered by supplying nothing rather than by baking in
607
+ * a string that would make every advertised endpoint malformed.
608
+ */
609
+ export function resolveDeployPublicUrl(url) {
610
+ const trimmed = String(url ?? "").replace(/\/+$/, "");
611
+ let parsed;
612
+ try {
613
+ parsed = new URL(trimmed);
614
+ }
615
+ catch {
616
+ return null;
617
+ }
618
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:")
619
+ return null;
620
+ return isLoopbackUrl(trimmed) ? null : trimmed;
621
+ }
622
+ /**
623
+ * Prepare the directory harper will pack, supplying `FLAIR_PUBLIC_URL` when the
624
+ * payload does not already carry it. Returns `packageRoot` unchanged (and a no-op
625
+ * cleanup) whenever there is nothing to add — the common cases being a loopback
626
+ * target and an operator who has already set the key.
627
+ */
628
+ export function stageDeployRoot(packageRoot, publicUrl) {
629
+ const envPath = join(packageRoot, COMPONENT_ENV_FILENAME);
630
+ const existing = existsSync(envPath) ? readFileSync(envPath, "utf8") : null;
631
+ const plan = planComponentEnv(existing, publicUrl);
632
+ if (plan.text === null) {
633
+ return { dir: packageRoot, plan, cleanup: () => { } };
634
+ }
635
+ const dir = mkdtempSync(join(tmpdir(), STAGING_PREFIX));
636
+ try {
637
+ cpSync(packageRoot, dir, {
638
+ recursive: true,
639
+ dereference: false,
640
+ verbatimSymlinks: true,
641
+ filter: (src) => !isNodeModulesPath(packageRoot, src),
642
+ });
643
+ // 0600 even though the generated content is a public URL: an operator's own
644
+ // keys may have been merged through, and the file's permissions should not
645
+ // depend on what happens to be in it.
646
+ writeFileSync(join(dir, COMPONENT_ENV_FILENAME), plan.text, { mode: 0o600 });
647
+ }
648
+ catch (err) {
649
+ rmSync(dir, { recursive: true, force: true });
650
+ throw err;
651
+ }
652
+ return { dir, plan, cleanup: () => rmSync(dir, { recursive: true, force: true }) };
653
+ }
654
+ // ─── Post-deploy: is the instance advertising a URL a client can reach? ───────
655
+ //
656
+ // This is the check that makes the writer above testable in production rather than
657
+ // merely present. flair#1000 was a deploy that reported success while the served
658
+ // OAuth discovery document named `http://127.0.0.1:9980` for every endpoint, so a
659
+ // remote client followed discovery to its own loopback. Nothing in the deploy
660
+ // noticed, because nothing looked.
661
+ //
662
+ // A failure here fails the command even though the component IS deployed — the same
663
+ // contract as verifyResourcesServing above ("the tool must not be able to lie"). A
664
+ // deploy that leaves an instance no client can authorize against is not a success,
665
+ // and the operator needs to hear that at deploy time rather than from a user.
666
+ //
667
+ // An unreadable document is NOT treated as a pass: it is reported as a check that
668
+ // did not run, with the reason.
669
+ //
670
+ // It POLLS rather than reading once. A Fabric restart is rolling, so for a while
671
+ // after `harper deploy` returns, a request to the cluster can be answered by a node
672
+ // that has not restarted yet and is still running the previous environment. Reading
673
+ // once would turn that race into a failed deploy for a change that was fine. The
674
+ // poll only ever converts a "not yet" into a wait — a genuinely misconfigured
675
+ // instance still fails, at the deadline.
676
+ export const OAUTH_METADATA_PATH = "/OAuthMetadata";
677
+ export const DEFAULT_ISSUER_CHECK_TIMEOUT_MS = 60_000;
678
+ export const ISSUER_CHECK_POLL_INTERVAL_MS = 5_000;
679
+ /** One read of the discovery document. `issuer: null` means "could not be read". */
680
+ async function readAdvertisedIssuer(url, fetchImpl) {
681
+ try {
682
+ const res = await fetchImpl(url, { method: "GET", signal: AbortSignal.timeout(10_000) });
683
+ if (!res.ok)
684
+ return { issuer: null, detail: `HTTP ${res.status} from ${url}` };
685
+ const doc = (await res.json());
686
+ if (typeof doc?.issuer !== "string" || doc.issuer === "") {
687
+ return { issuer: null, detail: `${url} returned no issuer` };
688
+ }
689
+ return { issuer: doc.issuer, detail: `issuer ${doc.issuer}` };
690
+ }
691
+ catch (err) {
692
+ return { issuer: null, detail: `${url} could not be read: ${err?.message ?? err}` };
693
+ }
694
+ }
695
+ export async function verifyPublicIssuer(o) {
696
+ const { baseUrl, timeoutMs = DEFAULT_ISSUER_CHECK_TIMEOUT_MS, pollIntervalMs = ISSUER_CHECK_POLL_INTERVAL_MS, fetchImpl = fetch, onProgress, } = o;
697
+ const base = baseUrl.replace(/\/+$/, "");
698
+ const url = `${base}${OAUTH_METADATA_PATH}`;
699
+ onProgress?.(`checking ${OAUTH_METADATA_PATH} advertises a reachable issuer...`);
700
+ const deadline = Date.now() + timeoutMs;
701
+ let last = await readAdvertisedIssuer(url, fetchImpl);
702
+ for (;;) {
703
+ if (last.issuer !== null && !isLoopbackUrl(last.issuer)) {
704
+ return { checked: true, issuer: last.issuer, detail: last.detail };
705
+ }
706
+ if (Date.now() >= deadline)
707
+ break;
708
+ onProgress?.(last.issuer === null
709
+ ? `${OAUTH_METADATA_PATH} not readable yet (${last.detail}) — retrying...`
710
+ : `${OAUTH_METADATA_PATH} still advertises ${last.issuer} — the restart may not have reached every node yet, retrying...`);
711
+ await sleep(Math.min(pollIntervalMs, Math.max(0, deadline - Date.now())));
712
+ last = await readAdvertisedIssuer(url, fetchImpl);
713
+ }
714
+ if (last.issuer !== null) {
715
+ throw new Error(`deploy verification: ${url} advertises issuer ${last.issuer} — a loopback address, which ` +
716
+ `every remote client will follow to its own machine (flair#1000). ${PUBLIC_URL_KEY} is ` +
717
+ `not reaching the deployed component's process.env. Fix: ` +
718
+ `${publicUrlRemedy(`${COMPONENT_ENV_FILENAME} in the deploy root`, base)}, then re-deploy.`);
719
+ }
720
+ return { checked: false, issuer: null, detail: last.detail };
721
+ }
567
722
  // The tool must not be able to lie. harper's deploy CLI can print
568
723
  // "Successfully deployed" for an empty component — the only way to know the
569
724
  // deploy actually worked is to curl the served API and check it isn't 404.
@@ -607,7 +762,25 @@ export async function deploy(opts) {
607
762
  CLI_TARGET_USERNAME: opts.fabricUser,
608
763
  CLI_TARGET_PASSWORD: opts.fabricPassword,
609
764
  };
610
- const { replicationWarning, convergedAfterReplicationError } = await runHarperDeploy(harperBin, args, packageRoot, childEnv, opts, url, project);
765
+ // flair#1005 item 2: supply FLAIR_PUBLIC_URL to the component being deployed.
766
+ // `deployRoot` is packageRoot itself whenever there is nothing to add.
767
+ const publicUrl = resolveDeployPublicUrl(url);
768
+ const staged = stageDeployRoot(packageRoot, publicUrl);
769
+ for (const notice of staged.plan.notices) {
770
+ console.warn(`⚠ flair deploy: ${notice}`);
771
+ opts.onProgress?.(notice);
772
+ }
773
+ if (staged.plan.action === "added") {
774
+ opts.onProgress?.(`shipping ${COMPONENT_ENV_FILENAME} with ${PUBLIC_URL_KEY}=${staged.plan.effectiveValue}`);
775
+ }
776
+ let replicationWarning;
777
+ let convergedAfterReplicationError;
778
+ try {
779
+ ({ replicationWarning, convergedAfterReplicationError } = await runHarperDeploy(harperBin, args, staged.dir, childEnv, opts, url, project));
780
+ }
781
+ finally {
782
+ staged.cleanup();
783
+ }
611
784
  // harper can print "Successfully deployed" for a component that isn't
612
785
  // actually serving anything (the incident this closes: an empty deploy,
613
786
  // reported success, /Memory 404ing in prod). Verify by curling the served
@@ -622,6 +795,20 @@ export async function deploy(opts) {
622
795
  timeoutMs: opts.verifyTimeoutMs ?? DEFAULT_VERIFY_TIMEOUT_MS,
623
796
  onProgress: opts.onProgress,
624
797
  });
798
+ // Only meaningful for a target that is not loopback: a local Harper SHOULD
799
+ // advertise loopback, and asserting otherwise there would be wrong.
800
+ if (publicUrl) {
801
+ const issuerCheck = await verifyPublicIssuer({ baseUrl: url, onProgress: opts.onProgress });
802
+ if (issuerCheck.checked) {
803
+ opts.onProgress?.(`discovery advertises ${issuerCheck.issuer}`);
804
+ }
805
+ else {
806
+ // Not a pass. Say which check did not run, and why.
807
+ console.warn(`⚠ flair deploy: could not verify the advertised OAuth issuer — ${issuerCheck.detail}. ` +
808
+ `This check did NOT run; ${PUBLIC_URL_KEY} may or may not have taken effect.`);
809
+ opts.onProgress?.(`issuer check did not run — ${issuerCheck.detail}`);
810
+ }
811
+ }
625
812
  }
626
813
  return {
627
814
  url,