@tpsdev-ai/flair 0.11.0 → 0.13.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 (38) hide show
  1. package/dist/cli.js +247 -61
  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 +8 -39
  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 +29 -42
  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/dist/resources/memory-consolidate-lib.js +72 -0
  37. package/dist/resources/scoring.js +52 -0
  38. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -510,35 +510,11 @@ export async function seedAgentViaOpsApi(opsPortOrUrl, agentId, pubKeyB64url, ad
510
510
  throw new Error(`Operations API insert failed (${res.status}): ${text}`);
511
511
  }
512
512
  }
513
- // Seed an agent record via the Harper REST API.
514
- // Uses localhost and the given HTTP port.
515
- export async function seedAgentViaRestApi(httpPort, agentId, pubKeyB64url, adminPass) {
516
- const baseUrl = `http://127.0.0.1:${httpPort}`;
517
- const body = {
518
- operation: "insert",
519
- database: "flair",
520
- table: "Agent",
521
- records: [{ id: agentId, name: agentId, publicKey: pubKeyB64url, createdAt: new Date().toISOString() }],
522
- };
523
- // Only send Authorization header if adminPass is provided.
524
- // Matches the auth pattern in api() which respects authorizeLocal=true.
525
- const headers = { "Content-Type": "application/json" };
526
- if (adminPass) {
527
- headers.Authorization = `Basic ${Buffer.from(`admin:${adminPass}`).toString("base64")}`;
528
- }
529
- const res = await fetch(`${baseUrl}/Agent`, {
530
- method: "POST",
531
- headers,
532
- body: JSON.stringify(body),
533
- signal: AbortSignal.timeout(10_000),
534
- });
535
- if (!res.ok) {
536
- const text = await res.text().catch(() => "");
537
- if (res.status === 409 || text.includes("duplicate") || text.includes("already exists"))
538
- return;
539
- throw new Error(`REST API insert failed (${res.status}): ${text}`);
540
- }
541
- }
513
+ // NOTE: agent records are seeded exclusively via the Harper operations API
514
+ // (seedAgentViaOpsApi above). A former seedAgentViaRestApi() helper POSTed the
515
+ // ops-insert body to the REST root, which Harper 405s as a collection POST to
516
+ // /Agent (the Agent table resource has no POST handler). It was removed in the
517
+ // #499 fix; do not reintroduce a REST-root insert path.
542
518
  // ─── FederationInstance seed via ops API ──────────────────────────────────────
543
519
  //
544
520
  // Remote init writes FederationInstance through the ops API (Basic auth with
