@tpsdev-ai/flair 0.20.1 → 0.21.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 +29 -4
- package/SECURITY.md +28 -18
- package/dist/cli.js +465 -32
- package/dist/deploy.js +93 -11
- package/dist/doctor-client.js +312 -0
- package/dist/install/clients.js +18 -0
- package/dist/rem/restore.js +1 -1
- package/dist/resources/Admin.js +1 -1
- package/dist/resources/AdminConnectors.js +1 -1
- package/dist/resources/AdminDashboard.js +1 -1
- package/dist/resources/AdminIdp.js +1 -1
- package/dist/resources/AdminInstance.js +1 -1
- package/dist/resources/AdminMemory.js +2 -2
- package/dist/resources/AdminPrincipals.js +1 -1
- package/dist/resources/Agent.js +14 -0
- package/dist/resources/AgentCard.js +1 -1
- package/dist/resources/AgentSeed.js +1 -1
- package/dist/resources/Federation.js +98 -2
- package/dist/resources/IngestEvents.js +1 -1
- package/dist/resources/Integration.js +1 -1
- package/dist/resources/Memory.js +123 -17
- package/dist/resources/MemoryBootstrap.js +46 -36
- package/dist/resources/MemoryGrant.js +1 -1
- package/dist/resources/OAuth.js +61 -4
- package/dist/resources/OrgEventCatchup.js +1 -1
- package/dist/resources/Presence.js +55 -3
- package/dist/resources/Relationship.js +10 -1
- package/dist/resources/SemanticSearch.js +14 -14
- package/dist/resources/Soul.js +14 -0
- package/dist/resources/WorkspaceLatest.js +1 -1
- package/dist/resources/WorkspaceState.js +1 -1
- package/dist/resources/agent-auth.js +1 -1
- package/dist/resources/agentcard-fields.js +2 -2
- package/dist/resources/auth-middleware.js +24 -5
- package/dist/resources/bm25-filter.js +1 -1
- package/dist/resources/bm25.js +1 -1
- package/dist/resources/dedup.js +2 -2
- package/dist/resources/ed25519-auth.js +2 -2
- package/dist/resources/embeddings-provider.js +1 -1
- package/dist/resources/federation-nonce-store.js +195 -0
- package/dist/resources/instance-identity.js +53 -0
- package/dist/resources/memory-bootstrap-lib.js +1 -1
- package/dist/resources/memory-read-scope.js +58 -71
- package/dist/resources/memory-visibility.js +37 -0
- package/dist/version-check.js +167 -0
- package/package.json +2 -2
- package/schemas/agent.graphql +7 -0
- package/schemas/federation.graphql +12 -0
- package/schemas/memory.graphql +16 -0
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* federation-nonce-store.ts — persistent backing for the federation
|
|
3
|
+
* anti-replay NonceStore (federation-edge-hardening slice 4).
|
|
4
|
+
*
|
|
5
|
+
* The gap: `federationNonceStore` in Federation.ts was a bare in-memory Map
|
|
6
|
+
* (`createNonceStore()` in federation-crypto.ts). On process restart it's
|
|
7
|
+
* wiped, so a signed federation request captured within its ±30s freshness
|
|
8
|
+
* window (verifyBodySignatureFresh) could be replayed once, post-restart.
|
|
9
|
+
* This module closes that window by backing the SAME `NonceStore` interface
|
|
10
|
+
* with the `Nonce` Harper table, while keeping every method synchronous —
|
|
11
|
+
* `verifyBodySignatureFresh` and its 2 call sites in Federation.ts are
|
|
12
|
+
* UNCHANGED (they still call `.has()` / `.set()` / `.evict()` with no
|
|
13
|
+
* `await`), so the entire existing federation-crypto-replay test suite
|
|
14
|
+
* (all synchronous assertions) keeps working unmodified.
|
|
15
|
+
*
|
|
16
|
+
* How the sync interface stays sync against an inherently-async DB API:
|
|
17
|
+
* - `has()` / `evict()` read/mutate an in-memory Map (via the existing
|
|
18
|
+
* `createNonceStore()`), exactly as before — no DB round trip.
|
|
19
|
+
* - `set()` updates that same in-memory Map synchronously (so replay
|
|
20
|
+
* detection within THIS process's lifetime is unchanged), and fires an
|
|
21
|
+
* async `Nonce.put()` in the background (not awaited) to persist the
|
|
22
|
+
* nonce for the NEXT restart. A failed/slow persist never blocks or
|
|
23
|
+
* fails request handling — see the perf/durability tradeoff note below.
|
|
24
|
+
* - `hydrate()` is the new piece: an explicit async step that loads
|
|
25
|
+
* not-yet-expired rows from the `Nonce` table into the in-memory Map.
|
|
26
|
+
* Call it once, on startup, BEFORE the store starts guarding live
|
|
27
|
+
* traffic — that's what makes a nonce recorded by the PREVIOUS process
|
|
28
|
+
* visible to a FRESH store instance after a restart (the whole point).
|
|
29
|
+
*
|
|
30
|
+
* Known tradeoff (flagged, not fixed here — would require making NonceStore
|
|
31
|
+
* async, which ripples into ~40 synchronous call sites across
|
|
32
|
+
* federation-crypto-replay.test.ts + federation-sync-e2e.test.ts):
|
|
33
|
+
* `set()`'s persistence is fire-and-forget. A crash in the narrow gap
|
|
34
|
+
* between "response sent" and "async put() resolved" can still lose that
|
|
35
|
+
* one nonce — narrowing the replay window to that gap, not eliminating it
|
|
36
|
+
* outright. Defense-in-depth: the ±30s freshness check + signature
|
|
37
|
+
* verification are still the primary guards; this closes the common case
|
|
38
|
+
* (graceful restart / redeploy), not the crash-mid-request edge case.
|
|
39
|
+
*/
|
|
40
|
+
import { createNonceStore } from "./federation-crypto.js";
|
|
41
|
+
/** 2x the standard 30s freshness window — matches the in-memory `evict()` cutoff already used by verifyBodySignatureFresh. */
|
|
42
|
+
export const DEFAULT_RETENTION_MS = 60_000;
|
|
43
|
+
async function resolveDb(opts, cache) {
|
|
44
|
+
if (opts.db)
|
|
45
|
+
return opts.db;
|
|
46
|
+
if (!cache.db) {
|
|
47
|
+
cache.db = import("@harperfast/harper").then((h) => h.databases);
|
|
48
|
+
}
|
|
49
|
+
return cache.db;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Create a `NonceStore` backed by the `Nonce` Harper table.
|
|
53
|
+
*
|
|
54
|
+
* Wraps the existing in-memory `createNonceStore()` (unchanged behavior for
|
|
55
|
+
* `has()`/`set()`/`evict()` within this process) and adds:
|
|
56
|
+
* - background persistence of newly-set nonces (`set()`)
|
|
57
|
+
* - an explicit `hydrate()` to reload previously-persisted nonces (used
|
|
58
|
+
* at startup, and by tests simulating a restart with a fresh instance)
|
|
59
|
+
*/
|
|
60
|
+
export function createPersistentNonceStore(opts = {}) {
|
|
61
|
+
const memory = createNonceStore();
|
|
62
|
+
const dbCache = {};
|
|
63
|
+
return {
|
|
64
|
+
has(key) {
|
|
65
|
+
return memory.has(key);
|
|
66
|
+
},
|
|
67
|
+
evict(olderThan) {
|
|
68
|
+
memory.evict(olderThan);
|
|
69
|
+
},
|
|
70
|
+
set(key, value) {
|
|
71
|
+
memory.set(key, value);
|
|
72
|
+
// Fire-and-forget persistence — see module header for the tradeoff.
|
|
73
|
+
void (async () => {
|
|
74
|
+
try {
|
|
75
|
+
const db = await resolveDb(opts, dbCache);
|
|
76
|
+
await db.flair.Nonce.put({ id: key, seenAt: value });
|
|
77
|
+
}
|
|
78
|
+
catch (err) {
|
|
79
|
+
console.error("[federation-nonce-store] failed to persist nonce (in-memory record already made — this process's replay guard is unaffected):", err?.message ?? err);
|
|
80
|
+
}
|
|
81
|
+
})();
|
|
82
|
+
},
|
|
83
|
+
async hydrate(hydrateOpts) {
|
|
84
|
+
const retentionMs = hydrateOpts?.retentionMs ?? DEFAULT_RETENTION_MS;
|
|
85
|
+
const now = hydrateOpts?.now ?? Date.now();
|
|
86
|
+
const cutoff = now - retentionMs;
|
|
87
|
+
try {
|
|
88
|
+
const db = await resolveDb(opts, dbCache);
|
|
89
|
+
for await (const row of db.flair.Nonce.search()) {
|
|
90
|
+
if (row?.id != null && typeof row.seenAt === "number" && row.seenAt >= cutoff) {
|
|
91
|
+
memory.set(String(row.id), row.seenAt);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
catch (err) {
|
|
96
|
+
console.error("[federation-nonce-store] failed to hydrate from Nonce table (starting with an empty replay guard):", err?.message ?? err);
|
|
97
|
+
}
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
// ─── Eviction sweep — periodic Nonce table hygiene ─────────────────────────
|
|
102
|
+
//
|
|
103
|
+
// Mirrors federation-cleanup.ts's 5-min setInterval hub-pattern. Decoupled
|
|
104
|
+
// from the in-memory `evict()` above (which runs per-request and only trims
|
|
105
|
+
// the local Map): this sweep deletes rows from the `Nonce` TABLE itself so
|
|
106
|
+
// it doesn't grow unbounded across restarts. No native TTL in this repo —
|
|
107
|
+
// same app-managed-sweep idiom as PairingToken cleanup.
|
|
108
|
+
//
|
|
109
|
+
// Runs on every instance (hub AND spoke) — unlike PairingToken cleanup
|
|
110
|
+
// (hub-only, since only hubs issue pairing tokens), both FederationPair and
|
|
111
|
+
// FederationSync can be the RECEIVING side of a signed request on either
|
|
112
|
+
// role, so both roles accumulate rows in the Nonce table and both need the
|
|
113
|
+
// sweep.
|
|
114
|
+
const NONCE_CLEANUP_INTERVAL_MS = 300_000; // 5 minutes
|
|
115
|
+
let nonceCleanupTimer = null;
|
|
116
|
+
/**
|
|
117
|
+
* Core sweep logic — exposed for unit testing. Deletes `Nonce` rows with
|
|
118
|
+
* `seenAt` older than `retentionMs` (default: 2x the 30s freshness window).
|
|
119
|
+
*/
|
|
120
|
+
export async function runNonceCleanupTick(opts = {}) {
|
|
121
|
+
let db;
|
|
122
|
+
if (opts.db) {
|
|
123
|
+
db = opts.db;
|
|
124
|
+
}
|
|
125
|
+
else {
|
|
126
|
+
const harper = await import("@harperfast/harper");
|
|
127
|
+
db = harper.databases;
|
|
128
|
+
}
|
|
129
|
+
const retentionMs = opts.retentionMs ?? DEFAULT_RETENTION_MS;
|
|
130
|
+
const now = opts.now ?? Date.now();
|
|
131
|
+
const cutoff = now - retentionMs;
|
|
132
|
+
const toDelete = [];
|
|
133
|
+
let scanned = 0;
|
|
134
|
+
try {
|
|
135
|
+
for await (const row of db.flair.Nonce.search()) {
|
|
136
|
+
scanned++;
|
|
137
|
+
if (typeof row?.seenAt === "number" && row.seenAt < cutoff) {
|
|
138
|
+
toDelete.push(row.id);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
catch (err) {
|
|
143
|
+
console.error("[federation-nonce-store] failed to query Nonce records:", err?.message ?? err);
|
|
144
|
+
return { deleted: 0, scanned: 0 };
|
|
145
|
+
}
|
|
146
|
+
let deleted = 0;
|
|
147
|
+
for (const id of toDelete) {
|
|
148
|
+
try {
|
|
149
|
+
await db.flair.Nonce.delete(id);
|
|
150
|
+
deleted++;
|
|
151
|
+
}
|
|
152
|
+
catch (err) {
|
|
153
|
+
console.error("[federation-nonce-store] failed to delete expired nonce:", err?.message ?? err);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return { deleted, scanned };
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Initialise the periodic Nonce-table eviction sweep (5-min cadence).
|
|
160
|
+
*
|
|
161
|
+
* In test environments, callers pass a mock `db` via `opts` so this module
|
|
162
|
+
* never imports @harperfast/harper at the top level (mirrors
|
|
163
|
+
* federation-cleanup.ts's `initFederationCleanup`).
|
|
164
|
+
*/
|
|
165
|
+
export async function initNonceStoreCleanup(opts) {
|
|
166
|
+
const immediate = opts?.immediateTick ?? true;
|
|
167
|
+
let db;
|
|
168
|
+
if (opts?.db) {
|
|
169
|
+
db = opts.db;
|
|
170
|
+
}
|
|
171
|
+
else {
|
|
172
|
+
try {
|
|
173
|
+
const harper = await import("@harperfast/harper");
|
|
174
|
+
db = harper.databases;
|
|
175
|
+
}
|
|
176
|
+
catch (err) {
|
|
177
|
+
console.error("[federation-nonce-store] failed to load @harperfast/harper:", err?.message ?? err);
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
const retentionMs = opts?.retentionMs ?? DEFAULT_RETENTION_MS;
|
|
182
|
+
console.log("[federation-nonce-store] starting nonce eviction sweep (5-min cadence)");
|
|
183
|
+
if (nonceCleanupTimer)
|
|
184
|
+
clearInterval(nonceCleanupTimer);
|
|
185
|
+
nonceCleanupTimer = setInterval(() => {
|
|
186
|
+
runNonceCleanupTick({ db, retentionMs }).catch((err) => {
|
|
187
|
+
console.error("[federation-nonce-store] cleanup tick error:", err?.message ?? err);
|
|
188
|
+
});
|
|
189
|
+
}, NONCE_CLEANUP_INTERVAL_MS);
|
|
190
|
+
if (immediate) {
|
|
191
|
+
runNonceCleanupTick({ db, retentionMs }).catch((err) => {
|
|
192
|
+
console.error("[federation-nonce-store] initial cleanup tick error:", err?.message ?? err);
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { databases } from "@harperfast/harper";
|
|
2
|
+
/**
|
|
3
|
+
* ─── Local instance identity (federation-edge-hardening slice 1) ────────────
|
|
4
|
+
*
|
|
5
|
+
* The write-time `originatorInstanceId` stamp (Memory.ts/Soul.ts/Agent.ts/
|
|
6
|
+
* Relationship.ts post()/put()) needs to know THIS instance's own federation
|
|
7
|
+
* identity — the same `id` FederationInstance.get() (resources/Federation.ts)
|
|
8
|
+
* finds-or-creates on first boot and persists in the `Instance` table
|
|
9
|
+
* (schemas/federation.graphql). Exactly one row is expected in a given
|
|
10
|
+
* instance's own Instance table — that row IS this instance; other instances
|
|
11
|
+
* it has paired with live in the separate `Peer` table, never here.
|
|
12
|
+
*
|
|
13
|
+
* Deliberately READ-ONLY: this module never creates an Instance row — that
|
|
14
|
+
* first-boot bootstrap (keypair generation + keystore write) stays
|
|
15
|
+
* FederationInstance.get()'s job (resources/Federation.ts). If no Instance
|
|
16
|
+
* row exists yet (a fresh, never-federated instance, or a unit-test
|
|
17
|
+
* environment with no Instance table at all), localInstanceId() resolves to
|
|
18
|
+
* null and callers stamp nothing — originatorInstanceId is nullable by
|
|
19
|
+
* design (schemas/memory.graphql), and a null tag reads as "pre-tag /
|
|
20
|
+
* local-origin by default" per the federation-edge-hardening design.
|
|
21
|
+
*
|
|
22
|
+
* Cached at module scope after the FIRST successful resolution — a write
|
|
23
|
+
* must not pay a DB lookup every call. An unresolved (null) result is NOT
|
|
24
|
+
* cached, since that state can legitimately change later (federation gets
|
|
25
|
+
* bootstrapped after this process already started serving writes) and the
|
|
26
|
+
* cost of re-checking only applies to instances that have never federated.
|
|
27
|
+
*/
|
|
28
|
+
let cachedInstanceId = null;
|
|
29
|
+
export async function localInstanceId() {
|
|
30
|
+
if (cachedInstanceId)
|
|
31
|
+
return cachedInstanceId;
|
|
32
|
+
try {
|
|
33
|
+
for await (const row of databases.flair.Instance.search()) {
|
|
34
|
+
if (row?.id) {
|
|
35
|
+
cachedInstanceId = row.id;
|
|
36
|
+
}
|
|
37
|
+
break; // exactly one row expected — this instance's own identity
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
// Instance table not present (test env / not yet migrated) — no local id.
|
|
42
|
+
}
|
|
43
|
+
return cachedInstanceId;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Test-only: force re-resolution on the next localInstanceId() call. The
|
|
47
|
+
* module-level cache otherwise persists across test files that share the
|
|
48
|
+
* same `bun test` process (same collision class documented in
|
|
49
|
+
* memory-integrity.test.ts re: the Memory class singleton).
|
|
50
|
+
*/
|
|
51
|
+
export function _resetLocalInstanceIdCacheForTests() {
|
|
52
|
+
cachedInstanceId = null;
|
|
53
|
+
}
|
|
@@ -38,5 +38,5 @@ export function formatTeamLine(teammateIds) {
|
|
|
38
38
|
const plural = teammateIds.length === 1 ? "agent shares" : "agents share";
|
|
39
39
|
return (`${teammateIds.length} other ${plural} this Flair office (${wrapUntrusted(teammateIds.join(", "))}). ` +
|
|
40
40
|
`Before deep-diving an unfamiliar problem, search their memories for related work — ` +
|
|
41
|
-
`\`memory_search\` covers any agent
|
|
41
|
+
`\`memory_search\` covers any agent's non-private memories on this instance (open-within-org read; no grant required).`);
|
|
42
42
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { databases } from "@harperfast/harper";
|
|
2
|
+
import { PRIVATE_VISIBILITY, isPrivateVisibility } from "./memory-visibility.js";
|
|
2
3
|
/**
|
|
3
|
-
* ─── Centralized Memory read-scoping (
|
|
4
|
+
* ─── Centralized Memory read-scoping (the original grant-gated read model → within-org-read-open) ─
|
|
4
5
|
*
|
|
5
6
|
* The SINGLE source every cross-agent Memory read path resolves its scope
|
|
6
7
|
* through: Memory.search()/Memory.get() (resources/Memory.ts), SemanticSearch
|
|
@@ -9,40 +10,49 @@ import { databases } from "@harperfast/harper";
|
|
|
9
10
|
* existed, SemanticSearch had its OWN inline grant-resolution + a
|
|
10
11
|
* `visibility === "office"` global OR-clause that leaked ANY authenticated
|
|
11
12
|
* agent's read of ANY other agent's memories once that memory happened to
|
|
12
|
-
* carry `visibility: "office"` (
|
|
13
|
+
* carry `visibility: "office"` (the office-visibility read leak). Scattering the scoping rule per
|
|
13
14
|
* path is exactly how that leak happened — this module exists so it can't
|
|
14
15
|
* happen again: one rule, one place, every path imports it.
|
|
15
16
|
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
* does not have that problem).
|
|
24
|
-
*
|
|
25
|
-
* ── The model (private | shared) ─────────────────────────────────────────────
|
|
26
|
-
* `Memory.visibility` is writer intent: "private" (owner-only) or "shared"
|
|
27
|
-
* (visible to anyone holding a read/search MemoryGrant on the owner). A
|
|
28
|
-
* reader's full read-scope is:
|
|
17
|
+
* ── The model (open-within-org read) ─────────────────────────────────────────
|
|
18
|
+
* Knowledge-refinement, not access-control (per the MEMORY-MODEL-REFRAME):
|
|
19
|
+
* an org/instance is one shared knowledge base. `Memory.visibility` is writer
|
|
20
|
+
* intent: "private" is the ONLY owner-only exception; anything else (`shared`,
|
|
21
|
+
* null/absent) is org-open — readable by ANY verified agent on this instance,
|
|
22
|
+
* not just owners who happen to hold a per-owner MemoryGrant. A reader's full
|
|
23
|
+
* read-scope is:
|
|
29
24
|
* - ALL of the reader's own records, any visibility, unrestricted.
|
|
30
|
-
* -
|
|
25
|
+
* - EVERY other agent's non-private record on this instance.
|
|
26
|
+
* The federation edge (resources/Federation.ts's push filter / src/cli.ts's
|
|
27
|
+
* runFederationSyncOnce) is the only remaining hard access boundary — it
|
|
28
|
+
* already excludes `private` rows from ever leaving this instance. Within an
|
|
29
|
+
* instance, there is no per-owner grant gate on READS anymore: MemoryGrant is
|
|
30
|
+
* no longer consulted by this module at all (see resolveAllowedOwners's doc
|
|
31
|
+
* below for why the function itself still exists).
|
|
31
32
|
*
|
|
32
|
-
* ── The
|
|
33
|
-
* Existing memories (written before
|
|
34
|
-
* field.
|
|
35
|
-
*
|
|
36
|
-
*
|
|
33
|
+
* ── The no-visibility-field invariant (non-negotiable) ───────────────────────
|
|
34
|
+
* Existing memories (written before the `visibility` field existed) have NO
|
|
35
|
+
* `visibility` field. A record with no `visibility` field is NOT private — it
|
|
36
|
+
* reads exactly like an explicit `shared` record (org-open). This is why the
|
|
37
|
+
* exclusion condition is `visibility != 'private'` (`not_equal`, which
|
|
37
38
|
* INCLUDES records missing the field entirely), never `visibility == 'shared'`
|
|
38
|
-
* (`equals`, which would EXCLUDE them and silently
|
|
39
|
-
*
|
|
39
|
+
* (`equals`, which would EXCLUDE them and silently retroactively privatize
|
|
40
|
+
* every legacy row) — nothing is retroactively made private, nothing is
|
|
41
|
+
* excluded from the broadening that wasn't already excluded before it. There
|
|
42
|
+
* is no migration/backfill step: pure-open means every pre-existing record
|
|
43
|
+
* reads as non-private automatically, the moment this code is deployed —
|
|
44
|
+
* gating that on an operator-run step would itself be a knob the
|
|
45
|
+
* emergent-trust reframe rejects (zero knobs).
|
|
40
46
|
*/
|
|
41
47
|
/**
|
|
42
|
-
* Owner ids a non-admin agent
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
48
|
+
* Owner ids a non-admin agent holds an explicit "read" or "search" scoped
|
|
49
|
+
* MemoryGrant from (plus itself). NO LONGER used by resolveReadScope() below
|
|
50
|
+
* — reads are open-within-org now, so a per-read grant lookup is dead weight
|
|
51
|
+
* on every read path. Kept exported and unchanged in shape/behavior because
|
|
52
|
+
* it is still the right tool for "who has this agent explicitly granted
|
|
53
|
+
* itself to" listings / admin tooling (grants remain a real, inspectable
|
|
54
|
+
* relationship — they just no longer gate reads). Do NOT delete this
|
|
55
|
+
* function, and do NOT reintroduce a call to it from resolveReadScope().
|
|
46
56
|
*/
|
|
47
57
|
export async function resolveAllowedOwners(authAgentId) {
|
|
48
58
|
const allowedOwners = [authAgentId];
|
|
@@ -58,55 +68,32 @@ export async function resolveAllowedOwners(authAgentId) {
|
|
|
58
68
|
catch { /* MemoryGrant table not yet populated — ignore */ }
|
|
59
69
|
return allowedOwners;
|
|
60
70
|
}
|
|
61
|
-
const PRIVATE = "private";
|
|
62
71
|
/**
|
|
63
|
-
* Resolve the full read-scope (
|
|
64
|
-
*
|
|
65
|
-
*
|
|
72
|
+
* Resolve the full read-scope (condition + in-process predicate) for a
|
|
73
|
+
* reader. This is the ONE function every cross-agent Memory read path must
|
|
74
|
+
* call — see the module doc above. No longer async-dependent on a DB lookup
|
|
75
|
+
* (MemoryGrant is not consulted for reads), but stays declared `async` /
|
|
76
|
+
* Promise-returning for call-site stability — every existing caller already
|
|
77
|
+
* `await`s it.
|
|
66
78
|
*/
|
|
67
79
|
export async function resolveReadScope(authAgentId) {
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
const
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
? { attribute: "agentId", comparator: "equals", value: grantedOwners[0] }
|
|
82
|
-
: { operator: "or", conditions: grantedOwners.map((id) => ({ attribute: "agentId", comparator: "equals", value: id })) };
|
|
83
|
-
condition = {
|
|
84
|
-
operator: "or",
|
|
85
|
-
conditions: [
|
|
86
|
-
selfCondition,
|
|
87
|
-
{
|
|
88
|
-
operator: "and",
|
|
89
|
-
conditions: [
|
|
90
|
-
grantedOwnerCondition,
|
|
91
|
-
// not_equal (NOT equals 'shared') — see module doc: a record with
|
|
92
|
-
// NO visibility field must still read as shared. This is the
|
|
93
|
-
// migration-equivalence invariant, enforced in the condition
|
|
94
|
-
// itself so every path that uses it gets it for free.
|
|
95
|
-
{ attribute: "visibility", comparator: "not_equal", value: PRIVATE },
|
|
96
|
-
],
|
|
97
|
-
},
|
|
98
|
-
],
|
|
99
|
-
};
|
|
100
|
-
}
|
|
101
|
-
const grantedSet = new Set(grantedOwners);
|
|
80
|
+
// Open-within-org read: own records (any visibility) OR any non-private
|
|
81
|
+
// record on the instance. No grant lookup — see module doc.
|
|
82
|
+
const condition = {
|
|
83
|
+
operator: "or",
|
|
84
|
+
conditions: [
|
|
85
|
+
{ attribute: "agentId", comparator: "equals", value: authAgentId },
|
|
86
|
+
// not_equal (NOT equals 'private') — see module doc: a record with NO
|
|
87
|
+
// visibility field must still read as non-private (the migration-
|
|
88
|
+
// equivalence invariant), enforced in the condition itself so every
|
|
89
|
+
// path that uses it gets it for free.
|
|
90
|
+
{ attribute: "visibility", comparator: "not_equal", value: PRIVATE_VISIBILITY },
|
|
91
|
+
],
|
|
92
|
+
};
|
|
102
93
|
const isAllowed = (record) => {
|
|
103
94
|
if (!record)
|
|
104
95
|
return false;
|
|
105
|
-
|
|
106
|
-
return true;
|
|
107
|
-
if (!record.agentId || !grantedSet.has(record.agentId))
|
|
108
|
-
return false;
|
|
109
|
-
return record.visibility !== PRIVATE;
|
|
96
|
+
return record.agentId === authAgentId || !isPrivateVisibility(record.visibility);
|
|
110
97
|
};
|
|
111
|
-
return { allowedOwners, condition, isAllowed };
|
|
98
|
+
return { allowedOwners: [authAgentId], condition, isAllowed };
|
|
112
99
|
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ─── The single "is this Memory private" predicate (federation-edge-hardening
|
|
3
|
+
* slice 2: one rule, one place) ───────────────────────────────────
|
|
4
|
+
*
|
|
5
|
+
* Shared by BOTH:
|
|
6
|
+
* - resources/memory-read-scope.ts's resolveReadScope() — the cross-agent
|
|
7
|
+
* READ scope every read path (Memory.search/get, SemanticSearch,
|
|
8
|
+
* MemoryBootstrap, the by-id auth-middleware guard) resolves through.
|
|
9
|
+
* - src/cli.ts's runFederationSyncOnce() — the federation-sync PUSH filter
|
|
10
|
+
* that must not replicate `private` memories to peer instances.
|
|
11
|
+
*
|
|
12
|
+
* Deliberately has ZERO imports — not even "@harperfast/harper". That is
|
|
13
|
+
* intentional and load-bearing: src/cli.ts is a standalone CLI entrypoint
|
|
14
|
+
* that runs OUTSIDE any running Harper instance (e.g. `flair federation
|
|
15
|
+
* sync` invoked from a cron/launchd job). resources/memory-read-scope.ts
|
|
16
|
+
* imports `databases` from "@harperfast/harper", and that package's
|
|
17
|
+
* top-level init eagerly resolves storage paths and THROWS when there is no
|
|
18
|
+
* live Harper runtime backing it (confirmed empirically — it takes down
|
|
19
|
+
* even `flair --help`). So src/cli.ts must never import
|
|
20
|
+
* resources/memory-read-scope.ts (or anything else that drags that
|
|
21
|
+
* side-effecting import in) directly. This module is the safe seam: a pure
|
|
22
|
+
* function + constant that both sides can import without dragging in
|
|
23
|
+
* "@harperfast/harper".
|
|
24
|
+
*
|
|
25
|
+
* ── The migration invariant (non-negotiable, mirrors memory-read-scope.ts) ──
|
|
26
|
+
* A record with NO `visibility` field (written before the field existed) is
|
|
27
|
+
* NOT private — it must keep syncing/reading exactly as before. This is why
|
|
28
|
+
* the predicate is "is this exactly 'private'", never "is this not 'shared'":
|
|
29
|
+
* missing/null/anything-other-than-'private' all count as non-private.
|
|
30
|
+
*/
|
|
31
|
+
export const PRIVATE_VISIBILITY = "private";
|
|
32
|
+
/** True only when visibility is the literal string "private". Null, undefined,
|
|
33
|
+
* "shared", or any other value are all non-private (see migration invariant
|
|
34
|
+
* above) — never invert this to an allowlist of "shared". */
|
|
35
|
+
export function isPrivateVisibility(visibility) {
|
|
36
|
+
return visibility === PRIVATE_VISIBILITY;
|
|
37
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* version-check.ts — offline-tolerant, cached check of whether the installed
|
|
3
|
+
* @tpsdev-ai/flair is behind the latest published npm release.
|
|
4
|
+
*
|
|
5
|
+
* Motivation (flair#587): a laptop install sat on 0.16.1 through v0.17.0 and
|
|
6
|
+
* v0.19.0 (P0 security fixes) and v0.18.0 (memory-integrity fix) — `flair
|
|
7
|
+
* status` reported "✓ all checks passing" the whole time. Nothing anywhere
|
|
8
|
+
* in the CLI told the operator they were behind. `flair status` and `flair
|
|
9
|
+
* doctor` both wire this in.
|
|
10
|
+
*
|
|
11
|
+
* Non-negotiable design constraints — this must never make status/doctor
|
|
12
|
+
* WORSE:
|
|
13
|
+
* - Offline-tolerant: a failed/timed-out registry fetch falls back to a
|
|
14
|
+
* stale cache, or is skipped entirely. NEVER throws, NEVER hangs (short
|
|
15
|
+
* fetch timeout).
|
|
16
|
+
* - Cached with a TTL so a healthy network doesn't cost a registry round
|
|
17
|
+
* trip on every single `flair status`/`flair doctor` invocation.
|
|
18
|
+
* - No advisory data — we don't know which release fixed which CVE, so the
|
|
19
|
+
* severity heuristic is purely the version GAP (major/minor count), not
|
|
20
|
+
* "did this release carry a security fix". See classifyGap().
|
|
21
|
+
*/
|
|
22
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
23
|
+
import { homedir } from "node:os";
|
|
24
|
+
import { dirname, join } from "node:path";
|
|
25
|
+
import { parseSemverCore } from "./fabric-upgrade.js";
|
|
26
|
+
export const FLAIR_PKG_NAME = "@tpsdev-ai/flair";
|
|
27
|
+
export const DEFAULT_CACHE_PATH = join(homedir(), ".flair", ".version-check-cache.json");
|
|
28
|
+
/** How long a cached "latest" answer is trusted before we re-hit the registry. */
|
|
29
|
+
export const DEFAULT_TTL_MS = 12 * 60 * 60 * 1000; // 12h
|
|
30
|
+
/** Registry fetch timeout — this runs on every status/doctor call, so it must stay short. */
|
|
31
|
+
export const DEFAULT_TIMEOUT_MS = 3000;
|
|
32
|
+
function readCacheFile(path) {
|
|
33
|
+
try {
|
|
34
|
+
if (!existsSync(path))
|
|
35
|
+
return null;
|
|
36
|
+
const raw = JSON.parse(readFileSync(path, "utf-8"));
|
|
37
|
+
if (typeof raw?.latest === "string" && typeof raw?.checkedAt === "number") {
|
|
38
|
+
return { latest: raw.latest, checkedAt: raw.checkedAt };
|
|
39
|
+
}
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
// Corrupt/unreadable cache — treat as absent, never throw.
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
function writeCacheFile(path, entry) {
|
|
48
|
+
try {
|
|
49
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
50
|
+
writeFileSync(path, JSON.stringify(entry), "utf-8");
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
// Best-effort — a cache-write failure must never surface as a
|
|
54
|
+
// status/doctor error (e.g. read-only $HOME).
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
async function defaultFetchLatest(timeoutMs) {
|
|
58
|
+
try {
|
|
59
|
+
const res = await fetch(`https://registry.npmjs.org/${FLAIR_PKG_NAME}/latest`, {
|
|
60
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
61
|
+
});
|
|
62
|
+
if (!res.ok)
|
|
63
|
+
return null;
|
|
64
|
+
const data = (await res.json());
|
|
65
|
+
return typeof data?.version === "string" ? data.version : null;
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
// Offline, DNS failure, timeout, registry 5xx, bad JSON — all the same:
|
|
69
|
+
// we couldn't determine "latest" over the network this time.
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
export function defaultVersionCheckDeps() {
|
|
74
|
+
return {
|
|
75
|
+
fetchLatest: defaultFetchLatest,
|
|
76
|
+
cachePath: DEFAULT_CACHE_PATH,
|
|
77
|
+
ttlMs: DEFAULT_TTL_MS,
|
|
78
|
+
timeoutMs: DEFAULT_TIMEOUT_MS,
|
|
79
|
+
now: () => Date.now(),
|
|
80
|
+
readCache: readCacheFile,
|
|
81
|
+
writeCache: writeCacheFile,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Resolve the latest published @tpsdev-ai/flair version, preferring a fresh
|
|
86
|
+
* cache hit over a network round trip, and falling back to a stale cache (or
|
|
87
|
+
* giving up quietly) when the registry is unreachable. NEVER throws.
|
|
88
|
+
*/
|
|
89
|
+
export async function checkVersion(installed, injected = {}) {
|
|
90
|
+
const deps = { ...defaultVersionCheckDeps(), ...injected };
|
|
91
|
+
const nowMs = deps.now();
|
|
92
|
+
const cached = deps.readCache(deps.cachePath);
|
|
93
|
+
if (cached && nowMs - cached.checkedAt < deps.ttlMs) {
|
|
94
|
+
return { installed, latest: cached.latest, source: "cache" };
|
|
95
|
+
}
|
|
96
|
+
// Defense-in-depth: the default fetchLatest already catches everything
|
|
97
|
+
// internally (network error, timeout, non-2xx, bad JSON) and resolves
|
|
98
|
+
// null rather than rejecting. This try/catch guards the contract even if
|
|
99
|
+
// a caller-injected fetchLatest misbehaves and throws/rejects instead —
|
|
100
|
+
// status/doctor must never crash or hang on a version check either way.
|
|
101
|
+
let fetched = null;
|
|
102
|
+
try {
|
|
103
|
+
fetched = await deps.fetchLatest(deps.timeoutMs);
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
fetched = null;
|
|
107
|
+
}
|
|
108
|
+
if (fetched) {
|
|
109
|
+
deps.writeCache(deps.cachePath, { latest: fetched, checkedAt: nowMs });
|
|
110
|
+
return { installed, latest: fetched, source: "network" };
|
|
111
|
+
}
|
|
112
|
+
// Registry unreachable/timed out — fall back to a stale cache rather than
|
|
113
|
+
// reporting nothing, but never block or throw trying to get a fresh one.
|
|
114
|
+
if (cached) {
|
|
115
|
+
return { installed, latest: cached.latest, source: "cache" };
|
|
116
|
+
}
|
|
117
|
+
return { installed, latest: null, source: "unavailable" };
|
|
118
|
+
}
|
|
119
|
+
const NO_GAP = { severity: "none", majorBehind: false, releasesBehind: 0 };
|
|
120
|
+
/**
|
|
121
|
+
* Classify how far `installed` is behind `latest` using major.minor.patch
|
|
122
|
+
* math only — we don't have advisory data, so:
|
|
123
|
+
* - any major version behind, or ≥2 minor versions behind → "red" (loud;
|
|
124
|
+
* heuristic for "you've likely missed a security fix")
|
|
125
|
+
* - a single minor version behind, or a patch-only gap → "yellow"
|
|
126
|
+
* - equal, ahead, or unparseable → "none"
|
|
127
|
+
*/
|
|
128
|
+
export function classifyGap(installed, latest) {
|
|
129
|
+
const a = parseSemverCore(installed);
|
|
130
|
+
const b = parseSemverCore(latest);
|
|
131
|
+
if (!a || !b)
|
|
132
|
+
return NO_GAP;
|
|
133
|
+
const [aMaj, aMin, aPatch] = a;
|
|
134
|
+
const [bMaj, bMin, bPatch] = b;
|
|
135
|
+
if (bMaj > aMaj)
|
|
136
|
+
return { severity: "red", majorBehind: true, releasesBehind: 0 };
|
|
137
|
+
if (bMaj < aMaj)
|
|
138
|
+
return NO_GAP; // installed is ahead (e.g. local/pre-release build)
|
|
139
|
+
if (bMin > aMin) {
|
|
140
|
+
const releasesBehind = bMin - aMin;
|
|
141
|
+
return { severity: releasesBehind >= 2 ? "red" : "yellow", majorBehind: false, releasesBehind };
|
|
142
|
+
}
|
|
143
|
+
if (bMin < aMin)
|
|
144
|
+
return NO_GAP; // ahead on minor
|
|
145
|
+
if (bPatch > aPatch)
|
|
146
|
+
return { severity: "yellow", majorBehind: false, releasesBehind: bPatch - aPatch };
|
|
147
|
+
return NO_GAP; // equal, or ahead on patch
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Build the human-readable nudge line for `flair status`/`flair doctor`, or
|
|
151
|
+
* null when there's nothing worth printing — current, ahead (local/dev
|
|
152
|
+
* build), or we couldn't determine latest at all (offline with no cache).
|
|
153
|
+
* Callers own icon/color; this returns plain text plus a severity to color by.
|
|
154
|
+
*/
|
|
155
|
+
export function formatVersionNudge(result) {
|
|
156
|
+
if (!result.latest)
|
|
157
|
+
return null;
|
|
158
|
+
const gap = classifyGap(result.installed, result.latest);
|
|
159
|
+
if (gap.severity === "none")
|
|
160
|
+
return null;
|
|
161
|
+
const countHint = gap.majorBehind
|
|
162
|
+
? "major version"
|
|
163
|
+
: `${gap.releasesBehind} release${gap.releasesBehind === 1 ? "" : "s"}`;
|
|
164
|
+
const message = `flair ${result.installed} is behind — latest is ${result.latest} (${countHint} behind). ` +
|
|
165
|
+
`Upgrade: npm i -g ${FLAIR_PKG_NAME}@latest`;
|
|
166
|
+
return { severity: gap.severity, message };
|
|
167
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tpsdev-ai/flair",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.21.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",
|
|
@@ -55,7 +55,7 @@
|
|
|
55
55
|
"node": ">=22"
|
|
56
56
|
},
|
|
57
57
|
"dependencies": {
|
|
58
|
-
"@harperfast/harper": "5.1.
|
|
58
|
+
"@harperfast/harper": "5.1.15",
|
|
59
59
|
"@harperfast/oauth": "2.1.0",
|
|
60
60
|
"@types/js-yaml": "4.0.9",
|
|
61
61
|
"commander": "14.0.3",
|
package/schemas/agent.graphql
CHANGED
|
@@ -22,6 +22,13 @@ type Agent @table(database: "flair") @export {
|
|
|
22
22
|
|
|
23
23
|
createdAt: String!
|
|
24
24
|
updatedAt: String
|
|
25
|
+
originatorInstanceId: String @indexed # federation-edge-hardening slice 1 — write-time instance identity,
|
|
26
|
+
# stamped server-side in resources/Agent.ts's post()/put() from
|
|
27
|
+
# resources/instance-identity.ts's localInstanceId(). Nullable (existing rows
|
|
28
|
+
# read null = clean upgrade); preserved (never re-stamped) as the record flows
|
|
29
|
+
# through federation sync merges. See schemas/memory.graphql's Memory.
|
|
30
|
+
# originatorInstanceId for the full contract — same field, same idiom, this is
|
|
31
|
+
# one of the 4 synced tables (Memory/Soul/Agent/Relationship).
|
|
25
32
|
}
|
|
26
33
|
|
|
27
34
|
# Credential table — auth surfaces for Principals.
|