@tpsdev-ai/flair 0.33.0 → 0.35.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/config.yaml +17 -0
- package/dist/cli.js +570 -83
- package/dist/component-env.js +286 -0
- package/dist/deploy.js +190 -3
- package/dist/doctor-client.js +370 -8
- package/dist/engine-version.js +239 -0
- 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 +26 -8
- 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/mcp-handler.js +91 -4
- package/dist/resources/mcp-oauth.js +136 -7
- 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/hosted-on-fabric.md +1 -1
- package/docs/mcp-clients.md +24 -2
- package/docs/troubleshooting.md +36 -0
- package/package.json +1 -1
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
// engine-version.ts — Harper engine version tracking (flair#1047)
|
|
2
|
+
//
|
|
3
|
+
// Two concerns, one module:
|
|
4
|
+
//
|
|
5
|
+
// 1. STORE STAMP — flair records the engine version that last wrote the data
|
|
6
|
+
// directory. At boot, if the store was written by a NEWER engine than the
|
|
7
|
+
// one running, flair refuses to start and says so: which version wrote the
|
|
8
|
+
// store, which is running now, and what to do about it.
|
|
9
|
+
//
|
|
10
|
+
// 2. VERSION READ — read the Harper version installed alongside this flair
|
|
11
|
+
// package, and (when a target flair version is known) the Harper version
|
|
12
|
+
// that target declares. Used by the upgrade path to decide whether the
|
|
13
|
+
// engine version is changing and a pre-upgrade snapshot is therefore
|
|
14
|
+
// mandatory.
|
|
15
|
+
//
|
|
16
|
+
// The stamp is a single-line file in the data directory. It must survive the
|
|
17
|
+
// data directory being moved and must not require a Harper query to read — if
|
|
18
|
+
// the engine cannot boot, we still need to read it.
|
|
19
|
+
import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
20
|
+
import { join, resolve } from "node:path";
|
|
21
|
+
import { homedir } from "node:os";
|
|
22
|
+
/** Filename of the engine-version stamp inside the data directory. */
|
|
23
|
+
export const ENGINE_VERSION_STAMP = "engine-version.txt";
|
|
24
|
+
/** Root directory for pre-upgrade snapshots (~/.flair/upgrade-snapshots). */
|
|
25
|
+
export const UPGRADE_SNAPSHOT_ROOT = resolve(homedir(), ".flair", "upgrade-snapshots");
|
|
26
|
+
/** Read the Harper version installed alongside this flair package. */
|
|
27
|
+
export function readInstalledHarperVersion(packageRoot) {
|
|
28
|
+
for (const name of ["harper", "@harperfast/harper"]) {
|
|
29
|
+
const pkgPath = join(packageRoot, "node_modules", ...name.split("/"), "package.json");
|
|
30
|
+
if (!existsSync(pkgPath))
|
|
31
|
+
continue;
|
|
32
|
+
try {
|
|
33
|
+
const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
|
|
34
|
+
if (pkg.version)
|
|
35
|
+
return pkg.version;
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Fetch the Harper version that a given @tpsdev-ai/flair version declares as
|
|
45
|
+
* a dependency. Returns null when the lookup fails (network, unparseable, etc.)
|
|
46
|
+
* — callers treat null as "cannot determine, assume it might change."
|
|
47
|
+
*/
|
|
48
|
+
export async function fetchDeclaredHarperVersion(flairVersion) {
|
|
49
|
+
try {
|
|
50
|
+
const res = await fetch(`https://registry.npmjs.org/@tpsdev-ai/flair/${flairVersion}`, { signal: AbortSignal.timeout(5000) });
|
|
51
|
+
if (!res.ok)
|
|
52
|
+
return null;
|
|
53
|
+
const data = await res.json();
|
|
54
|
+
return data.dependencies?.harper ?? data.dependencies?.["@harperfast/harper"] ?? null;
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
// ─── Store stamp ─────────────────────────────────────────────────────────────
|
|
61
|
+
/** Write the engine version stamp into the data directory. */
|
|
62
|
+
export function writeEngineVersionStamp(dataDir, version) {
|
|
63
|
+
writeFileSync(join(dataDir, ENGINE_VERSION_STAMP), `${version}\n`, "utf-8");
|
|
64
|
+
}
|
|
65
|
+
/** Read the engine version stamp from the data directory, or null if absent. */
|
|
66
|
+
export function readEngineVersionStamp(dataDir) {
|
|
67
|
+
const stampPath = join(dataDir, ENGINE_VERSION_STAMP);
|
|
68
|
+
if (!existsSync(stampPath))
|
|
69
|
+
return null;
|
|
70
|
+
try {
|
|
71
|
+
return readFileSync(stampPath, "utf-8").trim() || null;
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Check whether the running engine is OLDER than the engine that last wrote
|
|
79
|
+
* the store. Returns null when the check passes (no stamp, or stamp ≤ running),
|
|
80
|
+
* or an error message when the store is newer.
|
|
81
|
+
*
|
|
82
|
+
* The error must be actionable: actor, state, remedy.
|
|
83
|
+
*/
|
|
84
|
+
export function checkEngineVersionBackwards(dataDir, runningVersion) {
|
|
85
|
+
const stamp = readEngineVersionStamp(dataDir);
|
|
86
|
+
if (!stamp)
|
|
87
|
+
return null; // no stamp — nothing to compare (pre-stamp install)
|
|
88
|
+
const parsed = compareVersions(stamp, runningVersion);
|
|
89
|
+
if (parsed === null) {
|
|
90
|
+
// Genuinely unparseable — cannot determine ordering. Refuse with a
|
|
91
|
+
// message that does NOT claim one is newer than the other.
|
|
92
|
+
return [
|
|
93
|
+
`This Flair install is running Harper ${runningVersion}, but the data directory at`,
|
|
94
|
+
` ${dataDir}`,
|
|
95
|
+
`was last written by Harper ${stamp}.`,
|
|
96
|
+
``,
|
|
97
|
+
`The engine version stamp could not be compared to the running version.`,
|
|
98
|
+
`An older Harper cannot safely read a store written by a newer one.`,
|
|
99
|
+
``,
|
|
100
|
+
...buildRecoveryLines(),
|
|
101
|
+
].join("\n");
|
|
102
|
+
}
|
|
103
|
+
if (parsed > 0) {
|
|
104
|
+
// stamp > running — backwards boot, refuse.
|
|
105
|
+
return [
|
|
106
|
+
`This Flair install is running Harper ${runningVersion}, but the data directory at`,
|
|
107
|
+
` ${dataDir}`,
|
|
108
|
+
`was last written by Harper ${stamp} — a newer engine version.`,
|
|
109
|
+
``,
|
|
110
|
+
`An older Harper cannot safely read a store written by a newer one.`,
|
|
111
|
+
`The data may appear intact but can be silently unreadable.`,
|
|
112
|
+
``,
|
|
113
|
+
...buildRecoveryLines(),
|
|
114
|
+
].join("\n");
|
|
115
|
+
}
|
|
116
|
+
return null; // running >= stamp — allowed
|
|
117
|
+
}
|
|
118
|
+
// ─── Version comparison (flair#1047) ─────────────────────────────────────────
|
|
119
|
+
/**
|
|
120
|
+
* Compare two semver-like version strings.
|
|
121
|
+
* Returns negative when a < b, positive when a > b, zero when equal,
|
|
122
|
+
* or null when either version is genuinely unparseable (not N.N.N at all).
|
|
123
|
+
*
|
|
124
|
+
* Pre-release ordering follows semver: a version WITH a pre-release tag is
|
|
125
|
+
* LOWER than the same core without one (5.2.0-rc1 < 5.2.0). When both have
|
|
126
|
+
* pre-releases, identifiers are compared dot by dot — numeric parts
|
|
127
|
+
* numerically, the rest as strings.
|
|
128
|
+
*/
|
|
129
|
+
function compareVersions(a, b) {
|
|
130
|
+
const pa = parseVersion(a);
|
|
131
|
+
const pb = parseVersion(b);
|
|
132
|
+
if (!pa || !pb)
|
|
133
|
+
return null;
|
|
134
|
+
// Compare major.minor.patch (and any additional numeric components) numerically.
|
|
135
|
+
const coreLen = Math.max(pa.core.length, pb.core.length);
|
|
136
|
+
for (let i = 0; i < coreLen; i++) {
|
|
137
|
+
const ac = pa.core[i] ?? 0;
|
|
138
|
+
const bc = pb.core[i] ?? 0;
|
|
139
|
+
if (ac !== bc)
|
|
140
|
+
return ac - bc;
|
|
141
|
+
}
|
|
142
|
+
// Cores are equal — compare pre-release tags.
|
|
143
|
+
if (pa.pre === null && pb.pre === null)
|
|
144
|
+
return 0;
|
|
145
|
+
if (pa.pre === null)
|
|
146
|
+
return 1; // a has no pre-release → a > b
|
|
147
|
+
if (pb.pre === null)
|
|
148
|
+
return -1; // b has no pre-release → a < b
|
|
149
|
+
// Both have pre-releases — compare identifiers dot by dot.
|
|
150
|
+
const len = Math.max(pa.pre.length, pb.pre.length);
|
|
151
|
+
for (let i = 0; i < len; i++) {
|
|
152
|
+
const ai = pa.pre[i];
|
|
153
|
+
const bi = pb.pre[i];
|
|
154
|
+
if (ai === undefined)
|
|
155
|
+
return -1; // fewer identifiers → lower
|
|
156
|
+
if (bi === undefined)
|
|
157
|
+
return 1;
|
|
158
|
+
const an = Number(ai);
|
|
159
|
+
const bn = Number(bi);
|
|
160
|
+
const aIsNum = !isNaN(an);
|
|
161
|
+
const bIsNum = !isNaN(bn);
|
|
162
|
+
if (aIsNum && bIsNum) {
|
|
163
|
+
if (an !== bn)
|
|
164
|
+
return an - bn;
|
|
165
|
+
}
|
|
166
|
+
else if (aIsNum) {
|
|
167
|
+
return -1; // numeric < string
|
|
168
|
+
}
|
|
169
|
+
else if (bIsNum) {
|
|
170
|
+
return 1;
|
|
171
|
+
}
|
|
172
|
+
else {
|
|
173
|
+
if (ai !== bi)
|
|
174
|
+
return ai < bi ? -1 : 1;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return 0;
|
|
178
|
+
}
|
|
179
|
+
function parseVersion(v) {
|
|
180
|
+
// Split off pre-release: everything after the first hyphen.
|
|
181
|
+
const hyphenIdx = v.indexOf("-");
|
|
182
|
+
const coreStr = hyphenIdx === -1 ? v : v.slice(0, hyphenIdx);
|
|
183
|
+
const preStr = hyphenIdx === -1 ? null : v.slice(hyphenIdx + 1);
|
|
184
|
+
const coreParts = coreStr.split(".");
|
|
185
|
+
if (coreParts.length < 3)
|
|
186
|
+
return null; // not at least N.N.N
|
|
187
|
+
const core = coreParts.map(Number);
|
|
188
|
+
if (core.some(isNaN))
|
|
189
|
+
return null; // non-numeric core component
|
|
190
|
+
const pre = preStr ? preStr.split(".") : null;
|
|
191
|
+
return { core, pre };
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* Build the recovery lines for a backwards-boot refusal message.
|
|
195
|
+
* Inspects the snapshot directory so the operator gets a runnable command
|
|
196
|
+
* (or a clear "nothing to restore" message) instead of a literal placeholder.
|
|
197
|
+
*/
|
|
198
|
+
export function buildRecoveryLines(snapshotDir) {
|
|
199
|
+
const effectiveSnapshotDir = snapshotDir ?? UPGRADE_SNAPSHOT_ROOT;
|
|
200
|
+
const snapshots = readSnapshotFiles(effectiveSnapshotDir);
|
|
201
|
+
if (snapshots.length === 0) {
|
|
202
|
+
return [
|
|
203
|
+
`To recover:`,
|
|
204
|
+
` No pre-upgrade snapshot was found.`,
|
|
205
|
+
` 1. Reinstall the newer version: npm install -g @tpsdev-ai/flair@latest`,
|
|
206
|
+
` 2. Or restore from a flair backup export (if you have one).`,
|
|
207
|
+
``,
|
|
208
|
+
`This check only helps from the release that ships it onward — it cannot`,
|
|
209
|
+
`rescue a downgrade to a build that predates the stamp.`,
|
|
210
|
+
];
|
|
211
|
+
}
|
|
212
|
+
const newest = snapshots[0];
|
|
213
|
+
const lines = [`To recover:`];
|
|
214
|
+
if (snapshots.length > 1) {
|
|
215
|
+
lines.push(` 1. Reinstall the newer version: npm install -g @tpsdev-ai/flair@latest`, ` 2. Or restore from the newest pre-upgrade snapshot:`, ` flair snapshot restore ${newest.path}`, ``, ` (To see all snapshots: flair snapshot list)`);
|
|
216
|
+
}
|
|
217
|
+
else {
|
|
218
|
+
lines.push(` 1. Reinstall the newer version: npm install -g @tpsdev-ai/flair@latest`, ` 2. Or restore from the pre-upgrade snapshot:`, ` flair snapshot restore ${newest.path}`);
|
|
219
|
+
}
|
|
220
|
+
lines.push(``, `This check only helps from the release that ships it onward — it cannot`, `rescue a downgrade to a build that predates the stamp.`);
|
|
221
|
+
return lines;
|
|
222
|
+
}
|
|
223
|
+
/** Read snapshot files (.tar.gz), newest first. Lexical sort = chronological, because the
|
|
224
|
+
filename carries an ISO 8601 timestamp (see upgradeSnapshotFileName). If that format ever
|
|
225
|
+
changes, this sort must be revisited. */
|
|
226
|
+
function readSnapshotFiles(dir) {
|
|
227
|
+
if (!existsSync(dir))
|
|
228
|
+
return [];
|
|
229
|
+
try {
|
|
230
|
+
return readdirSync(dir)
|
|
231
|
+
.filter((f) => f.startsWith("flair-data-") && f.endsWith(".tar.gz"))
|
|
232
|
+
.sort() // alphabetical = chronological (flair-data-<timestamp>.tar.gz)
|
|
233
|
+
.reverse() // newest first
|
|
234
|
+
.map((f) => ({ name: f, path: join(dir, f) }));
|
|
235
|
+
}
|
|
236
|
+
catch {
|
|
237
|
+
return [];
|
|
238
|
+
}
|
|
239
|
+
}
|
package/dist/hook-install.js
CHANGED
|
@@ -41,14 +41,20 @@
|
|
|
41
41
|
// FlairClient's plain global `fetch`, no rejectUnauthorized/NODE_TLS_*
|
|
42
42
|
// bypass anywhere — test/unit/hook-install.test.ts asserts that
|
|
43
43
|
// statically).
|
|
44
|
-
// 5. Silent-fast degradation —
|
|
45
|
-
//
|
|
46
|
-
//
|
|
44
|
+
// 5. Silent-fast degradation — SPLIT, deliberately, since flair#1007.
|
|
45
|
+
// session-start-hook.ts owns it once the binary is running (hard
|
|
46
|
+
// timeout, no-op-on-any-failure). It cannot own the case where the
|
|
47
|
+
// binary never runs at all — an orphaned global install after a Node
|
|
48
|
+
// runtime change — because in that case its guard is behind the door it
|
|
49
|
+
// is meant to guard. That half is owned by the command string this
|
|
50
|
+
// module writes, which is built by doctor-client.ts's
|
|
51
|
+
// buildSessionStartHookCommand (see its section doc for the shell
|
|
52
|
+
// analysis and why the wrapper is `sh -c`, not a bare fragment).
|
|
47
53
|
// 6. Size-budgeted payload — also owned by session-start-hook.ts, which
|
|
48
54
|
// reuses bootstrap's own maxTokens machinery.
|
|
49
55
|
import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
50
56
|
import { dirname, join } from "node:path";
|
|
51
|
-
import { SESSION_START_HOOK_MARKER } from "./doctor-client.js";
|
|
57
|
+
import { SESSION_START_HOOK_MARKER, buildSessionStartHookCommand, hookCommandIsSilenced, isHookCommandValueSafe, } from "./doctor-client.js";
|
|
52
58
|
// ── harness registry ────────────────────────────────────────────────────────
|
|
53
59
|
/** v1 supports exactly one harness. The flag/type exist so a second harness
|
|
54
60
|
* is an additive registry entry, not a rewrite (Kern's #719 verdict: "a
|
|
@@ -77,9 +83,16 @@ export function hookBackupPath(settingsPath) {
|
|
|
77
83
|
/** The exact `command` string written into the SessionStart hook entry.
|
|
78
84
|
* Always carries both FLAIR_AGENT_ID and FLAIR_URL (see module doc above)
|
|
79
85
|
* and always contains SESSION_START_HOOK_MARKER verbatim, so doctor's
|
|
80
|
-
* existing checkSessionStartHook recognizes it unchanged.
|
|
86
|
+
* existing checkSessionStartHook recognizes it unchanged.
|
|
87
|
+
*
|
|
88
|
+
* Since flair#1007 this is a thin wrapper over doctor-client.ts's
|
|
89
|
+
* buildSessionStartHookCommand — ONE builder for every path that writes this
|
|
90
|
+
* string (`flair hook install`, `flair doctor --fix`, `flair init`'s hint),
|
|
91
|
+
* so the invocation's failure behaviour is defined and tested in one place
|
|
92
|
+
* instead of drifting across three literals. Throws when agentId/flairUrl
|
|
93
|
+
* cannot be represented safely; installHook() checks first and reports. */
|
|
81
94
|
export function buildHookCommand(agentId, flairUrl) {
|
|
82
|
-
return
|
|
95
|
+
return buildSessionStartHookCommand(agentId, flairUrl);
|
|
83
96
|
}
|
|
84
97
|
/** Best-effort recovery of the agentId/flairUrl a previously-wired hook
|
|
85
98
|
* command carries — used by `flair hook status`. Pure string scan, never
|
|
@@ -194,6 +207,19 @@ export function installHook(opts) {
|
|
|
194
207
|
const { homeDir, harness, agentId, flairUrl } = opts;
|
|
195
208
|
const dryRun = !!opts.dryRun;
|
|
196
209
|
const path = hookSettingsPath(homeDir, harness);
|
|
210
|
+
// The command is a single-quoted shell argument (flair#1007) and quoting
|
|
211
|
+
// rules are not uniform across the shells a harness might use, so unsafe
|
|
212
|
+
// values are REFUSED rather than escaped — checked before anything is
|
|
213
|
+
// backed up or written, so a bad input never half-mutates the file.
|
|
214
|
+
for (const [label, value] of [["agent id", agentId], ["Flair URL", flairUrl]]) {
|
|
215
|
+
if (!isHookCommandValueSafe(value)) {
|
|
216
|
+
return {
|
|
217
|
+
ok: false, path, harness, dryRun,
|
|
218
|
+
message: `${label} '${value}' contains characters that cannot be safely written into a shell hook command (allowed: letters, digits, . _ : / -) — refusing to write it`,
|
|
219
|
+
backupPath: null, delta: null,
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
}
|
|
197
223
|
if (dryRun) {
|
|
198
224
|
const read = readSettingsFile(path);
|
|
199
225
|
if (read.parseError) {
|
|
@@ -309,16 +335,20 @@ export function hookStatus(homeDir, harness) {
|
|
|
309
335
|
const path = hookSettingsPath(homeDir, harness);
|
|
310
336
|
const read = readSettingsFile(path);
|
|
311
337
|
if (read.parseError) {
|
|
312
|
-
return { harness, path, wired: false, correctShape: false, parseError: read.parseError };
|
|
338
|
+
return { harness, path, wired: false, correctShape: false, silenced: false, parseError: read.parseError };
|
|
313
339
|
}
|
|
314
340
|
const config = read.parsed ?? {};
|
|
315
341
|
const existing = findHookEntry(config);
|
|
316
342
|
if (!existing) {
|
|
317
|
-
return { harness, path, wired: false, correctShape: false, parseError: null };
|
|
343
|
+
return { harness, path, wired: false, correctShape: false, silenced: false, parseError: null };
|
|
318
344
|
}
|
|
319
345
|
const hookEntry = config.hooks.SessionStart[existing.groupIndex].hooks[existing.hookIndex];
|
|
320
346
|
const command = typeof hookEntry?.command === "string" ? hookEntry.command : "";
|
|
321
347
|
const correctShape = hookEntry?.type === "command" && command.includes(`npx -y @tpsdev-ai/flair-mcp ${SESSION_START_HOOK_MARKER}`);
|
|
322
348
|
const env = parseHookCommandEnv(command);
|
|
323
|
-
return {
|
|
349
|
+
return {
|
|
350
|
+
harness, path, wired: true, correctShape,
|
|
351
|
+
silenced: hookCommandIsSilenced(command),
|
|
352
|
+
agentId: env.agentId, flairUrl: env.flairUrl, command, parseError: null,
|
|
353
|
+
};
|
|
324
354
|
}
|
package/dist/lib/auth-resolve.js
CHANGED
|
@@ -197,9 +197,92 @@ export function buildEd25519Auth(agentId, method, path, keyPath) {
|
|
|
197
197
|
const sig = nodeCryptoSign(null, Buffer.from(payload), privKey).toString("base64");
|
|
198
198
|
return `TPS-Ed25519 ${agentId}:${ts}:${nonce}:${sig}`;
|
|
199
199
|
}
|
|
200
|
-
/**
|
|
200
|
+
/**
|
|
201
|
+
* Classify a throw from `buildEd25519Auth`.
|
|
202
|
+
*
|
|
203
|
+
* Keyed on the STRUCTURED `code` property rather than on message text,
|
|
204
|
+
* because the message is not stable across crypto backends. Probed directly
|
|
205
|
+
* on the same 60-byte malformed input:
|
|
206
|
+
*
|
|
207
|
+
* Node (OpenSSL) ERR_OSSL_UNSUPPORTED error:1E08010C:DECODER routines::unsupported
|
|
208
|
+
* ERR_OSSL_ASN1_WRONG_TAG error:068000A8:asn1 encoding routines::wrong tag
|
|
209
|
+
* Bun (BoringSSL) ERR_OSSL_NO_START_LINE error:0900006e:PEM routines:...:NO_START_LINE
|
|
210
|
+
* ERR_OSSL_WRONG_TAG error:0c0000be:ASN.1 encoding routines:...:WRONG_TAG
|
|
211
|
+
*
|
|
212
|
+
* The first line is the error in flair#1023, and matching it alone would have
|
|
213
|
+
* left the tests (bun) and the shipped CLI (node) classifying differently.
|
|
214
|
+
* So the rule is the `ERR_OSSL_` family as a whole — sound here because
|
|
215
|
+
* buildEd25519Auth does no I/O beyond reading the file: any crypto-backend
|
|
216
|
+
* error it raises is about the key, never about the instance. Message text is
|
|
217
|
+
* a secondary signal only, for a backend that reports no code we recognise.
|
|
218
|
+
*/
|
|
219
|
+
export function classifyKeyLoadFailure(err) {
|
|
220
|
+
const code = typeof err?.code === "string" ? err.code : "";
|
|
221
|
+
if (code === "ENOENT")
|
|
222
|
+
return "not-found";
|
|
223
|
+
if (code === "EACCES" || code === "EPERM" || code === "EISDIR")
|
|
224
|
+
return "unreadable";
|
|
225
|
+
if (code.startsWith("ERR_OSSL"))
|
|
226
|
+
return "decode";
|
|
227
|
+
const message = err instanceof Error ? err.message : String(err ?? "");
|
|
228
|
+
// `asn\.?1` because the two backends punctuate it differently:
|
|
229
|
+
// "asn1 encoding routines" (OpenSSL) vs "ASN.1 encoding routines" (BoringSSL).
|
|
230
|
+
if (/DECODER routines|asn\.?1 encoding routines|PEM routines/i.test(message))
|
|
231
|
+
return "decode";
|
|
232
|
+
return "unknown";
|
|
233
|
+
}
|
|
234
|
+
/** A signing key could not be loaded — distinct from the instance being down. */
|
|
235
|
+
export class KeyLoadError extends Error {
|
|
236
|
+
/** The file we tried to read. Known at the point of failure; used to be discarded. */
|
|
237
|
+
keyPath;
|
|
238
|
+
kind;
|
|
239
|
+
/** The underlying error's message, preserved verbatim for the operator. */
|
|
240
|
+
underlying;
|
|
241
|
+
constructor(keyPath, kind, underlying) {
|
|
242
|
+
super(describeKeyLoadFailure(keyPath, kind, underlying));
|
|
243
|
+
this.name = "KeyLoadError";
|
|
244
|
+
this.keyPath = keyPath;
|
|
245
|
+
this.kind = kind;
|
|
246
|
+
this.underlying = underlying;
|
|
247
|
+
}
|
|
248
|
+
static from(keyPath, err) {
|
|
249
|
+
const underlying = err instanceof Error ? err.message : String(err ?? "");
|
|
250
|
+
return new KeyLoadError(keyPath, classifyKeyLoadFailure(err), underlying);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
/**
|
|
254
|
+
* One line an operator can act on: what we were doing, which file, and — only
|
|
255
|
+
* when it has actually been established — why. The "unknown" arm deliberately
|
|
256
|
+
* states no cause and shows the raw error instead (flair#1023 requirement 1).
|
|
257
|
+
*/
|
|
258
|
+
export function describeKeyLoadFailure(keyPath, kind, underlying) {
|
|
259
|
+
switch (kind) {
|
|
260
|
+
case "not-found":
|
|
261
|
+
return `signing key ${keyPath} does not exist`;
|
|
262
|
+
case "unreadable":
|
|
263
|
+
return `signing key ${keyPath} could not be read (${underlying})`;
|
|
264
|
+
case "decode":
|
|
265
|
+
return `signing key ${keyPath} could not be parsed as an Ed25519 private key (${underlying})`;
|
|
266
|
+
case "unknown":
|
|
267
|
+
// No cause is asserted — we genuinely do not know one.
|
|
268
|
+
return `signing key ${keyPath} could not be loaded: ${underlying}`;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
/**
|
|
272
|
+
* Authenticated fetch against Flair using Ed25519.
|
|
273
|
+
*
|
|
274
|
+
* Throws {@link KeyLoadError} if the key could not be loaded — a failure that
|
|
275
|
+
* provably happened BEFORE any byte hit the network, so no caller need guess
|
|
276
|
+
* whether the instance is reachable. Anything else thrown here is transport.
|
|
277
|
+
*/
|
|
201
278
|
export async function authFetch(baseUrl, agentId, keyPath, method, path, body) {
|
|
202
|
-
|
|
279
|
+
let auth;
|
|
280
|
+
try {
|
|
281
|
+
auth = buildEd25519Auth(agentId, method, path, keyPath);
|
|
282
|
+
}
|
|
283
|
+
catch (err) {
|
|
284
|
+
throw KeyLoadError.from(keyPath, err);
|
|
285
|
+
}
|
|
203
286
|
const headers = { Authorization: auth };
|
|
204
287
|
if (body !== undefined)
|
|
205
288
|
headers["Content-Type"] = "application/json";
|