@tpsdev-ai/flair 0.11.0 → 0.12.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.
Files changed (36) hide show
  1. package/dist/cli.js +199 -16
  2. package/dist/resources/Agent.js +20 -7
  3. package/dist/resources/AgentCard.js +6 -0
  4. package/dist/resources/AgentSeed.js +7 -1
  5. package/dist/resources/Credential.js +24 -13
  6. package/dist/resources/IngestEvents.js +7 -0
  7. package/dist/resources/Instance.js +13 -0
  8. package/dist/resources/Integration.js +64 -0
  9. package/dist/resources/Memory.js +53 -8
  10. package/dist/resources/MemoryBootstrap.js +8 -0
  11. package/dist/resources/MemoryConsolidate.js +7 -1
  12. package/dist/resources/MemoryFeed.js +8 -0
  13. package/dist/resources/MemoryGrant.js +79 -0
  14. package/dist/resources/MemoryMaintenance.js +1 -1
  15. package/dist/resources/MemoryReflect.js +7 -1
  16. package/dist/resources/MemoryReindex.js +7 -1
  17. package/dist/resources/OAuthClient.js +15 -0
  18. package/dist/resources/ObsAgentSnapshot.js +13 -0
  19. package/dist/resources/ObsEventFeed.js +13 -0
  20. package/dist/resources/ObsOffice.js +19 -0
  21. package/dist/resources/OrgEvent.js +37 -21
  22. package/dist/resources/OrgEventCatchup.js +8 -0
  23. package/dist/resources/OrgEventMaintenance.js +7 -0
  24. package/dist/resources/PairingToken.js +14 -0
  25. package/dist/resources/Peer.js +15 -0
  26. package/dist/resources/Presence.js +30 -0
  27. package/dist/resources/Relationship.js +13 -7
  28. package/dist/resources/SemanticSearch.js +25 -8
  29. package/dist/resources/Soul.js +18 -9
  30. package/dist/resources/SoulFeed.js +7 -0
  31. package/dist/resources/WorkspaceLatest.js +7 -0
  32. package/dist/resources/WorkspaceState.js +38 -28
  33. package/dist/resources/XAA.js +8 -0
  34. package/dist/resources/agent-auth.js +209 -0
  35. package/dist/resources/auth-middleware.js +83 -55
  36. package/package.json +1 -1
