@tpsdev-ai/flair 0.20.0 → 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 +482 -32
- package/dist/deploy.js +280 -18
- 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
|
@@ -38,7 +38,7 @@ function getAdminPass() {
|
|
|
38
38
|
// an admin — one implementation guarantees they can't diverge.
|
|
39
39
|
// ─── Crypto + replay-guard helpers ────────────────────────────────────────────
|
|
40
40
|
// WINDOW_MS, isNonceReplay/recordNonce (the ONE shared nonce store), and
|
|
41
|
-
// importEd25519Key all live in ./ed25519-auth.ts
|
|
41
|
+
// importEd25519Key all live in ./ed25519-auth.ts — the single
|
|
42
42
|
// shared implementation imported by auth-middleware.ts, agent-auth.ts, and
|
|
43
43
|
// Presence.ts so a nonce recorded via any one of the three call sites is
|
|
44
44
|
// visible to the other two, and the crypto/decoder logic can't drift.
|
|
@@ -97,9 +97,28 @@ server.http(async (request, nextLayer) => {
|
|
|
97
97
|
// instances (rockit-local works only because authorizeLocal=true).
|
|
98
98
|
url.pathname === "/ObservationCenter" ||
|
|
99
99
|
// Presence roster is public-safe (field-allowlisted); GET serves the
|
|
100
|
-
// Office Space renderer without auth.
|
|
101
|
-
//
|
|
102
|
-
|
|
100
|
+
// Office Space renderer without auth. Scoped to GET only (#604): the
|
|
101
|
+
// exact-path match used to match ANY method, so a bare `PUT /Presence`
|
|
102
|
+
// (collection-level, no id — Harper routes it to the same .put() as
|
|
103
|
+
// by-id PUT) early-returned here too, skipping this middleware entirely.
|
|
104
|
+
// A credential-less loopback PUT then reached Presence.put()'s
|
|
105
|
+
// resolveAgentAuth() call with NO tpsAnonymous/tpsAgent annotation, which
|
|
106
|
+
// fell through to raw `context.user` — populated by Harper's
|
|
107
|
+
// `authorizeLocal` (config true) ambient super_user injection for ANY
|
|
108
|
+
// credential-less loopback request — so the ownership check saw an
|
|
109
|
+
// "admin" caller (isAdmin=true) and let the write through unauthenticated
|
|
110
|
+
// (`super.put()`, no signature, no password). Mirrors the A2A GET-only
|
|
111
|
+
// pattern above: POST/PUT/DELETE now always transit the general
|
|
112
|
+
// middleware path below, which marks a genuinely headerless request
|
|
113
|
+
// tpsAnonymous BEFORE Harper's ambient elevation lands (resolveAgentAuth
|
|
114
|
+
// checks tpsAnonymous first — see agent-auth.ts's resolution order), so
|
|
115
|
+
// the ownership check in Presence.put()/delete() correctly denies it.
|
|
116
|
+
// POST (the heartbeat) is unaffected in practice: it already prefers
|
|
117
|
+
// request.tpsAgent when the middleware set it, and falls back to its own
|
|
118
|
+
// Ed25519 header parse otherwise — transiting the general path now just
|
|
119
|
+
// means a genuinely headerless POST gets marked anonymous (still 401)
|
|
120
|
+
// instead of skipping straight to that fallback parse.
|
|
121
|
+
(request.method === "GET" && url.pathname === "/Presence"))
|
|
103
122
|
return nextLayer(request);
|
|
104
123
|
// If Harper has already authorized this request (e.g. Basic admin, or
|
|
105
124
|
// authorizeLocal=true on localhost), trust Harper's auth decision and pass
|
|
@@ -477,7 +496,7 @@ server.http(async (request, nextLayer) => {
|
|
|
477
496
|
if (memId) {
|
|
478
497
|
const record = await databases.flair.Memory.get(memId);
|
|
479
498
|
if (record && record.agentId && record.agentId !== agentId) {
|
|
480
|
-
// Centralized read-scope (
|
|
499
|
+
// Centralized read-scope (Layer 1): a grant only covers
|
|
481
500
|
// the owner's SHARED memories, never their private ones. This
|
|
482
501
|
// used to be a `visibility === "office"` bypass (any authenticated
|
|
483
502
|
// agent, no grant needed) — that's gone; the private-exclusion is
|
|
@@ -72,7 +72,7 @@ export function passesRecordFilters(record, f = {}) {
|
|
|
72
72
|
return false;
|
|
73
73
|
if (f.asOf && record?.validTo && record.validTo <= f.asOf)
|
|
74
74
|
return false;
|
|
75
|
-
//
|
|
75
|
+
// A past validTo ALWAYS means the record has been closed out —
|
|
76
76
|
// either by the server supersede path (Memory.ts closeSupersededRecord sets
|
|
77
77
|
// validTo without necessarily setting `archived`) or any other writer. This
|
|
78
78
|
// is unconditional (not gated on `f.asOf`) so a server-superseded record
|
package/dist/resources/bm25.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// ─── BM25 + union-RRF hybrid retrieval (Harper-free, unit-testable) ──────────
|
|
2
|
-
// Per spec FLAIR-BM25-HYBRID-RETRIEVAL (Kern-approved
|
|
2
|
+
// Per spec FLAIR-BM25-HYBRID-RETRIEVAL (Kern-approved). This module is
|
|
3
3
|
// deliberately Harper-free — same rationale as ./scoring.ts — so the BM25
|
|
4
4
|
// scoring, the candidate-union RRF fusion, and the security conditions-filter
|
|
5
5
|
// can be unit-tested directly against the SHIPPED code without a live Harper.
|
package/dist/resources/dedup.js
CHANGED
|
@@ -30,10 +30,10 @@ export const DEDUP_MIN_CONTENT_LENGTH = 20;
|
|
|
30
30
|
* Cosine similarity of two equal-length embedding vectors, computed directly
|
|
31
31
|
* in JS. Used as a fallback for Harper's HNSW cosine-sort query omitting a
|
|
32
32
|
* computed `$distance` on its top candidate when the query's post-filter
|
|
33
|
-
* result set contains exactly ONE matching record (
|
|
33
|
+
* result set contains exactly ONE matching record (found in
|
|
34
34
|
* resources/Memory.ts's findConservativeDedupMatch; the identical quirk is
|
|
35
35
|
* also fixed in resources/SemanticSearch.ts's scoring loop for the same
|
|
36
|
-
* reason
|
|
36
|
+
* reason). A mismatched length, empty vector, or
|
|
37
37
|
* zero-magnitude side yields 0 (no signal, never treated as "identical" by a
|
|
38
38
|
* degenerate computation).
|
|
39
39
|
*/
|
|
@@ -11,8 +11,8 @@
|
|
|
11
11
|
* `importEd25519Key`. Three independent replay windows meant a nonce
|
|
12
12
|
* recorded as "seen" via one path was invisible to the other two — a
|
|
13
13
|
* defense-in-depth gap, and a drift hazard (any future fix to one copy
|
|
14
|
-
* silently didn't apply to the other two).
|
|
15
|
-
*
|
|
14
|
+
* silently didn't apply to the other two). Consolidating all three into
|
|
15
|
+
* this one module means there is exactly ONE replay guard and ONE
|
|
16
16
|
* key-import implementation, imported by all 3 sites.
|
|
17
17
|
*
|
|
18
18
|
* `b64ToArrayBuffer` was already unified into resources/b64.ts in a prior
|
|
@@ -65,7 +65,7 @@ async function ensureInit() {
|
|
|
65
65
|
// Not initialized — init with modelsDir pointing at a USER-WRITABLE
|
|
66
66
|
// location. On a sudo/root-owned global install the Flair package dir
|
|
67
67
|
// (process.cwd()) is root-owned, so a model download into <cwd>/models
|
|
68
|
-
// fails with EACCES and semantic search silently dies
|
|
68
|
+
// fails with EACCES and semantic search silently dies. The
|
|
69
69
|
// model — and everything else Flair writes — must live under ~/.flair.
|
|
70
70
|
//
|
|
71
71
|
// NOTE: import.meta.dirname and __dirname are both undefined in Harper v5's
|
|
@@ -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
|
+
}
|