@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.
@@ -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
- const record = await this.get(id);
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) {
@@ -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 = [authAgent];
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
- const record = await this.get(id);
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
- const record = await this.get(id);
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
@@ -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
- const record = await this.get(id);
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.18.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",