@tpsdev-ai/flair 0.19.0 → 0.20.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +19 -2
- package/dist/deploy.js +188 -8
- package/dist/resources/AdminMemory.js +1 -1
- package/dist/resources/Memory.js +120 -33
- package/dist/resources/MemoryBootstrap.js +99 -22
- package/dist/resources/Presence.js +9 -36
- package/dist/resources/SemanticSearch.js +78 -33
- package/dist/resources/agent-auth.js +10 -34
- package/dist/resources/auth-middleware.js +20 -53
- package/dist/resources/bm25-filter.js +9 -0
- package/dist/resources/dedup.js +24 -0
- package/dist/resources/ed25519-auth.js +119 -0
- package/dist/resources/memory-read-scope.js +112 -0
- package/package.json +1 -1
|
@@ -2,7 +2,8 @@ import { patchRecord } from "./table-helpers.js";
|
|
|
2
2
|
import { server, databases } from "@harperfast/harper";
|
|
3
3
|
import { getEmbedding } from "./embeddings-provider.js";
|
|
4
4
|
import { isAdmin, FLAIR_AGENT_USERNAME } from "./agent-auth.js";
|
|
5
|
-
import { b64ToArrayBuffer } from "./
|
|
5
|
+
import { WINDOW_MS, isNonceReplay, recordNonce, importEd25519Key, b64ToArrayBuffer } from "./ed25519-auth.js";
|
|
6
|
+
import { resolveReadScope } from "./memory-read-scope.js";
|
|
6
7
|
// --- Admin credentials ---
|
|
7
8
|
// Admin auth is sourced exclusively from Harper's own environment variables
|
|
8
9
|
// (HDB_ADMIN_PASSWORD / FLAIR_ADMIN_PASSWORD). No filesystem token file.
|
|
@@ -30,36 +31,17 @@ function getAdminPass() {
|
|
|
30
31
|
// No admin password configured — return null and let callers fall through
|
|
31
32
|
return null;
|
|
32
33
|
}
|
|
33
|
-
const WINDOW_MS = 30_000;
|
|
34
|
-
const nonceSeen = new Map();
|
|
35
34
|
// ─── Admin resolution ─────────────────────────────────────────────────────────
|
|
36
35
|
// `isAdmin` (FLAIR_ADMIN_AGENTS env + Agent role==="admin", 60s-cached) now lives
|
|
37
36
|
// in agent-auth.ts as the single source of truth, imported above. During the
|
|
38
37
|
// auth reshape this gate and the per-resource allow* helpers must agree on who's
|
|
39
38
|
// an admin — one implementation guarantees they can't diverge.
|
|
40
|
-
// ─── Crypto helpers
|
|
41
|
-
//
|
|
42
|
-
//
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
return keyCache.get(publicKeyStr);
|
|
47
|
-
// Accept hex (64-char) or base64 (44-char) encoded 32-byte Ed25519 public key
|
|
48
|
-
let raw;
|
|
49
|
-
if (/^[0-9a-f]{64}$/i.test(publicKeyStr)) {
|
|
50
|
-
// Hex-encoded raw key (TPS CLI default: Buffer.toString('hex'))
|
|
51
|
-
const bytes = new Uint8Array(32);
|
|
52
|
-
for (let i = 0; i < 32; i++)
|
|
53
|
-
bytes[i] = parseInt(publicKeyStr.slice(i * 2, i * 2 + 2), 16);
|
|
54
|
-
raw = bytes.buffer;
|
|
55
|
-
}
|
|
56
|
-
else {
|
|
57
|
-
raw = b64ToArrayBuffer(publicKeyStr);
|
|
58
|
-
}
|
|
59
|
-
const key = await crypto.subtle.importKey("raw", raw, { name: "Ed25519" }, false, ["verify"]);
|
|
60
|
-
keyCache.set(publicKeyStr, key);
|
|
61
|
-
return key;
|
|
62
|
-
}
|
|
39
|
+
// ─── Crypto + replay-guard helpers ────────────────────────────────────────────
|
|
40
|
+
// WINDOW_MS, isNonceReplay/recordNonce (the ONE shared nonce store), and
|
|
41
|
+
// importEd25519Key all live in ./ed25519-auth.ts (bd ops-c4op) — the single
|
|
42
|
+
// shared implementation imported by auth-middleware.ts, agent-auth.ts, and
|
|
43
|
+
// Presence.ts so a nonce recorded via any one of the three call sites is
|
|
44
|
+
// visible to the other two, and the crypto/decoder logic can't drift.
|
|
63
45
|
async function backfillEmbedding(memoryId) {
|
|
64
46
|
try {
|
|
65
47
|
const record = await databases.flair.Memory.get(memoryId);
|
|
@@ -255,11 +237,7 @@ server.http(async (request, nextLayer) => {
|
|
|
255
237
|
const now = Date.now();
|
|
256
238
|
if (!Number.isFinite(ts) || Math.abs(now - ts) > WINDOW_MS)
|
|
257
239
|
return new Response(JSON.stringify({ error: "timestamp_out_of_window" }), { status: 401 });
|
|
258
|
-
|
|
259
|
-
if (now - signatureTs > WINDOW_MS)
|
|
260
|
-
nonceSeen.delete(k);
|
|
261
|
-
const nonceKey = `${agentId}:${nonce}`;
|
|
262
|
-
if (nonceSeen.has(nonceKey))
|
|
240
|
+
if (isNonceReplay(agentId, nonce, now))
|
|
263
241
|
return new Response(JSON.stringify({ error: "nonce_replay_detected" }), { status: 401 });
|
|
264
242
|
const agent = await databases.flair.Agent.get(agentId);
|
|
265
243
|
if (!agent)
|
|
@@ -276,7 +254,7 @@ server.http(async (request, nextLayer) => {
|
|
|
276
254
|
catch (e) {
|
|
277
255
|
return new Response(JSON.stringify({ error: "signature_verification_failed", detail: e?.message }), { status: 401 });
|
|
278
256
|
}
|
|
279
|
-
|
|
257
|
+
recordNonce(agentId, nonce, ts);
|
|
280
258
|
request.tpsAgent = agentId;
|
|
281
259
|
request._tpsAuthVerified = true;
|
|
282
260
|
request.tpsAgentIsAdmin = await isAdmin(agentId);
|
|
@@ -499,27 +477,16 @@ server.http(async (request, nextLayer) => {
|
|
|
499
477
|
if (memId) {
|
|
500
478
|
const record = await databases.flair.Memory.get(memId);
|
|
501
479
|
if (record && record.agentId && record.agentId !== agentId) {
|
|
502
|
-
//
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
hasGrant = true;
|
|
513
|
-
break;
|
|
514
|
-
}
|
|
515
|
-
}
|
|
516
|
-
}
|
|
517
|
-
catch { }
|
|
518
|
-
if (!hasGrant) {
|
|
519
|
-
return new Response(JSON.stringify({
|
|
520
|
-
error: `forbidden: cannot read memory owned by ${record.agentId}`,
|
|
521
|
-
}), { status: 403, headers: { "Content-Type": "application/json" } });
|
|
522
|
-
}
|
|
480
|
+
// Centralized read-scope (ops-2dm3 Layer 1): a grant only covers
|
|
481
|
+
// the owner's SHARED memories, never their private ones. This
|
|
482
|
+
// used to be a `visibility === "office"` bypass (any authenticated
|
|
483
|
+
// agent, no grant needed) — that's gone; the private-exclusion is
|
|
484
|
+
// now enforced the same way every other read path enforces it.
|
|
485
|
+
const scope = await resolveReadScope(agentId);
|
|
486
|
+
if (!scope.isAllowed(record)) {
|
|
487
|
+
return new Response(JSON.stringify({
|
|
488
|
+
error: `forbidden: cannot read memory owned by ${record.agentId}`,
|
|
489
|
+
}), { status: 403, headers: { "Content-Type": "application/json" } });
|
|
523
490
|
}
|
|
524
491
|
}
|
|
525
492
|
}
|
|
@@ -72,6 +72,15 @@ 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
|
+
// ops-9rc6: a past validTo ALWAYS means the record has been closed out —
|
|
76
|
+
// either by the server supersede path (Memory.ts closeSupersededRecord sets
|
|
77
|
+
// validTo without necessarily setting `archived`) or any other writer. This
|
|
78
|
+
// is unconditional (not gated on `f.asOf`) so a server-superseded record
|
|
79
|
+
// can never resurface in the DEFAULT (no-asOf) recall path just because its
|
|
80
|
+
// successor happened not to be co-present in the same result set. A record
|
|
81
|
+
// with no validTo, or a future validTo, is unaffected.
|
|
82
|
+
if (record?.validTo && Date.parse(record.validTo) < now)
|
|
83
|
+
return false;
|
|
75
84
|
return true;
|
|
76
85
|
}
|
|
77
86
|
// The full BM25-candidate security filter: a record is a valid BM25 candidate
|
package/dist/resources/dedup.js
CHANGED
|
@@ -26,6 +26,30 @@ export const DEDUP_LEXICAL_THRESHOLD_DEFAULT = 0.5;
|
|
|
26
26
|
/** Below this content length, similarity scoring is unreliable ("ok" would
|
|
27
27
|
* match "ok" trivially) — the dedup gate is bypassed entirely. */
|
|
28
28
|
export const DEDUP_MIN_CONTENT_LENGTH = 20;
|
|
29
|
+
/**
|
|
30
|
+
* Cosine similarity of two equal-length embedding vectors, computed directly
|
|
31
|
+
* in JS. Used as a fallback for Harper's HNSW cosine-sort query omitting a
|
|
32
|
+
* computed `$distance` on its top candidate when the query's post-filter
|
|
33
|
+
* result set contains exactly ONE matching record (ops-ume4, found in
|
|
34
|
+
* resources/Memory.ts's findConservativeDedupMatch; the identical quirk is
|
|
35
|
+
* also fixed in resources/SemanticSearch.ts's scoring loop for the same
|
|
36
|
+
* reason — see ops-syzm there). A mismatched length, empty vector, or
|
|
37
|
+
* zero-magnitude side yields 0 (no signal, never treated as "identical" by a
|
|
38
|
+
* degenerate computation).
|
|
39
|
+
*/
|
|
40
|
+
export function cosineSimilarity(a, b) {
|
|
41
|
+
if (!Array.isArray(a) || !Array.isArray(b) || a.length === 0 || a.length !== b.length)
|
|
42
|
+
return 0;
|
|
43
|
+
let dot = 0, normA = 0, normB = 0;
|
|
44
|
+
for (let i = 0; i < a.length; i++) {
|
|
45
|
+
dot += a[i] * b[i];
|
|
46
|
+
normA += a[i] * a[i];
|
|
47
|
+
normB += b[i] * b[i];
|
|
48
|
+
}
|
|
49
|
+
if (normA === 0 || normB === 0)
|
|
50
|
+
return 0;
|
|
51
|
+
return dot / (Math.sqrt(normA) * Math.sqrt(normB));
|
|
52
|
+
}
|
|
29
53
|
/** Jaccard similarity of two token sets: |A∩B| / |A∪B|. An empty side yields 0
|
|
30
54
|
* (no signal — never treated as "fully similar" by vacuous set equality). */
|
|
31
55
|
export function jaccardSimilarity(tokensA, tokensB) {
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ed25519-auth — single shared source of truth for the TPS-Ed25519 auth
|
|
3
|
+
* primitives used at all 3 signature-verification call sites:
|
|
4
|
+
*
|
|
5
|
+
* - resources/auth-middleware.ts (instance-wide HTTP gate)
|
|
6
|
+
* - resources/agent-auth.ts (resolveAgentAuth's per-resource fallback)
|
|
7
|
+
* - resources/Presence.ts (POST /Presence heartbeat auth)
|
|
8
|
+
*
|
|
9
|
+
* Before this module existed, each of the 3 files carried its OWN
|
|
10
|
+
* module-level `nonceSeen` Map (replay guard) plus its own copy of
|
|
11
|
+
* `importEd25519Key`. Three independent replay windows meant a nonce
|
|
12
|
+
* recorded as "seen" via one path was invisible to the other two — a
|
|
13
|
+
* defense-in-depth gap, and a drift hazard (any future fix to one copy
|
|
14
|
+
* silently didn't apply to the other two). bd ops-c4op consolidates all
|
|
15
|
+
* three into this one module so there is exactly ONE replay guard and ONE
|
|
16
|
+
* key-import implementation, imported by all 3 sites.
|
|
17
|
+
*
|
|
18
|
+
* `b64ToArrayBuffer` was already unified into resources/b64.ts in a prior
|
|
19
|
+
* pass (see that file's header) — re-exported here so callers can import
|
|
20
|
+
* everything Ed25519-auth-related from one place.
|
|
21
|
+
*
|
|
22
|
+
* NOT included here: resources/Federation.ts's replay guard. Federation
|
|
23
|
+
* uses its own purpose-built NonceStore (federation-crypto.ts) for a
|
|
24
|
+
* different signing scheme (federation peer-to-peer body signatures, not
|
|
25
|
+
* agent TPS-Ed25519 auth) — deliberately left alone.
|
|
26
|
+
*
|
|
27
|
+
* Signed payload format (must match the TPS CLI signer exactly — changing
|
|
28
|
+
* it breaks every agent's auth): `${agentId}:${ts}:${nonce}:${METHOD}:${pathname}${search}`.
|
|
29
|
+
* Auth header format: `TPS-Ed25519 <agentId>:<ts>:<nonce>:<signatureB64>`.
|
|
30
|
+
* nonceKey format (replay-guard map key): `${agentId}:${nonce}`.
|
|
31
|
+
*/
|
|
32
|
+
import { b64ToArrayBuffer } from "./b64.js";
|
|
33
|
+
export { b64ToArrayBuffer };
|
|
34
|
+
/**
|
|
35
|
+
* Replay window, in ms. Confirmed identical (30_000) as a literal constant
|
|
36
|
+
* in auth-middleware.ts and Presence.ts. agent-auth.ts alone additionally
|
|
37
|
+
* read an (undocumented, never-set-anywhere) `FLAIR_AGENT_AUTH_WINDOW_MS`
|
|
38
|
+
* env override — see the flag on this in the consolidation report; the
|
|
39
|
+
* override is preserved here (uniformly, for all 3 sites) since no config,
|
|
40
|
+
* docs, or test in this repo currently sets that var, so preserving it is
|
|
41
|
+
* additive/no-op for today's deployments while keeping agent-auth.ts's
|
|
42
|
+
* stated "plugin-shaped, config via env" design intent.
|
|
43
|
+
*/
|
|
44
|
+
export const WINDOW_MS = Number(process.env.FLAIR_AGENT_AUTH_WINDOW_MS) || 30_000;
|
|
45
|
+
// ─── Replay guard (single shared instance) ─────────────────────────────────
|
|
46
|
+
//
|
|
47
|
+
// nonceSeen is the ONE module-level singleton — the whole point of this
|
|
48
|
+
// consolidation. A nonce recorded via any one of the 3 call sites is
|
|
49
|
+
// immediately visible to the other two, because they all import this same
|
|
50
|
+
// module (Node/bun module cache = one instance per process).
|
|
51
|
+
const nonceSeen = new Map();
|
|
52
|
+
/** Remove nonce records older than WINDOW_MS relative to `now`. */
|
|
53
|
+
export function pruneNonces(now = Date.now()) {
|
|
54
|
+
for (const [k, ts] of nonceSeen) {
|
|
55
|
+
if (now - ts > WINDOW_MS)
|
|
56
|
+
nonceSeen.delete(k);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Prune expired entries, then report whether (agentId, nonce) has already
|
|
61
|
+
* been recorded within the current window. Returns true = REPLAY (reject).
|
|
62
|
+
*
|
|
63
|
+
* Deliberately does NOT record as a side effect — callers check this BEFORE
|
|
64
|
+
* verifying the signature and call `recordNonce` only AFTER the signature is
|
|
65
|
+
* confirmed valid (matches the pre-consolidation per-site behavior at all 3
|
|
66
|
+
* sites exactly: an invalid-signature attempt never burns the nonce, so a
|
|
67
|
+
* client that retries with a corrected signature isn't locked out).
|
|
68
|
+
*/
|
|
69
|
+
export function isNonceReplay(agentId, nonce, now = Date.now()) {
|
|
70
|
+
pruneNonces(now);
|
|
71
|
+
return nonceSeen.has(`${agentId}:${nonce}`);
|
|
72
|
+
}
|
|
73
|
+
/** Record (agentId, nonce) as seen at `ts`. Call only after successful verification. */
|
|
74
|
+
export function recordNonce(agentId, nonce, ts) {
|
|
75
|
+
nonceSeen.set(`${agentId}:${nonce}`, ts);
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Test-only escape hatch: clear all recorded nonces. Never called by any
|
|
79
|
+
* production call site — exists so unit tests can isolate the shared
|
|
80
|
+
* singleton between cases instead of relying on WINDOW_MS-based expiry.
|
|
81
|
+
*/
|
|
82
|
+
export function __clearNoncesForTest() {
|
|
83
|
+
nonceSeen.clear();
|
|
84
|
+
}
|
|
85
|
+
// ─── Ed25519 public key import (cached) ────────────────────────────────────
|
|
86
|
+
//
|
|
87
|
+
// Single shared key cache, keyed by the raw publicKeyStr. Consolidating the
|
|
88
|
+
// 3 previously-independent caches into one just avoids redundant
|
|
89
|
+
// crypto.subtle.importKey calls across sites for the same agent — no
|
|
90
|
+
// security-relevant behavior change (the cache holds only CryptoKey handles,
|
|
91
|
+
// never logged or exposed).
|
|
92
|
+
const keyCache = new Map();
|
|
93
|
+
/**
|
|
94
|
+
* Import an agent's Ed25519 public key as a CryptoKey, cached by the raw
|
|
95
|
+
* key string. Accepts hex (64-char) or base64/base64url (44-char, or
|
|
96
|
+
* unpadded) encoded 32-byte Ed25519 public keys — identical logic across
|
|
97
|
+
* all 3 pre-consolidation copies (auth-middleware.ts, agent-auth.ts,
|
|
98
|
+
* Presence.ts).
|
|
99
|
+
*/
|
|
100
|
+
export async function importEd25519Key(publicKeyStr) {
|
|
101
|
+
const cached = keyCache.get(publicKeyStr);
|
|
102
|
+
if (cached)
|
|
103
|
+
return cached;
|
|
104
|
+
// Accept hex (64-char) or base64 (44-char) encoded 32-byte Ed25519 public key.
|
|
105
|
+
let raw;
|
|
106
|
+
if (/^[0-9a-f]{64}$/i.test(publicKeyStr)) {
|
|
107
|
+
// Hex-encoded raw key (TPS CLI default: Buffer.toString('hex'))
|
|
108
|
+
const bytes = new Uint8Array(32);
|
|
109
|
+
for (let i = 0; i < 32; i++)
|
|
110
|
+
bytes[i] = parseInt(publicKeyStr.slice(i * 2, i * 2 + 2), 16);
|
|
111
|
+
raw = bytes.buffer;
|
|
112
|
+
}
|
|
113
|
+
else {
|
|
114
|
+
raw = b64ToArrayBuffer(publicKeyStr);
|
|
115
|
+
}
|
|
116
|
+
const key = await crypto.subtle.importKey("raw", raw, { name: "Ed25519" }, false, ["verify"]);
|
|
117
|
+
keyCache.set(publicKeyStr, key);
|
|
118
|
+
return key;
|
|
119
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { databases } from "@harperfast/harper";
|
|
2
|
+
/**
|
|
3
|
+
* ─── Centralized Memory read-scoping (ops-2dm3 Layer 1) ─────────────────────
|
|
4
|
+
*
|
|
5
|
+
* The SINGLE source every cross-agent Memory read path resolves its scope
|
|
6
|
+
* through: Memory.search()/Memory.get() (resources/Memory.ts), SemanticSearch
|
|
7
|
+
* (resources/SemanticSearch.ts), MemoryBootstrap (resources/MemoryBootstrap.ts),
|
|
8
|
+
* and the by-id GET guard in resources/auth-middleware.ts. Before this module
|
|
9
|
+
* existed, SemanticSearch had its OWN inline grant-resolution + a
|
|
10
|
+
* `visibility === "office"` global OR-clause that leaked ANY authenticated
|
|
11
|
+
* 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
|
+
* path is exactly how that leak happened — this module exists so it can't
|
|
14
|
+
* happen again: one rule, one place, every path imports it.
|
|
15
|
+
*
|
|
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:
|
|
29
|
+
* - ALL of the reader's own records, any visibility, unrestricted.
|
|
30
|
+
* - A GRANTED owner's records EXCEPT that owner's `private` ones.
|
|
31
|
+
*
|
|
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
|
|
37
|
+
* 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).
|
|
40
|
+
*/
|
|
41
|
+
/**
|
|
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.
|
|
46
|
+
*/
|
|
47
|
+
export async function resolveAllowedOwners(authAgentId) {
|
|
48
|
+
const allowedOwners = [authAgentId];
|
|
49
|
+
try {
|
|
50
|
+
for await (const grant of databases.flair.MemoryGrant.search({
|
|
51
|
+
conditions: [{ attribute: "granteeId", comparator: "equals", value: authAgentId }],
|
|
52
|
+
})) {
|
|
53
|
+
if (grant.ownerId && (grant.scope === "read" || grant.scope === "search")) {
|
|
54
|
+
allowedOwners.push(grant.ownerId);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
catch { /* MemoryGrant table not yet populated — ignore */ }
|
|
59
|
+
return allowedOwners;
|
|
60
|
+
}
|
|
61
|
+
const PRIVATE = "private";
|
|
62
|
+
/**
|
|
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.
|
|
66
|
+
*/
|
|
67
|
+
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);
|
|
102
|
+
const isAllowed = (record) => {
|
|
103
|
+
if (!record)
|
|
104
|
+
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;
|
|
110
|
+
};
|
|
111
|
+
return { allowedOwners, condition, isAllowed };
|
|
112
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tpsdev-ai/flair",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.20.1",
|
|
4
4
|
"description": "Identity, memory, and soul for AI agents. Cryptographic identity (Ed25519), semantic memory with local embeddings, and persistent personality — all in a single process.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|