@tpsdev-ai/flair 0.31.0 → 0.32.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +32 -6
- package/dist/cli.js +227 -71
- 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-api.js +382 -0
- 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/deployment-shapes.md +35 -0
- package/docs/deployment.md +2 -2
- package/docs/embedding-in-a-harper-app.md +174 -75
- package/docs/hosted-on-fabric.md +203 -0
- package/docs/secrets-and-keys.md +4 -4
- package/docs/standalone-local.md +243 -0
- package/docs/upgrade.md +7 -3
- package/package.json +7 -1
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
|
}
|