@@ -736,25 +712,30 @@ export async function provisionFabric(target, opsTarget, clusterAdminUser, clust
736
712
  // FederationPair resource handler. The role carries no table permissions itself
737
713
  // — the resource's own allowCreate bypass handles route-level access once the
738
714
  // request gets through the auth gate.
739
- /** Canonical permission spec for flair_pair_initiator. */
715
+ /**
716
+ * Canonical permission spec for flair_pair_initiator.
717
+ *
718
+ * The role intentionally carries NO table permissions — its only job is to exist
719
+ * so bootstrap credentials can pass Harper platform auth before reaching the
720
+ * FederationPair resource handler (the resource's own allowCreate bypass handles
721
+ * route-level access). A bare role with both flags false is valid, grants nothing,
722
+ * and is exactly that intent.
723
+ *
724
+ * This previously carried an all-false `flair.tables` block, but Harper's add_role
725
+ * REJECTED the whole spec with a 400 (verified live against a spawned Harper):
726
+ * - `cluster_user` is not a recognized top-level key — Harper reads unknown keys
727
+ * as database names ("database 'cluster_user' does not exist");
728
+ * - the table names were the logical shorthand, not the real @table names
729
+ * ("Table 'flair.Workspace' does not exist" — it's WorkspaceState; "Event" is
730
+ * OrgEvent; "OAuth" is OAuthClient);
731
+ * - each grant omitted the required `attribute_permissions` array.
732
+ * The all-false block granted nothing anyway, so dropping it loses no capability
733
+ * and unbreaks fresh hub provisioning (where add_role runs and the 400 aborted
734
+ * `flair init --remote`). Only top-level booleans Harper recognizes remain.
735
+ */
740
736
  const PAIR_INITIATOR_PERMISSION = {
741
737
  super_user: false,
742
- cluster_user: false,
743
738
  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
739
  };
759
740
  /**
760
741
  * Idempotently ensures the `flair_pair_initiator` role exists on the Harper
@@ -805,6 +786,174 @@ export async function ensureFlairPairInitiatorRole(opsUrl, adminUser, adminPass)
805
786
  }, adminUser, adminPass);
806
787
  console.log(`Role '${ROLE_NAME}' updated ✓`);
807
788
  }
789
+ // ─── flair_agent role ────────────────────────────────────────────────────────
790
+ //
791
+ // The auth reshape replaces the global gate's "verified agent borrows admin
792
+ // super_user" elevation with a real, least-privilege Harper role. After an
793
+ // agent's Ed25519 signature verifies, resources resolve the request to the
794
+ // shared `flair_agent`-roled user instead of admin — so agents get exactly the
795
+ // table CRUD below and nothing more. Critically: with no `super_user` and no
796
+ // operations grants, /sql and /graphql become NATIVELY 403 for agents (the
797
+ // raw-query block the gate hand-rolled is now enforced by Harper itself).
798
+ //
799
+ // Row-level ownership (an agent touches only its OWN memories/soul/events) is
800
+ // NOT expressible in Harper's role model — it stays in each resource's allow*,
801
+ // keyed on the Ed25519-verified agentId. So these per-table grants are the
802
+ // coarse CRUD envelope; allow* is the ownership boundary inside it.
803
+ //
804
+ // VALIDATION GATES (must confirm against a live Harper before this role goes
805
+ // live — flagged for Sherlock + the PR, not assumed):
806
+ // 1. HNSW/vector search (SemanticSearch over Memory) works with table `read`
807
+ // alone — the old elevation comment claimed admin perms were needed for
808
+ // "HNSW-capable" access; confirm `read` suffices or widen precisely.
809
+ // 2. Role table keys must EXACTLY match the @table names (Memory, OrgEvent,
810
+ // WorkspaceState, OAuthClient — NOT the logical Memory/Event/Workspace/OAuth
811
+ // shorthand the flair_pair_initiator spec used, which was harmless only
812
+ // because every grant there is false).
813
+ // 3. Obs* writes: if the presence-emitter writes ObsAgentSnapshot AS the agent
814
+ // it needs insert/update — currently read-only here; confirm the writer's
815
+ // identity (system vs agent) and widen only if it's the agent.
816
+ // Harper 5.0.21 add_role requires an `attribute_permissions` array on EVERY table
817
+ // grant (empty = no attribute-level restriction, so the table-level CRUD applies);
818
+ // omitting it makes add_role reject the whole spec ("Missing 'attribute_permissions'
819
+ // array"). Validated live against a spawned Harper. This helper guarantees the
820
+ // array is never forgotten. Also: `cluster_user` is NOT a valid top-level key —
821
+ // Harper reads unrecognized top-level keys as database names ("database
822
+ // 'cluster_user' does not exist"); only super_user / structure_user are recognized.
823
+ const grant = (read, insert, update, del) => ({ read, insert, update, delete: del, attribute_permissions: [] });
824
+ /** Canonical permission spec for flair_agent (least-privilege; real @table names). */
825
+ const FLAIR_AGENT_PERMISSION = {
826
+ super_user: false,
827
+ structure_user: false,
828
+ flair: {
829
+ tables: {
830
+ // Core agent-owned data — CRUD envelope; ownership enforced in allow*.
831
+ Memory: grant(true, true, true, true),
832
+ MemoryCandidate: grant(true, true, true, true),
833
+ MemoryGrant: grant(true, true, true, true),
834
+ Soul: grant(true, true, true, false),
835
+ OrgEvent: grant(true, true, true, true),
836
+ WorkspaceState: grant(true, true, true, true),
837
+ Relationship: grant(true, true, true, true),
838
+ Integration: grant(true, true, true, true),
839
+ Credential: grant(true, true, true, true),
840
+ Presence: grant(true, true, true, false),
841
+ // Agent: read for discovery, update own card; creation/removal is admin.
842
+ Agent: grant(true, false, true, false),
843
+ // Read-only reference data.
844
+ Instance: grant(true, false, false, false),
845
+ // Observatory read-models — public reads; writes are system-driven (gate 3).
846
+ ObsOffice: grant(true, false, false, false),
847
+ ObsAgentSnapshot: grant(true, false, false, false),
848
+ ObsEventFeed: grant(true, false, false, false),
849
+ // Federation / OAuth / IdP / internal — system + admin only; agents get none.
850
+ Peer: grant(false, false, false, false),
851
+ PairingToken: grant(false, false, false, false),
852
+ SyncLog: grant(false, false, false, false),
853
+ OAuthClient: grant(false, false, false, false),
854
+ OAuthToken: grant(false, false, false, false),
855
+ OAuthAuthCode: grant(false, false, false, false),
856
+ IdpConfig: grant(false, false, false, false),
857
+ IdJagReplay: grant(false, false, false, false),
858
+ },
859
+ },
860
+ };
861
+ /**
862
+ * Idempotently ensures the `flair_agent` role exists on the Harper instance at
863
+ * `opsUrl` with the canonical least-privilege spec. Same list/add/alter shape as
864
+ * ensureFlairPairInitiatorRole.
865
+ *
866
+ * - absent → `add_role`
867
+ * - exists with different permissions → `alter_role`
868
+ * - already matches → no-op
869
+ */
870
+ export async function ensureFlairAgentRole(opsUrl, adminUser, adminPass) {
871
+ const ROLE_NAME = "flair_agent";
872
+ let roles = [];
873
+ try {
874
+ const result = await callOpsApi(opsUrl, { operation: "list_roles" }, adminUser, adminPass);
875
+ roles = Array.isArray(result) ? result : [];
876
+ }
877
+ catch (err) {
878
+ const msg = err instanceof Error ? err.message : String(err);
879
+ throw new Error(`ensureFlairAgentRole: list_roles failed: ${msg}`);
880
+ }
881
+ const existing = roles.find((r) => r.role === ROLE_NAME || r.name === ROLE_NAME);
882
+ if (!existing) {
883
+ console.log(`Creating role '${ROLE_NAME}'...`);
884
+ await callOpsApi(opsUrl, {
885
+ operation: "add_role",
886
+ role: ROLE_NAME,
887
+ permission: FLAIR_AGENT_PERMISSION,
888
+ }, adminUser, adminPass);
889
+ console.log(`Role '${ROLE_NAME}' created ✓`);
890
+ return;
891
+ }
892
+ const existingPerm = existing.permission ?? existing.role?.permission;
893
+ const canonicalStr = JSON.stringify(FLAIR_AGENT_PERMISSION);
894
+ const existingStr = JSON.stringify(existingPerm);
895
+ if (existingStr === canonicalStr) {
896
+ console.log(`Role '${ROLE_NAME}' already exists with correct permissions — skipping`);
897
+ return;
898
+ }
899
+ console.log(`Role '${ROLE_NAME}' exists but permissions differ — updating...`);
900
+ await callOpsApi(opsUrl, {
901
+ operation: "alter_role",
902
+ role: ROLE_NAME,
903
+ permission: FLAIR_AGENT_PERMISSION,
904
+ }, adminUser, adminPass);
905
+ console.log(`Role '${ROLE_NAME}' updated ✓`);
906
+ }
907
+ /**
908
+ * Shared Harper user that verified Ed25519 agents are resolved to.
909
+ * MUST match FLAIR_AGENT_USERNAME in resources/agent-auth.ts (the gate side).
910
+ * Not imported from there because cli.ts is standalone and that module pulls in
911
+ * Harper's native bindings — kept as a cross-referenced literal instead.
912
+ */
913
+ export const FLAIR_AGENT_USERNAME = "flair-agent";
914
+ /**
915
+ * Idempotently ensures the shared `flair-agent` Harper user exists with the
916
+ * `flair_agent` role. Verified Ed25519 agents are resolved to THIS user
917
+ * (`getUser("flair-agent", null)` — no password check, identity already proven
918
+ * cryptographically), replacing the old `getUser("admin")` super_user elevation.
919
+ *
920
+ * The password is random and never used for authentication: the agent path
921
+ * resolves the user without it, and the auth gate's Basic path only accepts
922
+ * super_user / pair-bootstrap (a flair_agent Basic login is rejected there), so
923
+ * a Basic login as this user can't reach anything. Random + unused = safe.
924
+ *
925
+ * Row-level ownership stays in each resource's allow* (keyed on the verified
926
+ * agentId); this shared user only carries the flair_agent role's table grants.
927
+ */
928
+ export async function ensureFlairAgentUser(opsUrl, adminUser, adminPass) {
929
+ const unusedPassword = randomBytes(32).toString("base64url");
930
+ try {
931
+ await callOpsApi(opsUrl, {
932
+ operation: "add_user",
933
+ username: FLAIR_AGENT_USERNAME,
934
+ password: unusedPassword,
935
+ role: "flair_agent",
936
+ active: true,
937
+ }, adminUser, adminPass);
938
+ console.log(`User '${FLAIR_AGENT_USERNAME}' created ✓`);
939
+ }
940
+ catch (err) {
941
+ const msg = err instanceof Error ? err.message : String(err);
942
+ if (msg.includes("already exists") || msg.includes("duplicate")) {
943
+ // Idempotent: ensure the role is correct without churning the password.
944
+ await callOpsApi(opsUrl, {
945
+ operation: "alter_user",
946
+ username: FLAIR_AGENT_USERNAME,
947
+ role: "flair_agent",
948
+ active: true,
949
+ }, adminUser, adminPass);
950
+ console.log(`User '${FLAIR_AGENT_USERNAME}' already exists — role ensured ✓`);
951
+ }
952
+ else {
953
+ throw err;
954
+ }
955
+ }
956
+ }
808
957
  // ─── Upgrade presence probes ──────────────────────────────────────────────────
809
958
  //
810
959
  // `flair upgrade` previously called `npm list -g <pkg>` to detect the installed
@@ -1126,6 +1275,16 @@ program
1126
1275
  if (opts.remote) {
1127
1276
  await ensureFlairPairInitiatorRole(opsUrl, DEFAULT_ADMIN_USER, flairAdminPass);
1128
1277
  }
1278
+ // Every flair instance has agents, so provision the least-privilege
1279
+ // flair_agent role (idempotent, harmless until a user is assigned to it).
1280
+ await ensureFlairAgentRole(opsUrl, DEFAULT_ADMIN_USER, flairAdminPass);
1281
+ // THE FLIP (auth-rbac): provision the shared least-privilege flair-agent user.
1282
+ // This ACTIVATES the gate's per-agent de-elevation (verified non-admin agents
1283
+ // resolve to flair-agent instead of admin super_user). Safe now: #487 gave
1284
+ // every agent-facing resource its own allow* + resolveAgentAuth, so they no
1285
+ // longer rely on the admin super_user bypass. The gate also falls back to
1286
+ // admin if this user is ever absent, so de-elevation degrades gracefully.
1287
+ await ensureFlairAgentUser(opsUrl, DEFAULT_ADMIN_USER, flairAdminPass);
1129
1288
  }
