@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 +103 -31
- package/dist/resources/AdminPrincipals.js +10 -1
- package/dist/resources/Agent.js +105 -11
- package/dist/resources/AgentSeed.js +10 -2
- package/dist/resources/MemoryUsage.js +18 -0
- package/dist/resources/Presence.js +8 -1
- package/dist/resources/agent-admin.js +149 -0
- package/dist/resources/agent-auth.js +98 -5
- package/dist/resources/auth-middleware.js +92 -19
- package/dist/resources/in-process.js +9 -0
- package/dist/resources/mcp-handler.js +14 -4
- package/dist/resources/presence-internal.js +6 -1
- package/dist/resources/record-owner-guard.js +149 -0
- package/docs/embedding-in-a-harper-app.md +6 -2
- package/docs/upgrade.md +1 -1
- package/package.json +1 -1
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
|
-
*
|
|
504
|
-
*
|
|
505
|
-
* function
|
|
506
|
-
*
|
|
507
|
-
*
|
|
508
|
-
*
|
|
509
|
-
*
|
|
510
|
-
*
|
|
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
|
-
.
|
|
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.
|
|
2644
|
-
//
|
|
2645
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
6236
|
-
//
|
|
6237
|
-
//
|
|
6238
|
-
//
|
|
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
|
-
|
|
6310
|
-
|
|
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`
|
|
6314
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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>
|
package/dist/resources/Agent.js
CHANGED
|
@@ -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
|
-
|
|
38
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
36
|
-
// super_user — admin here gates flair-policy decisions (promotions,
|
|
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:
|
|
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
|
|
423
|
-
|
|
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
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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?
|
|
161
|
-
*
|
|
162
|
-
*
|
|
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
|
|
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
|
-
|
|
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 ? {
|
|
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
|
-
>
|
|
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.
|
|
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",
|