@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
package/dist/cli.js CHANGED
@@ -736,25 +736,30 @@ export async function provisionFabric(target, opsTarget, clusterAdminUser, clust
736
736
  // FederationPair resource handler. The role carries no table permissions itself
737
737
  // — the resource's own allowCreate bypass handles route-level access once the
738
738
  // request gets through the auth gate.
739
- /** Canonical permission spec for flair_pair_initiator. */
739
+ /**
740
+ * Canonical permission spec for flair_pair_initiator.
741
+ *
742
+ * The role intentionally carries NO table permissions — its only job is to exist
743
+ * so bootstrap credentials can pass Harper platform auth before reaching the
744
+ * FederationPair resource handler (the resource's own allowCreate bypass handles
745
+ * route-level access). A bare role with both flags false is valid, grants nothing,
746
+ * and is exactly that intent.
747
+ *
748
+ * This previously carried an all-false `flair.tables` block, but Harper's add_role
749
+ * REJECTED the whole spec with a 400 (verified live against a spawned Harper):
750
+ * - `cluster_user` is not a recognized top-level key — Harper reads unknown keys
751
+ * as database names ("database 'cluster_user' does not exist");
752
+ * - the table names were the logical shorthand, not the real @table names
753
+ * ("Table 'flair.Workspace' does not exist" — it's WorkspaceState; "Event" is
754
+ * OrgEvent; "OAuth" is OAuthClient);
755
+ * - each grant omitted the required `attribute_permissions` array.
756
+ * The all-false block granted nothing anyway, so dropping it loses no capability
757
+ * and unbreaks fresh hub provisioning (where add_role runs and the 400 aborted
758
+ * `flair init --remote`). Only top-level booleans Harper recognizes remain.
759
+ */
740
760
  const PAIR_INITIATOR_PERMISSION = {
741
761
  super_user: false,
742
- cluster_user: false,
743
762
  structure_user: false,
744
- flair: {
745
- tables: {
746
- Memory: { read: false, insert: false, update: false, delete: false },
747
- Soul: { read: false, insert: false, update: false, delete: false },
748
- Agent: { read: false, insert: false, update: false, delete: false },
749
- Workspace: { read: false, insert: false, update: false, delete: false },
750
- Event: { read: false, insert: false, update: false, delete: false },
751
- OAuth: { read: false, insert: false, update: false, delete: false },
752
- Instance: { read: false, insert: false, update: false, delete: false },
753
- Peer: { read: false, insert: false, update: false, delete: false },
754
- PairingToken: { read: false, insert: false, update: false, delete: false },
755
- SyncLog: { read: false, insert: false, update: false, delete: false },
756
- },
757
- },
758
763
  };
759
764
  /**
760
765
  * Idempotently ensures the `flair_pair_initiator` role exists on the Harper
@@ -805,6 +810,174 @@ export async function ensureFlairPairInitiatorRole(opsUrl, adminUser, adminPass)
805
810
  }, adminUser, adminPass);
806
811
  console.log(`Role '${ROLE_NAME}' updated ✓`);
807
812
  }
813
+ // ─── flair_agent role ────────────────────────────────────────────────────────
814
+ //
815
+ // The auth reshape replaces the global gate's "verified agent borrows admin
816
+ // super_user" elevation with a real, least-privilege Harper role. After an
817
+ // agent's Ed25519 signature verifies, resources resolve the request to the
818
+ // shared `flair_agent`-roled user instead of admin — so agents get exactly the
819
+ // table CRUD below and nothing more. Critically: with no `super_user` and no
820
+ // operations grants, /sql and /graphql become NATIVELY 403 for agents (the
821
+ // raw-query block the gate hand-rolled is now enforced by Harper itself).
822
+ //
823
+ // Row-level ownership (an agent touches only its OWN memories/soul/events) is
824
+ // NOT expressible in Harper's role model — it stays in each resource's allow*,
825
+ // keyed on the Ed25519-verified agentId. So these per-table grants are the
826
+ // coarse CRUD envelope; allow* is the ownership boundary inside it.
827
+ //
828
+ // VALIDATION GATES (must confirm against a live Harper before this role goes
829
+ // live — flagged for Sherlock + the PR, not assumed):
830
+ // 1. HNSW/vector search (SemanticSearch over Memory) works with table `read`
831
+ // alone — the old elevation comment claimed admin perms were needed for
832
+ // "HNSW-capable" access; confirm `read` suffices or widen precisely.
833
+ // 2. Role table keys must EXACTLY match the @table names (Memory, OrgEvent,
834
+ // WorkspaceState, OAuthClient — NOT the logical Memory/Event/Workspace/OAuth
835
+ // shorthand the flair_pair_initiator spec used, which was harmless only
836
+ // because every grant there is false).
837
+ // 3. Obs* writes: if the presence-emitter writes ObsAgentSnapshot AS the agent
838
+ // it needs insert/update — currently read-only here; confirm the writer's
839
+ // identity (system vs agent) and widen only if it's the agent.
840
+ // Harper 5.0.21 add_role requires an `attribute_permissions` array on EVERY table
841
+ // grant (empty = no attribute-level restriction, so the table-level CRUD applies);
842
+ // omitting it makes add_role reject the whole spec ("Missing 'attribute_permissions'
843
+ // array"). Validated live against a spawned Harper. This helper guarantees the
844
+ // array is never forgotten. Also: `cluster_user` is NOT a valid top-level key —
845
+ // Harper reads unrecognized top-level keys as database names ("database
846
+ // 'cluster_user' does not exist"); only super_user / structure_user are recognized.
847
+ const grant = (read, insert, update, del) => ({ read, insert, update, delete: del, attribute_permissions: [] });
848
+ /** Canonical permission spec for flair_agent (least-privilege; real @table names). */
849
+ const FLAIR_AGENT_PERMISSION = {
850
+ super_user: false,
851
+ structure_user: false,
852
+ flair: {
853
+ tables: {
854
+ // Core agent-owned data — CRUD envelope; ownership enforced in allow*.
855
+ Memory: grant(true, true, true, true),
856
+ MemoryCandidate: grant(true, true, true, true),
857
+ MemoryGrant: grant(true, true, true, true),
858
+ Soul: grant(true, true, true, false),
859
+ OrgEvent: grant(true, true, true, true),
860
+ WorkspaceState: grant(true, true, true, true),
861
+ Relationship: grant(true, true, true, true),
862
+ Integration: grant(true, true, true, true),
863
+ Credential: grant(true, true, true, true),
864
+ Presence: grant(true, true, true, false),
865
+ // Agent: read for discovery, update own card; creation/removal is admin.
866
+ Agent: grant(true, false, true, false),
867
+ // Read-only reference data.
868
+ Instance: grant(true, false, false, false),
869
+ // Observatory read-models — public reads; writes are system-driven (gate 3).
870
+ ObsOffice: grant(true, false, false, false),
871
+ ObsAgentSnapshot: grant(true, false, false, false),
872
+ ObsEventFeed: grant(true, false, false, false),
873
+ // Federation / OAuth / IdP / internal — system + admin only; agents get none.
874
+ Peer: grant(false, false, false, false),
875
+ PairingToken: grant(false, false, false, false),
876
+ SyncLog: grant(false, false, false, false),
877
+ OAuthClient: grant(false, false, false, false),
878
+ OAuthToken: grant(false, false, false, false),
879
+ OAuthAuthCode: grant(false, false, false, false),
880
+ IdpConfig: grant(false, false, false, false),
881
+ IdJagReplay: grant(false, false, false, false),
882
+ },
883
+ },
884
+ };
885
+ /**
886
+ * Idempotently ensures the `flair_agent` role exists on the Harper instance at
887
+ * `opsUrl` with the canonical least-privilege spec. Same list/add/alter shape as
888
+ * ensureFlairPairInitiatorRole.
889
+ *
890
+ * - absent → `add_role`
891
+ * - exists with different permissions → `alter_role`
892
+ * - already matches → no-op
893
+ */
894
+ export async function ensureFlairAgentRole(opsUrl, adminUser, adminPass) {
895
+ const ROLE_NAME = "flair_agent";
896
+ let roles = [];
897
+ try {
898
+ const result = await callOpsApi(opsUrl, { operation: "list_roles" }, adminUser, adminPass);
899
+ roles = Array.isArray(result) ? result : [];
900
+ }
901
+ catch (err) {
902
+ const msg = err instanceof Error ? err.message : String(err);
903
+ throw new Error(`ensureFlairAgentRole: list_roles failed: ${msg}`);
904
+ }
905
+ const existing = roles.find((r) => r.role === ROLE_NAME || r.name === ROLE_NAME);
906
+ if (!existing) {
907
+ console.log(`Creating role '${ROLE_NAME}'...`);
908
+ await callOpsApi(opsUrl, {
909
+ operation: "add_role",
910
+ role: ROLE_NAME,
911
+ permission: FLAIR_AGENT_PERMISSION,
912
+ }, adminUser, adminPass);
913
+ console.log(`Role '${ROLE_NAME}' created ✓`);
914
+ return;
915
+ }
916
+ const existingPerm = existing.permission ?? existing.role?.permission;
917
+ const canonicalStr = JSON.stringify(FLAIR_AGENT_PERMISSION);
918
+ const existingStr = JSON.stringify(existingPerm);
919
+ if (existingStr === canonicalStr) {
920
+ console.log(`Role '${ROLE_NAME}' already exists with correct permissions — skipping`);
921
+ return;
922
+ }
923
+ console.log(`Role '${ROLE_NAME}' exists but permissions differ — updating...`);
924
+ await callOpsApi(opsUrl, {
925
+ operation: "alter_role",
926
+ role: ROLE_NAME,
927
+ permission: FLAIR_AGENT_PERMISSION,
928
+ }, adminUser, adminPass);
929
+ console.log(`Role '${ROLE_NAME}' updated ✓`);
930
+ }
931
+ /**
932
+ * Shared Harper user that verified Ed25519 agents are resolved to.
933
+ * MUST match FLAIR_AGENT_USERNAME in resources/agent-auth.ts (the gate side).
934
+ * Not imported from there because cli.ts is standalone and that module pulls in
935
+ * Harper's native bindings — kept as a cross-referenced literal instead.
936
+ */
937
+ export const FLAIR_AGENT_USERNAME = "flair-agent";
938
+ /**
939
+ * Idempotently ensures the shared `flair-agent` Harper user exists with the
940
+ * `flair_agent` role. Verified Ed25519 agents are resolved to THIS user
941
+ * (`getUser("flair-agent", null)` — no password check, identity already proven
942
+ * cryptographically), replacing the old `getUser("admin")` super_user elevation.
943
+ *
944
+ * The password is random and never used for authentication: the agent path
945
+ * resolves the user without it, and the auth gate's Basic path only accepts
946
+ * super_user / pair-bootstrap (a flair_agent Basic login is rejected there), so
947
+ * a Basic login as this user can't reach anything. Random + unused = safe.
948
+ *
949
+ * Row-level ownership stays in each resource's allow* (keyed on the verified
950
+ * agentId); this shared user only carries the flair_agent role's table grants.
951
+ */
952
+ export async function ensureFlairAgentUser(opsUrl, adminUser, adminPass) {
953
+ const unusedPassword = randomBytes(32).toString("base64url");
954
+ try {
955
+ await callOpsApi(opsUrl, {
956
+ operation: "add_user",
957
+ username: FLAIR_AGENT_USERNAME,
958
+ password: unusedPassword,
959
+ role: "flair_agent",
960
+ active: true,
961
+ }, adminUser, adminPass);
962
+ console.log(`User '${FLAIR_AGENT_USERNAME}' created ✓`);
963
+ }
964
+ catch (err) {
965
+ const msg = err instanceof Error ? err.message : String(err);
966
+ if (msg.includes("already exists") || msg.includes("duplicate")) {
967
+ // Idempotent: ensure the role is correct without churning the password.
968
+ await callOpsApi(opsUrl, {
969
+ operation: "alter_user",
970
+ username: FLAIR_AGENT_USERNAME,
971
+ role: "flair_agent",
972
+ active: true,
973
+ }, adminUser, adminPass);
974
+ console.log(`User '${FLAIR_AGENT_USERNAME}' already exists — role ensured ✓`);
975
+ }
976
+ else {
977
+ throw err;
978
+ }
979
+ }
980
+ }
808
981
  // ─── Upgrade presence probes ──────────────────────────────────────────────────
809
982
  //
810
983
  // `flair upgrade` previously called `npm list -g <pkg>` to detect the installed
@@ -1126,6 +1299,16 @@ program
1126
1299
  if (opts.remote) {
1127
1300
  await ensureFlairPairInitiatorRole(opsUrl, DEFAULT_ADMIN_USER, flairAdminPass);
1128
1301
  }
1302
+ // Every flair instance has agents, so provision the least-privilege
1303
+ // flair_agent role (idempotent, harmless until a user is assigned to it).
1304
+ await ensureFlairAgentRole(opsUrl, DEFAULT_ADMIN_USER, flairAdminPass);
1305
+ // THE FLIP (auth-rbac): provision the shared least-privilege flair-agent user.
1306
+ // This ACTIVATES the gate's per-agent de-elevation (verified non-admin agents
1307
+ // resolve to flair-agent instead of admin super_user). Safe now: #487 gave
1308
+ // every agent-facing resource its own allow* + resolveAgentAuth, so they no
1309
+ // longer rely on the admin super_user bypass. The gate also falls back to
1310
+ // admin if this user is ever absent, so de-elevation degrades gracefully.
1311
+ await ensureFlairAgentUser(opsUrl, DEFAULT_ADMIN_USER, flairAdminPass);
1129
1312
  }
