@tpsdev-ai/flair 0.18.0 → 0.19.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/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 +8 -0
- package/dist/resources/AdminPrincipals.js +6 -0
- package/dist/resources/Integration.js +52 -2
- package/dist/resources/Memory.js +92 -13
- package/dist/resources/MemoryGrant.js +55 -2
- package/dist/resources/Relationship.js +49 -1
- package/dist/resources/Soul.js +12 -1
- package/dist/resources/WorkspaceState.js +56 -2
- package/package.json +1 -1
package/dist/resources/Admin.js
CHANGED
|
@@ -1,12 +1,24 @@
|
|
|
1
1
|
import { Resource } from "@harperfast/harper";
|
|
2
|
+
import { allowAdmin } from "./agent-auth.js";
|
|
2
3
|
/**
|
|
3
4
|
* GET /Admin — friendly redirect to /AdminDashboard.
|
|
4
5
|
*
|
|
5
6
|
* Operators bookmark or type the bare /Admin path. Without this resource
|
|
6
7
|
* they hit a 404 and assume the admin UI is broken. The dashboard is the
|
|
7
8
|
* canonical landing surface; redirect there.
|
|
9
|
+
*
|
|
10
|
+
* allowRead()=allowAdmin (ops-oox7 defense-in-depth): the /Admin* pathname
|
|
11
|
+
* gate in auth-middleware.ts only 401s when there's NO Authorization header
|
|
12
|
+
* at all (or Basic creds don't resolve to a real user) — a validly-verified
|
|
13
|
+
* TPS-Ed25519 agent that is NOT an admin, or a valid-but-non-super_user Basic
|
|
14
|
+
* user, currently sails through the middleware with no admin resource-level
|
|
15
|
+
* check at all. allowRead closes that gap the same way WorkspaceLatest.ts /
|
|
16
|
+
* MemoryReindex.ts already gate their own custom (non-@table) Resources.
|
|
8
17
|
*/
|
|
9
18
|
export class Admin extends Resource {
|
|
19
|
+
async allowRead() {
|
|
20
|
+
return allowAdmin(this.getContext?.());
|
|
21
|
+
}
|
|
10
22
|
async get() {
|
|
11
23
|
return new Response("", {
|
|
12
24
|
status: 302,
|
|
@@ -1,9 +1,15 @@
|
|
|
1
1
|
import { Resource, databases } from "@harperfast/harper";
|
|
2
2
|
import { layout, htmlResponse, esc } from "./admin-layout.js";
|
|
3
|
+
import { allowAdmin } from "./agent-auth.js";
|
|
3
4
|
/**
|
|
4
5
|
* GET /AdminConnectors — OAuth clients and active sessions.
|
|
6
|
+
*
|
|
7
|
+
* allowRead()=allowAdmin (ops-oox7 defense-in-depth): see Admin.ts.
|
|
5
8
|
*/
|
|
6
9
|
export class AdminConnectors extends Resource {
|
|
10
|
+
async allowRead() {
|
|
11
|
+
return allowAdmin(this.getContext?.());
|
|
12
|
+
}
|
|
7
13
|
async get() {
|
|
8
14
|
const clients = [];
|
|
9
15
|
try {
|
|
@@ -1,9 +1,18 @@
|
|
|
1
1
|
import { Resource, databases } from "@harperfast/harper";
|
|
2
2
|
import { layout, htmlResponse } from "./admin-layout.js";
|
|
3
|
+
import { allowAdmin } from "./agent-auth.js";
|
|
3
4
|
/**
|
|
4
5
|
* GET /AdminDashboard — admin home page with system overview.
|
|
6
|
+
*
|
|
7
|
+
* allowRead()=allowAdmin (ops-oox7 defense-in-depth): see Admin.ts for why
|
|
8
|
+
* this closes a real gap, not just belt-and-suspenders — a verified
|
|
9
|
+
* non-admin agent could otherwise read full instance stats (principal/agent/
|
|
10
|
+
* memory/relationship counts) with no admin check at all.
|
|
5
11
|
*/
|
|
6
12
|
export class AdminDashboard extends Resource {
|
|
13
|
+
async allowRead() {
|
|
14
|
+
return allowAdmin(this.getContext?.());
|
|
15
|
+
}
|
|
7
16
|
async get() {
|
|
8
17
|
let principalCount = 0;
|
|
9
18
|
let memoryCount = 0;
|
|
@@ -1,9 +1,15 @@
|
|
|
1
1
|
import { Resource, databases } from "@harperfast/harper";
|
|
2
2
|
import { layout, htmlResponse, esc } from "./admin-layout.js";
|
|
3
|
+
import { allowAdmin } from "./agent-auth.js";
|
|
3
4
|
/**
|
|
4
5
|
* GET /AdminIdp — enterprise IdP configuration management.
|
|
6
|
+
*
|
|
7
|
+
* allowRead()=allowAdmin (ops-oox7 defense-in-depth): see Admin.ts.
|
|
5
8
|
*/
|
|
6
9
|
export class AdminIdp extends Resource {
|
|
10
|
+
async allowRead() {
|
|
11
|
+
return allowAdmin(this.getContext?.());
|
|
12
|
+
}
|
|
7
13
|
async get() {
|
|
8
14
|
const idps = [];
|
|
9
15
|
try {
|
|
@@ -4,6 +4,7 @@ import { existsSync, readFileSync } from "node:fs";
|
|
|
4
4
|
import { join, dirname } from "node:path";
|
|
5
5
|
import { homedir } from "node:os";
|
|
6
6
|
import { fileURLToPath } from "node:url";
|
|
7
|
+
import { allowAdmin } from "./agent-auth.js";
|
|
7
8
|
/**
|
|
8
9
|
* Resolve the public URL operators reach this Flair on.
|
|
9
10
|
*
|
|
@@ -83,8 +84,13 @@ function resolveVersion() {
|
|
|
83
84
|
}
|
|
84
85
|
/**
|
|
85
86
|
* GET /AdminInstance — instance info, public key, version.
|
|
87
|
+
*
|
|
88
|
+
* allowRead()=allowAdmin (ops-oox7 defense-in-depth): see Admin.ts.
|
|
86
89
|
*/
|
|
87
90
|
export class AdminInstance extends Resource {
|
|
91
|
+
async allowRead() {
|
|
92
|
+
return allowAdmin(this.getContext?.());
|
|
93
|
+
}
|
|
88
94
|
async get() {
|
|
89
95
|
const version = resolveVersion();
|
|
90
96
|
// Pass the request through so URL resolution can derive from
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Resource, databases } from "@harperfast/harper";
|
|
2
2
|
import { layout, htmlResponse, esc } from "./admin-layout.js";
|
|
3
|
+
import { allowAdmin } from "./agent-auth.js";
|
|
3
4
|
/**
|
|
4
5
|
* GET /AdminMemory browse + search memories (list view)
|
|
5
6
|
* GET /AdminMemory?id=<id> per-memory detail view with full provenance pane
|
|
@@ -19,8 +20,15 @@ import { layout, htmlResponse, esc } from "./admin-layout.js";
|
|
|
19
20
|
* "memory that follows the agent across orchestrators" — the provenance pane
|
|
20
21
|
* makes that legible: every memory shows where it came from, what it derived
|
|
21
22
|
* from, what it superseded, and which peer it synced from.
|
|
23
|
+
*
|
|
24
|
+
* allowRead()=allowAdmin (ops-oox7 defense-in-depth): see Admin.ts — this
|
|
25
|
+
* page surfaces full memory content across ALL agents, so the gap it closes
|
|
26
|
+
* matters more here than on the other Admin* pages.
|
|
22
27
|
*/
|
|
23
28
|
export class AdminMemory extends Resource {
|
|
29
|
+
async allowRead() {
|
|
30
|
+
return allowAdmin(this.getContext?.());
|
|
31
|
+
}
|
|
24
32
|
async get() {
|
|
25
33
|
// Harper v5 does not populate this.request on Resource subclasses —
|
|
26
34
|
// getContext() is the only reliable path (ops-sal4: the previous
|
|
@@ -1,9 +1,15 @@
|
|
|
1
1
|
import { Resource, databases } from "@harperfast/harper";
|
|
2
2
|
import { layout, htmlResponse, esc } from "./admin-layout.js";
|
|
3
|
+
import { allowAdmin } from "./agent-auth.js";
|
|
3
4
|
/**
|
|
4
5
|
* GET /AdminPrincipals — list all principals with kind, trust, status.
|
|
6
|
+
*
|
|
7
|
+
* allowRead()=allowAdmin (ops-oox7 defense-in-depth): see Admin.ts.
|
|
5
8
|
*/
|
|
6
9
|
export class AdminPrincipals extends Resource {
|
|
10
|
+
async allowRead() {
|
|
11
|
+
return allowAdmin(this.getContext?.());
|
|
12
|
+
}
|
|
7
13
|
async get() {
|
|
8
14
|
const principals = [];
|
|
9
15
|
try {
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { databases } from "@harperfast/harper";
|
|
2
|
-
import { resolveAgentAuth } from "./agent-auth.js";
|
|
2
|
+
import { resolveAgentAuth, allowVerified } from "./agent-auth.js";
|
|
3
3
|
const FORBIDDEN = (msg) => new Response(JSON.stringify({ error: msg }), { status: 403, headers: { "Content-Type": "application/json" } });
|
|
4
4
|
const UNAUTH = () => new Response(JSON.stringify({ error: "authentication required" }), { status: 401, headers: { "Content-Type": "application/json" } });
|
|
5
|
+
const NOT_FOUND = () => new Response(JSON.stringify({ error: "not found" }), { status: 404, headers: { "Content-Type": "application/json" } });
|
|
5
6
|
/**
|
|
6
7
|
* Integration records are agent-owned. Auth: the non-rejecting gate annotates the
|
|
7
8
|
* request; this resource self-enforces (resolveAgentAuth → internal/agent/anonymous).
|
|
@@ -12,6 +13,50 @@ export class Integration extends databases.flair.Integration {
|
|
|
12
13
|
_auth() {
|
|
13
14
|
return resolveAgentAuth(this.getContext?.());
|
|
14
15
|
}
|
|
16
|
+
/**
|
|
17
|
+
* Self-authorize now that the global gate is non-rejecting (memory-soul-
|
|
18
|
+
* read-gate family fix, ops-oox7 — same pattern as Memory.ts/Soul.ts/
|
|
19
|
+
* WorkspaceState.ts/Relationship.ts). Closes the same P0 leak: Harper
|
|
20
|
+
* routes `GET /Integration/<id>` to get() and the collection describe
|
|
21
|
+
* (`GET /Integration`) outside search(), so neither was gated before this
|
|
22
|
+
* fix — an anonymous caller got a 200 with full record content. Per-record
|
|
23
|
+
* ownership scoping happens in get() below; the collection scope is still
|
|
24
|
+
* in search().
|
|
25
|
+
*/
|
|
26
|
+
allowRead() { return allowVerified(this.getContext?.()); }
|
|
27
|
+
/**
|
|
28
|
+
* Override get() to scope by-id reads the same way search() scopes
|
|
29
|
+
* collection reads (memory-soul-read-gate family fix). Never distinguishes
|
|
30
|
+
* "doesn't exist" from "exists but not yours" — both return 404, never
|
|
31
|
+
* 403, so a denied caller can't use get() to enumerate other agents'
|
|
32
|
+
* integration ids.
|
|
33
|
+
*/
|
|
34
|
+
async get(target) {
|
|
35
|
+
// Collection / query reads arrive as a RequestTarget with
|
|
36
|
+
// `isCollection === true`, and are governed by search() (same owner
|
|
37
|
+
// scoping). Only a genuine by-id get is ownership-checked below — see
|
|
38
|
+
// Memory.ts's get() for the full rationale (same bug class).
|
|
39
|
+
if (!target || (typeof target === "object" && target.isCollection)) {
|
|
40
|
+
return this.search(target);
|
|
41
|
+
}
|
|
42
|
+
const auth = await this._auth();
|
|
43
|
+
// Anonymous by-id read is already blocked at the allowRead() gate (403);
|
|
44
|
+
// this is defense-in-depth if get() is ever reached directly.
|
|
45
|
+
if (auth.kind === "anonymous") {
|
|
46
|
+
return NOT_FOUND();
|
|
47
|
+
}
|
|
48
|
+
// Trusted internal call or admin agent — unfiltered, unchanged behavior.
|
|
49
|
+
if (auth.kind === "internal" || (auth.kind === "agent" && auth.isAdmin)) {
|
|
50
|
+
return super.get(target);
|
|
51
|
+
}
|
|
52
|
+
// Non-admin agent: only its own integrations.
|
|
53
|
+
const record = await super.get(target);
|
|
54
|
+
if (!record)
|
|
55
|
+
return NOT_FOUND();
|
|
56
|
+
if (record.agentId !== auth.agentId)
|
|
57
|
+
return NOT_FOUND();
|
|
58
|
+
return record;
|
|
59
|
+
}
|
|
15
60
|
async search(query) {
|
|
16
61
|
const auth = await this._auth();
|
|
17
62
|
if (auth.kind === "anonymous")
|
|
@@ -66,7 +111,12 @@ export class Integration extends databases.flair.Integration {
|
|
|
66
111
|
if (auth.kind === "internal" || (auth.kind === "agent" && auth.isAdmin)) {
|
|
67
112
|
return super.delete(id);
|
|
68
113
|
}
|
|
69
|
-
|
|
114
|
+
// Use super.get(id), NOT this.get(id): the new get() override above 404s
|
|
115
|
+
// (a truthy Response) for a non-owner id, which would otherwise defeat
|
|
116
|
+
// the `if (!record)` check below and mis-route a genuinely-missing
|
|
117
|
+
// record into the FORBIDDEN branch instead of a clean super.delete(id)
|
|
118
|
+
// no-op. Mirrors Memory.ts's delete() — same rationale, same fix.
|
|
119
|
+
const record = await super.get(id);
|
|
70
120
|
if (!record)
|
|
71
121
|
return super.delete(id);
|
|
72
122
|
if (record.agentId !== auth.agentId) {
|
package/dist/resources/Memory.js
CHANGED
|
@@ -1,12 +1,34 @@
|
|
|
1
1
|
import { databases } from "@harperfast/harper";
|
|
2
2
|
import { patchRecord, withDetachedTxn } from "./table-helpers.js";
|
|
3
|
-
import { isAdmin, resolveAgentAuth } from "./agent-auth.js";
|
|
3
|
+
import { isAdmin, resolveAgentAuth, allowVerified } from "./agent-auth.js";
|
|
4
4
|
import { getEmbedding, getModelId } from "./embeddings-provider.js";
|
|
5
5
|
import { scanFields, isStrictMode } from "./content-safety.js";
|
|
6
6
|
import { checkRateLimit, rateLimitResponse } from "./rate-limiter.js";
|
|
7
7
|
import { DEDUP_COSINE_THRESHOLD_DEFAULT, DEDUP_LEXICAL_THRESHOLD_DEFAULT, DEDUP_MIN_CONTENT_LENGTH, computeMatchConfidence, isConservativeMatch, } from "./dedup.js";
|
|
8
8
|
const FORBIDDEN = (msg) => new Response(JSON.stringify({ error: msg }), { status: 403, headers: { "Content-Type": "application/json" } });
|
|
9
9
|
const UNAUTH = () => new Response(JSON.stringify({ error: "authentication required" }), { status: 401, headers: { "Content-Type": "application/json" } });
|
|
10
|
+
const NOT_FOUND = () => new Response(JSON.stringify({ error: "not found" }), { status: 404, headers: { "Content-Type": "application/json" } });
|
|
11
|
+
/**
|
|
12
|
+
* Owner ids a non-admin agent may READ: itself, plus any owner who has
|
|
13
|
+
* granted it a "read" or "search" scoped MemoryGrant. Shared by search()
|
|
14
|
+
* (collection scoping) and get() (per-id ownership check, memory-soul-read-
|
|
15
|
+
* gate fix) so the two paths cannot drift apart — this was previously
|
|
16
|
+
* computed inline only inside search().
|
|
17
|
+
*/
|
|
18
|
+
async function resolveAllowedOwners(authAgentId) {
|
|
19
|
+
const allowedOwners = [authAgentId];
|
|
20
|
+
try {
|
|
21
|
+
for await (const grant of databases.flair.MemoryGrant.search({
|
|
22
|
+
conditions: [{ attribute: "granteeId", comparator: "equals", value: authAgentId }],
|
|
23
|
+
})) {
|
|
24
|
+
if (grant.ownerId && (grant.scope === "read" || grant.scope === "search")) {
|
|
25
|
+
allowedOwners.push(grant.ownerId);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
catch { /* MemoryGrant table not yet populated — ignore */ }
|
|
30
|
+
return allowedOwners;
|
|
31
|
+
}
|
|
10
32
|
/**
|
|
11
33
|
* ─── Server-side conservative-duplicate gate (memory-integrity fix) ──────────
|
|
12
34
|
*
|
|
@@ -231,6 +253,65 @@ async function closeSupersededIfNeeded(ctx, content, methodLabel) {
|
|
|
231
253
|
}
|
|
232
254
|
}
|
|
233
255
|
export class Memory extends databases.flair.Memory {
|
|
256
|
+
/**
|
|
257
|
+
* Self-authorize now that the global gate is non-rejecting. Closes the P0
|
|
258
|
+
* leak: Harper routes `GET /Memory/<id>` to get() and the collection
|
|
259
|
+
* describe (`GET /Memory`) to a path outside search() — neither was gated
|
|
260
|
+
* before this fix, so an anonymous caller got a 200 with full record
|
|
261
|
+
* content / schema even though search() (and the write paths) correctly
|
|
262
|
+
* 401/403'd. Per-record ownership/grant scoping happens in get() below;
|
|
263
|
+
* the collection scope is still in search().
|
|
264
|
+
*
|
|
265
|
+
* allowCreate/allowUpdate/allowDelete are deliberately NOT added here:
|
|
266
|
+
* post()/put()/delete() already self-enforce per-agent ownership inline
|
|
267
|
+
* (resolveAgentAuth + explicit agentId checks in post()/put(), and the
|
|
268
|
+
* isAdmin durability check in delete()). Adding allow* on top of that,
|
|
269
|
+
* unverified, risks regressing owner writes/deletes on a P0 security fix
|
|
270
|
+
* that is scoped to the read leak — left as-is on purpose.
|
|
271
|
+
*/
|
|
272
|
+
allowRead() { return allowVerified(this.getContext?.()); }
|
|
273
|
+
/**
|
|
274
|
+
* Override get() to scope by-id reads the same way search() scopes
|
|
275
|
+
* collection reads (memory-soul-read-gate fix). Never distinguishes
|
|
276
|
+
* "doesn't exist" from "exists but not yours" — both return 404, never
|
|
277
|
+
* 403, so a denied caller can't use get() to enumerate other agents'
|
|
278
|
+
* memory ids.
|
|
279
|
+
*/
|
|
280
|
+
async get(target) {
|
|
281
|
+
// Collection / query reads — the `GET /Memory/?<query>` form and the bare
|
|
282
|
+
// collection — arrive as a RequestTarget with `isCollection === true`, and
|
|
283
|
+
// are governed by search() (same owner/grant scoping). Only a genuine by-id
|
|
284
|
+
// get is ownership-checked below. Without this guard, get() would receive
|
|
285
|
+
// the query's RequestTarget, super.get() would return the (truthy) result
|
|
286
|
+
// set, the single-record check would find no `.agentId` on it, and a valid
|
|
287
|
+
// authenticated self-query would 404 (regression caught by the auth-
|
|
288
|
+
// middleware e2e "TPS-Ed25519 on GET /Memory/?agentId=X → 200"). A by-id
|
|
289
|
+
// get (RequestTarget with isCollection false, or a bare id) falls through.
|
|
290
|
+
if (!target || (typeof target === "object" && target.isCollection)) {
|
|
291
|
+
return this.search(target);
|
|
292
|
+
}
|
|
293
|
+
const ctx = this.getContext?.();
|
|
294
|
+
const auth = await resolveAgentAuth(ctx);
|
|
295
|
+
// Anonymous by-id read is already blocked at the allowRead() gate (403);
|
|
296
|
+
// this is defense-in-depth if get() is ever reached directly.
|
|
297
|
+
if (auth.kind === "anonymous") {
|
|
298
|
+
return NOT_FOUND();
|
|
299
|
+
}
|
|
300
|
+
// Trusted internal call or admin agent — unfiltered, unchanged behavior.
|
|
301
|
+
if (auth.kind === "internal" || (auth.kind === "agent" && auth.isAdmin)) {
|
|
302
|
+
return super.get(target);
|
|
303
|
+
}
|
|
304
|
+
// Non-admin agent: only its own memories, or an owner's memories it holds
|
|
305
|
+
// a read/search MemoryGrant for (same owner-set as search() — shared
|
|
306
|
+
// resolveAllowedOwners helper so the two paths cannot drift).
|
|
307
|
+
const record = await super.get(target);
|
|
308
|
+
if (!record)
|
|
309
|
+
return NOT_FOUND();
|
|
310
|
+
const allowedOwners = await resolveAllowedOwners(auth.agentId);
|
|
311
|
+
if (!allowedOwners.includes(record.agentId))
|
|
312
|
+
return NOT_FOUND();
|
|
313
|
+
return record;
|
|
314
|
+
}
|
|
234
315
|
/**
|
|
235
316
|
* Override search() to scope collection GETs by authenticated agent.
|
|
236
317
|
*
|
|
@@ -258,17 +339,7 @@ export class Memory extends databases.flair.Memory {
|
|
|
258
339
|
}
|
|
259
340
|
// Non-admin agent: scope to own + granted owners.
|
|
260
341
|
const authAgent = auth.agentId;
|
|
261
|
-
const allowedOwners =
|
|
262
|
-
try {
|
|
263
|
-
for await (const grant of databases.flair.MemoryGrant.search({
|
|
264
|
-
conditions: [{ attribute: "granteeId", comparator: "equals", value: authAgent }],
|
|
265
|
-
})) {
|
|
266
|
-
if (grant.ownerId && (grant.scope === "read" || grant.scope === "search")) {
|
|
267
|
-
allowedOwners.push(grant.ownerId);
|
|
268
|
-
}
|
|
269
|
-
}
|
|
270
|
-
}
|
|
271
|
-
catch { /* MemoryGrant table not yet populated — ignore */ }
|
|
342
|
+
const allowedOwners = await resolveAllowedOwners(authAgent);
|
|
272
343
|
// Build the agentId scope condition
|
|
273
344
|
const agentIdCondition = allowedOwners.length === 1
|
|
274
345
|
? { attribute: "agentId", comparator: "equals", value: allowedOwners[0] }
|
|
@@ -523,7 +594,15 @@ export class Memory extends databases.flair.Memory {
|
|
|
523
594
|
return buildWriteResponse(content, result, dedupMatch);
|
|
524
595
|
}
|
|
525
596
|
async delete(id) {
|
|
526
|
-
|
|
597
|
+
// Use super.get(id), NOT this.get(id): the new get() override above 404s
|
|
598
|
+
// (a truthy Response) for a non-owner/non-granted id, which would
|
|
599
|
+
// otherwise short-circuit the `record.durability === "permanent"` check
|
|
600
|
+
// below (a Response has no .durability) and silently bypass the
|
|
601
|
+
// admin-only permanent-delete guard for cross-agent deletes. This keeps
|
|
602
|
+
// delete()'s own pre-existing ownership/admin logic exactly as it was
|
|
603
|
+
// before the read-gate fix — the read-scoping override must not leak
|
|
604
|
+
// into delete()'s internal record lookup.
|
|
605
|
+
const record = await super.get(id);
|
|
527
606
|
if (!record)
|
|
528
607
|
return super.delete(id);
|
|
529
608
|
if (record.durability === "permanent") {
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { databases } from "@harperfast/harper";
|
|
2
|
-
import { resolveAgentAuth } from "./agent-auth.js";
|
|
2
|
+
import { resolveAgentAuth, allowVerified } from "./agent-auth.js";
|
|
3
3
|
const FORBIDDEN = (msg) => new Response(JSON.stringify({ error: msg }), { status: 403, headers: { "Content-Type": "application/json" } });
|
|
4
4
|
const UNAUTH = () => new Response(JSON.stringify({ error: "authentication required" }), { status: 401, headers: { "Content-Type": "application/json" } });
|
|
5
|
+
const NOT_FOUND = () => new Response(JSON.stringify({ error: "not found" }), { status: 404, headers: { "Content-Type": "application/json" } });
|
|
5
6
|
/**
|
|
6
7
|
* MemoryGrant — an agent (ownerId) grants another (granteeId) scoped access to its
|
|
7
8
|
* memories. Self-authorizes now that the global gate is non-rejecting (previously a
|
|
@@ -16,6 +17,52 @@ export class MemoryGrant extends databases.flair.MemoryGrant {
|
|
|
16
17
|
_auth() {
|
|
17
18
|
return resolveAgentAuth(this.getContext?.());
|
|
18
19
|
}
|
|
20
|
+
/**
|
|
21
|
+
* Self-authorize now that the global gate is non-rejecting (memory-soul-
|
|
22
|
+
* read-gate family fix, ops-oox7 — same pattern as Memory.ts/Soul.ts/
|
|
23
|
+
* WorkspaceState.ts/Relationship.ts/Integration.ts). Closes the same P0
|
|
24
|
+
* leak: Harper routes `GET /MemoryGrant/<id>` to get() and the collection
|
|
25
|
+
* describe (`GET /MemoryGrant`) outside search(), so neither was gated
|
|
26
|
+
* before this fix — an anonymous caller got a 200 with full grant content.
|
|
27
|
+
* Per-record owner/grantee scoping happens in get() below; the collection
|
|
28
|
+
* scope is still in search().
|
|
29
|
+
*/
|
|
30
|
+
allowRead() { return allowVerified(this.getContext?.()); }
|
|
31
|
+
/**
|
|
32
|
+
* Override get() to scope by-id reads the same way search() scopes
|
|
33
|
+
* collection reads (memory-soul-read-gate family fix). A grant is visible
|
|
34
|
+
* to either party (ownerId OR granteeId), mirroring search()'s owner-OR-
|
|
35
|
+
* grantee scope. Never distinguishes "doesn't exist" from "exists but not
|
|
36
|
+
* yours" — both return 404, never 403, so a denied caller can't use get()
|
|
37
|
+
* to enumerate other agents' grant ids.
|
|
38
|
+
*/
|
|
39
|
+
async get(target) {
|
|
40
|
+
// Collection / query reads arrive as a RequestTarget with
|
|
41
|
+
// `isCollection === true`, and are governed by search() (same owner/
|
|
42
|
+
// grantee scoping). Only a genuine by-id get is ownership-checked below
|
|
43
|
+
// — see Memory.ts's get() for the full rationale (same bug class).
|
|
44
|
+
if (!target || (typeof target === "object" && target.isCollection)) {
|
|
45
|
+
return this.search(target);
|
|
46
|
+
}
|
|
47
|
+
const auth = await this._auth();
|
|
48
|
+
// Anonymous by-id read is already blocked at the allowRead() gate (403);
|
|
49
|
+
// this is defense-in-depth if get() is ever reached directly.
|
|
50
|
+
if (auth.kind === "anonymous") {
|
|
51
|
+
return NOT_FOUND();
|
|
52
|
+
}
|
|
53
|
+
// Trusted internal call or admin agent — unfiltered, unchanged behavior.
|
|
54
|
+
if (auth.kind === "internal" || (auth.kind === "agent" && auth.isAdmin)) {
|
|
55
|
+
return super.get(target);
|
|
56
|
+
}
|
|
57
|
+
// Non-admin agent: visible if it's the owner OR the grantee (parity with
|
|
58
|
+
// search()'s owner-OR-grantee scope).
|
|
59
|
+
const record = await super.get(target);
|
|
60
|
+
if (!record)
|
|
61
|
+
return NOT_FOUND();
|
|
62
|
+
if (record.ownerId !== auth.agentId && record.granteeId !== auth.agentId)
|
|
63
|
+
return NOT_FOUND();
|
|
64
|
+
return record;
|
|
65
|
+
}
|
|
19
66
|
async search(query) {
|
|
20
67
|
const auth = await this._auth();
|
|
21
68
|
if (auth.kind === "anonymous")
|
|
@@ -59,7 +106,13 @@ export class MemoryGrant extends databases.flair.MemoryGrant {
|
|
|
59
106
|
if (auth.kind === "internal" || (auth.kind === "agent" && auth.isAdmin)) {
|
|
60
107
|
return super.delete(id, context);
|
|
61
108
|
}
|
|
62
|
-
|
|
109
|
+
// Use super.get(id), NOT this.get(id): the new get() override above 404s
|
|
110
|
+
// (a truthy Response) for a non-owner/non-grantee id, which would
|
|
111
|
+
// otherwise defeat the `if (!record)` check below and mis-route a
|
|
112
|
+
// genuinely-missing record into the FORBIDDEN branch instead of a clean
|
|
113
|
+
// super.delete(id, context) no-op. Mirrors Memory.ts's delete() — same
|
|
114
|
+
// rationale, same fix.
|
|
115
|
+
const record = await super.get(id);
|
|
63
116
|
if (!record)
|
|
64
117
|
return super.delete(id, context);
|
|
65
118
|
if (record.ownerId !== auth.agentId) {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { databases } from "@harperfast/harper";
|
|
2
|
-
import { resolveAgentAuth } from "./agent-auth.js";
|
|
2
|
+
import { resolveAgentAuth, allowVerified } from "./agent-auth.js";
|
|
3
3
|
import { checkRateLimit, rateLimitResponse } from "./rate-limiter.js";
|
|
4
|
+
const NOT_FOUND = () => new Response(JSON.stringify({ error: "not found" }), { status: 404, headers: { "Content-Type": "application/json" } });
|
|
4
5
|
/**
|
|
5
6
|
* Relationship resource — entity-to-entity relationships with temporal validity.
|
|
6
7
|
*
|
|
@@ -13,6 +14,53 @@ import { checkRateLimit, rateLimitResponse } from "./rate-limiter.js";
|
|
|
13
14
|
* Admin agents can query across all agents.
|
|
14
15
|
*/
|
|
15
16
|
export class Relationship extends databases.flair.Relationship {
|
|
17
|
+
/**
|
|
18
|
+
* Self-authorize now that the global gate is non-rejecting (memory-soul-
|
|
19
|
+
* read-gate family fix, ops-oox7 — same pattern as Memory.ts/Soul.ts/
|
|
20
|
+
* WorkspaceState.ts). Closes the same P0 leak: Harper routes
|
|
21
|
+
* `GET /Relationship/<id>` to get() and the collection describe
|
|
22
|
+
* (`GET /Relationship`) outside search(), so neither was gated before this
|
|
23
|
+
* fix — an anonymous caller got a 200 with full record content. Per-record
|
|
24
|
+
* ownership scoping happens in get() below; the collection scope is still
|
|
25
|
+
* in search().
|
|
26
|
+
*/
|
|
27
|
+
allowRead() { return allowVerified(this.getContext?.()); }
|
|
28
|
+
/**
|
|
29
|
+
* Override get() to scope by-id reads the same way search() scopes
|
|
30
|
+
* collection reads (memory-soul-read-gate family fix). Never distinguishes
|
|
31
|
+
* "doesn't exist" from "exists but not yours" — both return 404, never
|
|
32
|
+
* 403, so a denied caller can't use get() to enumerate other agents'
|
|
33
|
+
* relationship ids.
|
|
34
|
+
*/
|
|
35
|
+
async get(target) {
|
|
36
|
+
// Collection / query reads arrive as a RequestTarget with
|
|
37
|
+
// `isCollection === true`, and are governed by search() (same owner
|
|
38
|
+
// scoping). Only a genuine by-id get is ownership-checked below — see
|
|
39
|
+
// Memory.ts's get() for the full rationale (same bug class: without this
|
|
40
|
+
// guard, a query's RequestTarget would flow into super.get(), return the
|
|
41
|
+
// whole result set, and the single-record ownership check below would
|
|
42
|
+
// find no `.agentId` on it).
|
|
43
|
+
if (!target || (typeof target === "object" && target.isCollection)) {
|
|
44
|
+
return this.search(target);
|
|
45
|
+
}
|
|
46
|
+
const auth = await resolveAgentAuth(this.getContext?.());
|
|
47
|
+
// Anonymous by-id read is already blocked at the allowRead() gate (403);
|
|
48
|
+
// this is defense-in-depth if get() is ever reached directly.
|
|
49
|
+
if (auth.kind === "anonymous") {
|
|
50
|
+
return NOT_FOUND();
|
|
51
|
+
}
|
|
52
|
+
// Trusted internal call or admin agent — unfiltered, unchanged behavior.
|
|
53
|
+
if (auth.kind === "internal" || (auth.kind === "agent" && auth.isAdmin)) {
|
|
54
|
+
return super.get(target);
|
|
55
|
+
}
|
|
56
|
+
// Non-admin agent: only its own relationships.
|
|
57
|
+
const record = await super.get(target);
|
|
58
|
+
if (!record)
|
|
59
|
+
return NOT_FOUND();
|
|
60
|
+
if (record.agentId !== auth.agentId)
|
|
61
|
+
return NOT_FOUND();
|
|
62
|
+
return record;
|
|
63
|
+
}
|
|
16
64
|
async search(query) {
|
|
17
65
|
const auth = await resolveAgentAuth(this.getContext?.());
|
|
18
66
|
// Anonymous HTTP must NOT read relationships (previously `!authAgent` was
|
package/dist/resources/Soul.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { databases } from "@harperfast/harper";
|
|
2
|
-
import { resolveAgentAuth } from "./agent-auth.js";
|
|
2
|
+
import { resolveAgentAuth, allowVerified } from "./agent-auth.js";
|
|
3
3
|
const FORBIDDEN = (msg) => new Response(JSON.stringify({ error: msg }), { status: 403, headers: { "Content-Type": "application/json" } });
|
|
4
4
|
const UNAUTH = () => new Response(JSON.stringify({ error: "authentication required" }), { status: 401, headers: { "Content-Type": "application/json" } });
|
|
5
5
|
/**
|
|
@@ -19,6 +19,17 @@ async function enforceWriteAuth(self, data) {
|
|
|
19
19
|
return null;
|
|
20
20
|
}
|
|
21
21
|
export class Soul extends databases.flair.Soul {
|
|
22
|
+
/**
|
|
23
|
+
* Self-authorize now that the global gate is non-rejecting. Closes the P0
|
|
24
|
+
* leak: Harper routes `GET /Soul/<id>` to get() and the collection
|
|
25
|
+
* describe (`GET /Soul`) to a path outside search()/allow* — neither was
|
|
26
|
+
* gated before this fix, so an anonymous caller got a 200 with full soul
|
|
27
|
+
* content. Deliberately NO get() override / per-agent scoping on top of
|
|
28
|
+
* this: souls are identity/discovery data, intentionally readable by any
|
|
29
|
+
* verified agent — same posture as Agent.ts's allowRead. Write ownership
|
|
30
|
+
* is unaffected — enforceWriteAuth() below already gates post()/put().
|
|
31
|
+
*/
|
|
32
|
+
allowRead() { return allowVerified(this.getContext?.()); }
|
|
22
33
|
async post(content, context) {
|
|
23
34
|
const denied = await enforceWriteAuth(this, content);
|
|
24
35
|
if (denied)
|
|
@@ -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) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tpsdev-ai/flair",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.19.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",
|