@tpsdev-ai/flair 0.9.0 → 0.10.1

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.
@@ -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
- url.pathname === "/a2a" ||
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).
@@ -136,7 +142,7 @@ server.http(async (request, nextLayer) => {
136
142
  // and inline JS, with no embedded data. The JS prompts for admin-pass and
137
143
  // auths every API call (/Agent, /SemanticSearch, /FederationPeers, etc).
138
144
  // Without this allow-list entry, the HTML is 401-blocked on hosted Flair
139
- // instances (rockit-local works only because authorizeLocal=true).
145
+ // instances (a localhost caller works only because authorizeLocal=true).
140
146
  url.pathname === "/ObservationCenter")
141
147
  return nextLayer(request);
142
148
  // If Harper has already authorized this request (e.g. authorizeLocal=true on localhost),
@@ -266,32 +272,34 @@ server.http(async (request, nextLayer) => {
266
272
  request.tpsAgent = agentId;
267
273
  request._tpsAuthVerified = true;
268
274
  request.tpsAgentIsAdmin = await isAdmin(agentId);
269
- // Swap the Authorization header to Basic admin auth so Harper's internal auth
270
- // pipeline (passport) authenticates the request with full permissions including
271
- // HNSW vector search. This requires HDB_ADMIN_PASSWORD to be set.
272
- // NOTE: server.getUser() alone doesn't grant HNSW permissions in Harper v5.
273
- const adminPass = getAdminPass();
274
- if (adminPass !== null) {
275
- try {
276
- const superAuth = "Basic " + btoa("admin:" + adminPass);
277
- request.headers.set("authorization", superAuth);
278
- if (request.headers.asObject)
279
- request.headers.asObject.authorization = superAuth;
280
- }
281
- catch {
282
- // Header manipulation failed fall back to getUser
283
- try {
284
- request.user = await server.getUser("admin", null, request);
285
- }
286
- catch { }
287
- }
275
+ // Grant Harper-level permissions for the cryptographically-verified agent by
276
+ // setting request.user directly to the admin super_user.
277
+ //
278
+ // Prior approach swapped the Authorization header to "Basic admin:<pass>" so
279
+ // Harper's auth pipeline would re-authenticate with full (HNSW-capable)
280
+ // permissions. That silently broke under Harper 5.0.9: its authentication
281
+ // layer (security/auth.ts `authentication()`) reads the Authorization header
282
+ // and resolves request.user BEFORE invoking this middleware via nextHandler.
283
+ // A TPS-Ed25519 header matches no Basic/Bearer strategy, so Harper sets
284
+ // request.user = null and never re-reads the header — our post-hoc swap was
285
+ // ignored, the request ran unauthenticated, and Harper surfaced it downstream
286
+ // as a generic "Login failed" 401. (Broke at the 2026-05-27 daemon restart,
287
+ // which loaded 5.0.9 fresh; the prior long-running process held an older
288
+ // in-memory Harper where the late header read still worked.)
289
+ //
290
+ // Setting request.user directly is the supported extension path and is honored
291
+ // by all downstream resources, including HNSW vector search. getUser(admin,
292
+ // null) looks up the admin record WITHOUT password validation — safe here
293
+ // because the Ed25519 signature verified above already proves agent identity
294
+ // cryptographically; no Basic credential is presented. This preserves the
295
+ // prior privilege model (verified agents act with admin perms; per-agent data
296
+ // isolation is still enforced downstream via the x-tps-agent header below).
297
+ try {
298
+ request.user = await server.getUser("admin", null, request);
288
299
  }
289
- else {
290
- // No admin password configured try server.getUser as fallback (limited permissions)
291
- try {
292
- request.user = await server.getUser("admin", null, request);
293
- }
294
- catch { }
300
+ catch {
301
+ // Admin record unavailablerequest proceeds as the verified tpsAgent
302
+ // without elevated perms; resource-level scoping still applies.
295
303
  }
296
304
  // Propagate authenticated agent to downstream resources via header.
297
305
  // Resources can read this to enforce agent-level scoping.
@@ -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
+ }
@@ -23,10 +23,10 @@ const exists = async (path) => {
23
23
  * which is what makes /Health work for callers outside `authorizeLocal`'s
24
24
  * localhost-bypass. Without this, Harper's intrinsic Basic-auth gate fires
25
25
  * BEFORE our HTTP middleware can apply the /Health bypass list in
26
- * auth-middleware.ts, so remote callers (e.g., from rockit hitting a Fabric-
26
+ * auth-middleware.ts, so remote callers (e.g., from a remote host hitting a Fabric-
27
27
  * hosted Flair) get a 401 even though the Resource handler is intentionally
28
28
  * unauthenticated. Symptom matched: Fabric returned 401 + WWW-Authenticate:
29
- * Basic on /Health while rockit-localhost returned 200 — the difference was
29
+ * Basic on /Health while a localhost caller returned 200 — the difference was
30
30
  * Harper's localhost-auto-auth, not anything in our code.
31
31
  *
32
32
  * Same pattern as `FederationPair.allowCreate(){ return true }` (PR #299):
@@ -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
- const byPriority = { critical: 0, high: 0, standard: 0, low: 0 };
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 p = (s.priority ?? "standard");
207
- if (p in byPriority)
208
- byPriority[p]++;
212
+ const k = (s.key ?? "(unkeyed)");
213
+ byKey[k] = (byKey[k] ?? 0) + 1;
209
214
  }
210
- stats.soul = { total: souls.length, byPriority };
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.9.0",
3
+ "version": "0.10.1",
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",
@@ -55,7 +55,7 @@
55
55
  "node": ">=22"
56
56
  },
57
57
  "dependencies": {
58
- "@harperfast/harper": "5.0.9",
58
+ "@harperfast/harper": "5.0.21",
59
59
  "@types/js-yaml": "^4.0.9",
60
60
  "commander": "14.0.3",
61
61
  "harper-fabric-embeddings": "0.2.3",
@@ -64,6 +64,15 @@
64
64
  "tar": "^7.5.13",
65
65
  "tweetnacl": "1.0.3"
66
66
  },
67
+ "optionalDependencies": {
68
+ "@node-llama-cpp/linux-arm64": "3.18.1",
69
+ "@node-llama-cpp/linux-armv7l": "3.18.1",
70
+ "@node-llama-cpp/linux-x64": "3.18.1",
71
+ "@node-llama-cpp/mac-arm64-metal": "3.18.1",
72
+ "@node-llama-cpp/mac-x64": "3.18.1",
73
+ "@node-llama-cpp/win-arm64": "3.18.1",
74
+ "@node-llama-cpp/win-x64": "3.18.1"
75
+ },
67
76
  "devDependencies": {
68
77
  "@playwright/test": "^1.59.1",
69
78
  "@types/node": "24.11.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 successful sync timestamp
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 sync batch
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 # error message if failed
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
  }
@@ -2,7 +2,7 @@
2
2
  # ─── Observatory tables ───────────────────────────────────────────────────────
3
3
 
4
4
  type ObsOffice @table(database: "flair") @export {
5
- id: ID @primaryKey # e.g. "rockit"
5
+ id: ID @primaryKey # e.g. "office1"
6
6
  name: String!
7
7
  publicKey: String! # Ed25519 hex public key for auth verification
8
8
  status: String @indexed # "online" | "offline" | "degraded"