@tpsdev-ai/flair 0.31.0 → 0.31.1

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.
package/dist/cli.js CHANGED
@@ -500,15 +500,14 @@ function readOpsPortFromConfig(path = configPath()) {
500
500
  * a clean install has always done, instead of the refusal that would make
501
501
  * `flair init --data-dir <new>` impossible.
502
502
  *
503
- * Note that `init`'s own `--port` option carries a commander default of
504
- * DEFAULT_PORT, so in practice `opts.port` is always set there and this
505
- * function returns on the first rung — including when re-initialising an
506
- * instance that already serves a different port, which is silently renumbered
507
- * to DEFAULT_PORT. That is pre-existing (it is equally true of the default
508
- * install on a custom port, where re-running init is `flair doctor`'s standing
509
- * remedy) and is tracked separately; the "create" rung is what this function
510
- * would answer if that default were removed, and is what keeps the mode
511
- * distinction honest rather than hypothetical.
503
+ * The "create" rung is load-bearing rather than hypothetical (flair#928).
504
+ * `init`'s `--port` used to carry a commander default of DEFAULT_PORT, so
505
+ * `opts.port` was ALWAYS set there and this function returned on the first rung
506
+ * including when re-initialising an instance already serving a different
507
+ * port, which was silently renumbered to DEFAULT_PORT. Commander cannot tell
508
+ * "the user passed the default" from "the user passed nothing", so the default
509
+ * was the bug. It is gone; a bare `init` now falls through to the ladder, and
510
+ * only a directory no instance has ever been served from reaches DEFAULT_PORT.
512
511
  *
513
512
  * Nothing is copied, migrated or written here. Harper's config already IS the
514
513
  * per-instance record, so there is no second file to keep in step — which is
@@ -2386,6 +2385,14 @@ const __pkgVersion = flairCliVersion();
2386
2385
  export { mcpServerSpec };
2387
2386
  const program = new Command();
2388
2387
  program.name("flair").version(__pkgVersion, "-v, --version");
2388
+ // flair#926: an option declared on a parent is consumed by the PARENT even when
2389
+ // it appears after a subcommand name, so a subcommand must NOT redeclare one —
2390
+ // the duplicate never receives a value, it only makes the flag look local.
2391
+ // Removing those duplicates would have hidden working flags from the
2392
+ // subcommand's help, so the help is taught to show inherited options instead.
2393
+ // This is commander's own answer to the problem, and it applies to every
2394
+ // subcommand at once rather than one hand-maintained list of exceptions.
2395
+ program.configureHelp({ showGlobalOptions: true });
2389
2396
  // ─── CLI↔server version handshake (flair#695 §B) ────────────────────────────
2390
2397
  // Every command invocation gets a cheap, cached (~60s), short-timeout check
2391
2398
  // of the running server's version against this CLI's own — catches the
@@ -2434,7 +2441,12 @@ program
2434
2441
  .description("One-command Flair setup — bootstrap the instance, register an agent, and wire MCP clients")
2435
2442
  .option("--agent-id <id>", "Agent ID to register (omit to bootstrap instance without agent)")
2436
2443
  .option("--agent <id>", "Alias for --agent-id")
2437
- .option("--port <port>", "Harper HTTP port", String(DEFAULT_PORT))
2444
+ // No commander default (flair#928). A default here is indistinguishable from
2445
+ // the user typing it, so a BARE `flair init` used to state DEFAULT_PORT and
2446
+ // renumber an instance already serving a custom one. Absent means absent, and
2447
+ // resolveHttpPort's "create" ladder supplies DEFAULT_PORT for a genuinely new
2448
+ // instance — which is the only case that ever wanted one.
2449
+ .option("--port <port>", "Harper HTTP port (default: this instance's current port, or 19926 for a new one)")
2438
2450
  .option("--ops-port <port>", "Harper operations API port")
2439
2451
  .option("--ops-bind <addr>", "Harper ops API bind address (env: FLAIR_OPS_BIND; default: 127.0.0.1 loopback-only for single-host — pass e.g. 0.0.0.0 for multi-host/Fabric remote admin)")
2440
2452
  .option("--admin-pass <pass>", "Admin password (generated if omitted)")
@@ -2640,9 +2652,14 @@ program
2640
2652
  // directory with no recorded port is a new instance taking the default,
2641
2653
  // not the hard error every other caller gets — otherwise `flair init
2642
2654
  // --data-dir <new>` could never succeed. `dataDir` is resolved first so
2643
- // this is never asked before the instance is known. (`--port` carries a
2644
- // commander default, so this usually returns on the flag rung; see
2645
- // resolveHttpPort's doc comment.)
2655
+ // this is never asked before the instance is known.
2656
+ //
2657
+ // flair#928: `--port` deliberately carries NO commander default, so a bare
2658
+ // `init` reaches the ladder below instead of restating DEFAULT_PORT and
2659
+ // renumbering an instance that already serves a custom port. `init` is
2660
+ // `flair doctor`'s standing remedy and is recommended in ten places, so the
2661
+ // command handed to an operator whose install is already wrong must not be
2662
+ // the one that moves their port.
2646
2663
  const httpPort = resolveHttpPort(opts, "create");
2647
2664
  // The already-resolved port is handed to the ops resolver rather than
2648
2665
  // letting it re-resolve — its last rung is `resolveHttpPort(opts) - 1`,
@@ -3601,7 +3618,8 @@ agent
3601
3618
  }
3602
3619
  if (out.defaultTrustTier)
3603
3620
  console.log(render.kv("trust tier", String(out.defaultTrustTier)));
3604
- if (out.admin)
3621
+ // flair#941 — read the authority, not the mirror. See `principal show`.
3622
+ if (agentRecordIsAdmin(out))
3605
3623
  console.log(render.kv("admin", render.wrap(render.c.magenta, "yes")));
3606
3624
  if (out.runtime)
3607
3625
  console.log(render.kv("runtime", String(out.runtime)));
@@ -4753,6 +4771,23 @@ mcp
4753
4771
  // ─── flair principal ─────────────────────────────────────────────────────────
4754
4772
  // 1.0 identity management. The Principal model extends Agent — this is the
4755
4773
  // preferred CLI surface for managing identities going forward.
4774
+ /**
4775
+ * The exact `role` value that denotes a flair administrator, and the predicate
4776
+ * that reads it.
4777
+ *
4778
+ * DUPLICATED FROM resources/agent-admin.ts on purpose — the same deliberate
4779
+ * copy as the federation crypto helpers above: src/cli.ts must not import from
4780
+ * resources/, because those imports don't survive npm packaging. The two must
4781
+ * stay in sync.
4782
+ *
4783
+ * flair#941: `role` is the authority and `admin` is its mirror. The CLI used to
4784
+ * both write and display ONLY the mirror, so `principal add --admin` created a
4785
+ * principal the gate refuses and `principal show` printed "admin: yes" for it.
4786
+ */
4787
+ const ADMIN_ROLE = "admin";
4788
+ function agentRecordIsAdmin(record) {
4789
+ return record?.role === ADMIN_ROLE;
4790
+ }
4756
4791
  const principal = program.command("principal").description("Manage principals (humans and agents)");
4757
4792
  principal
4758
4793
  .command("add <id>")
@@ -4822,6 +4857,11 @@ principal
4822
4857
  status: "active",
4823
4858
  publicKey: pubKeyB64url,
4824
4859
  defaultTrustTier: trustTier,
4860
+ // flair#941 — write BOTH. This is an ops-API upsert, so the Agent
4861
+ // resource's reconciliation never runs; writing only the `admin` mirror
4862
+ // is what made `--admin` a no-op at the gate for every principal this
4863
+ // command has ever created.
4864
+ role: isAdmin ? ADMIN_ROLE : "agent",
4825
4865
  admin: isAdmin,
4826
4866
  runtime: runtime ?? null,
4827
4867
  createdAt: new Date().toISOString(),
@@ -4874,7 +4914,10 @@ principal
4874
4914
  table: "Agent",
4875
4915
  operator: "and",
4876
4916
  conditions,
4877
- get_attributes: ["id", "name", "kind", "status", "defaultTrustTier", "admin", "runtime", "createdAt"],
4917
+ // `role` is the authority behind admin status (flair#941); the
4918
+ // projection used to omit it, so this listing could only ever report
4919
+ // the mirror.
4920
+ get_attributes: ["id", "name", "kind", "status", "defaultTrustTier", "role", "admin", "runtime", "createdAt"],
4878
4921
  }),
4879
4922
  });
