@tpsdev-ai/flair 0.2.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/LICENSE +19 -0
- package/README.md +246 -0
- package/SECURITY.md +116 -0
- package/config.yaml +16 -0
- package/dist/resources/A2AAdapter.js +474 -0
- package/dist/resources/Agent.js +9 -0
- package/dist/resources/AgentCard.js +45 -0
- package/dist/resources/AgentSeed.js +111 -0
- package/dist/resources/IngestEvents.js +149 -0
- package/dist/resources/Integration.js +13 -0
- package/dist/resources/IssueTokens.js +19 -0
- package/dist/resources/Memory.js +122 -0
- package/dist/resources/MemoryBootstrap.js +263 -0
- package/dist/resources/MemoryConsolidate.js +105 -0
- package/dist/resources/MemoryFeed.js +41 -0
- package/dist/resources/MemoryReflect.js +105 -0
- package/dist/resources/OrgEvent.js +43 -0
- package/dist/resources/OrgEventCatchup.js +65 -0
- package/dist/resources/OrgEventMaintenance.js +29 -0
- package/dist/resources/SemanticSearch.js +147 -0
- package/dist/resources/SkillScan.js +101 -0
- package/dist/resources/Soul.js +9 -0
- package/dist/resources/SoulFeed.js +12 -0
- package/dist/resources/WorkspaceLatest.js +45 -0
- package/dist/resources/WorkspaceState.js +76 -0
- package/dist/resources/auth-middleware.js +470 -0
- package/dist/resources/embeddings-provider.js +127 -0
- package/dist/resources/embeddings.js +42 -0
- package/dist/resources/health.js +6 -0
- package/dist/resources/memory-feed-lib.js +15 -0
- package/dist/resources/table-helpers.js +35 -0
- package/package.json +62 -0
- package/resources/A2AAdapter.ts +510 -0
- package/resources/Agent.ts +10 -0
- package/resources/AgentCard.ts +65 -0
- package/resources/AgentSeed.ts +119 -0
- package/resources/IngestEvents.ts +189 -0
- package/resources/Integration.ts +14 -0
- package/resources/IssueTokens.ts +29 -0
- package/resources/Memory.ts +138 -0
- package/resources/MemoryBootstrap.ts +283 -0
- package/resources/MemoryConsolidate.ts +121 -0
- package/resources/MemoryFeed.ts +48 -0
- package/resources/MemoryReflect.ts +122 -0
- package/resources/OrgEvent.ts +63 -0
- package/resources/OrgEventCatchup.ts +89 -0
- package/resources/OrgEventMaintenance.ts +37 -0
- package/resources/SemanticSearch.ts +157 -0
- package/resources/SkillScan.ts +146 -0
- package/resources/Soul.ts +10 -0
- package/resources/SoulFeed.ts +15 -0
- package/resources/WorkspaceLatest.ts +66 -0
- package/resources/WorkspaceState.ts +102 -0
- package/resources/auth-middleware.ts +502 -0
- package/resources/embeddings-provider.ts +144 -0
- package/resources/embeddings.ts +28 -0
- package/resources/health.ts +7 -0
- package/resources/memory-feed-lib.ts +22 -0
- package/resources/table-helpers.ts +46 -0
- package/schemas/agent.graphql +22 -0
- package/schemas/event.graphql +12 -0
- package/schemas/memory.graphql +50 -0
- package/schemas/schema.graphql +41 -0
- package/schemas/workspace.graphql +14 -0
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WorkspaceState.ts — Harper table resource for workspace state records (OPS-47 Phase 2)
|
|
3
|
+
*
|
|
4
|
+
* Auth: Ed25519 middleware sets request.tpsAgent. Agent can only read/write own records.
|
|
5
|
+
* Pattern follows Memory.ts — extends Harper auto-generated table class.
|
|
6
|
+
*
|
|
7
|
+
* Note: Harper's static methods call instance methods with positional args only.
|
|
8
|
+
* Use this.getContext() to access request context (tpsAgent, tpsAgentIsAdmin).
|
|
9
|
+
*/
|
|
10
|
+
import { databases } from "@harperfast/harper";
|
|
11
|
+
export class WorkspaceState extends databases.flair.WorkspaceState {
|
|
12
|
+
/**
|
|
13
|
+
* Helper to extract auth info from Harper's Resource instance context.
|
|
14
|
+
*/
|
|
15
|
+
_authInfo() {
|
|
16
|
+
const ctx = this.getContext?.();
|
|
17
|
+
const request = ctx?.request ?? ctx;
|
|
18
|
+
return {
|
|
19
|
+
agentId: request?.tpsAgent,
|
|
20
|
+
isAdmin: request?.tpsAgentIsAdmin ?? false,
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Override search() to scope collection GETs to the authenticated agent's
|
|
25
|
+
* own workspace state records. Admin agents see all records.
|
|
26
|
+
*/
|
|
27
|
+
async search(query) {
|
|
28
|
+
const { agentId: authAgent, isAdmin: isAdminAgent } = this._authInfo();
|
|
29
|
+
if (!authAgent || isAdminAgent) {
|
|
30
|
+
return super.search(query);
|
|
31
|
+
}
|
|
32
|
+
const agentIdCondition = { attribute: "agentId", comparator: "equals", value: authAgent };
|
|
33
|
+
// Harper passes `query` as a request target object (with pathname, id, isCollection, etc.)
|
|
34
|
+
// Inject scope condition into its `.conditions` array so Table.search() processes it correctly.
|
|
35
|
+
if (query && typeof query === "object" && !Array.isArray(query)) {
|
|
36
|
+
const existing = query.conditions ?? [];
|
|
37
|
+
query.conditions = Array.isArray(existing)
|
|
38
|
+
? [agentIdCondition, ...existing]
|
|
39
|
+
: [agentIdCondition, existing];
|
|
40
|
+
return super.search(query);
|
|
41
|
+
}
|
|
42
|
+
const conditions = Array.isArray(query) && query.length > 0
|
|
43
|
+
? [agentIdCondition, ...query]
|
|
44
|
+
: [agentIdCondition];
|
|
45
|
+
return super.search(conditions);
|
|
46
|
+
}
|
|
47
|
+
async post(content) {
|
|
48
|
+
const { agentId, isAdmin: isAdminAgent } = this._authInfo();
|
|
49
|
+
// Agent-scoped: agentId in body must match authenticated agent
|
|
50
|
+
if (agentId && !isAdminAgent && content.agentId !== agentId) {
|
|
51
|
+
return new Response(JSON.stringify({ error: "forbidden: cannot write workspace state for another agent" }), { status: 403, headers: { "Content-Type": "application/json" } });
|
|
52
|
+
}
|
|
53
|
+
content.createdAt = new Date().toISOString();
|
|
54
|
+
content.timestamp ||= content.createdAt;
|
|
55
|
+
return super.post(content);
|
|
56
|
+
}
|
|
57
|
+
async put(content) {
|
|
58
|
+
const { agentId, isAdmin: isAdminAgent } = this._authInfo();
|
|
59
|
+
if (agentId && !isAdminAgent && content.agentId !== agentId) {
|
|
60
|
+
return new Response(JSON.stringify({ error: "forbidden: cannot write workspace state for another agent" }), { status: 403, headers: { "Content-Type": "application/json" } });
|
|
61
|
+
}
|
|
62
|
+
return super.put(content);
|
|
63
|
+
}
|
|
64
|
+
async delete(id) {
|
|
65
|
+
const { agentId, isAdmin: isAdminAgent } = this._authInfo();
|
|
66
|
+
if (!agentId)
|
|
67
|
+
return super.delete(id);
|
|
68
|
+
const record = await this.get(id);
|
|
69
|
+
if (!record)
|
|
70
|
+
return super.delete(id);
|
|
71
|
+
if (!isAdminAgent && record.agentId !== agentId) {
|
|
72
|
+
return new Response(JSON.stringify({ error: "forbidden: cannot delete workspace state for another agent" }), { status: 403, headers: { "Content-Type": "application/json" } });
|
|
73
|
+
}
|
|
74
|
+
return super.delete(id);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
@@ -0,0 +1,470 @@
|
|
|
1
|
+
import { patchRecord } from "./table-helpers.js";
|
|
2
|
+
import { server, databases } from "@harperfast/harper";
|
|
3
|
+
import { initEmbeddings, getEmbedding } from "./embeddings-provider.js";
|
|
4
|
+
// --- Admin credentials ---
|
|
5
|
+
// Admin auth is sourced exclusively from Harper's own environment variables
|
|
6
|
+
// (HDB_ADMIN_PASSWORD / FLAIR_ADMIN_PASSWORD). No filesystem token file.
|
|
7
|
+
//
|
|
8
|
+
// FLAIR_ADMIN_TOKEN env var is still accepted for backwards compat but
|
|
9
|
+
// emits a deprecation warning on first use.
|
|
10
|
+
let _adminPass = null;
|
|
11
|
+
let _deprecationWarned = false;
|
|
12
|
+
function getAdminPass() {
|
|
13
|
+
if (_adminPass)
|
|
14
|
+
return _adminPass;
|
|
15
|
+
// Primary source: Harper's own admin password (set at startup via env)
|
|
16
|
+
const primary = process.env.HDB_ADMIN_PASSWORD ?? process.env.FLAIR_ADMIN_PASSWORD;
|
|
17
|
+
if (primary) {
|
|
18
|
+
_adminPass = primary;
|
|
19
|
+
return _adminPass;
|
|
20
|
+
}
|
|
21
|
+
// Backwards compat: FLAIR_ADMIN_TOKEN (deprecated — never write to disk)
|
|
22
|
+
if (process.env.FLAIR_ADMIN_TOKEN) {
|
|
23
|
+
if (!_deprecationWarned) {
|
|
24
|
+
console.warn("[auth] DEPRECATION: FLAIR_ADMIN_TOKEN is deprecated. Use HDB_ADMIN_PASSWORD instead.");
|
|
25
|
+
_deprecationWarned = true;
|
|
26
|
+
}
|
|
27
|
+
_adminPass = process.env.FLAIR_ADMIN_TOKEN;
|
|
28
|
+
return _adminPass;
|
|
29
|
+
}
|
|
30
|
+
const msg = "[auth] FATAL: no admin password found. Set HDB_ADMIN_PASSWORD env var.";
|
|
31
|
+
console.error(msg);
|
|
32
|
+
throw new Error(msg);
|
|
33
|
+
}
|
|
34
|
+
const WINDOW_MS = 30_000;
|
|
35
|
+
const nonceSeen = new Map();
|
|
36
|
+
// ─── Admin resolution ─────────────────────────────────────────────────────────
|
|
37
|
+
// Admin agents: from FLAIR_ADMIN_AGENTS env var (comma-separated) OR
|
|
38
|
+
// Agent records with role === "admin". Both sources are OR-combined.
|
|
39
|
+
// Result is cached for 60s to avoid per-request DB hits.
|
|
40
|
+
let adminCacheExpiry = 0;
|
|
41
|
+
let adminCache = new Set();
|
|
42
|
+
async function getAdminAgents() {
|
|
43
|
+
const now = Date.now();
|
|
44
|
+
if (now < adminCacheExpiry)
|
|
45
|
+
return adminCache;
|
|
46
|
+
const from_env = (process.env.FLAIR_ADMIN_AGENTS ?? "")
|
|
47
|
+
.split(",").map((s) => s.trim()).filter(Boolean);
|
|
48
|
+
let from_db = [];
|
|
49
|
+
try {
|
|
50
|
+
const results = await databases.flair.Agent.search([{ attribute: "role", value: "admin", condition: "equals" }]);
|
|
51
|
+
for await (const row of results) {
|
|
52
|
+
if (row?.id)
|
|
53
|
+
from_db.push(row.id);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
catch { /* Agent table might not be populated yet */ }
|
|
57
|
+
adminCache = new Set([...from_env, ...from_db]);
|
|
58
|
+
adminCacheExpiry = now + 60_000;
|
|
59
|
+
return adminCache;
|
|
60
|
+
}
|
|
61
|
+
export async function isAdmin(agentId) {
|
|
62
|
+
const admins = await getAdminAgents();
|
|
63
|
+
return admins.has(agentId);
|
|
64
|
+
}
|
|
65
|
+
// ─── Crypto helpers ───────────────────────────────────────────────────────────
|
|
66
|
+
function b64ToArrayBuffer(b64) {
|
|
67
|
+
// Handle both standard and URL-safe base64
|
|
68
|
+
const std = b64.replace(/-/g, '+').replace(/_/g, '/');
|
|
69
|
+
const bin = atob(std);
|
|
70
|
+
const buf = new ArrayBuffer(bin.length);
|
|
71
|
+
const view = new Uint8Array(buf);
|
|
72
|
+
for (let i = 0; i < bin.length; i++)
|
|
73
|
+
view[i] = bin.charCodeAt(i);
|
|
74
|
+
return buf;
|
|
75
|
+
}
|
|
76
|
+
const keyCache = new Map();
|
|
77
|
+
async function importEd25519Key(publicKeyStr) {
|
|
78
|
+
if (keyCache.has(publicKeyStr))
|
|
79
|
+
return keyCache.get(publicKeyStr);
|
|
80
|
+
// Accept hex (64-char) or base64 (44-char) encoded 32-byte Ed25519 public key
|
|
81
|
+
let raw;
|
|
82
|
+
if (/^[0-9a-f]{64}$/i.test(publicKeyStr)) {
|
|
83
|
+
// Hex-encoded raw key (TPS CLI default: Buffer.toString('hex'))
|
|
84
|
+
const bytes = new Uint8Array(32);
|
|
85
|
+
for (let i = 0; i < 32; i++)
|
|
86
|
+
bytes[i] = parseInt(publicKeyStr.slice(i * 2, i * 2 + 2), 16);
|
|
87
|
+
raw = bytes.buffer;
|
|
88
|
+
}
|
|
89
|
+
else {
|
|
90
|
+
raw = b64ToArrayBuffer(publicKeyStr);
|
|
91
|
+
}
|
|
92
|
+
const key = await crypto.subtle.importKey("raw", raw, { name: "Ed25519" }, false, ["verify"]);
|
|
93
|
+
keyCache.set(publicKeyStr, key);
|
|
94
|
+
return key;
|
|
95
|
+
}
|
|
96
|
+
initEmbeddings().catch((err) => console.error("[embeddings] init:", err.message));
|
|
97
|
+
async function backfillEmbedding(memoryId) {
|
|
98
|
+
try {
|
|
99
|
+
const record = await databases.flair.Memory.get(memoryId);
|
|
100
|
+
if (!record?.content)
|
|
101
|
+
return;
|
|
102
|
+
if (record.embedding?.length > 100)
|
|
103
|
+
return;
|
|
104
|
+
const embedding = await getEmbedding(record.content);
|
|
105
|
+
if (!embedding)
|
|
106
|
+
return;
|
|
107
|
+
await patchRecord(databases.flair.Memory, memoryId, { embedding });
|
|
108
|
+
console.log(`[auto-embed] ${memoryId}: ${embedding.length}d`);
|
|
109
|
+
}
|
|
110
|
+
catch (err) {
|
|
111
|
+
console.error(`[auto-embed] Failed for ${memoryId}: ${err.message}`);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
// ─── HTTP middleware ──────────────────────────────────────────────────────────
|
|
115
|
+
server.http(async (request, nextLayer) => {
|
|
116
|
+
const url = new URL(request.url, "http://" + (request.headers.get("host") || "localhost"));
|
|
117
|
+
if (url.pathname === "/health" ||
|
|
118
|
+
url.pathname === "/Health" ||
|
|
119
|
+
url.pathname === "/a2a" ||
|
|
120
|
+
url.pathname === "/A2AAdapter" ||
|
|
121
|
+
url.pathname === "/AgentCard" ||
|
|
122
|
+
url.pathname.startsWith("/A2AAdapter/") ||
|
|
123
|
+
url.pathname.startsWith("/AgentCard/"))
|
|
124
|
+
return nextLayer(request);
|
|
125
|
+
// Skip re-entry: if we already swapped auth to Basic, pass through
|
|
126
|
+
if (request._tpsAuthVerified)
|
|
127
|
+
return nextLayer(request);
|
|
128
|
+
const header = request.headers.get("authorization") || request.headers?.asObject?.authorization || "";
|
|
129
|
+
// ── Basic admin auth ──────────────────────────────────────────────────────
|
|
130
|
+
// Allow Basic auth with the admin password for CLI operations (backup, etc.)
|
|
131
|
+
// This is checked BEFORE Ed25519 so admin tools can use simple auth.
|
|
132
|
+
if (header.startsWith("Basic ")) {
|
|
133
|
+
try {
|
|
134
|
+
const decoded = Buffer.from(header.slice(6), "base64").toString("utf-8");
|
|
135
|
+
const [user, pass] = decoded.split(":");
|
|
136
|
+
if (user === "admin" && pass === getAdminPass()) {
|
|
137
|
+
// Mark as verified and pass through to Harper with admin credentials
|
|
138
|
+
request._tpsAuthVerified = true;
|
|
139
|
+
request.headers.set("x-tps-agent", "admin");
|
|
140
|
+
if (request.headers.asObject)
|
|
141
|
+
request.headers.asObject["x-tps-agent"] = "admin";
|
|
142
|
+
return nextLayer(request);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
catch { /* fall through to Ed25519 check */ }
|
|
146
|
+
return new Response(JSON.stringify({ error: "invalid_admin_credentials" }), { status: 401 });
|
|
147
|
+
}
|
|
148
|
+
// ── Ed25519 agent auth ────────────────────────────────────────────────────
|
|
149
|
+
const m = header.match(/^TPS-Ed25519\s+([^:]+):(\d+):([^:]+):(.+)$/);
|
|
150
|
+
if (!m) {
|
|
151
|
+
return new Response(JSON.stringify({ error: "missing_or_invalid_authorization" }), { status: 401 });
|
|
152
|
+
}
|
|
153
|
+
const [, agentId, tsRaw, nonce, signatureB64] = m;
|
|
154
|
+
const ts = Number(tsRaw);
|
|
155
|
+
const now = Date.now();
|
|
156
|
+
if (!Number.isFinite(ts) || Math.abs(now - ts) > WINDOW_MS)
|
|
157
|
+
return new Response(JSON.stringify({ error: "timestamp_out_of_window" }), { status: 401 });
|
|
158
|
+
for (const [k, signatureTs] of nonceSeen.entries())
|
|
159
|
+
if (now - signatureTs > WINDOW_MS)
|
|
160
|
+
nonceSeen.delete(k);
|
|
161
|
+
const nonceKey = `${agentId}:${nonce}`;
|
|
162
|
+
if (nonceSeen.has(nonceKey))
|
|
163
|
+
return new Response(JSON.stringify({ error: "nonce_replay_detected" }), { status: 401 });
|
|
164
|
+
const agent = await databases.flair.Agent.get(agentId);
|
|
165
|
+
if (!agent)
|
|
166
|
+
return new Response(JSON.stringify({ error: "unknown_agent" }), { status: 401 });
|
|
167
|
+
try {
|
|
168
|
+
const payload = `${agentId}:${tsRaw}:${nonce}:${request.method}:${url.pathname}${url.search}`;
|
|
169
|
+
const key = await importEd25519Key(agent.publicKey);
|
|
170
|
+
const sigBuf = b64ToArrayBuffer(signatureB64);
|
|
171
|
+
const payloadBuf = new TextEncoder().encode(payload);
|
|
172
|
+
const ok = await crypto.subtle.verify({ name: "Ed25519" }, key, sigBuf, payloadBuf);
|
|
173
|
+
if (!ok)
|
|
174
|
+
return new Response(JSON.stringify({ error: "invalid_signature" }), { status: 401 });
|
|
175
|
+
}
|
|
176
|
+
catch (e) {
|
|
177
|
+
return new Response(JSON.stringify({ error: "signature_verification_failed", detail: e?.message }), { status: 401 });
|
|
178
|
+
}
|
|
179
|
+
nonceSeen.set(nonceKey, ts);
|
|
180
|
+
request.tpsAgent = agentId;
|
|
181
|
+
request._tpsAuthVerified = true;
|
|
182
|
+
request.tpsAgentIsAdmin = await isAdmin(agentId);
|
|
183
|
+
const superAuth = "Basic " + btoa("admin:" + getAdminPass());
|
|
184
|
+
request.headers.set("authorization", superAuth);
|
|
185
|
+
if (request.headers.asObject)
|
|
186
|
+
request.headers.asObject.authorization = superAuth;
|
|
187
|
+
// Propagate authenticated agent to downstream resources via header.
|
|
188
|
+
// Resources can read this to enforce agent-level scoping.
|
|
189
|
+
request.headers.set("x-tps-agent", agentId);
|
|
190
|
+
if (request.headers.asObject)
|
|
191
|
+
request.headers.asObject["x-tps-agent"] = agentId;
|
|
192
|
+
// ── Raw query endpoint block (non-admins) ─────────────────────────────────
|
|
193
|
+
// SQL and GraphQL endpoints bypass all resource-level scoping — block them
|
|
194
|
+
// for non-admin agents. Admins (bootstrap, consolidation scripts) still pass.
|
|
195
|
+
if (!request.tpsAgentIsAdmin) {
|
|
196
|
+
const rawPath = url.pathname.toLowerCase();
|
|
197
|
+
if (rawPath === "/sql" || rawPath.startsWith("/sql/") ||
|
|
198
|
+
rawPath === "/graphql" || rawPath.startsWith("/graphql/")) {
|
|
199
|
+
return new Response(JSON.stringify({ error: "forbidden: raw query endpoints require admin access" }), { status: 403, headers: { "Content-Type": "application/json" } });
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
// ── Server-side permission guards ──────────────────────────────────────────
|
|
203
|
+
const method = request.method.toUpperCase();
|
|
204
|
+
const isMutation = method === "POST" || method === "PUT" || method === "PATCH" || method === "DELETE";
|
|
205
|
+
if (isMutation) {
|
|
206
|
+
// OrgEvent: authorId must match authenticated agent
|
|
207
|
+
if ((url.pathname === "/OrgEvent" || url.pathname.startsWith("/OrgEvent/")) &&
|
|
208
|
+
(method === "POST" || method === "PUT" || method === "PATCH")) {
|
|
209
|
+
if (!request.tpsAgentIsAdmin) {
|
|
210
|
+
try {
|
|
211
|
+
const clone = request.clone();
|
|
212
|
+
const body = await clone.json();
|
|
213
|
+
if (body?.authorId && body.authorId !== agentId) {
|
|
214
|
+
return new Response(JSON.stringify({
|
|
215
|
+
error: "forbidden: authorId must match authenticated agent"
|
|
216
|
+
}), { status: 403 });
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
catch { }
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
// OrgEvent DELETE: ownership check
|
|
223
|
+
if (url.pathname.startsWith("/OrgEvent/") && method === "DELETE") {
|
|
224
|
+
if (!request.tpsAgentIsAdmin) {
|
|
225
|
+
try {
|
|
226
|
+
const pathParts = url.pathname.split("/").filter(Boolean);
|
|
227
|
+
const eventId = pathParts[1] ? decodeURIComponent(pathParts[1]) : null;
|
|
228
|
+
if (eventId) {
|
|
229
|
+
const record = await databases.flair.OrgEvent.get(eventId);
|
|
230
|
+
if (record && record.authorId && record.authorId !== agentId) {
|
|
231
|
+
return new Response(JSON.stringify({
|
|
232
|
+
error: "forbidden: cannot delete events authored by another agent"
|
|
233
|
+
}), { status: 403 });
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
catch { }
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
// WorkspaceState: agent-scoped mutations (non-admin can only write own records)
|
|
241
|
+
if ((url.pathname === "/WorkspaceState" || url.pathname.startsWith("/WorkspaceState/")) &&
|
|
242
|
+
(method === "POST" || method === "PUT" || method === "PATCH")) {
|
|
243
|
+
if (!request.tpsAgentIsAdmin) {
|
|
244
|
+
try {
|
|
245
|
+
const clone = request.clone();
|
|
246
|
+
const body = await clone.json();
|
|
247
|
+
if (body?.agentId && body.agentId !== agentId) {
|
|
248
|
+
return new Response(JSON.stringify({
|
|
249
|
+
error: "forbidden: cannot write workspace state for another agent"
|
|
250
|
+
}), { status: 403 });
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
catch { }
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
// WorkspaceState DELETE: ownership check
|
|
257
|
+
if ((url.pathname.startsWith("/WorkspaceState/")) && method === "DELETE") {
|
|
258
|
+
if (!request.tpsAgentIsAdmin) {
|
|
259
|
+
try {
|
|
260
|
+
const pathParts = url.pathname.split("/").filter(Boolean);
|
|
261
|
+
const wsId = pathParts[1] ? decodeURIComponent(pathParts[1]) : null;
|
|
262
|
+
if (wsId) {
|
|
263
|
+
const record = await databases.flair.WorkspaceState.get(wsId);
|
|
264
|
+
if (record && record.agentId && record.agentId !== agentId) {
|
|
265
|
+
return new Response(JSON.stringify({
|
|
266
|
+
error: "forbidden: cannot delete workspace state for another agent"
|
|
267
|
+
}), { status: 403 });
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
catch { }
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
// Soul PUT: only owner or admin
|
|
275
|
+
if (url.pathname.startsWith("/Soul") && (method === "PUT" || method === "POST")) {
|
|
276
|
+
if (!request.tpsAgentIsAdmin) {
|
|
277
|
+
let bodyAgentId = null;
|
|
278
|
+
try {
|
|
279
|
+
const clone = request.clone();
|
|
280
|
+
const body = await clone.json();
|
|
281
|
+
bodyAgentId = body?.agentId ?? null;
|
|
282
|
+
}
|
|
283
|
+
catch { }
|
|
284
|
+
if (bodyAgentId && bodyAgentId !== agentId) {
|
|
285
|
+
return new Response(JSON.stringify({ error: "forbidden: non-admin cannot modify another agent's soul" }), { status: 403 });
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
// Memory promotion guard: only admin can approve or set durability=permanent
|
|
290
|
+
if (((url.pathname === "/Memory" || url.pathname.startsWith("/Memory/") || url.pathname === "/memory" || url.pathname.startsWith("/memory/"))) &&
|
|
291
|
+
(method === "PUT" || method === "POST" || method === "PATCH")) {
|
|
292
|
+
if (!request.tpsAgentIsAdmin) {
|
|
293
|
+
try {
|
|
294
|
+
const clone = request.clone();
|
|
295
|
+
const body = await clone.json();
|
|
296
|
+
const setsApproved = body?.promotionStatus === "approved";
|
|
297
|
+
const setsPermanent = body?.durability === "permanent";
|
|
298
|
+
const setsArchived = body?.archived === true;
|
|
299
|
+
if (setsApproved || setsPermanent || setsArchived) {
|
|
300
|
+
return new Response(JSON.stringify({
|
|
301
|
+
error: "forbidden: only admins can approve promotions, set permanent durability, or archive memories"
|
|
302
|
+
}), { status: 403 });
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
catch { }
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
// Memory PUT/DELETE: ownership check (non-admin can only modify their own memories)
|
|
309
|
+
if (((url.pathname === "/Memory" || url.pathname.startsWith("/Memory/") || url.pathname === "/memory" || url.pathname.startsWith("/memory/"))) &&
|
|
310
|
+
(method === "PUT" || method === "DELETE" || method === "PATCH")) {
|
|
311
|
+
if (!request.tpsAgentIsAdmin) {
|
|
312
|
+
try {
|
|
313
|
+
const pathParts = url.pathname.split("/").filter(Boolean);
|
|
314
|
+
const memId = pathParts[1] ? decodeURIComponent(pathParts[1]) : null;
|
|
315
|
+
if (memId) {
|
|
316
|
+
const record = await databases.flair.Memory.get(memId);
|
|
317
|
+
if (record && record.agentId && record.agentId !== agentId) {
|
|
318
|
+
return new Response(JSON.stringify({
|
|
319
|
+
error: `forbidden: cannot modify memory owned by ${record.agentId}`
|
|
320
|
+
}), { status: 403 });
|
|
321
|
+
}
|
|
322
|
+
if (method === "DELETE" && record?.durability === "permanent") {
|
|
323
|
+
return new Response(JSON.stringify({
|
|
324
|
+
error: "forbidden: only admins can purge permanent memories"
|
|
325
|
+
}), { status: 403 });
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
catch { }
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
// ── WorkspaceState read guard: agent-scoped reads ───────────────────────────
|
|
334
|
+
if (method === "GET" && !request.tpsAgentIsAdmin) {
|
|
335
|
+
if (url.pathname === "/WorkspaceState" || url.pathname === "/WorkspaceState/") {
|
|
336
|
+
const queryAgent = url.searchParams.get("agentId");
|
|
337
|
+
if (queryAgent && queryAgent !== agentId) {
|
|
338
|
+
return new Response(JSON.stringify({
|
|
339
|
+
error: "forbidden: cannot read workspace state for another agent"
|
|
340
|
+
}), { status: 403, headers: { "Content-Type": "application/json" } });
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
// ── SemanticSearch: agentId must match authenticated agent ─────────────────
|
|
345
|
+
// Non-admin agents can only search their own memories (plus MemoryGrant access,
|
|
346
|
+
// which is enforced inside SemanticSearch.ts using the x-tps-agent header).
|
|
347
|
+
if (!request.tpsAgentIsAdmin &&
|
|
348
|
+
method === "POST" &&
|
|
349
|
+
(url.pathname === "/SemanticSearch" || url.pathname === "/SemanticSearch/")) {
|
|
350
|
+
try {
|
|
351
|
+
const clone = request.clone();
|
|
352
|
+
const body = await clone.json();
|
|
353
|
+
if (body?.agentId && body.agentId !== agentId) {
|
|
354
|
+
return new Response(JSON.stringify({
|
|
355
|
+
error: "forbidden: agentId must match authenticated agent",
|
|
356
|
+
}), { status: 403, headers: { "Content-Type": "application/json" } });
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
catch { /* malformed body — let resource return its own error */ }
|
|
360
|
+
}
|
|
361
|
+
// ── BootstrapMemories: agentId must match authenticated agent ───────────────
|
|
362
|
+
if (!request.tpsAgentIsAdmin &&
|
|
363
|
+
method === "POST" &&
|
|
364
|
+
(url.pathname === "/BootstrapMemories" || url.pathname === "/BootstrapMemories/")) {
|
|
365
|
+
try {
|
|
366
|
+
const clone = request.clone();
|
|
367
|
+
const body = await clone.json();
|
|
368
|
+
if (body?.agentId && body.agentId !== agentId) {
|
|
369
|
+
return new Response(JSON.stringify({
|
|
370
|
+
error: "forbidden: agentId must match authenticated agent",
|
|
371
|
+
}), { status: 403, headers: { "Content-Type": "application/json" } });
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
catch { /* malformed body — let resource return its own error */ }
|
|
375
|
+
}
|
|
376
|
+
// ── Memory POST (create): agentId must match authenticated agent ────────────
|
|
377
|
+
if (!request.tpsAgentIsAdmin &&
|
|
378
|
+
method === "POST" &&
|
|
379
|
+
(url.pathname === "/Memory" || url.pathname === "/Memory/")) {
|
|
380
|
+
try {
|
|
381
|
+
const clone = request.clone();
|
|
382
|
+
const body = await clone.json();
|
|
383
|
+
if (body?.agentId && body.agentId !== agentId) {
|
|
384
|
+
return new Response(JSON.stringify({
|
|
385
|
+
error: "forbidden: cannot create memories for another agent",
|
|
386
|
+
}), { status: 403, headers: { "Content-Type": "application/json" } });
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
catch { }
|
|
390
|
+
}
|
|
391
|
+
// ── Soul POST/PUT: agentId must match authenticated agent ───────────────────
|
|
392
|
+
if (!request.tpsAgentIsAdmin &&
|
|
393
|
+
(method === "POST" || method === "PUT") &&
|
|
394
|
+
(url.pathname === "/Soul" || url.pathname === "/Soul/" || url.pathname.startsWith("/Soul/"))) {
|
|
395
|
+
try {
|
|
396
|
+
const clone = request.clone();
|
|
397
|
+
const body = await clone.json();
|
|
398
|
+
if (body?.agentId && body.agentId !== agentId) {
|
|
399
|
+
return new Response(JSON.stringify({
|
|
400
|
+
error: "forbidden: cannot write another agent's soul",
|
|
401
|
+
}), { status: 403, headers: { "Content-Type": "application/json" } });
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
catch { }
|
|
405
|
+
}
|
|
406
|
+
// ── Memory PUT: agentId must match authenticated agent ──────────────────────
|
|
407
|
+
if (!request.tpsAgentIsAdmin &&
|
|
408
|
+
method === "PUT" &&
|
|
409
|
+
(url.pathname === "/Memory" || url.pathname === "/Memory/" || url.pathname.startsWith("/Memory/"))) {
|
|
410
|
+
try {
|
|
411
|
+
const clone = request.clone();
|
|
412
|
+
const body = await clone.json();
|
|
413
|
+
if (body?.agentId && body.agentId !== agentId) {
|
|
414
|
+
return new Response(JSON.stringify({
|
|
415
|
+
error: "forbidden: cannot write memories for another agent",
|
|
416
|
+
}), { status: 403, headers: { "Content-Type": "application/json" } });
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
catch { }
|
|
420
|
+
}
|
|
421
|
+
// ── Memory GET: non-admin can only read own memories (by ID) ────────────────
|
|
422
|
+
if (!request.tpsAgentIsAdmin && method === "GET") {
|
|
423
|
+
if (url.pathname.startsWith("/Memory/")) {
|
|
424
|
+
try {
|
|
425
|
+
const pathParts = url.pathname.split("/").filter(Boolean);
|
|
426
|
+
const memId = pathParts[1] ? decodeURIComponent(pathParts[1]) : null;
|
|
427
|
+
if (memId) {
|
|
428
|
+
const record = await databases.flair.Memory.get(memId);
|
|
429
|
+
if (record && record.agentId && record.agentId !== agentId) {
|
|
430
|
+
// Allow office-wide memories
|
|
431
|
+
if (record.visibility !== "office") {
|
|
432
|
+
// Check MemoryGrant
|
|
433
|
+
let hasGrant = false;
|
|
434
|
+
try {
|
|
435
|
+
for await (const grant of databases.flair.MemoryGrant.search({
|
|
436
|
+
conditions: [{ attribute: "granteeId", comparator: "equals", value: agentId }],
|
|
437
|
+
})) {
|
|
438
|
+
if (grant.ownerId === record.agentId &&
|
|
439
|
+
(grant.scope === "read" || grant.scope === "search")) {
|
|
440
|
+
hasGrant = true;
|
|
441
|
+
break;
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
catch { }
|
|
446
|
+
if (!hasGrant) {
|
|
447
|
+
return new Response(JSON.stringify({
|
|
448
|
+
error: `forbidden: cannot read memory owned by ${record.agentId}`,
|
|
449
|
+
}), { status: 403, headers: { "Content-Type": "application/json" } });
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
catch { /* record not found or table error — let resource handle */ }
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
// ── Embedding backfill ─────────────────────────────────────────────────────
|
|
459
|
+
const isMemoryWrite = isMutation && (url.pathname === "/Memory" || url.pathname.startsWith("/Memory/"));
|
|
460
|
+
let memoryId = null;
|
|
461
|
+
if (isMemoryWrite) {
|
|
462
|
+
const pathParts = url.pathname.split("/").filter(Boolean);
|
|
463
|
+
memoryId = pathParts.length >= 2 ? decodeURIComponent(pathParts[1]) : (request.headers.get("x-memory-id") ?? null);
|
|
464
|
+
}
|
|
465
|
+
const response = await nextLayer(request);
|
|
466
|
+
if (isMemoryWrite && memoryId && response.status >= 200 && response.status < 300) {
|
|
467
|
+
backfillEmbedding(memoryId).catch(() => { });
|
|
468
|
+
}
|
|
469
|
+
return response;
|
|
470
|
+
}, { runFirst: true });
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-process embeddings via harper-fabric-embeddings (v0.2.1+).
|
|
3
|
+
*
|
|
4
|
+
* Uses process-level singleton for cross-thread model sharing.
|
|
5
|
+
* Avoids dynamic imports inside Harper's VM sandbox by using
|
|
6
|
+
* globalThis.__hfe_resolve__ set during module load.
|
|
7
|
+
*/
|
|
8
|
+
const MAX_CHARS = 500;
|
|
9
|
+
const MODELS_DIR = process.env.FLAIR_MODELS_DIR || "/tmp/flair-models";
|
|
10
|
+
const SINGLETON_KEY = "__flair_hfe_021__";
|
|
11
|
+
const QUEUE_KEY = "__flair_embed_queue_021__";
|
|
12
|
+
function getSingleton() {
|
|
13
|
+
if (!process[SINGLETON_KEY]) {
|
|
14
|
+
process[SINGLETON_KEY] = { hfe: null, dims: 0, mode: "none", initPromise: null };
|
|
15
|
+
}
|
|
16
|
+
return process[SINGLETON_KEY];
|
|
17
|
+
}
|
|
18
|
+
function getQueue() {
|
|
19
|
+
if (!process[QUEUE_KEY]) {
|
|
20
|
+
process[QUEUE_KEY] = { queue: [], processing: false };
|
|
21
|
+
}
|
|
22
|
+
return process[QUEUE_KEY];
|
|
23
|
+
}
|
|
24
|
+
// ─── Init ─────────────────────────────────────────────────────────────────────
|
|
25
|
+
export async function initEmbeddings() {
|
|
26
|
+
const s = getSingleton();
|
|
27
|
+
if (s.mode !== "none")
|
|
28
|
+
return;
|
|
29
|
+
if (s.initPromise) {
|
|
30
|
+
await s.initPromise;
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
s.initPromise = doInit(s);
|
|
34
|
+
await s.initPromise;
|
|
35
|
+
}
|
|
36
|
+
async function doInit(s) {
|
|
37
|
+
// Resolve path at build time relative to this file's location
|
|
38
|
+
const hfePath = new URL("../../node_modules/harper-fabric-embeddings/dist/index.js", import.meta.url).href;
|
|
39
|
+
try {
|
|
40
|
+
// Use globalThis.process to do a native dynamic import outside the
|
|
41
|
+
// VM sandbox's importModuleDynamically interception. The Function
|
|
42
|
+
// constructor creates code in the global scope, not the VM context.
|
|
43
|
+
const importFn = new Function("url", "return import(url)");
|
|
44
|
+
const mod = await importFn(hfePath);
|
|
45
|
+
if (typeof mod.init !== "function") {
|
|
46
|
+
throw new Error(`Module has no init(). Keys: ${Object.keys(mod)}`);
|
|
47
|
+
}
|
|
48
|
+
const result = mod.init({ modelsDir: MODELS_DIR, gpuLayers: 99 });
|
|
49
|
+
if (result?.then)
|
|
50
|
+
await result;
|
|
51
|
+
s.hfe = mod;
|
|
52
|
+
s.dims = mod.dimensions();
|
|
53
|
+
s.mode = "native";
|
|
54
|
+
console.log(`[embeddings] Native in-process (v0.2.1): ${s.dims} dims`);
|
|
55
|
+
}
|
|
56
|
+
catch (err) {
|
|
57
|
+
console.error(`[embeddings] Native load failed: ${err.message}`);
|
|
58
|
+
// Fallback to hash-based embeddings
|
|
59
|
+
try {
|
|
60
|
+
s.dims = 512;
|
|
61
|
+
s.mode = "hash";
|
|
62
|
+
console.log(`[embeddings] Fallback: 512 dims (hash-based)`);
|
|
63
|
+
}
|
|
64
|
+
catch (e2) {
|
|
65
|
+
console.error(`[embeddings] All embedding modes failed: ${e2.message}`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
// ─── Public API ───────────────────────────────────────────────────────────────
|
|
70
|
+
export function getDimensions() { return getSingleton().dims; }
|
|
71
|
+
export function getMode() { return getSingleton().mode; }
|
|
72
|
+
export async function getEmbedding(text) {
|
|
73
|
+
const s = getSingleton();
|
|
74
|
+
if (s.mode === "none")
|
|
75
|
+
await initEmbeddings();
|
|
76
|
+
return new Promise((resolve) => {
|
|
77
|
+
const q = getQueue();
|
|
78
|
+
q.queue.push({ text, resolve });
|
|
79
|
+
processQueue(q);
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
export function getQueueLength() { return getQueue().queue.length; }
|
|
83
|
+
async function processQueue(q) {
|
|
84
|
+
if (q.processing)
|
|
85
|
+
return;
|
|
86
|
+
q.processing = true;
|
|
87
|
+
while (q.queue.length > 0) {
|
|
88
|
+
const job = q.queue.shift();
|
|
89
|
+
try {
|
|
90
|
+
const s = getSingleton();
|
|
91
|
+
if (s.mode === "native" && s.hfe) {
|
|
92
|
+
job.resolve(await s.hfe.embed(job.text.slice(0, MAX_CHARS)));
|
|
93
|
+
}
|
|
94
|
+
else if (s.mode === "hash") {
|
|
95
|
+
// Hash fallback doesn't need the module
|
|
96
|
+
const text = job.text.slice(0, MAX_CHARS);
|
|
97
|
+
job.resolve(fallbackEmbed(text));
|
|
98
|
+
}
|
|
99
|
+
else {
|
|
100
|
+
job.resolve(null);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
catch (err) {
|
|
104
|
+
console.error(`[embeddings] embed failed: ${err.message}`);
|
|
105
|
+
job.resolve(null);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
q.processing = false;
|
|
109
|
+
}
|
|
110
|
+
// ─── Hash Fallback ────────────────────────────────────────────────────────────
|
|
111
|
+
function fallbackEmbed(text) {
|
|
112
|
+
const dims = 512;
|
|
113
|
+
const vec = new Array(dims).fill(0);
|
|
114
|
+
for (let i = 0; i < text.length; i++) {
|
|
115
|
+
const code = text.charCodeAt(i);
|
|
116
|
+
vec[i % dims] += code / 128;
|
|
117
|
+
vec[(i * 7 + 3) % dims] += Math.sin(code * 0.1) * 0.5;
|
|
118
|
+
}
|
|
119
|
+
// Normalize
|
|
120
|
+
let mag = 0;
|
|
121
|
+
for (let i = 0; i < dims; i++)
|
|
122
|
+
mag += vec[i] * vec[i];
|
|
123
|
+
mag = Math.sqrt(mag) || 1;
|
|
124
|
+
for (let i = 0; i < dims; i++)
|
|
125
|
+
vec[i] /= mag;
|
|
126
|
+
return vec;
|
|
127
|
+
}
|