@@ -0,0 +1,79 @@
1
+ import { databases } from "@harperfast/harper";
2
+ import { resolveAgentAuth } from "./agent-auth.js";
3
+ const FORBIDDEN = (msg) => new Response(JSON.stringify({ error: msg }), { status: 403, headers: { "Content-Type": "application/json" } });
4
+ const UNAUTH = () => new Response(JSON.stringify({ error: "authentication required" }), { status: 401, headers: { "Content-Type": "application/json" } });
5
+ /**
6
+ * MemoryGrant — an agent (ownerId) grants another (granteeId) scoped access to its
7
+ * memories. Self-authorizes now that the global gate is non-rejecting (previously a
8
+ * pure @table protected only by the gate → anonymous could read/write any grant).
9
+ *
10
+ * Write: non-admin agents may only create/modify/delete grants they OWN
11
+ * (ownerId === self) — you can only share your own memories.
12
+ * Read: non-admin agents see grants where they are owner OR grantee.
13
+ * Internal calls (e.g. Memory.search's grant lookup) and admins pass unfiltered.
14
+ */
15
+ export class MemoryGrant extends databases.flair.MemoryGrant {
16
+ _auth() {
17
+ return resolveAgentAuth(this.getContext?.());
18
+ }
19
+ async search(query) {
20
+ const auth = await this._auth();
21
+ if (auth.kind === "anonymous")
22
+ return UNAUTH();
23
+ if (auth.kind === "internal" || (auth.kind === "agent" && auth.isAdmin)) {
24
+ return super.search(query);
25
+ }
26
+ // owner OR grantee
27
+ const scope = {
28
+ operator: "or",
29
+ conditions: [
30
+ { attribute: "ownerId", comparator: "equals", value: auth.agentId },
31
+ { attribute: "granteeId", comparator: "equals", value: auth.agentId },
32
+ ],
33
+ };
34
+ if (query && typeof query === "object" && !Array.isArray(query)) {
35
+ const existing = query.conditions ?? [];
36
+ query.conditions = Array.isArray(existing) ? [scope, ...existing] : [scope, existing];
37
+ return super.search(query);
38
+ }
39
+ const conditions = Array.isArray(query) && query.length > 0 ? [scope, ...query] : [scope];
40
+ return super.search(conditions);
41
+ }
42
+ async post(content, context) {
43
+ const denied = await this._enforceOwnerWrite(content);
44
+ if (denied)
45
+ return denied;
46
+ content.createdAt ||= new Date().toISOString();
47
+ return super.post(content, context);
48
+ }
49
+ async put(content, context) {
50
+ const denied = await this._enforceOwnerWrite(content);
51
+ if (denied)
52
+ return denied;
53
+ return super.put(content, context);
54
+ }
55
+ async delete(id, context) {
56
+ const auth = await this._auth();
57
+ if (auth.kind === "anonymous")
58
+ return UNAUTH();
59
+ if (auth.kind === "internal" || (auth.kind === "agent" && auth.isAdmin)) {
60
+ return super.delete(id, context);
61
+ }
62
+ const record = await this.get(id);
63
+ if (!record)
64
+ return super.delete(id, context);
65
+ if (record.ownerId !== auth.agentId) {
66
+ return FORBIDDEN("forbidden: cannot delete a grant owned by another agent");
67
+ }
68
+ return super.delete(id, context);
69
+ }
70
+ async _enforceOwnerWrite(content) {
71
+ const auth = await this._auth();
72
+ if (auth.kind === "anonymous")
73
+ return UNAUTH();
74
+ if (auth.kind === "agent" && !auth.isAdmin && content?.ownerId && content.ownerId !== auth.agentId) {
75
+ return FORBIDDEN("forbidden: cannot grant access to another agent's memories");
76
+ }
77
+ return null;
78
+ }
79
+ }
@@ -17,7 +17,7 @@
17
17
  * to gate auth correctly.
18
18
  */
19
19
  import { Resource, databases } from "@harperfast/harper";