1130
1289
  else {
1131
1290
  // ── Existing behavior: --admin-pass required for already-running Flair ──
@@ -1797,8 +1956,21 @@ program
1797
1956
  writeConfig(httpPort);
1798
1957
  }
1799
1958
  else {
1800
- // Flair already initialized — resolve admin pass from env or running instance
1959
+ // Flair already initialized — resolve admin pass from env, falling back to
1960
+ // the persisted ~/.flair/admin-pass written at first install. Agent
1961
+ // seeding now goes through the ops API (#499), which always requires Basic
1962
+ // admin auth (it does NOT honor authorizeLocal), so a re-run of
1963
+ // `flair install --agent <new-id>` against an already-initialized local
1964
+ // Flair needs a real pass even when no env var is set.
1801
1965
  adminPass = process.env.FLAIR_ADMIN_PASS ?? process.env.HDB_ADMIN_PASSWORD ?? "";
1966
+ if (!adminPass) {
1967
+ try {
1968
+ const persisted = join(homedir(), ".flair", "admin-pass");
1969
+ if (existsSync(persisted))
1970
+ adminPass = readFileSync(persisted, "utf-8").trim();
1971
+ }
1972
+ catch { /* fall through with empty pass */ }
1973
+ }
1802
1974
  }
1803
1975
  const httpUrl = `http://127.0.0.1:${httpPort}`;
