@tpsdev-ai/flair 0.18.0 → 0.20.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/dist/cli.js +1 -1
- package/dist/resources/Admin.js +12 -0
- package/dist/resources/AdminConnectors.js +6 -0
- package/dist/resources/AdminDashboard.js +9 -0
- package/dist/resources/AdminIdp.js +6 -0
- package/dist/resources/AdminInstance.js +6 -0
- package/dist/resources/AdminMemory.js +9 -1
- package/dist/resources/AdminPrincipals.js +6 -0
- package/dist/resources/Integration.js +52 -2
- package/dist/resources/Memory.js +187 -21
- package/dist/resources/MemoryBootstrap.js +99 -22
- package/dist/resources/MemoryGrant.js +55 -2
- package/dist/resources/Presence.js +9 -36
- package/dist/resources/Relationship.js +49 -1
- package/dist/resources/SemanticSearch.js +78 -33
- package/dist/resources/Soul.js +12 -1
- package/dist/resources/WorkspaceState.js +56 -2
- 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
|
@@ -8,15 +8,64 @@
|
|
|
8
8
|
* Use this.getContext() to access request context (tpsAgent, tpsAgentIsAdmin).
|
|
9
9
|
*/
|
|
10
10
|
import { databases } from "@harperfast/harper";
|
|
11
|
-
import { resolveAgentAuth } from "./agent-auth.js";
|
|
11
|
+
import { resolveAgentAuth, allowVerified } from "./agent-auth.js";
|
|
12
12
|
const FORBIDDEN = (msg) => new Response(JSON.stringify({ error: msg }), { status: 403, headers: { "Content-Type": "application/json" } });
|
|
13
13
|
const UNAUTH = () => new Response(JSON.stringify({ error: "authentication required" }), { status: 401, headers: { "Content-Type": "application/json" } });
|
|
14
|
+
const NOT_FOUND = () => new Response(JSON.stringify({ error: "not found" }), { status: 404, headers: { "Content-Type": "application/json" } });
|
|
14
15
|
export class WorkspaceState extends databases.flair.WorkspaceState {
|
|
15
16
|
/** Auth verdict from the request context. internal = trusted in-process call;
|
|
16
17
|
* agent = verified Ed25519; anonymous = HTTP with no valid agent → deny. */
|
|
17
18
|
_auth() {
|
|
18
19
|
return resolveAgentAuth(this.getContext?.());
|
|
19
20
|
}
|
|
21
|
+
/**
|
|
22
|
+
* Self-authorize now that the global gate is non-rejecting (memory-soul-
|
|
23
|
+
* read-gate family fix, ops-oox7 — applying the Memory.ts/Soul.ts pattern
|
|
24
|
+
* to WorkspaceState/Relationship/Integration/MemoryGrant). Closes the same
|
|
25
|
+
* P0 leak: Harper routes `GET /WorkspaceState/<id>` to get() and the
|
|
26
|
+
* collection describe (`GET /WorkspaceState`) to a path outside search(),
|
|
27
|
+
* so neither was gated before this fix — an anonymous caller got a 200 with
|
|
28
|
+
* full record content. Per-record ownership scoping happens in get() below;
|
|
29
|
+
* the collection scope is still in search().
|
|
30
|
+
*/
|
|
31
|
+
allowRead() { return allowVerified(this.getContext?.()); }
|
|
32
|
+
/**
|
|
33
|
+
* Override get() to scope by-id reads the same way search() scopes
|
|
34
|
+
* collection reads (memory-soul-read-gate family fix). Never distinguishes
|
|
35
|
+
* "doesn't exist" from "exists but not yours" — both return 404, never
|
|
36
|
+
* 403, so a denied caller can't use get() to enumerate other agents'
|
|
37
|
+
* workspace-state ids.
|
|
38
|
+
*/
|
|
39
|
+
async get(target) {
|
|
40
|
+
// Collection / query reads — the `GET /WorkspaceState/?<query>` form and
|
|
41
|
+
// the bare collection — arrive as a RequestTarget with `isCollection ===
|
|
42
|
+
// true`, and are governed by search() (same owner scoping). Only a
|
|
43
|
+
// genuine by-id get is ownership-checked below. Without this guard,
|
|
44
|
+
// get() would receive the query's RequestTarget, super.get() would
|
|
45
|
+
// return the (truthy) result set, and the single-record ownership check
|
|
46
|
+
// would find no `.agentId` on it (see Memory.ts's get() for the full
|
|
47
|
+
// rationale — same bug class).
|
|
48
|
+
if (!target || (typeof target === "object" && target.isCollection)) {
|
|
49
|
+
return this.search(target);
|
|
50
|
+
}
|
|
51
|
+
const auth = await this._auth();
|
|
52
|
+
// Anonymous by-id read is already blocked at the allowRead() gate (403);
|
|
53
|
+
// this is defense-in-depth if get() is ever reached directly.
|
|
54
|
+
if (auth.kind === "anonymous") {
|
|
55
|
+
return NOT_FOUND();
|
|
56
|
+
}
|
|
57
|
+
// Trusted internal call or admin agent — unfiltered, unchanged behavior.
|
|
58
|
+
if (auth.kind === "internal" || (auth.kind === "agent" && auth.isAdmin)) {
|
|
59
|
+
return super.get(target);
|
|
60
|
+
}
|
|
61
|
+
// Non-admin agent: only its own workspace-state records.
|
|
62
|
+
const record = await super.get(target);
|
|
63
|
+
if (!record)
|
|
64
|
+
return NOT_FOUND();
|
|
65
|
+
if (record.agentId !== auth.agentId)
|
|
66
|
+
return NOT_FOUND();
|
|
67
|
+
return record;
|
|
68
|
+
}
|
|
20
69
|
/**
|
|
21
70
|
* Override search() to scope collection GETs to the authenticated agent's own
|
|
22
71
|
* records. Internal calls + admin agents see all; anonymous is denied
|
|
@@ -85,7 +134,12 @@ export class WorkspaceState extends databases.flair.WorkspaceState {
|
|
|
85
134
|
if (auth.kind === "internal" || (auth.kind === "agent" && auth.isAdmin)) {
|
|
86
135
|
return super.delete(id);
|
|
87
136
|
}
|
|
88
|
-
|
|
137
|
+
// Use super.get(id), NOT this.get(id): the new get() override above 404s
|
|
138
|
+
// (a truthy Response) for a non-owner id, which would otherwise defeat
|
|
139
|
+
// the `if (!record)` check below and mis-route a genuinely-missing
|
|
140
|
+
// record into the FORBIDDEN branch instead of a clean super.delete(id)
|
|
141
|
+
// no-op. Mirrors Memory.ts's delete() — same rationale, same fix.
|
|
142
|
+
const record = await super.get(id);
|
|
89
143
|
if (!record)
|
|
90
144
|
return super.delete(id);
|
|
91
145
|
if (record.agentId !== auth.agentId) {
|
|
@@ -16,8 +16,7 @@
|
|
|
16
16
|
* 30s timestamp window + a per-(agent,nonce) seen-set pruned to that window.
|
|
17
17
|
*/
|
|
18
18
|
import { databases } from "@harperfast/harper";
|
|
19
|
-
import { b64ToArrayBuffer } from "./
|
|
20
|
-
const WINDOW_MS = Number(process.env.FLAIR_AGENT_AUTH_WINDOW_MS) || 30_000;
|
|
19
|
+
import { WINDOW_MS, isNonceReplay, recordNonce, importEd25519Key, b64ToArrayBuffer } from "./ed25519-auth.js";
|
|
21
20
|
/**
|
|
22
21
|
* Shared Harper user that verified Ed25519 agents resolve to (least-privilege
|
|
23
22
|
* `flair_agent` role), replacing the old admin super_user elevation. Single
|
|
@@ -25,31 +24,12 @@ const WINDOW_MS = Number(process.env.FLAIR_AGENT_AUTH_WINDOW_MS) || 30_000;
|
|
|
25
24
|
* provisions it (ensureFlairAgentUser); they MUST agree on the name.
|
|
26
25
|
*/
|
|
27
26
|
export const FLAIR_AGENT_USERNAME = "flair-agent";
|
|
28
|
-
//
|
|
29
|
-
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
// so
|
|
33
|
-
|
|
34
|
-
async function importEd25519Key(publicKeyStr) {
|
|
35
|
-
const cached = keyCache.get(publicKeyStr);
|
|
36
|
-
if (cached)
|
|
37
|
-
return cached;
|
|
38
|
-
// Accept hex (64-char) or base64 (44-char) encoded 32-byte Ed25519 public key.
|
|
39
|
-
let raw;
|
|
40
|
-
if (/^[0-9a-f]{64}$/i.test(publicKeyStr)) {
|
|
41
|
-
const bytes = new Uint8Array(32);
|
|
42
|
-
for (let i = 0; i < 32; i++)
|
|
43
|
-
bytes[i] = parseInt(publicKeyStr.slice(i * 2, i * 2 + 2), 16);
|
|
44
|
-
raw = bytes.buffer;
|
|
45
|
-
}
|
|
46
|
-
else {
|
|
47
|
-
raw = b64ToArrayBuffer(publicKeyStr);
|
|
48
|
-
}
|
|
49
|
-
const key = await crypto.subtle.importKey("raw", raw, { name: "Ed25519" }, false, ["verify"]);
|
|
50
|
-
keyCache.set(publicKeyStr, key);
|
|
51
|
-
return key;
|
|
52
|
-
}
|
|
27
|
+
// ─── Crypto + replay-guard helpers ────────────────────────────────────────────
|
|
28
|
+
// WINDOW_MS, isNonceReplay/recordNonce (the ONE shared nonce store), and
|
|
29
|
+
// importEd25519Key all live in ./ed25519-auth.ts (bd ops-c4op) — the single
|
|
30
|
+
// shared implementation imported by auth-middleware.ts, agent-auth.ts, and
|
|
31
|
+
// Presence.ts so a nonce recorded via any one of the three call sites is
|
|
32
|
+
// visible to the other two, and the crypto/decoder logic can't drift.
|
|
53
33
|
// ─── Admin resolution ─────────────────────────────────────────────────────────
|
|
54
34
|
// Admin agents come from FLAIR_ADMIN_AGENTS (comma-separated) OR Agent records
|
|
55
35
|
// with role === "admin". OR-combined, cached 60s. Distinct from Harper's
|
|
@@ -89,12 +69,8 @@ async function doVerify(request) {
|
|
|
89
69
|
const now = Date.now();
|
|
90
70
|
if (!Number.isFinite(ts) || Math.abs(now - ts) > WINDOW_MS)
|
|
91
71
|
return null;
|
|
92
|
-
//
|
|
93
|
-
|
|
94
|
-
if (now - t > WINDOW_MS)
|
|
95
|
-
nonceSeen.delete(k);
|
|
96
|
-
const nonceKey = `${agentId}:${nonce}`;
|
|
97
|
-
if (nonceSeen.has(nonceKey))
|
|
72
|
+
// Reject replays within the window (isNonceReplay prunes expired entries first).
|
|
73
|
+
if (isNonceReplay(agentId, nonce, now))
|
|
98
74
|
return null;
|
|
99
75
|
const agent = await databases.flair.Agent.get(agentId).catch(() => null);
|
|
100
76
|
if (!agent?.publicKey)
|
|
@@ -112,7 +88,7 @@ async function doVerify(request) {
|
|
|
112
88
|
catch {
|
|
113
89
|
return null;
|
|
114
90
|
}
|
|
115
|
-
|
|
91
|
+
recordNonce(agentId, nonce, ts);
|
|
116
92
|
return { agentId, isAdmin: await isAdmin(agentId) };
|
|
117
93
|
}
|
|
118
94
|
/**
|
|
@@ -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.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",
|