@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.
@@ -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 "./b64.js";
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
- // b64ToArrayBuffer lives in ./b64.ts (shared with agent-auth.ts + Presence.ts so
42
- // the base64/base64url decoder can't drift across the three auth call sites).
43
- const keyCache = new Map();
44
- async function importEd25519Key(publicKeyStr) {
45
- if (keyCache.has(publicKeyStr))
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
- for (const [k, signatureTs] of nonceSeen.entries())
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
- nonceSeen.set(nonceKey, ts);
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
- // Allow office-wide memories
503
- if (record.visibility !== "office") {
504
- // Check MemoryGrant
505
- let hasGrant = false;
506
- try {
507
- for await (const grant of databases.flair.MemoryGrant.search({
508
- conditions: [{ attribute: "granteeId", comparator: "equals", value: agentId }],
509
- })) {
510
- if (grant.ownerId === record.agentId &&
511
- (grant.scope === "read" || grant.scope === "search")) {
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
@@ -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.19.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",