1804
1976
  // ── Step 2: Detect MCP clients ──
@@ -1857,17 +2029,16 @@ program
1857
2029
  writeFileSync(pubPath, Buffer.from(kp.publicKey));
1858
2030
  const pubKeyB64url = b64url(kp.publicKey);
1859
2031
  console.log(`Keypair written: ${privPath} ✓`);
1860
- // Seed agent - use REST API for local Harper, Ops API only when --ops-target is specified
1861
- if (opts.opsTarget) {
1862
- // Remote Harper via explicit ops target
1863
- console.log(`Seeding agent '${agentId}' via operations API (--ops-target)...`);
1864
- await seedAgentViaOpsApi(opts.opsTarget, agentId, pubKeyB64url, adminUser, adminPass);
1865
- }
1866
- else {
1867
- // Local Harper - use REST API
1868
- console.log(`Seeding agent '${agentId}' via REST API...`);
1869
- await seedAgentViaRestApi(httpPort, agentId, pubKeyB64url, adminPass);
1870
- }
2032
+ // Seed agent via the Harper operations API. The Agent table is a REST
2033
+ // resource without a POST handler, so the insert body must go to the ops
2034
+ // API (POST <opsUrl>/ with {operation:"insert",...}), NOT the REST root
2035
+ // (which 405s the body as a collection POST to /Agent). This is the same
2036
+ // path `flair agent add` uses. (#499)
2037
+ const seedOpsTarget = opts.opsTarget ?? opsPort;
2038
+ console.log(opts.opsTarget
2039
+ ? `Seeding agent '${agentId}' via operations API (--ops-target)...`
2040
+ : `Seeding agent '${agentId}' via operations API...`);
2041
+ await seedAgentViaOpsApi(seedOpsTarget, agentId, pubKeyB64url, adminUser, adminPass);
1871
2042
  console.log(`Agent '${agentId}' registered ✓`);