20
- import { isAdmin } from "./auth-middleware.js";
20
+ import { isAdmin } from "./agent-auth.js";
21
21
  export class MemoryMaintenance extends Resource {
22
22
  /** POST requires auth — either an agent acting on its own memories, or admin. */
23
23
  allowCreate() {
@@ -20,7 +20,7 @@
20
20
  * count number — number of memories included
21
21
  */
22
22
  import { Resource, databases } from "@harperfast/harper";
23
- import { isAdmin } from "./auth-middleware.js";
23
+ import { isAdmin, allowVerified } from "./agent-auth.js";
24
24
  import { patchRecordSilent } from "./table-helpers.js";
25
25
  const FOCUS_PROMPTS = {
26
26
  lessons_learned: "Review these memories and identify concrete lessons learned. For each lesson: what happened, what you learned, and how it should change future behavior. Write atomic memories with durability=persistent.",
@@ -29,6 +29,12 @@ const FOCUS_PROMPTS = {
29
29
  errors: "Extract errors, bugs, and failures. For each: what failed, root cause, and fix applied. These are high-value persistent memories.",
30
30
  };
31
31
  export class ReflectMemories extends Resource {
32
+ // Self-authorize via the Ed25519 agent verify (auth reshape removes the gate's
33
+ // admin elevation). Any verified agent may reflect; the isAdmin checks in post()
34
+ // handle finer-grained authorization.
35
+ async allowCreate() {
36
+ return allowVerified(this.getContext?.());
37
+ }
32
38
  async post(data) {
33
39
  const { agentId: bodyAgentId, scope = "recent", since, maxMemories = 50, focus = "lessons_learned", tag, } = data || {};
34
40
  // Authenticated identity comes from getContext().request, not this.request
@@ -24,8 +24,14 @@
24
24
  * This endpoint is idempotent: running it again finds zero drift.
25
25
  */
26
26
  import { Resource, databases } from "@harperfast/harper";
27
- import { isAdmin } from "./auth-middleware.js";
27
+ import { isAdmin, allowAdmin } from "./agent-auth.js";
28
28
  export class MemoryReindex extends Resource {
29
+ // Admin-only: permit verified ADMIN agents (Basic-admin is super_user and
30
+ // bypasses allow*); non-admin agents denied. Mirrors the handler's admin gate
31
+ // but is the real authorization now that the gate no longer elevates agents.
32
+ async allowCreate() {
33
+ return allowAdmin(this.getContext?.());
34
+ }
29
35
  async post(data) {
30
36
  const ctx = this.getContext?.();
31
37
  const request = ctx?.request ?? ctx ?? this.request;
@@ -0,0 +1,15 @@
1
+ import { databases } from "@harperfast/harper";
2
+ import { allowAdmin } from "./agent-auth.js";
3
+ /**
4
+ * OAuthClient holds registered OAuth client records (incl. secrets) — admin/system
5
+ * only, no agent grant. OAuth endpoints (OAuthRegister/Authorize/Token) read+write
6
+ * clients via internal calls (allowAdmin permits internal), so the OAuth flows keep
7
+ * working; direct anonymous table access is denied. Self-authorizes now that the
8
+ * global gate is non-rejecting.
9
+ */
10
+ export class OAuthClient extends databases.flair.OAuthClient {
11
+ allowRead() { return allowAdmin(this.getContext?.()); }
12
+ allowCreate() { return allowAdmin(this.getContext?.()); }
13
+ allowUpdate() { return allowAdmin(this.getContext?.()); }
14
+ allowDelete() { return allowAdmin(this.getContext?.()); }
15
+ }
@@ -0,0 +1,13 @@
1
+ import { databases } from "@harperfast/harper";
2
+ import { allowVerified, allowAdmin } from "./agent-auth.js";
3
+ /**
4
+ * ObsAgentSnapshot — observatory per-agent snapshot read-model. Writes are
5
+ * system-driven (internal sync); agents may read. See ObsOffice for the
6
+ * allowRead=allowVerified security default (flag for Sherlock).
7
+ */
8
+ export class ObsAgentSnapshot extends databases.flair.ObsAgentSnapshot {
9
+ allowRead() { return allowVerified(this.getContext?.()); }
10
+ allowCreate() { return allowAdmin(this.getContext?.()); }
11
+ allowUpdate() { return allowAdmin(this.getContext?.()); }
12
+ allowDelete() { return allowAdmin(this.getContext?.()); }
13
+ }
@@ -0,0 +1,13 @@
1
+ import { databases } from "@harperfast/harper";
2
+ import { allowVerified, allowAdmin } from "./agent-auth.js";
3
+ /**
4
+ * ObsEventFeed — observatory event-feed read-model. Writes are system-driven
5
+ * (internal sync); agents may read. See ObsOffice for the allowRead=allowVerified
6
+ * security default (flag for Sherlock).
7
+ */
8
+ export class ObsEventFeed extends databases.flair.ObsEventFeed {
9
+ allowRead() { return allowVerified(this.getContext?.()); }
10
+ allowCreate() { return allowAdmin(this.getContext?.()); }
11
+ allowUpdate() { return allowAdmin(this.getContext?.()); }
12
+ allowDelete() { return allowAdmin(this.getContext?.()); }
13
+ }
@@ -0,0 +1,19 @@
1
+ import { databases } from "@harperfast/harper";
2
+ import { allowVerified, allowAdmin } from "./agent-auth.js";
3
+ /**
4
+ * ObsOffice — observatory office-layout read-model. Writes are system-driven
5
+ * (internal sync); agents may read. Self-authorizes now that the global gate is
6
+ * non-rejecting.
7
+ *
8
+ * SECURITY DEFAULT (flag for Sherlock): allowRead is allowVerified (anonymous
9
+ * denied). The ObservationCenter HTML shell is public but its data API calls
10
+ * authenticate (admin-pass). If the public Office Space renderer needs anonymous
11
+ * Obs reads, relax to allowRead=true WITH field-allowlisting (as Presence.get does)
12
+ * — do NOT expose raw rows publicly.
13
+ */
14
+ export class ObsOffice extends databases.flair.ObsOffice {
15
+ allowRead() { return allowVerified(this.getContext?.()); }
16
+ allowCreate() { return allowAdmin(this.getContext?.()); }
17
+ allowUpdate() { return allowAdmin(this.getContext?.()); }
18
+ allowDelete() { return allowAdmin(this.getContext?.()); }
19
+ }
@@ -1,42 +1,58 @@
1
1
  /**
2
2
  * OrgEvent.ts — Harper table resource for org-wide activity events.
3
3
  *
4
- * Auth: Ed25519 middleware sets request.tpsAgent.
5
- * Write: authorId must match authenticated agent (or admin).
6
- * Read: any authenticated participant can read (org-scoped).
4
+ * Auth (self-enforced now that the global gate is non-rejecting):
5
+ * Read — any verified agent/admin (org-scoped); anonymous denied.
6
+ * Write authorId must match the authenticated agent (or admin); anonymous denied.
7
+ *
8
+ * The previous version read context.request.tpsAgent and treated a MISSING agent
9
+ * as trusted (`if (agentId && …)` / `if (!agentId) super.delete`), so anonymous
10
+ * requests slipped through once the gate stopped rejecting. resolveAgentAuth
11
+ * distinguishes internal/agent/anonymous explicitly.
7
12
  */
8
13
  import { databases } from "@harperfast/harper";
14
+ import { resolveAgentAuth, allowVerified } from "./agent-auth.js";
15
+ const FORBIDDEN = (msg) => new Response(JSON.stringify({ error: msg }), { status: 403, headers: { "Content-Type": "application/json" } });
16
+ const UNAUTH = () => new Response(JSON.stringify({ error: "authentication required" }), { status: 401, headers: { "Content-Type": "application/json" } });
9
17
  export class OrgEvent extends databases.flair.OrgEvent {
10
- async post(content, context) {
11
- const agentId = context?.request?.tpsAgent;
12
- // authorId must match authenticated agent (unless admin)
13
- if (agentId && !context?.request?.tpsAgentIsAdmin && content.authorId !== agentId) {
14
- return new Response(JSON.stringify({ error: "forbidden: authorId must match authenticated agent" }), { status: 403, headers: { "Content-Type": "application/json" } });
15
- }
16
- // Generate composite ID if not provided
17
- if (!content.id) {
18
- content.id = `${content.authorId}-${Date.now()}`;
18
+ allowRead() { return allowVerified(this.getContext?.()); }
19
+ _auth() {
20
+ return resolveAgentAuth(this.getContext?.());
21
+ }
22
+ async post(content) {
23
+ const auth = await this._auth();
24
+ if (auth.kind === "anonymous")
25
+ return UNAUTH();
26
+ if (auth.kind === "agent" && !auth.isAdmin && content.authorId !== auth.agentId) {
27
+ return FORBIDDEN("forbidden: authorId must match authenticated agent");
19
28
  }
29
+ if (!content.id)
30
+ content.id = `${content.authorId}-${new Date().toISOString()}`;
20
31
  content.createdAt = new Date().toISOString();
21
- // Harper 5: table resources use put() for create/upsert (post() removed)
32
+ // Harper 5: table resources use put() for create/upsert (post() removed).
22
33
  return databases.flair.OrgEvent.put(content);
23
34
  }
24
- async put(content, context) {
25
- const agentId = context?.request?.tpsAgent;
26
- if (agentId && !context?.request?.tpsAgentIsAdmin && content.authorId !== agentId) {
27
- return new Response(JSON.stringify({ error: "forbidden: authorId must match authenticated agent" }), { status: 403, headers: { "Content-Type": "application/json" } });
35
+ async put(content) {
36
+ const auth = await this._auth();
37
+ if (auth.kind === "anonymous")
38
+ return UNAUTH();
39
+ if (auth.kind === "agent" && !auth.isAdmin && content.authorId !== auth.agentId) {
40
+ return FORBIDDEN("forbidden: authorId must match authenticated agent");
28
41
  }
29
42
  return databases.flair.OrgEvent.put(content);
30
43
  }
31
44
  async delete(id, context) {
32
- const agentId = context?.request?.tpsAgent;
33
- if (!agentId)
45
+ const auth = await this._auth();
46
+ if (auth.kind === "anonymous")
47
+ return UNAUTH();
48
+ if (auth.kind === "internal" || (auth.kind === "agent" && auth.isAdmin)) {
34
49
  return super.delete(id, context);
50
+ }
35
51
  const record = await this.get(id);
36
52
  if (!record)
37
53
  return super.delete(id, context);
38
- if (!context?.request?.tpsAgentIsAdmin && record.authorId !== agentId) {
39
- return new Response(JSON.stringify({ error: "forbidden: cannot delete events authored by another agent" }), { status: 403, headers: { "Content-Type": "application/json" } });
54
+ if (record.authorId !== auth.agentId) {
55
+ return FORBIDDEN("forbidden: cannot delete events authored by another agent");
40
56
  }
41
57
  return super.delete(id, context);
42
58
  }
@@ -10,7 +10,15 @@
10
10
  * Limit 50 events max.
11
11
  */
12
12
  import { Resource, databases } from "@harperfast/harper";
13
+ import { allowVerified } from "./agent-auth.js";
13
14
  export class OrgEventCatchup extends Resource {
15
+ // Self-authorize via the Ed25519 agent verify (auth reshape removes the gate's
16
+ // admin elevation). Any verified agent may catch up; participant scoping is in
17
+ // get(). Uses getContext().request — the reliable v5 path (this.request is not
18
+ // populated on Harper v5 Resources).
19
+ async allowRead() {
20
+ return allowVerified(this.getContext?.());
21
+ }
14
22
  // HarperDB calls get(pathInfo, context) where pathInfo is the URL segment after /OrgEventCatchup/
15
23
  async get(pathInfo) {
16
24
  const request = this.request;
@@ -5,7 +5,14 @@
5
5
  * Auth: admin only.
6
6
  */
7
7
  import { Resource, databases } from "@harperfast/harper";
8
+ import { allowAdmin } from "./agent-auth.js";
8
9
  export class OrgEventMaintenance extends Resource {
10
+ // Admin-only: permit verified ADMIN agents (Basic-admin is super_user and
11
+ // bypasses allow*); non-admin agents denied. Real authorization now that the
12
+ // gate no longer elevates agents to admin.
13
+ async allowCreate() {
14
+ return allowAdmin(this.getContext?.());
15
+ }
9
16
  async post(_data, context) {
10
17
  // Admin-only
11
18
  if (!context?.request?.tpsAgentIsAdmin) {
@@ -0,0 +1,14 @@
1
+ import { databases } from "@harperfast/harper";
2
+ import { allowAdmin } from "./agent-auth.js";
3
+ /**
4
+ * PairingToken holds one-time federation pairing secrets — admin/system only,
5
+ * no agent grant. The FederationPair endpoint consumes tokens via internal calls
6
+ * (allowAdmin permits internal), so dedicated flows keep working; direct anonymous
7
+ * table access is denied. Self-authorizes now that the global gate is non-rejecting.
8
+ */
9
+ export class PairingToken extends databases.flair.PairingToken {
10
+ allowRead() { return allowAdmin(this.getContext?.()); }
11
+ allowCreate() { return allowAdmin(this.getContext?.()); }
12
+ allowUpdate() { return allowAdmin(this.getContext?.()); }
13
+ allowDelete() { return allowAdmin(this.getContext?.()); }
14
+ }
@@ -0,0 +1,15 @@
1
+ import { databases } from "@harperfast/harper";
2
+ import { allowAdmin } from "./agent-auth.js";
3
+ /**
4
+ * Peer holds federation peer records (URLs + credentials) — system/admin data,
5
+ * no agent grant. Before the auth flip the global gate was its only protection;
6
+ * the non-rejecting gate means it must self-authorize. Admin/internal only on
7
+ * every path. (Federation pairing/sync use dedicated endpoints — FederationPair,
8
+ * FederationSync — not direct Peer table access.)
9
+ */
10
+ export class Peer extends databases.flair.Peer {
11
+ allowRead() { return allowAdmin(this.getContext?.()); }
12
+ allowCreate() { return allowAdmin(this.getContext?.()); }
13
+ allowUpdate() { return allowAdmin(this.getContext?.()); }
14
+ allowDelete() { return allowAdmin(this.getContext?.()); }
15
+ }
@@ -17,6 +17,7 @@
17
17
  * - currentTask is agent-authored free text → cap length, escape on render.
18
18
  */
19
19
  import { databases } from "@harperfast/harper";
20
+ import { resolveAgentAuth } from "./agent-auth.js";
20
21
  // ─── Constants ────────────────────────────────────────────────────────────────
21
22
  const WINDOW_MS = 30_000;
22
23
  const CURRENT_TASK_MAX_LENGTH = 200;
@@ -267,4 +268,33 @@ export class Presence extends databases.flair.Presence {
267
268
  return new Response(JSON.stringify({ error: "presence_write_failed", detail: e?.message }), { status: 500, headers: { "Content-Type": "application/json" } });
268
269
  }
269
270
  }
271
+ /**
272
+ * PUT/DELETE are not part of the agent heartbeat contract (writes go through
273
+ * POST, which carries its own Ed25519 auth). GET is intentionally public and
274
+ * allowCreate=true lets POST self-auth — but those bypasses must NOT extend to
275
+ * PUT/DELETE. The non-rejecting gate would otherwise let anonymous PUT/DELETE
276
+ * straight to Harper's default handler (the leak this closes). Require a verified
277
+ * non-anonymous principal, scoped to the agent's own record (Presence is keyed
278
+ * by agentId).
279
+ */
280
+ async put(content, context) {
281
+ const auth = await resolveAgentAuth(this.getContext?.());
282
+ if (auth.kind === "anonymous") {
283
+ return new Response(JSON.stringify({ error: "authentication required" }), { status: 401, headers: { "Content-Type": "application/json" } });
284
+ }
285
+ if (auth.kind === "agent" && !auth.isAdmin && content?.agentId && content.agentId !== auth.agentId) {
286
+ return new Response(JSON.stringify({ error: "forbidden: cannot write presence for another agent" }), { status: 403, headers: { "Content-Type": "application/json" } });
287
+ }
288
+ return super.put(content, context);
289
+ }
290
+ async delete(id) {
291
+ const auth = await resolveAgentAuth(this.getContext?.());
292
+ if (auth.kind === "anonymous") {
293
+ return new Response(JSON.stringify({ error: "authentication required" }), { status: 401, headers: { "Content-Type": "application/json" } });
294
+ }
295
+ if (auth.kind === "agent" && !auth.isAdmin && id != null && String(id) !== auth.agentId) {
296
+ return new Response(JSON.stringify({ error: "forbidden: cannot delete presence for another agent" }), { status: 403, headers: { "Content-Type": "application/json" } });
297
+ }
298
+ return super.delete(id);
299
+ }
270
300
  }
@@ -1,4 +1,5 @@
1
1
  import { databases } from "@harperfast/harper";
2
+ import { resolveAgentAuth } from "./agent-auth.js";
2
3
  import { checkRateLimit, rateLimitResponse } from "./rate-limiter.js";
3
4
  /**
4
5
  * Relationship resource — entity-to-entity relationships with temporal validity.
@@ -13,15 +14,20 @@ import { checkRateLimit, rateLimitResponse } from "./rate-limiter.js";
13
14
  */
14
15
  export class Relationship extends databases.flair.Relationship {
15
16
  async search(query) {
16
- const ctx = this.getContext?.();
17
- const request = ctx?.request ?? ctx;
18
- const authAgent = request?.tpsAgent;
19
- const isAdminAgent = request?.tpsAgentIsAdmin ?? false;
20
- if (!authAgent || isAdminAgent) {
17
+ const auth = await resolveAgentAuth(this.getContext?.());
18
+ // Anonymous HTTP must NOT read relationships (previously `!authAgent` was
19
+ // treated as unfiltered — the anonymous-read leak).
20
+ if (auth.kind === "anonymous") {
21
+ return new Response(JSON.stringify({ error: "authentication required" }), {
22
+ status: 401, headers: { "content-type": "application/json" },
23
+ });
24
+ }
25
+ // Trusted internal call or admin agent → unfiltered.
26
+ if (auth.kind === "internal" || (auth.kind === "agent" && auth.isAdmin)) {
21
27
  return super.search(query);
22
28
  }
23
- // Non-admin: scope to own relationships
24
- const agentCondition = { attribute: "agentId", comparator: "equals", value: authAgent };
29
+ // Non-admin agent: scope to own relationships.
30
+ const agentCondition = { attribute: "agentId", comparator: "equals", value: auth.agentId };
25
31
  if (!query?.conditions) {
26
32
  return super.search({ conditions: [agentCondition], ...(query || {}) });
27
33
  }
@@ -1,4 +1,5 @@
1
1
  import { Resource, databases } from "@harperfast/harper";
2
+ import { resolveAgentAuth, allowVerified } from "./agent-auth.js";
2
3
  import { getEmbedding, getMode } from "./embeddings-provider.js";
3
4
  import { patchRecord, withDetachedTxn } from "./table-helpers.js";
4
5
  import { checkRateLimit, rateLimitResponse } from "./rate-limiter.js";
@@ -45,6 +46,15 @@ function distanceToSimilarity(distance) {
45
46
  // so composite re-ranking has enough headroom to reorder results.
46
47
  const CANDIDATE_MULTIPLIER = 5;
47
48
  export class SemanticSearch extends Resource {
49
+ // Self-authorize via the Ed25519 agent verify instead of relying on the auth
50
+ // gate's admin super_user elevation (removed in the auth reshape). Any
51
+ // cryptographically-verified agent may search; per-agent RESULT scoping is
52
+ // enforced in post() below (an agent only sees its own + office-visible +
53
+ // granted memories). Without this, Harper's default denies the POST for the
54
+ // least-privilege flair_agent role (AccessViolation 403).
55
+ async allowCreate() {
56
+ return allowVerified(this.getContext?.());
57
+ }
48
58
  async post(data) {
49
59
  const { agentId: bodyAgentId, q, queryEmbedding, tag, subject, subjects, limit = 10, includeSuperseded = false, scoring = "composite", minScore = 0, since, asOf } = data || {};
50
60
  // Authenticated identity lives on the Harper Resource context (getContext().request).
@@ -52,11 +62,18 @@ export class SemanticSearch extends Resource {
52
62
  // silently returned undefined and the defense-in-depth scope check below
53
63
  // was bypassed, letting a non-admin agent read another agent's memories
54
64
  // by putting the victim's id in the body.
55
- const ctx = this.getContext?.();
56
- const request = ctx?.request ?? ctx;
57
- const authenticatedAgent = request?.tpsAgent ?? request?.headers?.get?.("x-tps-agent");
58
- const callerIsAdmin = request?.tpsAgentIsAdmin === true;
59
- // Rate limiting use authenticated agent ID, not client-supplied body
65
+ const auth = await resolveAgentAuth(this.getContext?.());
66
+ // Anonymous HTTP must NOT search. Previously the no-auth path fell through to
67
+ // honoring the body-supplied agentId (line below), so an unauthenticated
68
+ // caller could read any agent's memories by putting that id in the body.
69
+ if (auth.kind === "anonymous") {
70
+ return new Response(JSON.stringify({ error: "authentication required" }), {
71
+ status: 401, headers: { "Content-Type": "application/json" },
72
+ });
73
+ }
74
+ const authenticatedAgent = auth.kind === "agent" ? auth.agentId : undefined;
75
+ const callerIsAdmin = auth.kind === "agent" && auth.isAdmin;
76
+ // Rate limiting — use authenticated agent ID (internal calls have none).
60
77
  if (authenticatedAgent) {
61
78
  const bucket = q && !queryEmbedding ? "embedding" : "general";
62
79
  const rl = checkRateLimit(authenticatedAgent, bucket);
@@ -70,14 +87,14 @@ export class SemanticSearch extends Resource {
70
87
  : null;
71
88
  // Enforce agentId = authenticated agent for non-admins. A mismatched body
72
89
  // agentId is a cross-agent read attempt — reject outright. Admins can query
73
- // any agentId (needed for bootstrap / consolidation scripts).
90
+ // any agentId (bootstrap / consolidation).
74
91
  if (authenticatedAgent && !callerIsAdmin && bodyAgentId && bodyAgentId !== authenticatedAgent) {
75
92
  return new Response(JSON.stringify({
76
93
  error: "forbidden: agentId must match authenticated agent",
77
94
  }), { status: 403, headers: { "Content-Type": "application/json" } });
78
95
  }
79
- // Scope search to the authenticated agent (own + granted). For admins or
80
- // unauthenticated internal calls, honor the body-supplied agentId.
96
+ // Scope: non-admin agent own (+ granted). Admin agent or trusted internal
97
+ // call (no request) honor the body-supplied agentId.
81
98
  const agentId = (authenticatedAgent && !callerIsAdmin)
82
99
  ? authenticatedAgent
83
100
  : bodyAgentId;
@@ -1,17 +1,26 @@
1
1
  import { databases } from "@harperfast/harper";
2
- function enforceAgentScope(self, data) {
3
- const authenticatedAgent = self.request?.headers?.get?.("x-tps-agent");
4
- const callerIsAdmin = self.request?.tpsAgentIsAdmin === true;
5
- if (authenticatedAgent && !callerIsAdmin && data?.agentId && data.agentId !== authenticatedAgent) {
6
- return new Response(JSON.stringify({
7
- error: "forbidden: agentId must match authenticated agent",
8
- }), { status: 403, headers: { "Content-Type": "application/json" } });
2
+ import { resolveAgentAuth } from "./agent-auth.js";
3
+ const FORBIDDEN = (msg) => new Response(JSON.stringify({ error: msg }), { status: 403, headers: { "Content-Type": "application/json" } });
4
+ const UNAUTH = () => new Response(JSON.stringify({ error: "authentication required" }), { status: 401, headers: { "Content-Type": "application/json" } });
5
+ /**
6
+ * Deny anonymous; enforce per-agent write ownership for non-admin agents.
7
+ * The previous header-based check only fired when an agent WAS present (it read
8
+ * x-tps-agent), so an anonymous request which carries no x-tps-agent slipped
9
+ * through. With the non-rejecting gate, each write path self-enforces (resolveAgentAuth
10
+ * distinguishes internal/agent/anonymous). Mirrors the WorkspaceState pattern.
11
+ */
12
+ async function enforceWriteAuth(self, data) {
13
+ const auth = await resolveAgentAuth(self.getContext?.());
14
+ if (auth.kind === "anonymous")
15
+ return UNAUTH();
16
+ if (auth.kind === "agent" && !auth.isAdmin && data?.agentId && data.agentId !== auth.agentId) {
17
+ return FORBIDDEN("forbidden: agentId must match authenticated agent");
9
18
  }
10
19
  return null;
11
20
  }
12
21
  export class Soul extends databases.flair.Soul {
13
22
  async post(content, context) {
14
- const denied = enforceAgentScope(this, content);
23
+ const denied = await enforceWriteAuth(this, content);
15
24
  if (denied)
16
25
  return denied;
17
26
  content.durability ||= "permanent";
@@ -20,7 +29,7 @@ export class Soul extends databases.flair.Soul {
20
29
  return super.post(content, context);
21
30
  }
22
31
  async put(content, context) {
23
- const denied = enforceAgentScope(this, content);
32
+ const denied = await enforceWriteAuth(this, content);
24
33
  if (denied)
25
34
  return denied;
26
35
  content.updatedAt = new Date().toISOString();
@@ -1,5 +1,12 @@
1
1
  import { Resource, databases } from '@harperfast/harper';
2
+ import { allowVerified } from './agent-auth.js';
2
3
  export class FeedSouls extends Resource {
4
+ // Self-authorize the subscription via the Ed25519 agent verify (auth reshape
5
+ // removes the gate's admin elevation). FeedSouls extends Resource (not a Table),
6
+ // so getContext().request is reachable in allow* — same pattern as SemanticSearch.
7
+ async allowRead() {
8
+ return allowVerified(this.getContext?.());
9
+ }
3
10
  async *connect(target, incomingMessages) {
4
11
  const subscription = await databases.flair.Soul.subscribe(target);
5
12
  if (!incomingMessages) {
@@ -5,7 +5,14 @@
5
5
  * Auth: requesting agent must match agentId in path (or be admin).
6
6
  */
7
7
  import { Resource, databases } from "@harperfast/harper";
8
+ import { allowVerified } from "./agent-auth.js";
8
9
  export class WorkspaceLatest extends Resource {
10
+ // Self-authorize via the Ed25519 agent verify (auth reshape removes the gate's
11
+ // admin elevation). Any verified agent may read; the path-vs-agent ownership
12
+ // check stays in get().
13
+ async allowRead() {
14
+ return allowVerified(this.getContext?.());
15
+ }
9
16
  async get(pathInfo) {
10
17
  const request = this.context?.request ?? this.request;
11
18
  const callerAgent = request?.tpsAgent;