1130
1313
  else {
1131
1314
  // ── Existing behavior: --admin-pass required for already-running Flair ──
@@ -1,4 +1,5 @@
1
1
  import { databases } from "@harperfast/harper";
2
+ import { resolveAgentAuth, allowVerified, allowAdmin } from "./agent-auth.js";
2
3
  /**
3
4
  * Agent resource — serves as the Principal table in 1.0.
4
5
  *
@@ -16,6 +17,14 @@ import { databases } from "@harperfast/harper";
16
17
  * - subjects: soul-level subject interests
17
18
  */
18
19
  export class Agent extends databases.flair.Agent {
20
+ // Self-authorize now that the global gate is non-rejecting. Verified agents read
21
+ // the principal table for discovery; an agent updates only its OWN record (put
22
+ // handler enforces ownership). Creating/deleting principals is admin-only
23
+ // (flair_agent grant: insert=false, delete=false). Anonymous denied throughout.
24
+ allowRead() { return allowVerified(this.getContext?.()); }
25
+ allowCreate() { return allowAdmin(this.getContext?.()); }
26
+ allowUpdate() { return allowVerified(this.getContext?.()); }
27
+ allowDelete() { return allowAdmin(this.getContext?.()); }
19
28
  async post(content, context) {
20
29
  const now = new Date().toISOString();
21
30
  // Backward compat: set type for legacy code
@@ -34,14 +43,18 @@ export class Agent extends databases.flair.Agent {
34
43
  return super.post(content, context);
35
44
  }
36
45
  async put(content) {
37
- const ctx = this.getContext?.();
38
- const request = ctx?.request ?? ctx;
39
- const authAgent = request?.tpsAgent;
40
- const isAdminAgent = request?.tpsAgentIsAdmin ?? false;
41
- // Only admin principals can modify other principals
42
- if (authAgent && !isAdminAgent) {
46
+ const auth = await resolveAgentAuth(this.getContext?.());
47
+ // Anonymous denied (defense-in-depth alongside allowUpdate; the old check read
48
+ // tpsAgent and treated a missing agent as trusted, so anonymous slipped through).
49
+ if (auth.kind === "anonymous") {
50
+ return new Response(JSON.stringify({ error: "authentication required" }), {
51
+ status: 401, headers: { "content-type": "application/json" },
52
+ });
53
+ }
54
+ // Only admin principals can modify OTHER principals; an agent updates its own.
55
+ if (auth.kind === "agent" && !auth.isAdmin) {
43
56
  const existing = await super.get();
44
- if (existing && existing.id !== authAgent) {
57
+ if (existing && existing.id !== auth.agentId) {
45
58
  return new Response(JSON.stringify({ error: "only admin principals can modify other principals" }), {
46
59
  status: 403, headers: { "content-type": "application/json" },
47
60
  });
@@ -1,6 +1,12 @@
1
1
  import { Resource, databases } from "@harperfast/harper";
2
2
  import { selectPublicDescription, selectPublicSkills } from "./agentcard-fields.js";
3
3
  export class AgentCard extends Resource {
4
+ // Public discovery metadata (per A2A spec, agent cards are intentionally
5
+ // public). Self-gates as public so it survives the global gate's removal — and
6
+ // its get() already returns only field-allowlisted public-safe data.
7
+ allowRead() {
8
+ return true;
9
+ }
4
10
  async get(pathInfo) {
5
11
  const agentId = (typeof pathInfo === "string" ? pathInfo : null) ??
6
12
  this.getId?.() ??
@@ -17,7 +17,7 @@
17
17
  * Auth: admin only.
18
18
  */
19
19
  import { Resource, databases } from "@harperfast/harper";
20
- import { isAdmin } from "./auth-middleware.js";
20
+ import { isAdmin, allowAdmin } from "./agent-auth.js";
21
21
  const DEFAULT_SOUL_KEYS = (agentId, displayName, role, now) => ({
22
22
  name: displayName,
23
23
  role,
@@ -32,6 +32,12 @@ const DEFAULT_MEMORIES = (agentId, now) => [
32
32
  },
33
33
  ];
34
34
  export class AgentSeed extends Resource {
35
+ // Admin-only: permit verified ADMIN agents (Basic-admin is super_user and
36
+ // bypasses allow*); non-admin agents denied. Real authorization now that the
37
+ // gate no longer elevates agents to admin.
38
+ async allowCreate() {
39
+ return allowAdmin(this.getContext?.());
40
+ }
35
41
  async post(data) {
36
42
  const actorId = this.request?.tpsAgent;
37
43
  if (!actorId || !(await isAdmin(actorId))) {
@@ -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
  * Credential resource — authentication surfaces for Principals.
@@ -13,15 +14,21 @@ import { checkRateLimit, rateLimitResponse } from "./rate-limiter.js";
13
14
  */
14
15
  export class Credential extends databases.flair.Credential {
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 credentials. (Previously `!authAgent` was
19
+ // treated as trusted/unfiltered — which leaked every credential to an
20
+ // unauthenticated caller once the gate stopped rejecting.)
21
+ if (auth.kind === "anonymous") {
22
+ return new Response(JSON.stringify({ error: "authentication required" }), {
23
+ status: 401, headers: { "content-type": "application/json" },
24
+ });
25
+ }
26
+ // Trusted internal call or admin agent → unfiltered.
27
+ if (auth.kind === "internal" || (auth.kind === "agent" && auth.isAdmin)) {
21
28
  return super.search(query);
22
29
  }
23
- // Non-admin: scope to own credentials
24
- const condition = { attribute: "principalId", comparator: "equals", value: authAgent };
30
+ // Non-admin agent: scope to own credentials.
31
+ const condition = { attribute: "principalId", comparator: "equals", value: auth.agentId };
25
32
  if (!query?.conditions) {
26
33
  return super.search({ conditions: [condition], ...(query || {}) });
27
34
  }
@@ -35,12 +42,16 @@ export class Credential extends databases.flair.Credential {
35
42
  const result = await super.get();
36
43
  if (!result)
37
44
  return result;
38
- const ctx = this.getContext?.();
39
- const request = ctx?.request ?? ctx;
40
- const authAgent = request?.tpsAgent;
41
- const isAdminAgent = request?.tpsAgentIsAdmin ?? false;
42
- // Non-admin can only see their own credentials
43
- if (authAgent && !isAdminAgent && result.principalId !== authAgent) {
45
+ const auth = await resolveAgentAuth(this.getContext?.());
46
+ // Anonymous HTTP must NOT read a credential (previously it fell through the
47
+ // ownership check and returned the record sans tokenHash — a metadata leak).
48
+ if (auth.kind === "anonymous") {
49
+ return new Response(JSON.stringify({ error: "forbidden" }), {
50
+ status: 403, headers: { "content-type": "application/json" },
51
+ });
52
+ }
53
+ // Non-admin agent can only see their own credentials. (Internal + admin pass.)
54
+ if (auth.kind === "agent" && !auth.isAdmin && result.principalId !== auth.agentId) {
44
55
  return new Response(JSON.stringify({ error: "forbidden" }), {
45
56
  status: 403, headers: { "content-type": "application/json" },
46
57
  });
@@ -55,6 +55,13 @@ function verifyEd25519Signature(publicKeyHex, authHeader, officeId) {
55
55
  }
56
56
  }
57
57
  export class IngestEvents extends Resource {
58
+ // Public POST by design: the handler self-verifies the OFFICE's Ed25519
59
+ // signature against ObsOffice.publicKey (not an agent identity), so it can't use
60
+ // agent auth. allowCreate=true lets the request reach post() where that
61
+ // verification happens — same self-gating pattern as FederationPair/FederationSync.
62
+ allowCreate() {
63
+ return true;
64
+ }
58
65
  async post(body, context) {
59
66
  const request = this.request;
60
67
  const authHeader = request?.headers?.get?.("authorization") ?? request?.headers?.authorization;
@@ -0,0 +1,13 @@
1
+ import { databases } from "@harperfast/harper";
2
+ import { allowVerified, allowAdmin } from "./agent-auth.js";
3
+ /**
4
+ * Instance is read-only reference data for agents (flair_agent grant: read only).
5
+ * Verified agents + admin may read; writes are admin/internal only. Self-authorizes
6
+ * now that the global gate is non-rejecting (anonymous denied).
7
+ */
8
+ export class Instance extends databases.flair.Instance {
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
+ }
@@ -1,6 +1,40 @@
1
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
+ * Integration records are agent-owned. Auth: the non-rejecting gate annotates the
7
+ * request; this resource self-enforces (resolveAgentAuth → internal/agent/anonymous).
8
+ * Anonymous HTTP is denied on every path; non-admin agents are scoped to their own
9
+ * agentId. Mirrors the WorkspaceState pattern.
10
+ */
2
11
  export class Integration extends databases.flair.Integration {
12
+ _auth() {
13
+ return resolveAgentAuth(this.getContext?.());
14
+ }
15
+ async search(query) {
16
+ const auth = await this._auth();
17
+ if (auth.kind === "anonymous")
18
+ return UNAUTH();
19
+ if (auth.kind === "internal" || (auth.kind === "agent" && auth.isAdmin)) {
20
+ return super.search(query);
21
+ }
22
+ const agentIdCondition = { attribute: "agentId", comparator: "equals", value: auth.agentId };
23
+ if (query && typeof query === "object" && !Array.isArray(query)) {
24
+ const existing = query.conditions ?? [];
25
+ query.conditions = Array.isArray(existing) ? [agentIdCondition, ...existing] : [agentIdCondition, existing];
26
+ return super.search(query);
27
+ }
28
+ const conditions = Array.isArray(query) && query.length > 0 ? [agentIdCondition, ...query] : [agentIdCondition];
29
+ return super.search(conditions);
30
+ }
3
31
  async post(content, context) {
32
+ const auth = await this._auth();
33
+ if (auth.kind === "anonymous")
34
+ return UNAUTH();
35
+ if (auth.kind === "agent" && !auth.isAdmin && content?.agentId && content.agentId !== auth.agentId) {
36
+ return FORBIDDEN("forbidden: cannot write integration for another agent");
37
+ }
4
38
  // S31-A: API never accepts plaintext credentials.
5
39
  if (typeof content?.credential === "string" || typeof content?.token === "string") {
6
40
  return new Response(JSON.stringify({ error: "plaintext_credentials_forbidden" }), {
@@ -10,4 +44,34 @@ export class Integration extends databases.flair.Integration {
10
44
  }
11
45
  return super.post(content, context);
12
46
  }
47
+ async put(content, context) {
48
+ const auth = await this._auth();
49
+ if (auth.kind === "anonymous")
50
+ return UNAUTH();
51
+ if (auth.kind === "agent" && !auth.isAdmin && content?.agentId && content.agentId !== auth.agentId) {
52
+ return FORBIDDEN("forbidden: cannot write integration for another agent");
53
+ }
54
+ if (typeof content?.credential === "string" || typeof content?.token === "string") {
55
+ return new Response(JSON.stringify({ error: "plaintext_credentials_forbidden" }), {
56
+ status: 400,
57
+ headers: { "Content-Type": "application/json" },
58
+ });
59
+ }
60
+ return super.put(content, context);
61
+ }
62
+ async delete(id) {
63
+ const auth = await this._auth();
64
+ if (auth.kind === "anonymous")
65
+ return UNAUTH();
66
+ if (auth.kind === "internal" || (auth.kind === "agent" && auth.isAdmin)) {
67
+ return super.delete(id);
68
+ }
69
+ const record = await this.get(id);
70
+ if (!record)
71
+ return super.delete(id);
72
+ if (record.agentId !== auth.agentId) {
73
+ return FORBIDDEN("forbidden: cannot delete integration for another agent");
74
+ }
75
+ return super.delete(id);
76
+ }
13
77
  }
@@ -1,6 +1,6 @@
1
1
  import { databases } from "@harperfast/harper";
2
2
  import { patchRecord, withDetachedTxn } from "./table-helpers.js";
3
- import { isAdmin } from "./auth-middleware.js";
3
+ import { isAdmin, resolveAgentAuth } 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";
@@ -16,16 +16,22 @@ export class Memory extends databases.flair.Memory {
16
16
  * Non-admin calls also check MemoryGrant to include granted memories.
17
17
  */
18
18
  async search(query) {
19
- // Access request context via Harper's Resource instance context
19
+ // Access request context via Harper's Resource instance context.
20
20
  const ctx = this.getContext?.();
21
- const request = ctx?.request ?? ctx;
22
- const authAgent = request?.tpsAgent;
23
- const isAdminAgent = request?.tpsAgentIsAdmin ?? false;
24
- // No auth context (internal admin call) or admin agent — unfiltered
25
- if (!authAgent || isAdminAgent) {
21
+ const auth = await resolveAgentAuth(ctx);
22
+ // Anonymous HTTP must NOT read memories. (Previously `!authAgent` was treated
23
+ // as unfiltered the anonymous-read leak once the gate stops rejecting.)
24
+ if (auth.kind === "anonymous") {
25
+ return new Response(JSON.stringify({ error: "authentication required" }), {
26
+ status: 401, headers: { "content-type": "application/json" },
27
+ });
28
+ }
29
+ // Trusted internal call (no request context) or admin agent — unfiltered.
30
+ if (auth.kind === "internal" || (auth.kind === "agent" && auth.isAdmin)) {
26
31
  return super.search(query);
27
32
  }
28
- // Collect agentIds this agent may read: own + any granted owners
33
+ // Non-admin agent: scope to own + granted owners.
34
+ const authAgent = auth.agentId;
29
35
  const allowedOwners = [authAgent];
30
36
  try {
31
37
  for await (const grant of databases.flair.MemoryGrant.search({
@@ -68,6 +74,26 @@ export class Memory extends databases.flair.Memory {
68
74
  if (!rl.allowed)
69
75
  return rateLimitResponse(rl.retryAfterMs, "write");
70
76
  }
77
+ // Create ownership: a non-admin agent may only write memories it owns. Use
78
+ // resolveAgentAuth (reads the gate's tpsAgent annotation) — NOT context.user
79
+ // .username, which is the fallback "admin" super_user while de-elevation is
80
+ // dormant and would wrongly 403 every agent's own write. internal/admin → pass.
81
+ {
82
+ const auth = await resolveAgentAuth(ctx);
83
+ // Anonymous HTTP must NOT write. Pre-flip the global gate rejected no-auth
84
+ // upstream; with the non-rejecting gate, each write path self-enforces (same
85
+ // rule search() applies to reads).
86
+ if (auth.kind === "anonymous") {
87
+ return new Response(JSON.stringify({ error: "authentication required" }), {
88
+ status: 401, headers: { "Content-Type": "application/json" },
89
+ });
90
+ }
91
+ if (auth.kind === "agent" && !auth.isAdmin && content?.agentId && content.agentId !== auth.agentId) {
92
+ return new Response(JSON.stringify({ error: "forbidden: cannot write memory owned by another agent" }), {
93
+ status: 403, headers: { "Content-Type": "application/json" },
94
+ });
95
+ }
96
+ }
71
97
  content.durability ||= "standard";
72
98
  content.createdAt = new Date().toISOString();
73
99
  content.updatedAt = content.createdAt;
@@ -151,6 +177,25 @@ export class Memory extends databases.flair.Memory {
151
177
  delete content._reindex;
152
178
  return super.put(content);
153
179
  }
180
+ // Create/update ownership (same rule as post): a non-admin agent may only
181
+ // write memories it owns, via resolveAgentAuth (gate annotation), not
182
+ // context.user.username (the dormant-de-elevation fallback is "admin").
183
+ // The _reindex admin path above bypasses this.
184
+ {
185
+ const octx = this.getContext?.();
186
+ const auth = await resolveAgentAuth(octx);
187
+ // Anonymous HTTP must NOT write (non-rejecting gate → self-enforce here).
188
+ if (auth.kind === "anonymous") {
189
+ return new Response(JSON.stringify({ error: "authentication required" }), {
190
+ status: 401, headers: { "Content-Type": "application/json" },
191
+ });
192
+ }
193
+ if (auth.kind === "agent" && !auth.isAdmin && content?.agentId && content.agentId !== auth.agentId) {
194
+ return new Response(JSON.stringify({ error: "forbidden: cannot write memory owned by another agent" }), {
195
+ status: 403, headers: { "Content-Type": "application/json" },
196
+ });
197
+ }
198
+ }
154
199
  const now = new Date().toISOString();
155
200
  content.updatedAt = now;
156
201
  // Set defaults that post() sets — put() is also used for new records via CLI
@@ -1,4 +1,5 @@
1
1
  import { Resource, databases } from "@harperfast/harper";
2
+ import { allowVerified } from "./agent-auth.js";
2
3
  import { getEmbedding } from "./embeddings-provider.js";
3
4
  import { wrapUntrusted } from "./content-safety.js";
4
5
  /**
@@ -40,6 +41,13 @@ function formatMemory(m, supersedes) {
40
41
  return base;
41
42
  }
42
43
  export class BootstrapMemories extends Resource {
44
+ // Self-authorize via the Ed25519 agent verify (the auth reshape removes the
45
+ // gate's admin super_user elevation, so custom resources must self-gate or
46
+ // Harper denies them for the least-privilege flair_agent role). Any verified
47
+ // agent may bootstrap; per-agent scoping is enforced in post() below.
48
+ async allowCreate() {
49
+ return allowVerified(this.getContext?.());
50
+ }
43
51
  async post(data, _context) {
44
52
  const { agentId: bodyAgentId, currentTask, maxTokens = 4000, includeSoul = true, since, channel, // e.g., "discord", "tps-mail", "claude-code"
45
53
  surface, // e.g., "tps-build", "tps-review", "cli-session"
@@ -16,7 +16,7 @@
16
16
  * prompt string
17
17
  */
18
18
  import { Resource, databases } from "@harperfast/harper";
19
- import { isAdmin } from "./auth-middleware.js";
19
+ import { isAdmin, allowVerified } from "./agent-auth.js";
20
20
  function parseDuration(s) {
21
21
  const m = s.match(/^(\d+)([dhm])$/);
22
22
  if (!m)
@@ -56,6 +56,12 @@ function evaluate(record, now, olderThanMs) {
56
56
  return { memory, suggestion: "keep", reason: `Retrieved ${count} times, ${Math.round(daysSinceRetrieved)} days since last retrieval` };
57
57
  }
58
58
  export class ConsolidateMemories extends Resource {
59
+ // Self-authorize via the Ed25519 agent verify (auth reshape removes the gate's
60
+ // admin elevation). Any verified agent may consolidate; the isAdmin checks in
61
+ // post() handle finer-grained authorization.
62
+ async allowCreate() {
63
+ return allowVerified(this.getContext?.());
64
+ }
59
65
  async post(data) {
60
66
  const { agentId: bodyAgentId, scope = "persistent", olderThan = "30d", limit = 20 } = data || {};
61
67
  // See SemanticSearch / MemoryBootstrap — `this.request` isn't populated on
@@ -1,6 +1,14 @@
1
1
  import { Resource, databases } from "@harperfast/harper";
2
+ import { allowVerified } from "./agent-auth.js";
2
3
  import { computeContentHash, findExistingMemoryByContentHash } from "./memory-feed-lib.js";
3
4
  export class FeedMemories extends Resource {
5
+ // Self-authorize via the Ed25519 agent verify (the auth reshape removes the
6
+ // gate's admin elevation). NOTE: post() trusts content.agentId from the body —
7
+ // closing that create-spoofing gap is tracked with the table-resource
8
+ // create-ownership work (Memory.allowCreate), not in this auth-coverage pass.
9
+ async allowCreate() {
10
+ return allowVerified(this.getContext?.());
11
+ }
4
12
  async post(content) {
5
13
  const agentId = String(content?.agentId ?? "");
6
14
  const body = String(content?.content ?? "");