1872
2043
  }
1873
2044
  // ── Step 4: Wire MCP clients ──
@@ -2026,10 +2197,16 @@ agent
2026
2197
  if (adminPass) {
2027
2198
  const opsPort = resolveOpsPort(opts);
2028
2199
  const auth = Buffer.from(`${DEFAULT_ADMIN_USER}:${adminPass}`).toString("base64");
2200
+ // List every Agent without null-scanning the primary key. A
2201
+ // `starts_with ""` on `id` makes Harper search the index for nulls, which
2202
+ // the bundled Harper (5.0.21) rejects with "id is not indexed for nulls".
2203
+ // Use `createdAt > 1970-01-01` as the total "select all" predicate: every
2204
+ // Agent row has a non-null createdAt (schema: createdAt: String!), and its
2205
+ // index is built — same pattern as the `flair reembed` Memory scan. (#500)
2029
2206
  const res = await fetch(`http://127.0.0.1:${opsPort}/`, {
2030
2207
  method: "POST",
2031
2208
  headers: { "Content-Type": "application/json", Authorization: `Basic ${auth}` },
2032
- body: JSON.stringify({ operation: "search_by_value", schema: "flair", table: "Agent", search_attribute: "id", search_type: "starts_with", search_value: "", get_attributes: ["id", "name", "createdAt"] }),
2209
+ body: JSON.stringify({ operation: "search_by_conditions", schema: "flair", table: "Agent", operator: "and", conditions: [{ search_attribute: "createdAt", search_type: "greater_than", search_value: "1970-01-01" }], get_attributes: ["id", "name", "createdAt"] }),
2033
2210
  });
2034
2211
  if (!res.ok) {
2035
2212
  const text = await res.text().catch(() => "");
@@ -4015,7 +4192,7 @@ rem
4015
4192
  console.log(result.prompt ?? "(no prompt returned)");
4016
4193
  console.log("--- End Prompt ---\n");
4017
4194
  console.log("Feed the prompt above to your LLM, then write insights back with:");
4018
- console.log(" flair memory add --agent <id> --content <insight> --durability persistent");
4195
+ console.log(" flair memory add --agent <id> --content <insight> --durability persistent --derived-from <source-ids>");
4019
4196
  }
4020
4197
  catch (err) {
4021
4198
  console.error(`Error: ${err.message}`);
@@ -6827,6 +7004,7 @@ memory.command("add [content]").requiredOption("--agent <id>")
6827
7004
  .option("--durability <d>", "standard").option("--tags <csv>")
6828
7005
  .option("--summary <text>", "agent-set multi-sentence dense compression (3-tier chain: subject → summary → content; ops-wkoh)")
6829
7006
  .option("--subject <text>", "one-line title / entity this memory is about")
7007
+ .option("--derived-from <csv>", "Comma-separated source Memory IDs this memory was distilled/reflected from (sets Memory.derivedFrom; used by the `rem rapid` reflection loop)")
6830
7008
  .action(async (contentArg, opts) => {
6831
7009
  const content = contentArg ?? opts.content;
6832
7010
  if (!content) {
@@ -6843,6 +7021,9 @@ memory.command("add [content]").requiredOption("--agent <id>")
6843
7021
  body.summary = opts.summary;
6844
7022
  if (opts.subject)
6845
7023
  body.subject = opts.subject;
7024
+ if (opts.derivedFrom) {
7025
+ body.derivedFrom = String(opts.derivedFrom).split(",").map((x) => x.trim()).filter(Boolean);
7026
+ }
6846
7027
  const out = await api("PUT", `/Memory/${memId}`, body);
6847
7028
  console.log(JSON.stringify(out, null, 2));
6848
7029
  });
@@ -7466,12 +7647,17 @@ soul.command("set")
7466
7647
  .option("--durability <d>", "permanent")
7467
7648
  .option("--json", "Emit raw JSON response (also: pipe + FLAIR_OUTPUT=json)")
7468
7649
  .action(async (opts) => {
7469
- const out = await api("POST", "/Soul", {
7470
- id: `${opts.agent}:${opts.key}`,
7650
+ // PUT /Soul/{agentId:key} (upsert by id), matching flair-client's soul.set().
7651
+ // The Soul table resource has no POST handler, so a collection POST /Soul
7652
+ // 405s; the record must be written by its primary key. (#498)
7653
+ const id = `${opts.agent}:${opts.key}`;
7654
+ const out = await api("PUT", `/Soul/${encodeURIComponent(id)}`, {
7655
+ id,
7471
7656
  agentId: opts.agent,
7472
7657
  key: opts.key,
7473
7658
  value: opts.value,
7474
7659
  durability: opts.durability,
7660
+ createdAt: new Date().toISOString(),
7475
7661
  });
7476
7662
  const mode = render.resolveOutputMode(opts);
7477
7663
  if (mode === "json") {
@@ -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
  }