@tpsdev-ai/flair 0.10.1 → 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 (37) hide show
  1. package/dist/cli.js +318 -34
  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 +300 -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 +89 -57
  36. package/package.json +1 -1
  37. package/schemas/schema.graphql +9 -0
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 ──
@@ -3454,24 +3637,35 @@ export async function runFederationSyncOnce(opts) {
3454
3637
  }
3455
3638
  let totalBatches = 0;
3456
3639
  for (const table of tables) {
3457
- let res;
3458
- try {
3459
- res = await fetch(`${opsEndpoint}/`, {
3460
- method: "POST",
3461
- headers: { "Content-Type": "application/json", Authorization: auth },
3462
- body: JSON.stringify({ operation: "search_by_conditions", schema: "flair", table, operator: "and", conditions: [{ search_attribute: "updatedAt", search_type: "greater_than", search_value: since }], get_attributes: ["*"] }),
3463
- signal: AbortSignal.timeout(15_000),
3464
- });
3465
- }
3466
- catch (err) {
3467
- return { pushed: totalMerged, skipped: totalSkipped, error: err instanceof Error ? err : new Error(String(err)) };
3468
- }
3469
- if (!res.ok) {
3470
- const text = await res.text().catch(() => "");
3471
- return { pushed: totalMerged, skipped: totalSkipped, error: new Error(`SQL query failed (${res.status}): ${text}`) };
3640
+ let rows = [];
3641
+ for (const query of [
3642
+ { search_attribute: "updatedAt", search_type: "greater_than", search_value: since },
3643
+ // Rows with null updatedAt (legacy direct-insert rows) use createdAt.
3644
+ // COALESCE(updatedAt, createdAt) > since pick up null-updatedAt rows
3645
+ // whose createdAt > since. Filtered in JS below.
3646
+ { search_attribute: "updatedAt", search_type: "equals", search_value: null },
3647
+ ]) {
3648
+ let res;
3649
+ try {
3650
+ res = await fetch(`${opsEndpoint}/`, {
3651
+ method: "POST",
3652
+ headers: { "Content-Type": "application/json", Authorization: auth },
3653
+ body: JSON.stringify({ operation: "search_by_conditions", schema: "flair", table, operator: "and", conditions: [query], get_attributes: ["*"] }),
3654
+ signal: AbortSignal.timeout(15_000),
3655
+ });
3656
+ }
3657
+ catch (err) {
3658
+ return { pushed: totalMerged, skipped: totalSkipped, error: err instanceof Error ? err : new Error(String(err)) };
3659
+ }
3660
+ if (!res.ok) {
3661
+ const text = await res.text().catch(() => "");
3662
+ return { pushed: totalMerged, skipped: totalSkipped, error: new Error(`SQL query failed (${res.status}): ${text}`) };
3663
+ }
3664
+ const batch = await res.json();
3665
+ // For null-updatedAt rows, use createdAt as the effective timestamp.
3666
+ // Skip rows created before the last sync cursor.
3667
+ rows = rows.concat(batch.filter((r) => r.updatedAt !== null || r.createdAt > since));
3472
3668
  }
3473
- // Stream-collect records into batches
3474
- const rows = await res.json();
3475
3669
  if (rows.length === 0)
3476
3670
  continue;
3477
3671
  let batch = [];
@@ -3528,6 +3722,31 @@ export async function runFederationSyncOnce(opts) {
3528
3722
  console.warn(`⚠️ Local hub.lastSyncAt advance error: ${advErr?.message ?? advErr}. Next poll will re-send memories.`);
3529
3723
  }
3530
3724
  if (totalBatches === 0) {
3725
+ // ops-c7t9: no-change syncs must still ping the hub so it updates the
3726
+ // spoke's lastSyncAt (liveness). Without this, idle-but-alive spokes
3727
+ // look indistinguishable from dead ones on the hub dashboard.
3728
+ try {
3729
+ if (!secretKey)
3730
+ secretKey = await loadInstanceSecretKey(instance.id, opts);
3731
+ const pingBody = signBodyFresh({
3732
+ instanceId: instance.id,
3733
+ records: [],
3734
+ lamportClock: Date.now(),
3735
+ }, secretKey);
3736
+ const pingRes = await fetch(`${hubUrl}/FederationSync`, {
3737
+ method: "POST",
3738
+ headers: { "Content-Type": "application/json" },
3739
+ body: JSON.stringify(pingBody),
3740
+ signal: AbortSignal.timeout(10_000),
3741
+ });
3742
+ if (!pingRes.ok) {
3743
+ const txt = await pingRes.text().catch(() => "");
3744
+ console.warn(`⚠️ Liveness ping to hub failed (${pingRes.status}): ${txt.slice(0, 200)}. Hub won't update spoke liveness.`);
3745
+ }
3746
+ }
3747
+ catch (pingErr) {
3748
+ console.warn(`⚠️ Liveness ping error: ${pingErr?.message ?? pingErr}. Hub won't update spoke liveness.`);
3749
+ }
3531
3750
  console.log("No changes since last sync.");
3532
3751
  return { pushed: 0, skipped: 0 };
3533
3752
  }
@@ -8815,9 +9034,74 @@ program
8815
9034
  process.exit(1);
8816
9035
  }
8817
9036
  });
