@tpsdev-ai/flair 0.9.0 → 0.10.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 +15 -17
- package/dist/cli.js +1395 -407
- package/dist/rem/restore.js +62 -2
- package/dist/render.js +168 -0
- package/dist/resources/A2AAdapter.js +33 -3
- package/dist/resources/AdminInstance.js +44 -8
- package/dist/resources/Federation.js +64 -33
- package/dist/resources/auth-middleware.js +9 -3
- package/dist/resources/federation-classify.js +29 -0
- package/dist/resources/health.js +10 -5
- package/package.json +1 -1
- package/schemas/federation.graphql +6 -3
package/dist/rem/restore.js
CHANGED
|
@@ -101,8 +101,20 @@ export async function applySnapshot(opts) {
|
|
|
101
101
|
rmSync(tmp, { recursive: true, force: true });
|
|
102
102
|
return result;
|
|
103
103
|
}
|
|
104
|
-
// 3. Verify agent id matches.
|
|
105
|
-
|
|
104
|
+
// 3. Verify agent id matches. Hard-fail on missing OR mismatched —
|
|
105
|
+
// the previous `metadata.agentId && ...` short-circuited on missing,
|
|
106
|
+
// which means a snapshot crafted without metadata.agentId would bypass
|
|
107
|
+
// the cross-agent guard entirely. v0.9.0+ snapshots always write
|
|
108
|
+
// metadata.agentId (see src/rem/snapshot.ts), so the missing case can
|
|
109
|
+
// only originate from pre-v0.9.0 snapshots or external/hand-edited
|
|
110
|
+
// input — both of which we must reject.
|
|
111
|
+
if (!metadata.agentId) {
|
|
112
|
+
errors.push(`snapshot is missing metadata.agentId — refusing to restore (pre-v0.9.0 or untrusted snapshot)`);
|
|
113
|
+
result.status = "failed";
|
|
114
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
115
|
+
return result;
|
|
116
|
+
}
|
|
117
|
+
if (metadata.agentId !== opts.agentId) {
|
|
106
118
|
errors.push(`snapshot agentId (${metadata.agentId}) does not match target (${opts.agentId}) — refusing to restore cross-agent`);
|
|
107
119
|
result.status = "failed";
|
|
108
120
|
rmSync(tmp, { recursive: true, force: true });
|
|
@@ -197,6 +209,54 @@ export async function applySnapshot(opts) {
|
|
|
197
209
|
errors.push(`put-soul ${s.id}: ${err?.message ?? String(err)}`);
|
|
198
210
|
}
|
|
199
211
|
}
|
|
212
|
+
// 7. Verify post-restore state (default on; opt-out via verifyPostRestore=false).
|
|
213
|
+
// Catches silent failures: Harper schema coercion, 4xx responses the
|
|
214
|
+
// apiCall layer masked as ok, partial-DELETE leftovers. Per-ID diff,
|
|
215
|
+
// not just counts — count parity can hide simultaneous PUT+DELETE
|
|
216
|
+
// failures that wash out numerically. See ops-90dq.
|
|
217
|
+
if (opts.verifyPostRestore !== false) {
|
|
218
|
+
try {
|
|
219
|
+
const verifyMem = asArray(await opts.apiCall("GET", `/Memory?agentId=${encodeURIComponent(opts.agentId)}`));
|
|
220
|
+
const verifySouls = asArray(await opts.apiCall("GET", `/Soul?agentId=${encodeURIComponent(opts.agentId)}`));
|
|
221
|
+
const expectedMemoryIds = memories.filter((m) => m?.id).map((m) => String(m.id));
|
|
222
|
+
const expectedSoulIds = souls.filter((s) => s?.id).map((s) => String(s.id));
|
|
223
|
+
const actualMemoryIds = verifyMem.filter((m) => m?.id).map((m) => String(m.id));
|
|
224
|
+
const actualSoulIds = verifySouls.filter((s) => s?.id).map((s) => String(s.id));
|
|
225
|
+
const expMem = new Set(expectedMemoryIds);
|
|
226
|
+
const expSoul = new Set(expectedSoulIds);
|
|
227
|
+
const actMem = new Set(actualMemoryIds);
|
|
228
|
+
const actSoul = new Set(actualSoulIds);
|
|
229
|
+
const missingMemoryIds = [...expMem].filter((id) => !actMem.has(id));
|
|
230
|
+
const missingSoulIds = [...expSoul].filter((id) => !actSoul.has(id));
|
|
231
|
+
const extraMemoryIds = [...actMem].filter((id) => !expMem.has(id));
|
|
232
|
+
const extraSoulIds = [...actSoul].filter((id) => !expSoul.has(id));
|
|
233
|
+
result.verified = {
|
|
234
|
+
expectedMemoryIds,
|
|
235
|
+
expectedSoulIds,
|
|
236
|
+
actualMemoryIds,
|
|
237
|
+
actualSoulIds,
|
|
238
|
+
missingMemoryIds,
|
|
239
|
+
missingSoulIds,
|
|
240
|
+
extraMemoryIds,
|
|
241
|
+
extraSoulIds,
|
|
242
|
+
};
|
|
243
|
+
if (missingMemoryIds.length > 0) {
|
|
244
|
+
errors.push(`post-restore-verify: ${missingMemoryIds.length} memory rows missing (e.g. ${missingMemoryIds.slice(0, 3).join(", ")})`);
|
|
245
|
+
}
|
|
246
|
+
if (missingSoulIds.length > 0) {
|
|
247
|
+
errors.push(`post-restore-verify: ${missingSoulIds.length} soul rows missing (e.g. ${missingSoulIds.slice(0, 3).join(", ")})`);
|
|
248
|
+
}
|
|
249
|
+
if (extraMemoryIds.length > 0) {
|
|
250
|
+
errors.push(`post-restore-verify: ${extraMemoryIds.length} unexpected memory rows present (e.g. ${extraMemoryIds.slice(0, 3).join(", ")})`);
|
|
251
|
+
}
|
|
252
|
+
if (extraSoulIds.length > 0) {
|
|
253
|
+
errors.push(`post-restore-verify: ${extraSoulIds.length} unexpected soul rows present (e.g. ${extraSoulIds.slice(0, 3).join(", ")})`);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
catch (err) {
|
|
257
|
+
errors.push(`post-restore-verify: ${err?.message ?? String(err)}`);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
200
260
|
rmSync(tmp, { recursive: true, force: true });
|
|
201
261
|
if (errors.length > 0) {
|
|
202
262
|
result.status = "failed";
|
package/dist/render.js
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
// CLI output renderer — color, tables, icons, spinner, output-mode resolution.
|
|
2
|
+
//
|
|
3
|
+
// Two output modes coexist across every command:
|
|
4
|
+
//
|
|
5
|
+
// human — pretty default, ANSI color when stdout is a TTY and NO_COLOR/
|
|
6
|
+
// FLAIR_NO_COLOR aren't set
|
|
7
|
+
// json — agent-default, also auto-selected when stdout is piped
|
|
8
|
+
//
|
|
9
|
+
// Precedence: --json flag > FLAIR_OUTPUT=json env > non-TTY stdout > TTY (human).
|
|
10
|
+
// Setting FLAIR_OUTPUT=human forces human mode even when piped.
|
|
11
|
+
//
|
|
12
|
+
// No third-party deps — minimal ANSI by hand keeps the dependency tree flat
|
|
13
|
+
// (the rest of the flair runtime already runs ANSI-free; we don't need
|
|
14
|
+
// chalk/picocolors' edge-case coverage for SGR codes we don't use).
|
|
15
|
+
const stdoutIsTTY = !!process.stdout.isTTY;
|
|
16
|
+
const stderrIsTTY = !!process.stderr.isTTY;
|
|
17
|
+
const noColorEnv = process.env.NO_COLOR != null || process.env.FLAIR_NO_COLOR != null;
|
|
18
|
+
const enableColor = stdoutIsTTY && !noColorEnv;
|
|
19
|
+
const C = (code) => (enableColor ? `\x1b[${code}m` : "");
|
|
20
|
+
export const c = {
|
|
21
|
+
reset: C("0"),
|
|
22
|
+
bold: C("1"),
|
|
23
|
+
dim: C("2"),
|
|
24
|
+
italic: C("3"),
|
|
25
|
+
underline: C("4"),
|
|
26
|
+
red: C("31"),
|
|
27
|
+
green: C("32"),
|
|
28
|
+
yellow: C("33"),
|
|
29
|
+
blue: C("34"),
|
|
30
|
+
magenta: C("35"),
|
|
31
|
+
cyan: C("36"),
|
|
32
|
+
white: C("37"),
|
|
33
|
+
gray: C("90"),
|
|
34
|
+
};
|
|
35
|
+
export function wrap(color, text) {
|
|
36
|
+
if (!color)
|
|
37
|
+
return text;
|
|
38
|
+
return `${color}${text}${c.reset}`;
|
|
39
|
+
}
|
|
40
|
+
export function resolveOutputMode(opts) {
|
|
41
|
+
if (opts.json)
|
|
42
|
+
return "json";
|
|
43
|
+
const envOut = process.env.FLAIR_OUTPUT;
|
|
44
|
+
if (envOut === "json")
|
|
45
|
+
return "json";
|
|
46
|
+
if (envOut === "human")
|
|
47
|
+
return "human";
|
|
48
|
+
// No explicit selection — pipe-friendly default: non-TTY stdout → json.
|
|
49
|
+
return stdoutIsTTY ? "human" : "json";
|
|
50
|
+
}
|
|
51
|
+
export const icons = {
|
|
52
|
+
ok: wrap(c.green, "✓"),
|
|
53
|
+
warn: wrap(c.yellow, "⚠"),
|
|
54
|
+
error: wrap(c.red, "✗"),
|
|
55
|
+
info: wrap(c.cyan, "ℹ"),
|
|
56
|
+
bullet: wrap(c.gray, "·"),
|
|
57
|
+
pending: wrap(c.gray, "○"),
|
|
58
|
+
arrow: wrap(c.gray, "→"),
|
|
59
|
+
};
|
|
60
|
+
// Status header: bullet + bold label. Use for top-level command titles.
|
|
61
|
+
export function header(label) {
|
|
62
|
+
return wrap(c.bold, label);
|
|
63
|
+
}
|
|
64
|
+
// Section divider — used between groups inside one command's output.
|
|
65
|
+
// Subtle line + bold label, no big ═══ banners (those compete with content).
|
|
66
|
+
export function section(label) {
|
|
67
|
+
return `\n${wrap(c.bold, label)}\n${wrap(c.gray, "─".repeat(Math.min(60, label.length + 8)))}`;
|
|
68
|
+
}
|
|
69
|
+
// Key/value pair with aligned label. Default label width 14 cols.
|
|
70
|
+
export function kv(label, value, labelWidth = 14) {
|
|
71
|
+
return ` ${wrap(c.dim, label.padEnd(labelWidth))} ${value}`;
|
|
72
|
+
}
|
|
73
|
+
export function table(columns, rows) {
|
|
74
|
+
if (rows.length === 0)
|
|
75
|
+
return wrap(c.dim, " (no rows)");
|
|
76
|
+
const widths = columns.map((col) => col.label.length);
|
|
77
|
+
const cells = rows.map((row) => columns.map((col, i) => {
|
|
78
|
+
const raw = row[col.key];
|
|
79
|
+
const formatted = col.format ? col.format(raw, row) : raw == null ? "—" : String(raw);
|
|
80
|
+
// Visible-width calculation — strip our own ANSI escapes before counting
|
|
81
|
+
const visibleLen = formatted.replace(/\x1b\[[0-9;]*m/g, "").length;
|
|
82
|
+
if (visibleLen > widths[i])
|
|
83
|
+
widths[i] = visibleLen;
|
|
84
|
+
return formatted;
|
|
85
|
+
}));
|
|
86
|
+
const align = (str, width, side) => {
|
|
87
|
+
const visibleLen = str.replace(/\x1b\[[0-9;]*m/g, "").length;
|
|
88
|
+
const pad = " ".repeat(Math.max(0, width - visibleLen));
|
|
89
|
+
return side === "right" ? pad + str : str + pad;
|
|
90
|
+
};
|
|
91
|
+
const headerRow = " " +
|
|
92
|
+
columns.map((col, i) => wrap(c.dim, align(col.label, widths[i], col.align))).join(" ");
|
|
93
|
+
const bodyRows = cells
|
|
94
|
+
.map((row) => " " + row.map((cell, i) => align(cell, widths[i], columns[i].align)).join(" "))
|
|
95
|
+
.join("\n");
|
|
96
|
+
return `${headerRow}\n${bodyRows}`;
|
|
97
|
+
}
|
|
98
|
+
export function spinner(label) {
|
|
99
|
+
if (!stderrIsTTY || noColorEnv) {
|
|
100
|
+
process.stderr.write(`${label}...\n`);
|
|
101
|
+
return {
|
|
102
|
+
stop: (final) => {
|
|
103
|
+
if (final)
|
|
104
|
+
process.stderr.write(`${final}\n`);
|
|
105
|
+
},
|
|
106
|
+
update: (next) => {
|
|
107
|
+
process.stderr.write(`${next}...\n`);
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
const frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
112
|
+
let current = label;
|
|
113
|
+
let i = 0;
|
|
114
|
+
const handle = setInterval(() => {
|
|
115
|
+
process.stderr.write(`\r${wrap(c.cyan, frames[i])} ${current}`);
|
|
116
|
+
i = (i + 1) % frames.length;
|
|
117
|
+
}, 80);
|
|
118
|
+
return {
|
|
119
|
+
stop: (final) => {
|
|
120
|
+
clearInterval(handle);
|
|
121
|
+
process.stderr.write("\r\x1b[K");
|
|
122
|
+
if (final)
|
|
123
|
+
process.stderr.write(`${final}\n`);
|
|
124
|
+
},
|
|
125
|
+
update: (next) => {
|
|
126
|
+
current = next;
|
|
127
|
+
},
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
// Canonical JSON output: 2-space indent, trailing newline omitted by caller.
|
|
131
|
+
export function asJSON(value) {
|
|
132
|
+
return JSON.stringify(value, null, 2);
|
|
133
|
+
}
|
|
134
|
+
// Bytes → human readable. Mirror of humanBytes in cli.ts; centralizing here
|
|
135
|
+
// so render-aware code shares the same format.
|
|
136
|
+
export function humanBytes(n) {
|
|
137
|
+
if (!Number.isFinite(n) || n < 0)
|
|
138
|
+
return "—";
|
|
139
|
+
if (n < 1024)
|
|
140
|
+
return `${n} B`;
|
|
141
|
+
if (n < 1024 * 1024)
|
|
142
|
+
return `${(n / 1024).toFixed(1)} KB`;
|
|
143
|
+
if (n < 1024 * 1024 * 1024)
|
|
144
|
+
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
|
|
145
|
+
return `${(n / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
|
146
|
+
}
|
|
147
|
+
// ISO timestamp → "12m ago" / "2h ago" / "5d ago" / "—" (null) / fallback.
|
|
148
|
+
export function relativeTime(iso) {
|
|
149
|
+
if (!iso)
|
|
150
|
+
return "—";
|
|
151
|
+
const ago = Date.now() - new Date(iso).getTime();
|
|
152
|
+
if (!Number.isFinite(ago) || ago < 0)
|
|
153
|
+
return "—";
|
|
154
|
+
const mins = Math.floor(ago / 60000);
|
|
155
|
+
const hrs = Math.floor(ago / 3600000);
|
|
156
|
+
const days = Math.floor(ago / 86400000);
|
|
157
|
+
return days > 0 ? `${days}d ago` : hrs > 0 ? `${hrs}h ago` : mins > 0 ? `${mins}m ago` : "just now";
|
|
158
|
+
}
|
|
159
|
+
// Convenience: print the right shape for the resolved output mode.
|
|
160
|
+
// Caller passes both representations and the resolveOutputMode result.
|
|
161
|
+
export function print(mode, jsonValue, humanText) {
|
|
162
|
+
if (mode === "json") {
|
|
163
|
+
console.log(asJSON(jsonValue));
|
|
164
|
+
}
|
|
165
|
+
else {
|
|
166
|
+
console.log(humanText);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
@@ -265,9 +265,15 @@ function statusFromOrgEvent(event) {
|
|
|
265
265
|
null);
|
|
266
266
|
}
|
|
267
267
|
export class A2AAdapter extends Resource {
|
|
268
|
-
// A2A discovery surface — agent
|
|
269
|
-
// public so other agents/clients can discover this Flair as a memory
|
|
270
|
-
//
|
|
268
|
+
// A2A discovery surface — agent-card metadata (GET) is intentionally
|
|
269
|
+
// public so other agents/clients can discover this Flair as a memory
|
|
270
|
+
// peer. POST is JSON-RPC for actions (message/send writes OrgEvents,
|
|
271
|
+
// tasks/list reads Beads issues, message/stream subscribes to events)
|
|
272
|
+
// and is auth-gated upstream by auth-middleware's narrowed allow-list
|
|
273
|
+
// (GET /a2a passes through; POST /a2a must carry TPS-Ed25519 or admin
|
|
274
|
+
// Basic). The post() handler below additionally enforces sender ==
|
|
275
|
+
// params.agentId for message/send so an authenticated caller can only
|
|
276
|
+
// act AS themselves, not impersonate another agent.
|
|
271
277
|
allowRead() { return true; }
|
|
272
278
|
allowCreate() { return true; }
|
|
273
279
|
async get() {
|
|
@@ -310,6 +316,20 @@ export class A2AAdapter extends Resource {
|
|
|
310
316
|
}
|
|
311
317
|
const id = body.id ?? null;
|
|
312
318
|
const params = body.params ?? {};
|
|
319
|
+
// Defense-in-depth: auth-middleware narrows the /a2a allow-list so
|
|
320
|
+
// POSTs only reach here after TPS-Ed25519 or admin Basic succeeds,
|
|
321
|
+
// which sets request.tpsAgent / tpsAgentIsAdmin. If middleware was
|
|
322
|
+
// misconfigured or someone added a back-door allow-list entry, fail
|
|
323
|
+
// closed here. Pattern matches WorkspaceState/Memory/etc.
|
|
324
|
+
const ctx = this.getContext?.() ?? _context ?? {};
|
|
325
|
+
const ctxRequest = ctx.request ?? ctx;
|
|
326
|
+
const callerAgent = ctxRequest?.tpsAgent;
|
|
327
|
+
const callerIsAdmin = ctxRequest?.tpsAgentIsAdmin === true;
|
|
328
|
+
if (!callerAgent && !callerIsAdmin) {
|
|
329
|
+
return rpcError(id, -32001, "Unauthorized", {
|
|
330
|
+
detail: "POST /a2a requires TPS-Ed25519 or admin Basic auth",
|
|
331
|
+
});
|
|
332
|
+
}
|
|
313
333
|
try {
|
|
314
334
|
if (body.method === "message/stream") {
|
|
315
335
|
const agentId = cleanText(params.agentId);
|
|
@@ -407,6 +427,16 @@ export class A2AAdapter extends Resource {
|
|
|
407
427
|
if (!agentId || !message || typeof message !== "object") {
|
|
408
428
|
return rpcError(id, -32602, "Invalid params: agentId and message are required");
|
|
409
429
|
}
|
|
430
|
+
// Sender must match params.agentId — you can only send AS yourself.
|
|
431
|
+
// Admin agents may send as anyone (operational convenience).
|
|
432
|
+
// Without this check, an authenticated agent could forge OrgEvents
|
|
433
|
+
// attributed to any other agent — defeats the whole signed-envelopes
|
|
434
|
+
// model that delegationChain enforces for TPS mail.
|
|
435
|
+
if (!callerIsAdmin && callerAgent !== agentId) {
|
|
436
|
+
return rpcError(id, -32001, "Forbidden", {
|
|
437
|
+
detail: `caller ${callerAgent ?? "(anon)"} cannot send as ${agentId}`,
|
|
438
|
+
});
|
|
439
|
+
}
|
|
410
440
|
const agent = await databases.flair.Agent.get(agentId).catch(() => null);
|
|
411
441
|
if (!agent) {
|
|
412
442
|
return rpcError(id, -32004, "Agent not found", { agentId });
|
|
@@ -13,17 +13,49 @@ import { fileURLToPath } from "node:url";
|
|
|
13
13
|
* 127.0.0.1 binding address Harper sees internally — so the Endpoints
|
|
14
14
|
* table is copy-pasteable.
|
|
15
15
|
*
|
|
16
|
-
*
|
|
16
|
+
* Resolution order:
|
|
17
|
+
* 1. `FLAIR_PUBLIC_URL` env var (explicit override, always wins)
|
|
18
|
+
* 2. Request headers (X-Forwarded-Proto + X-Forwarded-Host, or Host)
|
|
19
|
+
* — derives from how the operator actually reached the page
|
|
20
|
+
* 3. `http://127.0.0.1:${HTTP_PORT}` — local-only installs fallback
|
|
21
|
+
*
|
|
22
|
+
* The request-header path closes the common case from flair#404:
|
|
23
|
+
* remote/Fabric deployments without FLAIR_PUBLIC_URL set rendered
|
|
24
|
+
* localhost URLs that operators couldn't copy-paste. Trusting the Host
|
|
25
|
+
* header is correct when Harper terminates TLS directly; behind a
|
|
26
|
+
* reverse proxy that doesn't set X-Forwarded-* headers correctly, the
|
|
27
|
+
* operator should set FLAIR_PUBLIC_URL explicitly (which wins).
|
|
17
28
|
*/
|
|
18
|
-
function resolvePublicUrl() {
|
|
19
|
-
// Explicit override wins. Production deployments (Fabric, VPS-hosted)
|
|
20
|
-
// should set FLAIR_PUBLIC_URL in their launchd / systemd unit so the
|
|
21
|
-
// admin pane shows the URL operators actually type, not the binding
|
|
22
|
-
// address Harper sees internally. Auto-detecting from request headers
|
|
23
|
-
// is brittle across reverse-proxy configurations.
|
|
29
|
+
function resolvePublicUrl(request) {
|
|
24
30
|
if (process.env.FLAIR_PUBLIC_URL) {
|
|
25
31
|
return process.env.FLAIR_PUBLIC_URL.replace(/\/$/, "");
|
|
26
32
|
}
|
|
33
|
+
// Best-effort header derivation. Harper's request.headers exposes
|
|
34
|
+
// both .get(name) and .asObject (case-insensitive). Prefer X-Forwarded-*
|
|
35
|
+
// when present (caller is behind a proxy that set them); fall back
|
|
36
|
+
// to the direct Host header.
|
|
37
|
+
const getHeader = (name) => {
|
|
38
|
+
const h = request?.headers;
|
|
39
|
+
if (!h)
|
|
40
|
+
return undefined;
|
|
41
|
+
if (typeof h.get === "function")
|
|
42
|
+
return h.get(name) ?? h.get(name.toLowerCase()) ?? undefined;
|
|
43
|
+
if (typeof h === "object") {
|
|
44
|
+
const obj = h.asObject ?? h;
|
|
45
|
+
return obj[name] ?? obj[name.toLowerCase()] ?? undefined;
|
|
46
|
+
}
|
|
47
|
+
return undefined;
|
|
48
|
+
};
|
|
49
|
+
const fwdProto = getHeader("X-Forwarded-Proto");
|
|
50
|
+
const fwdHost = getHeader("X-Forwarded-Host");
|
|
51
|
+
const host = fwdHost ?? getHeader("Host");
|
|
52
|
+
if (host && /^[\w.\-:]+$/.test(host)) {
|
|
53
|
+
const scheme = fwdProto && (fwdProto === "http" || fwdProto === "https") ? fwdProto : "https";
|
|
54
|
+
// If no proxy headers and host has no port, assume http for safety —
|
|
55
|
+
// most production deployments are behind a proxy with X-Forwarded-Proto.
|
|
56
|
+
const effectiveScheme = fwdProto ? scheme : (host.includes(":") ? "http" : scheme);
|
|
57
|
+
return `${effectiveScheme}://${host}`;
|
|
58
|
+
}
|
|
27
59
|
return `http://127.0.0.1:${process.env.HTTP_PORT ?? "19926"}`;
|
|
28
60
|
}
|
|
29
61
|
/**
|
|
@@ -55,7 +87,11 @@ function resolveVersion() {
|
|
|
55
87
|
export class AdminInstance extends Resource {
|
|
56
88
|
async get() {
|
|
57
89
|
const version = resolveVersion();
|
|
58
|
-
|
|
90
|
+
// Pass the request through so URL resolution can derive from
|
|
91
|
+
// X-Forwarded-* / Host headers when FLAIR_PUBLIC_URL isn't set.
|
|
92
|
+
const ctx = this.getContext?.() ?? {};
|
|
93
|
+
const request = ctx.request ?? ctx;
|
|
94
|
+
const publicUrl = resolvePublicUrl(request);
|
|
59
95
|
// Try to read instance public key
|
|
60
96
|
let publicKey = "—";
|
|
61
97
|
const keyDir = join(homedir(), ".flair", "keys");
|
|
@@ -3,6 +3,8 @@ import { randomBytes } from "node:crypto";
|
|
|
3
3
|
import nacl from "tweetnacl";
|
|
4
4
|
import { canonicalize, signBody, verifyBodySignature, signBodyFresh, verifyBodySignatureFresh, createNonceStore, generateNonce, } from "./federation-crypto.js";
|
|
5
5
|
import { initFederationCleanup } from "./federation-cleanup.js";
|
|
6
|
+
import { classifyRecord } from "./federation-classify.js";
|
|
7
|
+
export { classifyRecord } from "./federation-classify.js";
|
|
6
8
|
// Module-level nonce store for federation anti-replay.
|
|
7
9
|
// Shared across FederationPair + FederationSync — nonces are globally unique
|
|
8
10
|
// (generated by signBodyFresh per request with 128-bit random nonces).
|
|
@@ -268,6 +270,12 @@ export class FederationSync extends Resource {
|
|
|
268
270
|
const startTime = Date.now();
|
|
269
271
|
let merged = 0;
|
|
270
272
|
let skipped = 0;
|
|
273
|
+
const skippedReasons = {};
|
|
274
|
+
const mergeErrors = [];
|
|
275
|
+
function recordSkip(reason) {
|
|
276
|
+
skipped++;
|
|
277
|
+
skippedReasons[reason] = (skippedReasons[reason] ?? 0) + 1;
|
|
278
|
+
}
|
|
271
279
|
// Table name → Harper database table mapping
|
|
272
280
|
const tableMap = {
|
|
273
281
|
Memory: databases.flair.Memory,
|
|
@@ -275,66 +283,88 @@ export class FederationSync extends Resource {
|
|
|
275
283
|
Agent: databases.flair.Agent,
|
|
276
284
|
Relationship: databases.flair.Relationship,
|
|
277
285
|
};
|
|
286
|
+
const knownTables = new Set(Object.keys(tableMap));
|
|
278
287
|
for (const record of records) {
|
|
279
|
-
const table = tableMap[record.table];
|
|
280
|
-
if (!table) {
|
|
281
|
-
skipped++;
|
|
282
|
-
continue;
|
|
283
|
-
}
|
|
284
|
-
// Originator enforcement: peers can only push records they originated.
|
|
285
|
-
// The hub can relay records from other peers (role === "hub"),
|
|
286
|
-
// but spokes can only push their own records.
|
|
287
|
-
const originator = record.originatorInstanceId ?? instanceId;
|
|
288
|
-
if (originator !== instanceId && peer.role !== "hub") {
|
|
289
|
-
skipped++;
|
|
290
|
-
continue;
|
|
291
|
-
}
|
|
292
288
|
try {
|
|
293
|
-
const
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
skipped++;
|
|
289
|
+
const table = tableMap[record.table];
|
|
290
|
+
const local = table ? await table.get(record.id) : null;
|
|
291
|
+
const decision = classifyRecord(record, peer.role, instanceId, local, knownTables);
|
|
292
|
+
if (decision.action === "skip") {
|
|
293
|
+
recordSkip(decision.reason);
|
|
299
294
|
continue;
|
|
300
295
|
}
|
|
301
296
|
const mergedData = mergeRecord(local, record);
|
|
302
|
-
|
|
303
|
-
mergedData._originatorInstanceId = originator;
|
|
297
|
+
mergedData._originatorInstanceId = decision.originator;
|
|
304
298
|
mergedData._syncedFrom = instanceId;
|
|
305
299
|
mergedData._syncedAt = new Date().toISOString();
|
|
306
300
|
await table.put(mergedData);
|
|
307
301
|
merged++;
|
|
308
302
|
}
|
|
309
|
-
catch {
|
|
310
|
-
|
|
303
|
+
catch (err) {
|
|
304
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
305
|
+
recordSkip("merge_error");
|
|
306
|
+
// Cap the captured-error list so a hostile/buggy peer can't blow up
|
|
307
|
+
// the SyncLog row, but ALWAYS log to console so operators don't lose
|
|
308
|
+
// the per-record signal — the silent-swallow is the bug we're fixing.
|
|
309
|
+
if (mergeErrors.length < 10) {
|
|
310
|
+
mergeErrors.push(`${record.table}/${record.id}: ${msg}`);
|
|
311
|
+
}
|
|
312
|
+
console.warn(`[Federation] merge error for ${record.table}/${record.id} from ${instanceId}: ${msg}`);
|
|
311
313
|
}
|
|
312
314
|
}
|
|
313
|
-
//
|
|
314
|
-
|
|
315
|
+
// Peer cursor update — distinguish liveness from progress:
|
|
316
|
+
// lastSyncAt → "we heard from this peer just now" (always update)
|
|
317
|
+
// lastMergeAt → "data actually flowed in" (only when merged > 0)
|
|
318
|
+
// Conflating them produced the "green dashboard while burning" failure
|
|
319
|
+
// mode — dogfood pass 2026-05-26 surfaced unconditional update masking
|
|
320
|
+
// 100%-skip syncs.
|
|
321
|
+
const nowIso = new Date().toISOString();
|
|
322
|
+
const peerUpdate = {
|
|
315
323
|
...peer,
|
|
316
|
-
lastSyncAt:
|
|
317
|
-
lastSyncCursor: lamportClock?.toString() ??
|
|
324
|
+
lastSyncAt: nowIso,
|
|
325
|
+
lastSyncCursor: lamportClock?.toString() ?? nowIso,
|
|
318
326
|
status: "connected",
|
|
319
|
-
updatedAt:
|
|
320
|
-
}
|
|
321
|
-
|
|
327
|
+
updatedAt: nowIso,
|
|
328
|
+
};
|
|
329
|
+
if (merged > 0) {
|
|
330
|
+
peerUpdate.lastMergeAt = nowIso;
|
|
331
|
+
}
|
|
332
|
+
await databases.flair.Peer.put(peerUpdate);
|
|
333
|
+
// Log sync operation with skip-reason breakdown so the partial state
|
|
334
|
+
// is debuggable instead of a black box.
|
|
322
335
|
try {
|
|
336
|
+
const errorParts = [];
|
|
337
|
+
if (Object.keys(skippedReasons).length > 0) {
|
|
338
|
+
errorParts.push("skipped: " +
|
|
339
|
+
Object.entries(skippedReasons)
|
|
340
|
+
.map(([reason, n]) => `${n} ${reason}`)
|
|
341
|
+
.join(", "));
|
|
342
|
+
}
|
|
343
|
+
if (mergeErrors.length > 0) {
|
|
344
|
+
errorParts.push("merge_errors: " + mergeErrors.join("; "));
|
|
345
|
+
}
|
|
346
|
+
const errorSummary = errorParts.length > 0 ? errorParts.join(" | ") : undefined;
|
|
347
|
+
const status = mergeErrors.length > 0
|
|
348
|
+
? "partial"
|
|
349
|
+
: skipped > 0 ? "partial" : "success";
|
|
323
350
|
await databases.flair.SyncLog.put({
|
|
324
351
|
id: `sync_${Date.now()}_${randomBytes(4).toString("hex")}`,
|
|
325
352
|
peerId: instanceId,
|
|
326
353
|
direction: "pull",
|
|
327
354
|
recordCount: merged,
|
|
328
|
-
|
|
329
|
-
|
|
355
|
+
skippedCount: skipped,
|
|
356
|
+
skippedReasons: JSON.stringify(skippedReasons),
|
|
357
|
+
status,
|
|
358
|
+
error: errorSummary,
|
|
330
359
|
durationMs: Date.now() - startTime,
|
|
331
|
-
createdAt:
|
|
360
|
+
createdAt: nowIso,
|
|
332
361
|
});
|
|
333
362
|
}
|
|
334
363
|
catch { /* non-fatal */ }
|
|
335
364
|
return {
|
|
336
365
|
merged,
|
|
337
366
|
skipped,
|
|
367
|
+
skippedReasons,
|
|
338
368
|
total: records.length,
|
|
339
369
|
durationMs: Date.now() - startTime,
|
|
340
370
|
};
|
|
@@ -354,6 +384,7 @@ export class FederationPeers extends Resource {
|
|
|
354
384
|
status: p.status,
|
|
355
385
|
endpoint: p.endpoint,
|
|
356
386
|
lastSyncAt: p.lastSyncAt,
|
|
387
|
+
lastMergeAt: p.lastMergeAt,
|
|
357
388
|
relayOnly: p.relayOnly,
|
|
358
389
|
pairedAt: p.pairedAt,
|
|
359
390
|
});
|
|
@@ -110,12 +110,18 @@ async function backfillEmbedding(memoryId) {
|
|
|
110
110
|
// ─── HTTP middleware ──────────────────────────────────────────────────────────
|
|
111
111
|
server.http(async (request, nextLayer) => {
|
|
112
112
|
const url = new URL(request.url, "http://" + (request.headers.get("host") || "localhost"));
|
|
113
|
+
// A2A discovery endpoints: GET returns public agent-card metadata (per
|
|
114
|
+
// A2A spec, cards are intentionally public). POST invokes JSON-RPC
|
|
115
|
+
// actions (message/send writes OrgEvents on behalf of agents,
|
|
116
|
+
// tasks/list reads Beads issues, message/stream subscribes to
|
|
117
|
+
// OrgEvents) — those must be authenticated. Narrowing to GET-only
|
|
118
|
+
// closes the P0 where any caller could forge OrgEvents as any agent
|
|
119
|
+
// and read all internal Beads issues unauthenticated.
|
|
120
|
+
const isA2APath = url.pathname === "/a2a" || url.pathname === "/A2AAdapter" || url.pathname.startsWith("/A2AAdapter/");
|
|
113
121
|
if (url.pathname === "/health" ||
|
|
114
122
|
url.pathname === "/Health" ||
|
|
115
|
-
|
|
116
|
-
url.pathname === "/A2AAdapter" ||
|
|
123
|
+
(request.method === "GET" && isA2APath) ||
|
|
117
124
|
url.pathname === "/AgentCard" ||
|
|
118
|
-
url.pathname.startsWith("/A2AAdapter/") ||
|
|
119
125
|
url.pathname.startsWith("/AgentCard/") ||
|
|
120
126
|
// FederationSync uses Ed25519 body-signature auth with anti-replay, validated
|
|
121
127
|
// by the resource handler (allowCreate=true, same pattern as FederationPair).
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure classifier for federation sync records — no Harper imports.
|
|
3
|
+
*
|
|
4
|
+
* Extracted from Federation.ts so the decision can be unit-tested without
|
|
5
|
+
* spinning up Harper's database module. The same SkipReason names are used
|
|
6
|
+
* in SyncLog.skippedReasons so operators can grep for them.
|
|
7
|
+
*/
|
|
8
|
+
export function classifyRecord(record, peerRole, receiverInstanceId, local, knownTables, now = new Date()) {
|
|
9
|
+
if (!knownTables.has(record.table)) {
|
|
10
|
+
return { action: "skip", reason: "unknown_table" };
|
|
11
|
+
}
|
|
12
|
+
const originator = record.originatorInstanceId ?? receiverInstanceId;
|
|
13
|
+
if (originator !== receiverInstanceId && peerRole !== "hub") {
|
|
14
|
+
return { action: "skip", reason: "non_originator" };
|
|
15
|
+
}
|
|
16
|
+
const fiveMinFromNow = new Date(now.getTime() + 5 * 60 * 1000).toISOString();
|
|
17
|
+
if (record.updatedAt > fiveMinFromNow) {
|
|
18
|
+
return { action: "skip", reason: "future_timestamp" };
|
|
19
|
+
}
|
|
20
|
+
const remoteContentHash = record.data?.contentHash;
|
|
21
|
+
if (local &&
|
|
22
|
+
local.contentHash &&
|
|
23
|
+
remoteContentHash &&
|
|
24
|
+
local.contentHash === remoteContentHash &&
|
|
25
|
+
record.updatedAt <= (local.updatedAt ?? "")) {
|
|
26
|
+
return { action: "skip", reason: "no_op_same_hash" };
|
|
27
|
+
}
|
|
28
|
+
return { action: "merge", originator };
|
|
29
|
+
}
|
package/dist/resources/health.js
CHANGED
|
@@ -201,13 +201,18 @@ export class HealthDetail extends Resource {
|
|
|
201
201
|
for await (const s of db.flair.Soul.search({}))
|
|
202
202
|
souls.push(s);
|
|
203
203
|
stats.soulEntries = souls.length;
|
|
204
|
-
|
|
204
|
+
// Soul entries have no severity dimension — they are keyed identity facts
|
|
205
|
+
// (role / project / standards / …). A per-priority breakdown was dead
|
|
206
|
+
// telemetry: nothing writes Soul.priority to anything but "standard", and
|
|
207
|
+
// the `?? "standard"` fallback also mislabelled *unset* as *standard*, so
|
|
208
|
+
// it always read 100% standard regardless of the data. Report the honest
|
|
209
|
+
// dimension instead — a count per key.
|
|
210
|
+
const byKey = {};
|
|
205
211
|
for (const s of souls) {
|
|
206
|
-
const
|
|
207
|
-
|
|
208
|
-
byPriority[p]++;
|
|
212
|
+
const k = (s.key ?? "(unkeyed)");
|
|
213
|
+
byKey[k] = (byKey[k] ?? 0) + 1;
|
|
209
214
|
}
|
|
210
|
-
stats.soul = { total: souls.length,
|
|
215
|
+
stats.soul = { total: souls.length, byKey };
|
|
211
216
|
}
|
|
212
217
|
catch {
|
|
213
218
|
stats.soulEntries = null;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tpsdev-ai/flair",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"description": "Identity, memory, and soul for AI agents. Cryptographic identity (Ed25519), semantic memory with local embeddings, and persistent personality — all in a single process.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -30,7 +30,8 @@ type Peer @table(database: "flair") @export {
|
|
|
30
30
|
role: String @indexed # "hub" | "spoke"
|
|
31
31
|
endpoint: String # wss:// URL for the peer
|
|
32
32
|
status: String @indexed # "paired" | "connected" | "disconnected" | "revoked"
|
|
33
|
-
lastSyncAt: String # last
|
|
33
|
+
lastSyncAt: String # last contact with this peer (liveness signal)
|
|
34
|
+
lastMergeAt: String # last sync where merged > 0 (data-progress signal)
|
|
34
35
|
lastSyncCursor: String # Lamport clock or ISO timestamp for incremental sync
|
|
35
36
|
relayOnly: Boolean # true for spoke-to-spoke peers announced by hub
|
|
36
37
|
pairedAt: String
|
|
@@ -43,9 +44,11 @@ type SyncLog @table(database: "flair") {
|
|
|
43
44
|
id: ID @primaryKey
|
|
44
45
|
peerId: String! @indexed # which peer this sync was with
|
|
45
46
|
direction: String! @indexed # "push" | "pull"
|
|
46
|
-
recordCount: Int # how many records in this
|
|
47
|
+
recordCount: Int # how many records merged in this batch
|
|
48
|
+
skippedCount: Int # how many records skipped (sum of skippedReasons)
|
|
49
|
+
skippedReasons: String # JSON: { unknown_table: N, non_originator: N, future_timestamp: N, no_op_same_hash: N, merge_error: N }
|
|
47
50
|
status: String @indexed # "success" | "partial" | "failed"
|
|
48
|
-
error: String #
|
|
51
|
+
error: String # human-readable summary if partial/failed (includes per-record error messages, capped)
|
|
49
52
|
durationMs: Int # how long the sync took
|
|
50
53
|
createdAt: String! @indexed
|
|
51
54
|
}
|