@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.
- package/README.md +64 -64
- package/SECURITY.md +7 -0
- package/config.yaml +34 -0
- package/dist/cli.js +659 -111
- package/dist/component-env.js +286 -0
- package/dist/deploy.js +190 -3
- package/dist/doctor-client.js +357 -7
- package/dist/hook-install.js +39 -9
- package/dist/lib/auth-resolve.js +85 -2
- package/dist/lib/launchd-management.js +328 -0
- package/dist/lib/mcp-enable.js +19 -0
- package/dist/resources/AdminInstance.js +20 -2
- package/dist/resources/Memory.js +24 -2
- package/dist/resources/OAuth.js +41 -25
- package/dist/resources/auth-middleware.js +26 -0
- package/dist/resources/dcr-gate.js +194 -0
- package/dist/resources/in-process-api.js +5 -1
- package/dist/resources/mcp-handler.js +91 -4
- package/dist/resources/mcp-oauth.js +89 -7
- package/dist/resources/mcp-tools.js +40 -0
- package/dist/resources/oauth-discovery.js +242 -0
- package/dist/resources/oauth-wellknown.js +111 -0
- package/dist/resources/rate-limit.js +400 -0
- package/docs/auth.md +122 -5
- package/docs/deploying-on-fabric.md +35 -2
- package/docs/deployment.md +1 -1
- package/docs/embedding-in-a-harper-app.md +6 -1
- package/docs/hosted-on-fabric.md +1 -1
- package/docs/mcp-clients.md +28 -4
- package/docs/quickstart.md +29 -4
- package/docs/the-team.md +8 -4
- package/docs/troubleshooting.md +37 -1
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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,
|