9037
+ // ─── flair presence ─────────────────────────────────────────────────────────
9038
+ const VALID_PRESENCE_ACTIVITIES = ["coding", "reviewing", "planning", "idle"];
9039
+ const MAX_TASK_LENGTH = 120;
9040
+ const presence = program.command("presence").description("Manage agent presence (The Office Space)");
9041
+ presence
9042
+ .command("set")
9043
+ .description("Set your agent's current activity and task (POST /Presence)")
9044
+ .option("--activity <activity>", `Activity type (${VALID_PRESENCE_ACTIVITIES.join("|")})`)
9045
+ .option("--task <text>", "Short description of what you're working on")
9046
+ .option("--agent <id>", "Agent ID (env: FLAIR_AGENT_ID)")
9047
+ .option("--port <port>", "Harper HTTP port")
9048
+ .option("--target <url>", "Remote Flair URL (env: FLAIR_TARGET)")
9049
+ .action(async (opts) => {
9050
+ const agentId = resolveAgentIdOrEnv(opts);
9051
+ if (!agentId) {
9052
+ console.error("Error: agent ID required. Pass --agent <id> or set FLAIR_AGENT_ID environment variable.");
9053
+ process.exit(1);
9054
+ }
9055
+ // Validate activity
9056
+ if (!opts.activity) {
9057
+ console.error("Error: --activity is required.");
9058
+ process.exit(1);
9059
+ }
9060
+ const activity = opts.activity;
9061
+ if (!VALID_PRESENCE_ACTIVITIES.includes(activity)) {
9062
+ console.error(`Error: invalid activity '${activity}'. Must be one of: ${VALID_PRESENCE_ACTIVITIES.join(", ")}`);
9063
+ process.exit(1);
9064
+ }
9065
+ // Validate task length
9066
+ const task = opts.task ?? undefined;
9067
+ if (task && task.length > MAX_TASK_LENGTH) {
9068
+ console.error(`Error: --task exceeds ${MAX_TASK_LENGTH} character limit (got ${task.length}).`);
9069
+ process.exit(1);
9070
+ }
9071
+ // Resolve key path
9072
+ const keyPath = resolveKeyPath(agentId);
9073
+ if (!keyPath) {
9074
+ console.error(`Error: private key not found for agent '${agentId}'. Check ~/.flair/keys/ or set FLAIR_KEY_DIR.`);
9075
+ process.exit(1);
9076
+ }
9077
+ // Build auth + POST
9078
+ const baseUrl = resolveBaseUrl(opts).replace(/\/$/, "");
9079
+ const auth = buildEd25519Auth(agentId, "POST", "/Presence", keyPath);
9080
+ const body = { activity };
9081
+ if (task)
9082
+ body.currentTask = task;
9083
+ const res = await fetch(`${baseUrl}/Presence`, {
9084
+ method: "POST",
9085
+ headers: {
9086
+ "Content-Type": "application/json",
9087
+ Authorization: auth,
9088
+ },
9089
+ body: JSON.stringify(body),
9090
+ });
9091
+ if (!res.ok) {
9092
+ const text = await res.text().catch(() => "");
9093
+ console.error(`Error: POST /Presence failed (${res.status}): ${text}`);
9094
+ process.exit(1);
9095
+ }
9096
+ const data = await res.json().catch(() => null);
9097
+ console.log(`✓ Presence updated for '${agentId}': activity=${activity}${task ? `, task="${task}"` : ""}`);
9098
+ if (data?.presenceStatus) {
9099
+ console.log(` Status: ${data.presenceStatus}`);
9100
+ }
9101
+ });
8818
9102
  // Run CLI only when this is the entry point (not when imported for testing)
8819
9103
  if (import.meta.main) {
8820
9104
  await program.parseAsync();
8821
9105
  }
8822
9106
  // ─── Exported for testing ─────────────────────────────────────────────────────
8823
- export { resolveKeyPath, buildEd25519Auth, readPortFromConfig, resolveHttpPort, resolveOpsPort, resolveTarget, resolveOpsTarget, resolveEffectiveOpsUrl, resolveOpsUrlFromTarget, signRequestBody, b64, b64url, program, api, isLocalBase, isLikelyRealSecret, shouldShowInlineSecretWarning, parseTokenFromFile, };
9107
+ export { resolveKeyPath, buildEd25519Auth, readPortFromConfig, resolveHttpPort, resolveOpsPort, resolveTarget, resolveOpsTarget, resolveEffectiveOpsUrl, resolveOpsUrlFromTarget, signRequestBody, b64, b64url, program, api, VALID_PRESENCE_ACTIVITIES, MAX_TASK_LENGTH, isLocalBase, isLikelyRealSecret, shouldShowInlineSecretWarning, parseTokenFromFile, };
@@ -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
  }