4880
4923
  if (!res.ok) {
@@ -4908,7 +4951,14 @@ principal
4908
4951
  {
4909
4952
  label: "admin",
4910
4953
  key: "admin",
4911
- format: (v) => (v ? render.wrap(render.c.red, "yes") : render.wrap(render.c.dim, "no")),
4954
+ // Report the status the gate will apply, and flag a record whose two
4955
+ // fields disagree rather than picking a side silently (flair#941).
4956
+ format: (_v, row) => {
4957
+ const isAdmin = agentRecordIsAdmin(row);
4958
+ const mismatch = isAdmin !== (row.admin === true);
4959
+ const base = isAdmin ? render.wrap(render.c.red, "yes") : render.wrap(render.c.dim, "no");
4960
+ return mismatch ? `${base} ${render.wrap(render.c.yellow, "(!)")}` : base;
4961
+ },
4912
4962
  },
4913
4963
  {
4914
4964
  label: "status",
@@ -4950,8 +5000,13 @@ principal
4950
5000
  }
4951
5001
  if (result.defaultTrustTier)
4952
5002
  console.log(render.kv("trust tier", String(result.defaultTrustTier)));
4953
- if (result.admin)
5003
+ // flair#941 — read the authority, not the mirror, and say so when the two
5004
+ // disagree (only reachable via a raw table write).
5005
+ if (agentRecordIsAdmin(result))
4954
5006
  console.log(render.kv("admin", render.wrap(render.c.red, "yes")));
5007
+ if (agentRecordIsAdmin(result) !== (result.admin === true)) {
5008
+ console.log(render.kv("admin", render.wrap(render.c.yellow, `record is inconsistent (role=${result.role ?? "unset"}, admin=${result.admin ?? "unset"}) — re-issue the grant to repair`)));
5009
+ }
4955
5010
  if (result.runtime)
4956
5011
  console.log(render.kv("runtime", String(result.runtime)));
4957
5012
  if (result.email)
@@ -6222,23 +6277,29 @@ federationSync
6222
6277
  .command("enable")
6223
6278
  .description("Install the sync driver (launchd on macOS, systemd timer on Linux)")
6224
6279
  .option("--interval <seconds>", `Seconds between syncs (default ${FEDERATION_SYNC_DEFAULT_INTERVAL})`, String(FEDERATION_SYNC_DEFAULT_INTERVAL))
6225
- .option("--admin-pass-file <path>", "Path to a 0600 file holding the admin password (default ~/.flair/admin-pass when it exists). The PATH is stored in the unit — never the password.")
6226
6280
  // Deliberately NOT `--no-admin-pass-file`: commander treats a `--no-x` flag
6227
6281
  // as the negation of `--x`, and declaring both on one command makes the
6228
6282
  // POSITIVE option silently parse to undefined — `--admin-pass-file /path`
6229
6283
  // would be accepted and dropped, producing a driver that fails auth every
6230
6284
  // cycle with no error anywhere. Verified against commander 14.
6231
6285
  .option("--no-credentials", "Do not wire any credential file into the unit")
6232
- .option("--target <url>", "Remote Flair URL to sync (default: the local instance)")
6286
+ // `--admin-pass-file` and `--target` are NOT redeclared here (flair#926).
6287
+ // The parent `flair federation sync` owns both, and commander matches an
6288
+ // option against the parent's list before dispatching — so a duplicate
6289
+ // declaration here never receives a value, it only makes the option LOOK
6290
+ // local. Both flags still work on this command; they arrive via
6291
+ // optsWithGlobals() below and are listed under "Global Options" in --help.
6292
+ .addHelpText("after", "\nCredentials:\n"
6293
+ + " --admin-pass-file defaults to ~/.flair/admin-pass when that file exists.\n"
6294
+ + " The PATH is stored in the unit — never the password.\n")
6233
6295
  .action(async (_opts, cmd) => {
6234
6296
  // optsWithGlobals(), NOT the action's first argument: `--admin-pass-file`
6235
- // and `--target` are declared on BOTH this subcommand and its parent
6236
- // (`flair federation sync`), and when a parent declares the same option
6237
- // name commander binds the value to the PARENT the subcommand's own
6238
- // opts come back undefined. Reading only the local opts silently dropped
6239
- // `--admin-pass-file <path>` here, which would have installed a driver
6297
+ // and `--target` are declared on the PARENT (`flair federation sync`), and
6298
+ // commander binds their values there. The subcommand's own opts() has no
6299
+ // entry for them at all. Reading only the local opts silently dropped
6300
+ // `--admin-pass-file <path>` here (flair#923), which installed a driver
6240
6301
  // that failed auth every cycle with no error anywhere. Verified against
6241
- // commander 14.
6302
+ // commander 14; test/unit/cli-option-collisions.test.ts pins the rule.
6242
6303
  const opts = cmd.optsWithGlobals();
6243
6304
  const intervalSeconds = Number(opts.interval);
6244
6305
  if (!Number.isFinite(intervalSeconds)) {
@@ -6306,12 +6367,14 @@ federationSync
6306
6367
  federationSync
6307
6368
  .command("status")
6308
6369
  .description("Show whether a sync driver is installed and genuinely active")
6309
- .option("--port <port>", "Harper HTTP port")
6310
- .option("--target <url>", "Remote Flair URL (env: FLAIR_TARGET)")
6370
+ // `--port` and `--target` are NOT redeclared here (flair#926) — the parent
6371
+ // `flair federation sync` owns them and commander binds them there. They
6372
+ // still work on this command, via optsWithGlobals() below.
6311
6373
  .option("--json", "Emit JSON")
6312
6374
  .action(async (_opts, cmd) => {
6313
- // See the comment on `enable` above: `--target`/`--port` are declared on
6314
- // the parent too, so commander binds them there.
6375
+ // See the comment on `enable` above: `--target`/`--port` live on the
6376
+ // parent, so commander binds them there and only optsWithGlobals() sees
6377
+ // them.
6315
6378
  const opts = cmd.optsWithGlobals();
6316
6379
  const { schedulerStatus, formatStatusReport, assessDriver } = await import("./federation/scheduler.js");
6317
6380
  try {
@@ -8482,7 +8545,9 @@ async function runFabricUpgrade(opts) {
8482
8545
  const upgradeOpts = {
8483
8546
  target: opts.target,
8484
8547
  project: opts.project,
8485
- version: opts.version,
8548
+ // flair#926: `--flair-version`, never `opts.version` — that attribute name
8549
+ // belongs to the program's `-v, --version` and never reaches this action.
8550
+ version: opts.flairVersion,
8486
8551
  harperVersion: opts.harperVersion,
8487
8552
  fabricUser,
8488
8553
  fabricPassword,
@@ -8992,7 +9057,14 @@ program
8992
9057
  .option("--fabric-user <user>", "Fabric admin username — for --target (env: FABRIC_USER preferred; inline leaks to shell history)")
8993
9058
  .option("--fabric-password <pass>", "Fabric admin password — for --target (prefer FABRIC_PASSWORD env or --fabric-password-file; inline leaks to shell history)")
8994
9059
  .option("--fabric-password-file <path>", "Read the Fabric admin password from a file (chmod 600) — for --target")
8995
- .option("--version <semver>", "Flair version to deploy with --target (default: latest published @tpsdev-ai/flair)")
9060
+ // NOT `--version` (flair#926). The program declares `-v, --version`, and
9061
+ // commander matches an option against the PARENT's list before dispatching to
9062
+ // the subcommand — so `flair upgrade --target X --version 1.2.3` printed the
9063
+ // CLI's own version and exited 0, never running the Fabric upgrade at all.
9064
+ // A colliding name is normally recoverable via optsWithGlobals(); this one is
9065
+ // not, because commander's version listener exits the process. The name had
9066
+ // to change. `--harper-version` below is the symmetry this follows.
9067
+ .option("--flair-version <semver>", "Flair version to deploy with --target (default: latest published @tpsdev-ai/flair)")
8996
9068
  .option("--harper-version <semver>", "Pin harper to this version for --target (default: registry latest, floored at the flair#513 fix)")
8997
9069
  .option("--project <name>", "Fabric component name for --target", "flair")
8998
9070
  .option("--no-replicated", "Disable cluster-wide replication for --target (default: replicated=true)")
@@ -1,6 +1,7 @@
1
1
  import { Resource, databases } from "harper";
2
2
  import { layout, htmlResponse, esc } from "./admin-layout.js";
3
3
  import { allowAdmin } from "./agent-auth.js";
4
+ import { adminFieldsDisagree, agentRecordIsAdmin } from "./agent-admin.js";
4
5
  /**
5
6
  * GET /AdminPrincipals — list all principals with kind, trust, status.
6
7
  *
@@ -39,7 +40,15 @@ export class AdminPrincipals extends Resource {
39
40
  const statusBadge = status === "active"
40
41
  ? `<span class="badge badge-green">${status}</span>`
41
42
  : `<span class="badge badge-gray">${status}</span>`;
42
- const admin = p.admin ? "yes" : "";
43
+ // flair#941 report the status the GATE will actually apply, not the
44
+ // `admin` mirror. This column used to read the mirror alone, so a
45
+ // principal created by `flair principal add --admin` showed "yes" while
46
+ // allowAdmin() rejected it. A record whose two fields disagree (only
47
+ // reachable by a raw table write) is flagged rather than silently
48
+ // resolved, so an operator can see that it needs repairing.
49
+ const admin = agentRecordIsAdmin(p)
50
+ ? (adminFieldsDisagree(p) ? "yes <small>(inconsistent record)</small>" : "yes")
51
+ : (adminFieldsDisagree(p) ? "no <small>(inconsistent record)</small>" : "");
43
52
  const created = p.createdAt?.slice(0, 10) ?? "—";
44
53
  tableRows += `
45
54
  <tr>
@@ -1,5 +1,6 @@
1
1
  import { databases } from "harper";
2
- import { resolveAgentAuth, allowVerified, allowAdmin } from "./agent-auth.js";
2
+ import { resolveAgentAuth, allowVerified, allowAdmin, invalidateAdminCache } from "./agent-auth.js";
3
+ import { agentRecordIsAdmin, reconcileAdminFields } from "./agent-admin.js";
3
4
  import { localInstanceId } from "./instance-identity.js";
4
5
  /**
5
6
  * Agent resource — serves as the Principal table in 1.0.
@@ -34,10 +35,18 @@ export class Agent extends databases.flair.Agent {
34
35
  content.kind ||= "agent";
35
36
  content.status ||= "active";
36
37
  content.displayName ||= content.name;
37
- content.admin ??= false;
38
- // Trust tier defaults per kind
38
+ // flair#941 — the two admin fields are reconciled BEFORE anything derives
39
+ // from them, so `role` and `admin` agree on disk whichever one the caller
40
+ // used. Replaces `content.admin ??= false`, which defaulted the mirror
41
+ // without ever consulting the authority: a caller who passed role:"admin"
42
+ // got a record that was an admin at the gate and a non-admin to every
43
+ // reporter. allowCreate() is allowAdmin(), so this path is admin-only.
44
+ reconcileAdminFields(content);
45
+ // Trust tier defaults per kind — derived from the SAME predicate the gate
46
+ // uses, so an admin principal cannot land on the non-admin default just
47
+ // because the caller spelled admin the other way.
39
48
  if (!content.defaultTrustTier) {
40
- content.defaultTrustTier = content.admin ? "endorsed" : "unverified";
49
+ content.defaultTrustTier = agentRecordIsAdmin(content) ? "endorsed" : "unverified";
41
50
  }
42
51
  content.createdAt = now;
43
52
  content.updatedAt = now;
@@ -51,7 +60,25 @@ export class Agent extends databases.flair.Agent {
51
60
  }
52
61
  return super.post(content, context);
53
62
  }
54
- async put(content) {
63
+ /**
64
+ * Authorization shared by BOTH mutation paths (PUT → put, PATCH → patch).
65
+ *
66
+ * It lives in one place because it previously lived only in put(), and PATCH
67
+ * does not route through put() — so every rule written here was enforced on
68
+ * one verb and not the other. Returns a Response to send, or null to proceed.
69
+ *
70
+ * Two rules:
71
+ * 1. Only an admin principal may modify a principal OTHER than itself.
72
+ * 2. Only an admin principal may change a principal's ADMIN STATUS — on any
73
+ * record, including the caller's own. Rule 1 alone never covered this:
74
+ * an agent editing its own record is inside its rights for ordinary
75
+ * fields (runtime, displayName, subjects) and must not be for the fields
76
+ * that decide whether it is an administrator.
77
+ *
78
+ * `internal` (in-process maintenance, federation merge) and admin agents pass
79
+ * through unchanged.
80
+ */
81
+ async authorizePrincipalWrite(content) {
55
82
  const auth = await resolveAgentAuth(this.getContext?.());
56
83
  // Anonymous denied (defense-in-depth alongside allowUpdate; the old check read
57
84
  // tpsAgent and treated a missing agent as trusted, so anonymous slipped through).
@@ -60,24 +87,91 @@ export class Agent extends databases.flair.Agent {
60
87
  status: 401, headers: { "content-type": "application/json" },
61
88
  });
62
89
  }
63
- // Only admin principals can modify OTHER principals; an agent updates its own.
64
- if (auth.kind === "agent" && !auth.isAdmin) {
65
- const existing = await super.get();
66
- if (existing && existing.id !== auth.agentId) {
67
- return new Response(JSON.stringify({ error: "only admin principals can modify other principals" }), {
90
+ if (auth.kind !== "agent" || auth.isAdmin)
91
+ return null;
92
+ const existing = await Promise.resolve(super.get()).catch(() => null);
93
+ // 1. Only admin principals can modify OTHER principals.
94
+ if (existing && existing.id !== auth.agentId) {
95
+ return new Response(JSON.stringify({ error: "only admin principals can modify other principals" }), {
96
+ status: 403, headers: { "content-type": "application/json" },
97
+ });
98
+ }
99
+ // 2. Only admin principals can change admin status. Compare the RESULTING
100
+ // status against the stored one so an ordinary self-update that simply
101
+ // doesn't mention either field is unaffected, and a no-op restatement of
102
+ // the caller's existing status is not a spurious denial.
103
+ const touchesPrivilegeFields = content != null && typeof content === "object" && ("role" in content || "admin" in content);
104
+ if (touchesPrivilegeFields) {
105
+ const merged = { ...(existing ?? {}), ...content };
106
+ const wouldBeAdmin = agentRecordIsAdmin(merged) || merged.admin === true;
107
+ const isAdminNow = agentRecordIsAdmin(existing) || (existing?.admin === true);
108
+ if (wouldBeAdmin !== isAdminNow) {
109
+ return new Response(JSON.stringify({ error: "only admin principals can change a principal's admin status" }), {
68
110
  status: 403, headers: { "content-type": "application/json" },
69
111
  });
70
112
  }
71
113
  }
114
+ return null;
115
+ }
116
+ async put(content) {
117
+ const denial = await this.authorizePrincipalWrite(content);
118
+ if (denial)
119
+ return denial;
72
120
  content.updatedAt = new Date().toISOString();
73
121
  // Protect immutable fields
74
122
  delete content.createdAt;
75
123
  delete content.publicKey; // key rotation goes through dedicated endpoint
124
+ // Keep the two admin fields agreeing on disk — see resources/agent-admin.ts.
125
+ // Only an admin can have reached here with a privilege change (see
126
+ // authorizePrincipalWrite), so this normalises an authorized intent; it
127
+ // never manufactures one.
128
+ reconcileAdminFields(content);
76
129
  // Write-time originatorInstanceId stamp — see post() above / Memory.ts's
77
130
  // stampOriginatorInstanceId doc. No-op if already set.
78
131
  if (content.originatorInstanceId == null) {
79
132
  content.originatorInstanceId = await localInstanceId();
80
133
  }
81
- return super.put(content);
134
+ const result = await super.put(content);
135
+ invalidateAdminCache();
136
+ return result;
137
+ }
138
+ /**
139
+ * PATCH → Harper's partial update (`Resource.patch(data, query)`; see
140
+ * harper/dist/server/REST.js's method switch and Resource.js's static patch).
141
+ *
142
+ * This override exists because there was none. Every per-record authorization
143
+ * rule this resource enforces was written in put(), and PATCH does not route
144
+ * through put() — so none of those rules ran on a PATCH. allowUpdate() is
145
+ * allowVerified(), which means the only check a PATCH ever met was "are you
146
+ * some verified agent"; the rules that make the principal table safe (you may
147
+ * only edit yourself; you may not change your own admin status) were not
148
+ * among them.
149
+ *
150
+ * That is the shape flair#941 keeps running into: a check that reads as
151
+ * complete because it exists, and simply is not on the path the caller took.
152
+ * Both verbs now share authorizePrincipalWrite().
153
+ */
154
+ async patch(content, query) {
155
+ const denial = await this.authorizePrincipalWrite(content);
156
+ if (denial)
157
+ return denial;
158
+ if (content != null && typeof content === "object") {
159
+ // A partial update must not be able to leave the record's two admin
160
+ // fields disagreeing, so reconcile against the MERGED result rather than
161
+ // the patch alone — patching only `role` has to carry the mirror with it.
162
+ if ("role" in content || "admin" in content) {
163
+ const existing = await Promise.resolve(super.get()).catch(() => null);
164
+ const merged = reconcileAdminFields({ ...(existing ?? {}), ...content });
165
+ content.role = merged.role;
166
+ content.admin = merged.admin;
167
+ }
168
+ // Immutable fields, matching put().
169
+ delete content.createdAt;
170
+ delete content.publicKey;
171
+ content.updatedAt = new Date().toISOString();
172
+ }
173
+ const result = await super.patch(content, query);
174
+ invalidateAdminCache();
175
+ return result;
82
176
  }
83
177
  }
@@ -17,7 +17,8 @@
17
17
  * Auth: admin only.
18
18
  */
19
19
  import { Resource, databases } from "harper";
20
- import { isAdmin, allowAdmin } from "./agent-auth.js";
20
+ import { isAdmin, allowAdmin, invalidateAdminCache } from "./agent-auth.js";
21
+ import { reconcileAdminFields } from "./agent-admin.js";
21
22
  const DEFAULT_SOUL_KEYS = (agentId, displayName, role, now) => ({
22
23
  name: displayName,
23
24
  role,
@@ -62,8 +63,15 @@ export class AgentSeed extends Resource {
62
63
  const existingAgent = await databases.flair.Agent.get(agentId).catch(() => null);
63
64
  let agent = existingAgent;
64
65
  if (!existingAgent) {
65
- agent = { id: agentId, name, role, publicKey: "pending", createdAt: now, updatedAt: now };
66
+ // flair#941 this writes the RAW table, so resources/Agent.ts's post()
67
+ // never runs and its field reconciliation does not apply here. Seeding
68
+ // role:"admin" without the mirror was the one path in the product that
69
+ // produced a genuine administrator every reporter displayed as an
70
+ // ordinary agent. Admin-only path (allowCreate + the isAdmin re-check
71
+ // above), so this normalises an authorized intent.
72
+ agent = reconcileAdminFields({ id: agentId, name, role, publicKey: "pending", createdAt: now, updatedAt: now });
66
73
  await databases.flair.Agent.put(agent);
74
+ invalidateAdminCache();
67
75
  }
68
76
  // ── Soul entries ──────────────────────────────────────────────────────────
69
77
  const defaults = DEFAULT_SOUL_KEYS(agentId, name, role, now);
@@ -95,6 +95,24 @@ export class MemoryUsage extends databases.flair.MemoryUsage {
95
95
  return super.put(content);
96
96
  return FORBIDDEN("forbidden: MemoryUsage rows are immutable once written");
97
97
  }
98
+ /**
99
+ * PATCH — same rule as put(), because this ledger's invariant is IMMUTABILITY,
100
+ * not ownership.
101
+ *
102
+ * The shared record-ownership guard (resources/record-owner-guard.ts) covers
103
+ * this table for cross-agent writes, but it cannot express this rule: it
104
+ * permits an agent to modify a row it owns, and here even the OWNER must not.
105
+ * Measured before this override existed: the owning agent rewrote its own
106
+ * row's attribution with a PATCH and got 204, because Harper routes PATCH to
107
+ * patch() and put()'s check never ran. A resource whose rule is stricter than
108
+ * "you own it" still has to say so on every verb.
109
+ */
110
+ async patch(content, query) {
111
+ const auth = await resolveAgentAuth(this.getContext?.());
112
+ if (auth.kind === "internal" || (auth.kind === "agent" && auth.isAdmin))
113
+ return super.patch(content, query);
114
+ return FORBIDDEN("forbidden: MemoryUsage rows are immutable once written");
115
+ }
98
116
  async delete(id) {
99
117
  const auth = await resolveAgentAuth(this.getContext?.());
100
118
  if (auth.kind === "internal" || (auth.kind === "agent" && auth.isAdmin))
@@ -32,6 +32,7 @@ import { dirname, join } from "node:path";
32
32
  import { fileURLToPath } from "node:url";
33
33
  import { createRequire } from "node:module";
34
34
  import { resolveAgentAuth, verifyAgentRequest } from "./agent-auth.js";
35
+ import { agentRecordIsAdmin } from "./agent-admin.js";
35
36
  import { WINDOW_MS, isNonceReplay, recordNonce, importEd25519Key, b64ToArrayBuffer, parseTpsEd25519Header } from "./ed25519-auth.js";
36
37
  // ─── Constants ────────────────────────────────────────────────────────────────
37
38
  const CURRENT_TASK_MAX_LENGTH = 200;
@@ -348,7 +349,13 @@ export class Presence extends databases.flair.Presence {
348
349
  const entry = {
349
350
  id: agentId,
350
351
  displayName: agent?.displayName ?? agent?.name ?? agentId,
351
- role: agent?.role ?? "agent",
352
+ // `role` is a human label on a PUBLIC roster (allowRead() is `true`),
353
+ // and it is also the field that decides administrator status
354
+ // (resources/agent-admin.ts). Publishing the admin sentinel would
355
+ // hand an unauthenticated reader the list of privileged principals —
356
+ // so the roster reports admins as ordinary agents. It is a display
357
+ // label here and nothing authorizes on it (flair#941).
358
+ role: agentRecordIsAdmin(agent) ? "agent" : (agent?.role ?? "agent"),
352
359
  runtime: agent?.runtime ?? null,
353
360
  // Current activity — only truthful while fresh; "idle" once decayed.
354
361
  activity: activityFresh ? rawActivity : "idle",
@@ -0,0 +1,149 @@
1
+ /**
2
+ * agent-admin.ts — the ONE answer to "is this principal an administrator?".
3
+ *
4
+ * The Agent (Principal) record carries two fields that both read as if they
5
+ * answered that question:
6
+ *
7
+ * role: String — administrator when the value is exactly "admin"
8
+ * admin: Boolean — "admin principals can manage other principals"
9
+ *
10
+ * They used to be consulted by DIFFERENT consumers, which is the whole defect
11
+ * (flair#941):
12
+ *
13
+ * - resources/agent-auth.ts's isAdmin() — the single gate behind allowAdmin(),
14
+ * and therefore behind every admin-only resource — searched `role` and
15
+ * ignored `admin` completely.
16
+ * - resources/mcp-handler.ts OR-ed the two together.
17
+ * - Every reporter (flair principal show/list, the admin dashboard) displayed
18
+ * `admin`, and every writer except AgentSeed wrote `admin`.
19
+ *
20
+ * So the field the product wrote and displayed was not the field the gate read.
21
+ * `flair principal add --admin` stored `admin: true` and granted nothing, while
22
+ * the dashboard confidently printed "admin: yes" for a principal that
23
+ * allowAdmin() rejects. Both directions are silent, and one of them is silent in
24
+ * the direction that flatters the operator.
25
+ *
26
+ * ─── The rule ────────────────────────────────────────────────────────────────
27
+ *
28
+ * `role === ADMIN_ROLE` is the AUTHORITY. `admin` is a SERVER-MAINTAINED MIRROR
29
+ * of it and is never read to reach an authorization decision again.
30
+ *
31
+ * Every decider and every reporter calls {@link agentRecordIsAdmin}, so no two
32
+ * surfaces can answer this question differently. Every write through a flair
33
+ * write path calls {@link reconcileAdminFields}, so a record that says one thing
34
+ * in one field and the opposite in the other cannot be STORED by any path flair
35
+ * offers.
36
+ *
37
+ * ─── Why `role` is the authority and not `admin` ─────────────────────────────
38
+ *
39
+ * `admin: Boolean` is honestly the better-shaped field: typed, indexed,
40
+ * unambiguous, and already the one every creation path and every UI uses.
41
+ * Making it the authority would nonetheless GRANT admin, on the primary HTTP
42
+ * gate, to every record that currently carries `admin: true` with a non-admin
43
+ * `role` — the records `flair principal add --admin` has been producing all
44
+ * along, which have never been admins. That is a widening of live access as a
45
+ * side effect of a consistency fix, decided by data nobody has audited. Keeping
46
+ * `role` as the authority changes no existing principal's rights on the gate:
47
+ * every principal that is an admin today is an admin after this change, and
48
+ * every principal that is not, is not.
49
+ *
50
+ * Switching the authority to `admin` is a reasonable follow-up, but it is a
51
+ * deliberate privilege migration with an audit of existing records — not
52
+ * something to slip in underneath a naming fix.
53
+ *
54
+ * ─── Records that already carry a mismatch ───────────────────────────────────
55
+ *
56
+ * Nothing is rewritten in bulk; no migration runs. What happens to each shape:
57
+ *
58
+ * role:"admin" + admin:false|absent — an admin today, an admin after. The
59
+ * mirror is stale, so the CLI and dashboard used to report "not an admin"
60
+ * for a principal holding admin rights; they now report the truth. The
61
+ * stored mirror is repaired the next time the record is written through the
62
+ * Agent resource.
63
+ *
64
+ * admin:true + role not "admin" — NOT an admin today on the gate, and not
65
+ * after. This is what `flair principal add --admin` produced. The CLI and
66
+ * dashboard used to report "admin: yes"; they now report the truth, which is
67
+ * how an operator finds out the grant never took. The remedy is to re-issue
68
+ * the grant (which now writes both fields). The one behaviour that does
69
+ * change is the native MCP surface, which used to honour this field on its
70
+ * own — see resources/mcp-handler.ts. That surface is gated behind
71
+ * FLAIR_MCP_OAUTH and is default-OFF, so no instance running the shipped
72
+ * defaults is affected.
73
+ *
74
+ * Deliberately dependency-free — pure predicates over a plain record, importing
75
+ * neither `harper` nor any resource, so the auth gate, the resources, the
76
+ * reporters and the tests can all share it without an import cycle.
77
+ */
78
+ /** The exact `role` value that denotes a flair administrator. */
79
+ export const ADMIN_ROLE = "admin";
80
+ /**
81
+ * THE admin predicate. Every authorization decision and every report of a
82
+ * principal's admin status resolves through this one function.
83
+ *
84
+ * Total over every field combination: a record with a contradictory `admin`
85
+ * mirror gets the SAME answer here as it does at the gate, so a contradiction
86
+ * can never produce two different answers on two different surfaces. It is
87
+ * deliberately NOT an OR over the two fields — see the module header.
88
+ *
89
+ * Note this covers Agent RECORDS only. `FLAIR_ADMIN_AGENTS` is a separate,
90
+ * env-configured admin source union-ed in by resources/agent-auth.ts's
91
+ * getAdminAgents(); it names agent ids and never touches a record.
92
+ */
93
+ export function agentRecordIsAdmin(record) {
94
+ return record?.role === ADMIN_ROLE;
95
+ }
96
+ /**
97
+ * Make a record about to be written self-consistent, so the two fields can
98
+ * never be STORED disagreeing.
99
+ *
100
+ * Admin is requested by EITHER spelling — `role: "admin"` or `admin: true` —
101
+ * because both have been documented and both are what an operator reaches for.
102
+ * Whichever they used, both fields come out of here agreeing. That is what
103
+ * makes `flair principal add --admin` finally do what it says: it writes the
104
+ * Boolean, and the Boolean now carries the record into the authority field.
105
+ *
106
+ * **Honouring `admin: true` here is not a privilege widening.** Every call site
107
+ * is already admin-gated — Agent.post() is allowCreate()=allowAdmin, Agent's
108
+ * update path refuses a privilege change from a non-admin, and AgentSeed is
109
+ * admin-only and re-checks. A caller that can reach this could have written
110
+ * `role: "admin"` directly; this only means they no longer have to know which
111
+ * of the two fields is the real one.
112
+ *
113
+ * A non-admin `role` is free text (a human label — "researcher", "COO") and is
114
+ * left exactly as supplied; only the admin sentinel is normalised.
115
+ *
116
+ * Mutates `content` in place and returns it, matching the surrounding
117
+ * defaults-stamping style in resources/Agent.ts's post().
118
+ */
119
+ export function reconcileAdminFields(content) {
120
+ if (!content || typeof content !== "object")
121
+ return content;
122
+ // Widened locally: `content` is generic so TypeScript will not accept writes
123
+ // to named properties on it, but the whole point here is to normalise those
124
+ // two properties in place and hand the caller back its own object.
125
+ const record = content;
126
+ if (record.role === ADMIN_ROLE || record.admin === true) {
127
+ record.role = ADMIN_ROLE;
128
+ record.admin = true;
129
+ }
130
+ else {
131
+ record.admin = false;
132
+ }
133
+ return content;
134
+ }
135
+ /**
136
+ * True when a stored record's two fields disagree — i.e. it was written by
137
+ * something other than a flair write path (a raw table write, an ops-API
138
+ * insert, a federation merge) and now claims one thing to a reader of `admin`
139
+ * and the opposite to the gate.
140
+ *
141
+ * Reporting-only. Nothing authorizes on this; it exists so a surface can SAY
142
+ * that a record is inconsistent instead of silently picking a side.
143
+ */
144
+ export function adminFieldsDisagree(record) {
145
+ const r = record;
146
+ if (!r || typeof r !== "object")
147
+ return false;
148
+ return agentRecordIsAdmin(r) !== (r.admin === true);
149
+ }
@@ -17,6 +17,7 @@
17
17
  */
18
18
  import { databases } from "harper";
19
19
  import { WINDOW_MS, isNonceReplay, recordNonce, importEd25519Key, b64ToArrayBuffer, parseTpsEd25519Header } from "./ed25519-auth.js";
20
+ import { ADMIN_ROLE, agentRecordIsAdmin } from "./agent-admin.js";
20
21
  /**
21
22
  * Shared Harper user that verified Ed25519 agents resolve to (least-privilege
22
23
  * `flair_agent` role), replacing the old admin super_user elevation. Single
@@ -32,8 +33,14 @@ export const FLAIR_AGENT_USERNAME = "flair-agent";
32
33
  // visible to the other two, and the crypto/decoder logic can't drift.
33
34
  // ─── Admin resolution ─────────────────────────────────────────────────────────
34
35
  // Admin agents come from FLAIR_ADMIN_AGENTS (comma-separated) OR Agent records
35
- // with role === "admin". OR-combined, cached 60s. Distinct from Harper's
36
- // super_user — admin here gates flair-policy decisions (promotions, raw ops).
36
+ // whose `role` is the admin sentinel. OR-combined, cached 60s. Distinct from
37
+ // Harper's super_user — admin here gates flair-policy decisions (promotions,
38
+ // raw ops).
39
+ //
40
+ // The record half of that union is the ONE place the Agent table is consulted
41
+ // for an authorization decision, and it resolves through
42
+ // resources/agent-admin.ts's shared predicate so it cannot drift from the
43
+ // reporters or from the MCP surface (flair#941 — they had drifted).
37
44
  let adminCacheExpiry = 0;
38
45
  let adminCache = new Set();
39
46
  async function getAdminAgents() {
@@ -43,9 +50,9 @@ async function getAdminAgents() {
43
50
  const fromEnv = (process.env.FLAIR_ADMIN_AGENTS ?? "").split(",").map((s) => s.trim()).filter(Boolean);
44
51
  const fromDb = [];
45
52
  try {
46
- const results = await databases.flair.Agent.search([{ attribute: "role", value: "admin", condition: "equals" }]);
53
+ const results = await databases.flair.Agent.search([{ attribute: "role", value: ADMIN_ROLE, condition: "equals" }]);
47
54
  for await (const row of results)
48
- if (row?.id)
55
+ if (row?.id && agentRecordIsAdmin(row))
49
56
  fromDb.push(row.id);
50
57
  }
51
58
  catch { /* Agent table may be empty */ }
@@ -53,6 +60,28 @@ async function getAdminAgents() {
53
60
  adminCacheExpiry = now + 60_000;
54
61
  return adminCache;
55
62
  }
63
+ /**
64
+ * Drop the memoised admin set so the NEXT isAdmin() re-reads the Agent table.
65
+ *
66
+ * The 60s TTL is a load optimisation, but on its own it makes a privilege
67
+ * change take up to a minute to appear — which reads as "the change didn't
68
+ * work", and is exactly the confusion flair#941 is about: an operator grants
69
+ * admin, tests it, sees a denial, and starts changing other things. The write
70
+ * path knows precisely when a principal's admin status changed, so it says so
71
+ * and the grant is effective on the caller's very next request.
72
+ *
73
+ * Deliberately NOT a distributed invalidation: Harper runs N worker threads,
74
+ * each with its own module instance and its own cache, so a promotion applied
75
+ * on thread A still takes up to the TTL to be seen by thread B. Making that
76
+ * exact would mean a shared invalidation channel, which is a much larger change
77
+ * than the confusion warrants — the bound stays 60s, unchanged, and the common
78
+ * single-threaded/dev case becomes immediate. Called by resources/Agent.ts's
79
+ * write paths.
80
+ */
81
+ export function invalidateAdminCache() {
82
+ adminCacheExpiry = 0;
83
+ adminCache = new Set();
84
+ }
56
85
  export async function isAdmin(agentId) {
57
86
  return (await getAdminAgents()).has(agentId);
58
87
  }
@@ -195,10 +224,73 @@ export async function allowAdmin(context) {
195
224
  const a = await resolveAgentAuth(context);
196
225
  return a.kind === "internal" || (a.kind === "agent" && a.isAdmin);
197
226
  }
227
+ /**
228
+ * flair#936 — the `internal` verdict is TRUSTED and UNFILTERED, and it is also
229
+ * what a caller gets for supplying no context at all. Nothing distinguishes
230
+ * "I am flair's own maintenance code" from "I am an application developer who
231
+ * did not know a context was required": both spell it `new Memory()`, both
232
+ * succeed, and both return plausible data. It surfaces months later as memories
233
+ * that were never scoped.
234
+ *
235
+ * This does not change the verdict — every authorization outcome is byte-
236
+ * identical — it ends the SILENCE. A deliberate elevated call says so with
237
+ * `internalContext()` (resources/in-process.ts), whose `__flairInternal` marker
238
+ * is read here and nowhere else; anything else that lands on `internal` gets one
239
+ * warning per process, with the stack, so the condition is observable where it
240
+ * was previously invisible.
241
+ *
242
+ * MEASURED, and the reason this is worth having: flair itself has NO in-process
243
+ * caller that relies on the omission — every maintenance, migration, federation
244
+ * and boot path goes through the raw `databases.flair.*` table accessor, which
245
+ * bypasses this resolver entirely rather than defaulting through it. So a
246
+ * warning from this line is, in practice, always either an embedding
247
+ * application that forgot a context or a flair call site that should be naming
248
+ * its intent. Neither is noise.
249
+ *
250
+ * The marker is read off `context`, NOT off `c`: `c` is rebound to
251
+ * `context.request` on the line below, and internalContext() carries the marker
252
+ * on the outer object.
253
+ */
254
+ let warnedInternalByOmission = false;
255
+ /**
256
+ * Was this elevated call DELIBERATE — i.e. did the caller name the authority
257
+ * with `internalContext()` rather than land on it by leaving a context off?
258
+ *
259
+ * Exported because it is the whole decision, and a decision worth testing is
260
+ * worth testing directly: the warning itself is latched once per process, which
261
+ * makes any test of the side effect order-dependent (bun shares one module
262
+ * registry across the whole run, so whichever file resolves a context-less call
263
+ * first consumes the latch and every later assertion silently passes). Pinning
264
+ * the predicate instead is deterministic.
265
+ *
266
+ * Grants nothing and is never consulted for an authorization decision — the
267
+ * verdict is identical either way.
268
+ */
269
+ export function isDeliberateInternalCall(context) {
270
+ return context?.__flairInternal === true;
271
+ }
272
+ /** The advisory text, exported so its content is pinned without tripping the latch. */
273
+ export const INTERNAL_BY_OMISSION_WARNING = "[flair-auth] a resource was invoked with NO caller context and resolved to the trusted " +
274
+ "`internal` verdict: reads are UNFILTERED across every agent, writes are unattributed, and " +
275
+ "the admin-only gate passes. If this is your application's code, pass agentContext(<id>) from " +
276
+ "@tpsdev-ai/flair's in-process seam so the call is scoped and attributed. If the elevated " +
277
+ "authority is genuinely intended, say so with internalContext() and this warning will stop. " +
278
+ "Fires once per process.";
279
+ function noteInternalVerdict(context) {
280
+ if (warnedInternalByOmission)
281
+ return;
282
+ if (isDeliberateInternalCall(context))
283
+ return; // deliberate, and said so
284
+ warnedInternalByOmission = true;
285
+ const stack = (new Error().stack ?? "").split("\n").slice(2, 7).join("\n");
286
+ console.error(INTERNAL_BY_OMISSION_WARNING + "\n" + stack);
287
+ }
198
288
  export async function resolveAgentAuth(context) {
199
289
  const c = context?.request ?? context;
200
- if (!c)
290
+ if (!c) {
291
+ noteInternalVerdict(context);
201
292
  return { kind: "internal" };
293
+ }
202
294
  if (c.tpsAnonymous === true)
203
295
  return { kind: "anonymous" };
204
296
  if (c.tpsAgent) {
@@ -231,5 +323,6 @@ export async function resolveAgentAuth(context) {
231
323
  return { kind: "agent", agentId: auth.agentId, isAdmin: auth.isAdmin };
232
324
  return { kind: "anonymous" };
233
325
  }
326
+ noteInternalVerdict(context);
234
327
  return { kind: "internal" };
235
328
  }
@@ -4,6 +4,7 @@ import { getEmbedding } from "./embeddings-provider.js";
4
4
  import { isAdmin, FLAIR_AGENT_USERNAME } from "./agent-auth.js";
5
5
  import { WINDOW_MS, isNonceReplay, recordNonce, importEd25519Key, b64ToArrayBuffer, parseTpsEd25519Header } from "./ed25519-auth.js";
6
6
  import { resolveReadScope } from "./memory-read-scope.js";
7
+ import { isForbiddenOwnerMutation, resolveGuardedRecord } from "./record-owner-guard.js";
7
8
  // --- Admin credentials ---
8
9
  // Admin auth is sourced exclusively from Harper's own environment variables
9
10
  // (HDB_ADMIN_PASSWORD / FLAIR_ADMIN_PASSWORD). No filesystem token file.
@@ -350,6 +351,37 @@ server.http(async (request, nextLayer) => {
350
351
  // ── Server-side permission guards ──────────────────────────────────────────
351
352
  const method = request.method.toUpperCase();
352
353
  const isMutation = method === "POST" || method === "PUT" || method === "PATCH" || method === "DELETE";
354
+ // ── THE record-ownership rule, for every table, on every mutating verb ─────
355
+ //
356
+ // One enforcement point rather than one per resource. See
357
+ // resources/record-owner-guard.ts for why this is not written into each
358
+ // resource's put(): Harper maps verbs to methods one-to-one, so a rule living
359
+ // in put() is enforced on PUT alone, and nearly every resource wrote its rules
360
+ // there. Doing this per-resource would be N chances to get one wrong and would
361
+ // still leave the next resource broken by default.
362
+ //
363
+ // Ownership is read from the STORED record named by the path — never from the
364
+ // request body, which is the caller's claim about who owns the row and was how
365
+ // a body that simply omitted the field passed unchecked.
366
+ //
367
+ // Deliberately scoped to records that ALREADY EXIST: creation is left to each
368
+ // resource's own no-forge attribution. That keeps this incapable of breaking a
369
+ // create, a self-write, or a legitimate cross-agent field like MemoryGrant's
370
+ // granteeId — it can only narrow mutation of another agent's stored row.
371
+ if (isMutation && !request.tpsAgentIsAdmin) {
372
+ const guarded = resolveGuardedRecord(url.pathname);
373
+ if (guarded) {
374
+ try {
375
+ const record = await databases.flair[guarded.table]?.get(guarded.id);
376
+ if (isForbiddenOwnerMutation(record, guarded.ownerField, agentId)) {
377
+ return new Response(JSON.stringify({
378
+ error: `forbidden: cannot modify ${guarded.table} owned by another principal`,
379
+ }), { status: 403, headers: { "Content-Type": "application/json" } });
380
+ }
381
+ }
382
+ catch { /* unreadable row → fall through to the resource's own rules */ }
383
+ }
384
+ }
353
385
  if (isMutation) {
354
386
  // OrgEvent: authorId must match authenticated agent
355
387
  if ((url.pathname === "/OrgEvent" || url.pathname.startsWith("/OrgEvent/")) &&
@@ -419,19 +451,59 @@ server.http(async (request, nextLayer) => {
419
451
  catch { }
420
452
  }
421
453
  }
422
- // Soul PUT: only owner or admin
423
- if (url.pathname.startsWith("/Soul") && (method === "PUT" || method === "POST")) {
454
+ // Soul mutations: only the owner or an admin may write a soul entry.
455
+ //
456
+ // This guard had two independent holes, either of which alone let one agent
457
+ // rewrite another's identity data:
458
+ //
459
+ // 1. THE VERB LIST enumerated PUT and POST only. Its three siblings above
460
+ // (OrgEvent, WorkspaceState, Memory) all include PATCH, and Memory
461
+ // includes DELETE; this one did neither. Harper routes PATCH to a
462
+ // resource method that carries no ownership check of its own
463
+ // (Soul.ts's enforceWriteAuth covers post()/put()), and DELETE reached
464
+ // the table with no per-record check at all. Both were live.
465
+ //
466
+ // 2. IT COMPARED THE BODY, not the target. `body.agentId` is the owner
467
+ // the CALLER claims, and the check only fired when that field was
468
+ // present and mismatched — so a body omitting it, which a partial
469
+ // write naturally does, was compared against nothing and passed
470
+ // whatever record the URL pointed at. The resource-level check behind
471
+ // it is "validate-truthy" attribution, which by design also passes an
472
+ // ABSENT owner field, so nothing downstream caught it either. (A PUT
473
+ // happened to fail anyway, but on `agentId: String!` schema
474
+ // validation — a 400 for the wrong reason, not an authorization
475
+ // decision, and not a defence to rely on.)
476
+ //
477
+ // Closing one hole leaves the other reachable through the remaining verbs,
478
+ // so both are closed here: every mutating verb is covered, and ownership is
479
+ // resolved from the STORED RECORD named by the path. That is the same shape
480
+ // as the Memory ownership guard below, which is the resource in this file
481
+ // that already had it right — worth copying rather than reinventing.
482
+ //
483
+ // The path test stays `startsWith("/Soul")` so sibling routes keep the
484
+ // body check they already had; the record lookup is scoped to the real
485
+ // `/Soul/<id>` collection so it never resolves an unrelated id.
486
+ if (url.pathname.startsWith("/Soul") &&
487
+ (method === "POST" || method === "PUT" || method === "PATCH" || method === "DELETE")) {
424
488
  if (!request.tpsAgentIsAdmin) {
425
- let bodyAgentId = null;
426
- try {
427
- const clone = request.clone();
428
- const body = await clone.json();
429
- bodyAgentId = body?.agentId ?? null;
430
- }
431
- catch { }
432
- if (bodyAgentId && bodyAgentId !== agentId) {
433
- return new Response(JSON.stringify({ error: "forbidden: non-admin cannot modify another agent's soul" }), { status: 403 });
489
+ // (a) A PRESENT, mismatched body agentId is a forged attribution.
490
+ if (method !== "DELETE") {
491
+ let bodyAgentId = null;
492
+ try {
493
+ const clone = request.clone();
494
+ const body = await clone.json();
495
+ bodyAgentId = body?.agentId ?? null;
496
+ }
497
+ catch { }
498
+ if (bodyAgentId && bodyAgentId !== agentId) {
499
+ return new Response(JSON.stringify({ error: "forbidden: non-admin cannot modify another agent's soul" }), { status: 403 });
500
+ }
434
501
  }
502
+ // (b) The owner of the record actually being written — the half that
503
+ // catches a body simply leaving agentId out — is now the shared
504
+ // record-ownership rule at the top of this block, which applies it to
505
+ // every table on every mutating verb. Deliberately NOT repeated here:
506
+ // one condition enforced in two places is how the two drift apart.
435
507
  }
436
508
  }
437
509
  // Memory promotion guard: only admin can approve or set durability=permanent
@@ -453,21 +525,22 @@ server.http(async (request, nextLayer) => {
453
525
  catch { }
454
526
  }
455
527
  }
456
- // Memory PUT/DELETE: ownership check (non-admin can only modify their own memories)
528
+ // Memory DELETE: permanent memories are admin-only to purge.
529
+ //
530
+ // The OWNERSHIP half of this guard — which was the model the shared
531
+ // record-ownership rule above was built from, and the only one in this file
532
+ // that already covered PATCH — now lives there and covers every table. What
533
+ // remains here is the part that is NOT about ownership: durability. An agent
534
+ // owning a permanent memory still may not purge it.
457
535
  if (((url.pathname === "/Memory" || url.pathname.startsWith("/Memory/") || url.pathname === "/memory" || url.pathname.startsWith("/memory/"))) &&
458
- (method === "PUT" || method === "DELETE" || method === "PATCH")) {
536
+ method === "DELETE") {
459
537
  if (!request.tpsAgentIsAdmin) {
460
538
  try {
461
539
  const pathParts = url.pathname.split("/").filter(Boolean);
462
540
  const memId = pathParts[1] ? decodeURIComponent(pathParts[1]) : null;
463
541
  if (memId) {
464
542
  const record = await databases.flair.Memory.get(memId);
465
- if (record && record.agentId && record.agentId !== agentId) {
466
- return new Response(JSON.stringify({
467
- error: `forbidden: cannot modify memory owned by ${record.agentId}`
468
- }), { status: 403 });
469
- }
470
- if (method === "DELETE" && record?.durability === "permanent") {
543
+ if (record?.durability === "permanent") {
471
544
  return new Response(JSON.stringify({
472
545
  error: "forbidden: only admins can purge permanent memories"
473
546
  }), { status: 403 });
@@ -208,6 +208,15 @@ export function internalContext() {
208
208
  * Reads do not need this — `Cls.get(id, context)` and `Cls.search(query,
209
209
  * context)` (Harper's static resource methods) already thread the context and
210
210
  * start their own transaction.
211
+ *
212
+ * **They do still need the CONTEXT.** Only the collection binding is
213
+ * unnecessary for a read, not the identity. `Cls.search(query)` with the second
214
+ * argument left off does not read "as nobody" — outside a Harper request scope
215
+ * (a boot hook, a timer, a queue worker, a detached promise) it resolves to the
216
+ * same trusted `internal` verdict this module exists to keep out of reach, and
217
+ * returns every agent's private records unfiltered. On that path the resource's
218
+ * own `allow*` gate is not consulted at all, so nothing else stands in the way.
219
+ * Pass the context to every call, read or write (flair#936).
211
220
  */
212
221
  export async function collectionResource(Cls, context) {
213
222
  if (context == null || typeof context !== "object") {
@@ -26,6 +26,7 @@
26
26
  import { databases } from "harper";
27
27
  import { randomBytes } from "node:crypto";
28
28
  import { TOOLS, listToolDefs } from "./mcp-tools.js";
29
+ import { agentRecordIsAdmin } from "./agent-admin.js";
29
30
  // The MCP protocol revision we implement (initialize handshake).
30
31
  const PROTOCOL_VERSION = "2025-06-18";
31
32
  const JSON_HEADERS = { "content-type": "application/json" };
@@ -157,14 +158,23 @@ async function jitProvisionPrincipal(sub) {
157
158
  return principalId;
158
159
  }
159
160
  /**
160
- * Is this Principal a flair admin? Reads the Agent record's `admin`/`role`
161
- * fields. A MCP-OAuth agent is NON-admin unless an operator has explicitly
162
- * marked its Agent record admin — the MCP surface never elevates on its own.
161
+ * Is this Principal a flair admin? A MCP-OAuth agent is NON-admin unless an
162
+ * operator has explicitly marked its Agent record admin the MCP surface never
163
+ * elevates on its own.
164
+ *
165
+ * flair#941: this used to OR the two admin fields together while the primary
166
+ * HTTP gate (resources/agent-auth.ts's isAdmin) read only `role`, so the same
167
+ * record could be an administrator here and an ordinary agent there. It now
168
+ * resolves through the one shared predicate, so both surfaces answer
169
+ * identically. A record carrying `admin: true` alone — which no flair write
170
+ * path produces, and which was never an admin on the HTTP gate — is no longer
171
+ * an admin here either; see resources/agent-admin.ts for the remedy. This
172
+ * surface is gated behind FLAIR_MCP_OAUTH and is default-OFF.
163
173
  */
164
174
  async function isAgentAdmin(principalId) {
165
175
  try {
166
176
  const agent = await databases.flair.Agent.get(principalId);
167
- return agent?.admin === true || agent?.role === "admin";
177
+ return agentRecordIsAdmin(agent);
168
178
  }
169
179
  catch {
170
180
  return false;
@@ -2,7 +2,12 @@ function presenceDelegationContext(auth) {
2
2
  const agentAuth = auth.kind === "agent"
3
3
  ? { agentId: auth.agentId, isAdmin: auth.isAdmin }
4
4
  : { agentId: "internal", isAdmin: true };
5
- return { request: { _flairAgentAuth: agentAuth } };
5
+ // `__flairInternal` marks the `internal` branch as DELIBERATE (flair#936) —
6
+ // an "internal" verdict relayed here is a trusted in-process call this file
7
+ // has already established, not a caller who forgot to pass a context. It is
8
+ // read only by resources/agent-auth.ts's accidental-omission warning; it
9
+ // grants nothing and is never consulted for an authorization decision.
10
+ return { request: { _flairAgentAuth: agentAuth }, __flairInternal: true };
6
11
  }
7
12
  /**
8
13
  * The full presence roster (one row per agent — bounded, per the K&S
@@ -0,0 +1,149 @@
1
+ /**
2
+ * record-owner-guard.ts — ONE enforcement point for "you may not modify a
3
+ * record you do not own".
4
+ *
5
+ * ─── The defect this deletes ─────────────────────────────────────────────────
6
+ *
7
+ * Harper's REST layer maps verbs to resource methods ONE-TO-ONE (see
8
+ * harper/dist/server/REST.js's method switch): GET→get, POST→post, PUT→put,
9
+ * PATCH→patch, DELETE→delete. There is no fallback — `patch()` does NOT route
10
+ * through `put()`. So an ownership rule written inside a resource's `put()` is
11
+ * enforced on PUT and on nothing else, and a resource with no `patch()` override
12
+ * reaches the table with only its `allow*` gate, which is typically
13
+ * `allowVerified()` — "any verified agent".
14
+ *
15
+ * Almost every flair resource wrote its per-record rules in `put()`. That is not
16
+ * a mistake anyone made once; it is what the resource model invites, because
17
+ * `put()` is where the write logic naturally goes and nothing anywhere says the
18
+ * other verbs exist. Fixing it resource-by-resource would mean N hand-written
19
+ * guards — N chances to get one subtly wrong — and would still leave the NEXT
20
+ * resource broken by default, because its author would have to know this file
21
+ * exists to be safe. So the rule lives here instead, once, on the path every
22
+ * HTTP request already takes.
23
+ *
24
+ * ─── The rule, and why it is this narrow ─────────────────────────────────────
25
+ *
26
+ * On a record that ALREADY EXISTS, a non-admin caller whose agent id does not
27
+ * match the record's stored owner is refused — on every mutating verb.
28
+ *
29
+ * It deliberately says nothing about creation. Over-blocking is the failure mode
30
+ * a security fix reaches for, and a blunter rule ("the caller must own whatever
31
+ * it names") would break real flows: Presence heartbeats arrive at the
32
+ * collection with no record yet, credential provisioning creates rows for other
33
+ * principals, and a MemoryGrant's `granteeId` is SUPPOSED to be someone else.
34
+ * Restricting this to records that exist means the guard can only ever narrow
35
+ * mutation of another agent's data. It cannot break a create, and it cannot
36
+ * break an agent writing its own record.
37
+ *
38
+ * ─── Owner is read from STORED STATE, never from the request body ────────────
39
+ *
40
+ * The guards this replaces compared the owner field in the request BODY — the
41
+ * owner the CALLER CLAIMS — and denied only when that field was present and
42
+ * mismatched. A body that simply omitted it was compared against nothing and
43
+ * passed, whatever record the URL named. Harper binds the write to the URL's id,
44
+ * not to the body, so body-derived authorization was answering a question nobody
45
+ * asked. That is the same defect as the verb gap wearing different clothes: an
46
+ * authorization decision made against attacker-supplied data instead of stored
47
+ * state. Both are closed here, together, because closing one leaves the other
48
+ * reachable.
49
+ *
50
+ * Per-resource body checks (no-forge attribution on CREATE) still belong in the
51
+ * resources — they answer a different question, "may you attribute a NEW record
52
+ * to someone else", which this guard does not address.
53
+ *
54
+ * ─── Keeping this honest as the codebase grows ───────────────────────────────
55
+ *
56
+ * OWNER_FIELDS below is static and PR-reviewed, matching the posture of
57
+ * resources/record-types.ts. A static map alone would rot, so
58
+ * test/unit/record-owner-guard-coverage.test.ts parses `schemas/*.graphql` and
59
+ * FAILS when a table declares an owner-shaped column and is neither listed here
60
+ * nor exempted with a stated reason. A table added later enters that test's
61
+ * scope the moment it declares the column — nobody has to know this file exists.
62
+ */
63
+ /**
64
+ * Table → the attribute naming the principal that owns a row.
65
+ *
66
+ * Derived from the columns actually declared in `schemas/*.graphql`, and pinned
67
+ * against them by the coverage test. Tables with no resource class are listed
68
+ * anyway: costing nothing when the route does not exist is much better than
69
+ * being absent on the day someone adds one.
70
+ */
71
+ export const OWNER_FIELDS = Object.freeze({
72
+ Credential: "principalId",
73
+ Integration: "agentId",
74
+ Memory: "agentId",
75
+ MemoryCandidate: "agentId",
76
+ MemoryGrant: "ownerId",
77
+ MemoryUsage: "agentId",
78
+ OAuthAuthCode: "principalId",
79
+ OAuthToken: "principalId",
80
+ OrgEvent: "authorId",
81
+ Presence: "agentId",
82
+ Relationship: "agentId",
83
+ Soul: "agentId",
84
+ WorkspaceState: "agentId",
85
+ });
86
+ /**
87
+ * Tables that declare an owner-shaped column but are deliberately NOT guarded
88
+ * here, each with the reason. The coverage test accepts these and rejects
89
+ * anything else, so an omission has to be argued rather than merely happen.
90
+ */
91
+ export const OWNER_GUARD_EXEMPT = Object.freeze({
92
+ // The principal table's own rule is "the record IS the caller", not "the
93
+ // record has an owner column" — a principal may edit itself, and only an
94
+ // admin may change anyone's admin status. That is enforced in
95
+ // resources/Agent.ts's shared write-authorization helper, which both its
96
+ // put() and its patch() route through.
97
+ Agent: "self-ownership by primary key; enforced in resources/Agent.ts for every verb",
98
+ });
99
+ /** The verbs that can mutate a record, and therefore need the rule applied. */
100
+ export const MUTATING_METHODS = Object.freeze(["POST", "PUT", "PATCH", "DELETE"]);
101
+ export function isMutatingMethod(method) {
102
+ return MUTATING_METHODS.includes(method.toUpperCase());
103
+ }
104
+ /**
105
+ * Resolve a request path to the guarded table and record id it addresses, or
106
+ * null when the path is not a guarded single-record route.
107
+ *
108
+ * Matches `/<Table>/<id>` ONLY. A collection path (`/<Table>`) addresses no
109
+ * existing record, so there is nothing to own and nothing to check — that is the
110
+ * "creation is untouched" property, expressed as a parse rather than a special
111
+ * case. The table segment is matched EXACTLY so `/SoulFeed/x` can never be read
112
+ * as a `Soul` route.
113
+ */
114
+ export function resolveGuardedRecord(pathname) {
115
+ const parts = pathname.split("/").filter(Boolean);
116
+ if (parts.length < 2)
117
+ return null;
118
+ const table = parts[0];
119
+ const ownerField = OWNER_FIELDS[table];
120
+ if (!ownerField)
121
+ return null;
122
+ let id;
123
+ try {
124
+ id = decodeURIComponent(parts[1]);
125
+ }
126
+ catch {
127
+ return null; // malformed percent-encoding addresses no record we can resolve
128
+ }
129
+ if (!id)
130
+ return null;
131
+ return { table, ownerField, id };
132
+ }
133
+ /**
134
+ * Decide whether a caller may mutate an already-stored record.
135
+ *
136
+ * Pure, so the decision is testable without a Harper instance — the middleware
137
+ * supplies the record it loaded. A record that does not exist, or that carries
138
+ * no owner value, is NOT refused here: the first is a create (or a 404 the
139
+ * resource will produce), and the second is a row with nothing to own, neither
140
+ * of which this rule is about.
141
+ */
142
+ export function isForbiddenOwnerMutation(record, ownerField, callerAgentId) {
143
+ if (!record)
144
+ return false;
145
+ const owner = record[ownerField];
146
+ if (owner == null || owner === "")
147
+ return false;
148
+ return owner !== callerAgentId;
149
+ }
@@ -54,6 +54,8 @@ export async function remember(agentId, content, opts = {}) {
54
54
  > ```
55
55
  >
56
56
  > `collectionResource(Cls, context)` is a two-line wrapper over the supported call — `Cls.getResource({}, context, { isCollection: true })` — and exists so this is written once. **Reads do not need it:** `Cls.get(id, context)` and `Cls.search(query, context)` thread the context themselves.
57
+ >
58
+ > They do still need the **context**. Only the collection binding is unnecessary for a read, never the identity — `Cls.search(query)` with the second argument left off resolves to the trusted `internal` verdict when it runs outside a request scope (a boot hook, a timer, a queue worker, a detached promise) and returns every agent's private records. On that path the resource's `allow*` gate is not consulted at all. Pass the context to every call, read and write.
57
59
 
58
60
  > ### ⚠️ A resource with no context is an administrator
59
61
  >
@@ -124,7 +126,7 @@ export async function registerAgent(id, { publicKey = "pending", admin = false }
124
126
  id, name: id, displayName: id,
125
127
  publicKey, // a placeholder is fine — see below
126
128
  runtime: "headless",
127
- ...(admin ? { role: "admin", admin: true } : {}), // role is what actually grants admin
129
+ ...(admin ? { admin: true } : {}), // sets role:"admin" too see below
128
130
  });
129
131
  }
130
132
  ```
@@ -133,7 +135,9 @@ Verified against a real instance: that lands `kind: "agent"`, `status: "active"`
133
135
 
134
136
  > **Prefer this to `databases.flair.Agent.put()`.** The raw table applies **no** defaults, so a hand-written literal has to reproduce every field above and then stay in step with Flair as the Principal model grows. Records written that way are missing `kind`/`status`/`defaultTrustTier` and read as under-specified Principals in the admin surfaces.
135
137
 
136
- > **`isAdmin()` reads `role === "admin"`, not the `admin` boolean.** They are separate fields and only `role` grants admin rights; set both to keep the record self-consistent. Admin lookups are cached for 60 seconds, so a newly-created admin is not effective immediately.
138
+ > **Admin is one meaning with one answer.** `role === "admin"` is the authority; the `admin` boolean is a mirror of it that the server maintains. Write **either** through the `Agent` resource and both are set you no longer have to know which one is real, and a record cannot be stored saying one thing in one field and the opposite in the other. Nothing reads the mirror to make an authorization decision, and a promotion applied through the resource takes effect on the next request rather than after the 60-second admin-lookup cache expires.
139
+ >
140
+ > A record written straight to the table (`databases.flair.Agent.put()`, an ops-API insert, a federation merge) skips that reconciliation and can still carry a mismatch. `flair principal show` and the admin dashboard flag such a record rather than silently picking a side; re-issuing the grant repairs it.
137
141
 
138
142
  `publicKey` is non-nullable in the schema, but it does not have to be a real key. An agent that only ever acts in-process never authenticates, and Flair's own paths write placeholders — `"pending"` when seeding, `mcp-oauth:<sub>` for token-authenticated agents. Give an agent a real key only if it must also authenticate **over HTTP**, which your app can do without any CLI:
139
143
 
package/docs/upgrade.md CHANGED
@@ -188,7 +188,7 @@ FABRIC_USER=<admin> FABRIC_PASSWORD=<pass> \
188
188
 
189
189
  (or `--fabric-password-file <path>` instead of the `FABRIC_PASSWORD` env var — reads the
190
190
  password from a file, chmod 600). This resolves the target version (latest published
191
- `@tpsdev-ai/flair`, or pin one with `--version`), stages a clean deployable with the
191
+ `@tpsdev-ai/flair`, or pin one with `--flair-version`), stages a clean deployable with the
192
192
  required `harper` version pin applied (`--harper-version` to override),
193
193
  confirms the staged Harper build before deploying, then reuses `flair deploy` to push it
194
194
  and verifies the result. `--check` shows the version diff and plan without deploying
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tpsdev-ai/flair",
3
- "version": "0.31.0",
3
+ "version": "0.31.1",
4
4
  "packageManager": "bun@1.3.10",
5
5
  "description": "Identity, memory, and soul for AI agents. Cryptographic identity (Ed25519), semantic memory with local embeddings, and persistent personality — all in a single process.",
6
6
  "type": "module",