@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.
Files changed (49) hide show
  1. package/README.md +29 -4
  2. package/SECURITY.md +28 -18
  3. package/dist/cli.js +465 -32
  4. package/dist/deploy.js +93 -11
  5. package/dist/doctor-client.js +312 -0
  6. package/dist/install/clients.js +18 -0
  7. package/dist/rem/restore.js +1 -1
  8. package/dist/resources/Admin.js +1 -1
  9. package/dist/resources/AdminConnectors.js +1 -1
  10. package/dist/resources/AdminDashboard.js +1 -1
  11. package/dist/resources/AdminIdp.js +1 -1
  12. package/dist/resources/AdminInstance.js +1 -1
  13. package/dist/resources/AdminMemory.js +2 -2
  14. package/dist/resources/AdminPrincipals.js +1 -1
  15. package/dist/resources/Agent.js +14 -0
  16. package/dist/resources/AgentCard.js +1 -1
  17. package/dist/resources/AgentSeed.js +1 -1
  18. package/dist/resources/Federation.js +98 -2
  19. package/dist/resources/IngestEvents.js +1 -1
  20. package/dist/resources/Integration.js +1 -1
  21. package/dist/resources/Memory.js +123 -17
  22. package/dist/resources/MemoryBootstrap.js +46 -36
  23. package/dist/resources/MemoryGrant.js +1 -1
  24. package/dist/resources/OAuth.js +61 -4
  25. package/dist/resources/OrgEventCatchup.js +1 -1
  26. package/dist/resources/Presence.js +55 -3
  27. package/dist/resources/Relationship.js +10 -1
  28. package/dist/resources/SemanticSearch.js +14 -14
  29. package/dist/resources/Soul.js +14 -0
  30. package/dist/resources/WorkspaceLatest.js +1 -1
  31. package/dist/resources/WorkspaceState.js +1 -1
  32. package/dist/resources/agent-auth.js +1 -1
  33. package/dist/resources/agentcard-fields.js +2 -2
  34. package/dist/resources/auth-middleware.js +24 -5
  35. package/dist/resources/bm25-filter.js +1 -1
  36. package/dist/resources/bm25.js +1 -1
  37. package/dist/resources/dedup.js +2 -2
  38. package/dist/resources/ed25519-auth.js +2 -2
  39. package/dist/resources/embeddings-provider.js +1 -1
  40. package/dist/resources/federation-nonce-store.js +195 -0
  41. package/dist/resources/instance-identity.js +53 -0
  42. package/dist/resources/memory-bootstrap-lib.js +1 -1
  43. package/dist/resources/memory-read-scope.js +58 -71
  44. package/dist/resources/memory-visibility.js +37 -0
  45. package/dist/version-check.js +167 -0
  46. package/package.json +2 -2
  47. package/schemas/agent.graphql +7 -0
  48. package/schemas/federation.graphql +12 -0
  49. 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 you hold a search grant from.`);
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 (ops-2dm3 Layer 1) ─────────────────────
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"` (ops-nzxa). Scattering the scoping rule per
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
- * This is a plain-function module (no `class X extends databases.flair.X`)
17
- * deliberately, so it can be safely imported + exercised under DIFFERENT
18
- * `@harperfast/harper` mocks from multiple test/unit/ files in the same bun
19
- * process without the class-capture collision documented in
20
- * test/unit/memory-soul-read-gate.test.ts (that collision is specific to a
21
- * class whose `extends` clause evaluates `databases.flair.X` at module-eval
22
- * time; a plain function that reads `databases` inside its body at CALL time
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
- * - A GRANTED owner's records EXCEPT that owner's `private` ones.
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 migration invariant (non-negotiable) ─────────────────────────────────
33
- * Existing memories (written before this field existed) have NO `visibility`
34
- * field. They must read EXACTLY as before: to whoever holds a grant today.
35
- * So a record with no `visibility` field is treated as "shared" — this is why
36
- * the exclusion condition is `visibility != 'private'` (`not_equal`, which
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 break every existing grant
39
- * relationship — nothing is retroactively made private, nothing broadened).
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 may READ: itself, plus any owner who has
43
- * granted it a "read" or "search" scoped MemoryGrant. This is the pre-existing
44
- * owner-set resolution (unchanged in shape/behavior) resolveReadScope()
45
- * below builds the full private-exclusion-aware scope on top of it.
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 (owner set + condition + in-process predicate)
64
- * for a reader. This is the ONE function every cross-agent Memory read path
65
- * must call — see the module doc above.
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
- const allowedOwners = await resolveAllowedOwners(authAgentId);
69
- const grantedOwners = allowedOwners.filter((id) => id !== authAgentId);
70
- const selfCondition = { attribute: "agentId", comparator: "equals", value: authAgentId };
71
- let condition;
72
- if (grantedOwners.length === 0) {
73
- // No grants held the reader's scope is just its own records. Emitting
74
- // the plain leaf condition here (rather than an `or` with a single
75
- // branch) keeps the common case's query shape identical to what
76
- // Memory.search() emitted before this change.
77
- condition = selfCondition;
78
- }
79
- else {
80
- const grantedOwnerCondition = grantedOwners.length === 1
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
- if (record.agentId === authAgentId)
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.20.1",
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.14",
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",
@@ -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.