@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.
@@ -1,5 +1,6 @@
1
1
  import { databases } from "harper";
2
- import { resolveAgentAuth, allowVerified, allowAdmin } from "./agent-auth.js";
2
+ import { resolveAgentAuth, allowVerified, allowAdmin, invalidateAdminCache } from "./agent-auth.js";
3
+ import { agentRecordIsAdmin, reconcileAdminFields } from "./agent-admin.js";
3
4
  import { localInstanceId } from "./instance-identity.js";
4
5
  /**
5
6
  * Agent resource — serves as the Principal table in 1.0.
@@ -34,10 +35,18 @@ export class Agent extends databases.flair.Agent {
34
35
  content.kind ||= "agent";
35
36
  content.status ||= "active";
36
37
  content.displayName ||= content.name;
37
- content.admin ??= false;
38
- // Trust tier defaults per kind
38
+ // flair#941 — the two admin fields are reconciled BEFORE anything derives
39
+ // from them, so `role` and `admin` agree on disk whichever one the caller
40
+ // used. Replaces `content.admin ??= false`, which defaulted the mirror
41
+ // without ever consulting the authority: a caller who passed role:"admin"
42
+ // got a record that was an admin at the gate and a non-admin to every
43
+ // reporter. allowCreate() is allowAdmin(), so this path is admin-only.
44
+ reconcileAdminFields(content);
45
+ // Trust tier defaults per kind — derived from the SAME predicate the gate
46
+ // uses, so an admin principal cannot land on the non-admin default just
47
+ // because the caller spelled admin the other way.
39
48
  if (!content.defaultTrustTier) {
40
- content.defaultTrustTier = content.admin ? "endorsed" : "unverified";
49
+ content.defaultTrustTier = agentRecordIsAdmin(content) ? "endorsed" : "unverified";
41
50
  }
42
51
  content.createdAt = now;
43
52
  content.updatedAt = now;
@@ -51,7 +60,25 @@ export class Agent extends databases.flair.Agent {
51
60
  }
52
61
  return super.post(content, context);
53
62
  }
54
- async put(content) {
63
+ /**
64
+ * Authorization shared by BOTH mutation paths (PUT → put, PATCH → patch).
65
+ *
66
+ * It lives in one place because it previously lived only in put(), and PATCH
67
+ * does not route through put() — so every rule written here was enforced on
68
+ * one verb and not the other. Returns a Response to send, or null to proceed.
69
+ *
70
+ * Two rules:
71
+ * 1. Only an admin principal may modify a principal OTHER than itself.
72
+ * 2. Only an admin principal may change a principal's ADMIN STATUS — on any
73
+ * record, including the caller's own. Rule 1 alone never covered this:
74
+ * an agent editing its own record is inside its rights for ordinary
75
+ * fields (runtime, displayName, subjects) and must not be for the fields
76
+ * that decide whether it is an administrator.
77
+ *
78
+ * `internal` (in-process maintenance, federation merge) and admin agents pass
79
+ * through unchanged.
80
+ */
81
+ async authorizePrincipalWrite(content) {
55
82
  const auth = await resolveAgentAuth(this.getContext?.());
56
83
  // Anonymous denied (defense-in-depth alongside allowUpdate; the old check read
57
84
  // tpsAgent and treated a missing agent as trusted, so anonymous slipped through).
@@ -60,24 +87,91 @@ export class Agent extends databases.flair.Agent {
60
87
  status: 401, headers: { "content-type": "application/json" },
61
88
  });
62
89
  }
63
- // Only admin principals can modify OTHER principals; an agent updates its own.
64
- if (auth.kind === "agent" && !auth.isAdmin) {
65
- const existing = await super.get();
66
- if (existing && existing.id !== auth.agentId) {
67
- return new Response(JSON.stringify({ error: "only admin principals can modify other principals" }), {
90
+ if (auth.kind !== "agent" || auth.isAdmin)
91
+ return null;
92
+ const existing = await Promise.resolve(super.get()).catch(() => null);
93
+ // 1. Only admin principals can modify OTHER principals.
94
+ if (existing && existing.id !== auth.agentId) {
95
+ return new Response(JSON.stringify({ error: "only admin principals can modify other principals" }), {
96
+ status: 403, headers: { "content-type": "application/json" },
97
+ });
98
+ }
99
+ // 2. Only admin principals can change admin status. Compare the RESULTING
100
+ // status against the stored one so an ordinary self-update that simply
101
+ // doesn't mention either field is unaffected, and a no-op restatement of
102
+ // the caller's existing status is not a spurious denial.
103
+ const touchesPrivilegeFields = content != null && typeof content === "object" && ("role" in content || "admin" in content);
104
+ if (touchesPrivilegeFields) {
105
+ const merged = { ...(existing ?? {}), ...content };
106
+ const wouldBeAdmin = agentRecordIsAdmin(merged) || merged.admin === true;
107
+ const isAdminNow = agentRecordIsAdmin(existing) || (existing?.admin === true);
108
+ if (wouldBeAdmin !== isAdminNow) {
109
+ return new Response(JSON.stringify({ error: "only admin principals can change a principal's admin status" }), {
68
110
  status: 403, headers: { "content-type": "application/json" },
69
111
  });
70
112
  }
71
113
  }
114
+ return null;
115
+ }
116
+ async put(content) {
117
+ const denial = await this.authorizePrincipalWrite(content);
118
+ if (denial)
119
+ return denial;
72
120
  content.updatedAt = new Date().toISOString();
73
121
  // Protect immutable fields
74
122
  delete content.createdAt;
75
123
  delete content.publicKey; // key rotation goes through dedicated endpoint
124
+ // Keep the two admin fields agreeing on disk — see resources/agent-admin.ts.
125
+ // Only an admin can have reached here with a privilege change (see
126
+ // authorizePrincipalWrite), so this normalises an authorized intent; it
127
+ // never manufactures one.
128
+ reconcileAdminFields(content);
76
129
  // Write-time originatorInstanceId stamp — see post() above / Memory.ts's
77
130
  // stampOriginatorInstanceId doc. No-op if already set.
78
131
  if (content.originatorInstanceId == null) {
79
132
  content.originatorInstanceId = await localInstanceId();
80
133
  }
81
- return super.put(content);
134
+ const result = await super.put(content);
135
+ invalidateAdminCache();
136
+ return result;
137
+ }
138
+ /**
139
+ * PATCH → Harper's partial update (`Resource.patch(data, query)`; see
140
+ * harper/dist/server/REST.js's method switch and Resource.js's static patch).
141
+ *
142
+ * This override exists because there was none. Every per-record authorization
143
+ * rule this resource enforces was written in put(), and PATCH does not route
144
+ * through put() — so none of those rules ran on a PATCH. allowUpdate() is
145
+ * allowVerified(), which means the only check a PATCH ever met was "are you
146
+ * some verified agent"; the rules that make the principal table safe (you may
147
+ * only edit yourself; you may not change your own admin status) were not
148
+ * among them.
149
+ *
150
+ * That is the shape flair#941 keeps running into: a check that reads as
151
+ * complete because it exists, and simply is not on the path the caller took.
152
+ * Both verbs now share authorizePrincipalWrite().
153
+ */
154
+ async patch(content, query) {
155
+ const denial = await this.authorizePrincipalWrite(content);
156
+ if (denial)
157
+ return denial;
158
+ if (content != null && typeof content === "object") {
159
+ // A partial update must not be able to leave the record's two admin
160
+ // fields disagreeing, so reconcile against the MERGED result rather than
161
+ // the patch alone — patching only `role` has to carry the mirror with it.
162
+ if ("role" in content || "admin" in content) {
163
+ const existing = await Promise.resolve(super.get()).catch(() => null);
164
+ const merged = reconcileAdminFields({ ...(existing ?? {}), ...content });
165
+ content.role = merged.role;
166
+ content.admin = merged.admin;
167
+ }
168
+ // Immutable fields, matching put().
169
+ delete content.createdAt;
170
+ delete content.publicKey;
171
+ content.updatedAt = new Date().toISOString();
172
+ }
173
+ const result = await super.patch(content, query);
174
+ invalidateAdminCache();
175
+ return result;
82
176
  }
83
177
  }
@@ -17,7 +17,8 @@
17
17
  * Auth: admin only.
18
18
  */
19
19
  import { Resource, databases } from "harper";
20
- import { isAdmin, allowAdmin } from "./agent-auth.js";
20
+ import { isAdmin, allowAdmin, invalidateAdminCache } from "./agent-auth.js";
21
+ import { reconcileAdminFields } from "./agent-admin.js";
21
22
  const DEFAULT_SOUL_KEYS = (agentId, displayName, role, now) => ({
22
23
  name: displayName,
23
24
  role,
@@ -62,8 +63,15 @@ export class AgentSeed extends Resource {
62
63
  const existingAgent = await databases.flair.Agent.get(agentId).catch(() => null);
63
64
  let agent = existingAgent;
64
65
  if (!existingAgent) {
65
- agent = { id: agentId, name, role, publicKey: "pending", createdAt: now, updatedAt: now };
66
+ // flair#941 this writes the RAW table, so resources/Agent.ts's post()
67
+ // never runs and its field reconciliation does not apply here. Seeding
68
+ // role:"admin" without the mirror was the one path in the product that
69
+ // produced a genuine administrator every reporter displayed as an
70
+ // ordinary agent. Admin-only path (allowCreate + the isAdmin re-check
71
+ // above), so this normalises an authorized intent.
72
+ agent = reconcileAdminFields({ id: agentId, name, role, publicKey: "pending", createdAt: now, updatedAt: now });
66
73
  await databases.flair.Agent.put(agent);
74
+ invalidateAdminCache();
67
75
  }
68
76
  // ── Soul entries ──────────────────────────────────────────────────────────
69
77
  const defaults = DEFAULT_SOUL_KEYS(agentId, name, role, now);
@@ -95,6 +95,24 @@ export class MemoryUsage extends databases.flair.MemoryUsage {
95
95
  return super.put(content);
96
96
  return FORBIDDEN("forbidden: MemoryUsage rows are immutable once written");
97
97
  }
98
+ /**
99
+ * PATCH — same rule as put(), because this ledger's invariant is IMMUTABILITY,
100
+ * not ownership.
101
+ *
102
+ * The shared record-ownership guard (resources/record-owner-guard.ts) covers
103
+ * this table for cross-agent writes, but it cannot express this rule: it
104
+ * permits an agent to modify a row it owns, and here even the OWNER must not.
105
+ * Measured before this override existed: the owning agent rewrote its own
106
+ * row's attribution with a PATCH and got 204, because Harper routes PATCH to
107
+ * patch() and put()'s check never ran. A resource whose rule is stricter than
108
+ * "you own it" still has to say so on every verb.
109
+ */
110
+ async patch(content, query) {
111
+ const auth = await resolveAgentAuth(this.getContext?.());
112
+ if (auth.kind === "internal" || (auth.kind === "agent" && auth.isAdmin))
113
+ return super.patch(content, query);
114
+ return FORBIDDEN("forbidden: MemoryUsage rows are immutable once written");
115
+ }
98
116
  async delete(id) {
99
117
  const auth = await resolveAgentAuth(this.getContext?.());
100
118
  if (auth.kind === "internal" || (auth.kind === "agent" && auth.isAdmin))
@@ -32,6 +32,7 @@ import { dirname, join } from "node:path";
32
32
  import { fileURLToPath } from "node:url";
33
33
  import { createRequire } from "node:module";
34
34
  import { resolveAgentAuth, verifyAgentRequest } from "./agent-auth.js";
35
+ import { agentRecordIsAdmin } from "./agent-admin.js";
35
36
  import { WINDOW_MS, isNonceReplay, recordNonce, importEd25519Key, b64ToArrayBuffer, parseTpsEd25519Header } from "./ed25519-auth.js";
36
37
  // ─── Constants ────────────────────────────────────────────────────────────────
37
38
  const CURRENT_TASK_MAX_LENGTH = 200;
@@ -348,7 +349,13 @@ export class Presence extends databases.flair.Presence {
348
349
  const entry = {
349
350
  id: agentId,
350
351
  displayName: agent?.displayName ?? agent?.name ?? agentId,
351
- role: agent?.role ?? "agent",
352
+ // `role` is a human label on a PUBLIC roster (allowRead() is `true`),
353
+ // and it is also the field that decides administrator status
354
+ // (resources/agent-admin.ts). Publishing the admin sentinel would
355
+ // hand an unauthenticated reader the list of privileged principals —
356
+ // so the roster reports admins as ordinary agents. It is a display
357
+ // label here and nothing authorizes on it (flair#941).
358
+ role: agentRecordIsAdmin(agent) ? "agent" : (agent?.role ?? "agent"),
352
359
  runtime: agent?.runtime ?? null,
353
360
  // Current activity — only truthful while fresh; "idle" once decayed.
354
361
  activity: activityFresh ? rawActivity : "idle",
@@ -0,0 +1,149 @@
1
+ /**
2
+ * agent-admin.ts — the ONE answer to "is this principal an administrator?".
3
+ *
4
+ * The Agent (Principal) record carries two fields that both read as if they
5
+ * answered that question:
6
+ *
7
+ * role: String — administrator when the value is exactly "admin"
8
+ * admin: Boolean — "admin principals can manage other principals"
9
+ *
10
+ * They used to be consulted by DIFFERENT consumers, which is the whole defect
11
+ * (flair#941):
12
+ *
13
+ * - resources/agent-auth.ts's isAdmin() — the single gate behind allowAdmin(),
14
+ * and therefore behind every admin-only resource — searched `role` and
15
+ * ignored `admin` completely.
16
+ * - resources/mcp-handler.ts OR-ed the two together.
17
+ * - Every reporter (flair principal show/list, the admin dashboard) displayed
18
+ * `admin`, and every writer except AgentSeed wrote `admin`.
19
+ *
20
+ * So the field the product wrote and displayed was not the field the gate read.
21
+ * `flair principal add --admin` stored `admin: true` and granted nothing, while
22
+ * the dashboard confidently printed "admin: yes" for a principal that
23
+ * allowAdmin() rejects. Both directions are silent, and one of them is silent in
24
+ * the direction that flatters the operator.
25
+ *
26
+ * ─── The rule ────────────────────────────────────────────────────────────────
27
+ *
28
+ * `role === ADMIN_ROLE` is the AUTHORITY. `admin` is a SERVER-MAINTAINED MIRROR
29
+ * of it and is never read to reach an authorization decision again.
30
+ *
31
+ * Every decider and every reporter calls {@link agentRecordIsAdmin}, so no two
32
+ * surfaces can answer this question differently. Every write through a flair
33
+ * write path calls {@link reconcileAdminFields}, so a record that says one thing
34
+ * in one field and the opposite in the other cannot be STORED by any path flair
35
+ * offers.
36
+ *
37
+ * ─── Why `role` is the authority and not `admin` ─────────────────────────────
38
+ *
39
+ * `admin: Boolean` is honestly the better-shaped field: typed, indexed,
40
+ * unambiguous, and already the one every creation path and every UI uses.
41
+ * Making it the authority would nonetheless GRANT admin, on the primary HTTP
42
+ * gate, to every record that currently carries `admin: true` with a non-admin
43
+ * `role` — the records `flair principal add --admin` has been producing all
44
+ * along, which have never been admins. That is a widening of live access as a
45
+ * side effect of a consistency fix, decided by data nobody has audited. Keeping
46
+ * `role` as the authority changes no existing principal's rights on the gate:
47
+ * every principal that is an admin today is an admin after this change, and
48
+ * every principal that is not, is not.
49
+ *
50
+ * Switching the authority to `admin` is a reasonable follow-up, but it is a
51
+ * deliberate privilege migration with an audit of existing records — not
52
+ * something to slip in underneath a naming fix.
53
+ *
54
+ * ─── Records that already carry a mismatch ───────────────────────────────────
55
+ *
56
+ * Nothing is rewritten in bulk; no migration runs. What happens to each shape:
57
+ *
58
+ * role:"admin" + admin:false|absent — an admin today, an admin after. The
59
+ * mirror is stale, so the CLI and dashboard used to report "not an admin"
60
+ * for a principal holding admin rights; they now report the truth. The
61
+ * stored mirror is repaired the next time the record is written through the
62
+ * Agent resource.
63
+ *
64
+ * admin:true + role not "admin" — NOT an admin today on the gate, and not
65
+ * after. This is what `flair principal add --admin` produced. The CLI and
66
+ * dashboard used to report "admin: yes"; they now report the truth, which is
67
+ * how an operator finds out the grant never took. The remedy is to re-issue
68
+ * the grant (which now writes both fields). The one behaviour that does
69
+ * change is the native MCP surface, which used to honour this field on its
70
+ * own — see resources/mcp-handler.ts. That surface is gated behind
71
+ * FLAIR_MCP_OAUTH and is default-OFF, so no instance running the shipped
72
+ * defaults is affected.
73
+ *
74
+ * Deliberately dependency-free — pure predicates over a plain record, importing
75
+ * neither `harper` nor any resource, so the auth gate, the resources, the
76
+ * reporters and the tests can all share it without an import cycle.
77
+ */
78
+ /** The exact `role` value that denotes a flair administrator. */
79
+ export const ADMIN_ROLE = "admin";
80
+ /**
81
+ * THE admin predicate. Every authorization decision and every report of a
82
+ * principal's admin status resolves through this one function.
83
+ *
84
+ * Total over every field combination: a record with a contradictory `admin`
85
+ * mirror gets the SAME answer here as it does at the gate, so a contradiction
86
+ * can never produce two different answers on two different surfaces. It is
87
+ * deliberately NOT an OR over the two fields — see the module header.
88
+ *
89
+ * Note this covers Agent RECORDS only. `FLAIR_ADMIN_AGENTS` is a separate,
90
+ * env-configured admin source union-ed in by resources/agent-auth.ts's
91
+ * getAdminAgents(); it names agent ids and never touches a record.
92
+ */
93
+ export function agentRecordIsAdmin(record) {
94
+ return record?.role === ADMIN_ROLE;
95
+ }
96
+ /**
97
+ * Make a record about to be written self-consistent, so the two fields can
98
+ * never be STORED disagreeing.
99
+ *
100
+ * Admin is requested by EITHER spelling — `role: "admin"` or `admin: true` —
101
+ * because both have been documented and both are what an operator reaches for.
102
+ * Whichever they used, both fields come out of here agreeing. That is what
103
+ * makes `flair principal add --admin` finally do what it says: it writes the
104
+ * Boolean, and the Boolean now carries the record into the authority field.
105
+ *
106
+ * **Honouring `admin: true` here is not a privilege widening.** Every call site
107
+ * is already admin-gated — Agent.post() is allowCreate()=allowAdmin, Agent's
108
+ * update path refuses a privilege change from a non-admin, and AgentSeed is
109
+ * admin-only and re-checks. A caller that can reach this could have written
110
+ * `role: "admin"` directly; this only means they no longer have to know which
111
+ * of the two fields is the real one.
112
+ *
113
+ * A non-admin `role` is free text (a human label — "researcher", "COO") and is
114
+ * left exactly as supplied; only the admin sentinel is normalised.
115
+ *
116
+ * Mutates `content` in place and returns it, matching the surrounding
117
+ * defaults-stamping style in resources/Agent.ts's post().
118
+ */
119
+ export function reconcileAdminFields(content) {
120
+ if (!content || typeof content !== "object")
121
+ return content;
122
+ // Widened locally: `content` is generic so TypeScript will not accept writes
123
+ // to named properties on it, but the whole point here is to normalise those
124
+ // two properties in place and hand the caller back its own object.
125
+ const record = content;
126
+ if (record.role === ADMIN_ROLE || record.admin === true) {
127
+ record.role = ADMIN_ROLE;
128
+ record.admin = true;
129
+ }
130
+ else {
131
+ record.admin = false;
132
+ }
133
+ return content;
134
+ }
135
+ /**
136
+ * True when a stored record's two fields disagree — i.e. it was written by
137
+ * something other than a flair write path (a raw table write, an ops-API
138
+ * insert, a federation merge) and now claims one thing to a reader of `admin`
139
+ * and the opposite to the gate.
140
+ *
141
+ * Reporting-only. Nothing authorizes on this; it exists so a surface can SAY
142
+ * that a record is inconsistent instead of silently picking a side.
143
+ */
144
+ export function adminFieldsDisagree(record) {
145
+ const r = record;
146
+ if (!r || typeof r !== "object")
147
+ return false;
148
+ return agentRecordIsAdmin(r) !== (r.admin === true);
149
+ }
@@ -17,6 +17,7 @@
17
17
  */
18
18
  import { databases } from "harper";
19
19
  import { WINDOW_MS, isNonceReplay, recordNonce, importEd25519Key, b64ToArrayBuffer, parseTpsEd25519Header } from "./ed25519-auth.js";
20
+ import { ADMIN_ROLE, agentRecordIsAdmin } from "./agent-admin.js";
20
21
  /**
21
22
  * Shared Harper user that verified Ed25519 agents resolve to (least-privilege
22
23
  * `flair_agent` role), replacing the old admin super_user elevation. Single
@@ -32,8 +33,14 @@ export const FLAIR_AGENT_USERNAME = "flair-agent";
32
33
  // visible to the other two, and the crypto/decoder logic can't drift.
33
34
  // ─── Admin resolution ─────────────────────────────────────────────────────────
34
35
  // Admin agents come from FLAIR_ADMIN_AGENTS (comma-separated) OR Agent records
35
- // with role === "admin". OR-combined, cached 60s. Distinct from Harper's
36
- // super_user — admin here gates flair-policy decisions (promotions, raw ops).
36
+ // whose `role` is the admin sentinel. OR-combined, cached 60s. Distinct from
37
+ // Harper's super_user — admin here gates flair-policy decisions (promotions,
38
+ // raw ops).
39
+ //
40
+ // The record half of that union is the ONE place the Agent table is consulted
41
+ // for an authorization decision, and it resolves through
42
+ // resources/agent-admin.ts's shared predicate so it cannot drift from the
43
+ // reporters or from the MCP surface (flair#941 — they had drifted).
37
44
  let adminCacheExpiry = 0;
38
45
  let adminCache = new Set();
39
46
  async function getAdminAgents() {
@@ -43,9 +50,9 @@ async function getAdminAgents() {
43
50
  const fromEnv = (process.env.FLAIR_ADMIN_AGENTS ?? "").split(",").map((s) => s.trim()).filter(Boolean);
44
51
  const fromDb = [];
45
52
  try {
46
- const results = await databases.flair.Agent.search([{ attribute: "role", value: "admin", condition: "equals" }]);
53
+ const results = await databases.flair.Agent.search([{ attribute: "role", value: ADMIN_ROLE, condition: "equals" }]);
47
54
  for await (const row of results)
48
- if (row?.id)
55
+ if (row?.id && agentRecordIsAdmin(row))
49
56
  fromDb.push(row.id);
50
57
  }
51
58
  catch { /* Agent table may be empty */ }
@@ -53,6 +60,28 @@ async function getAdminAgents() {
53
60
  adminCacheExpiry = now + 60_000;
54
61
  return adminCache;
55
62
  }
63
+ /**
64
+ * Drop the memoised admin set so the NEXT isAdmin() re-reads the Agent table.
65
+ *
66
+ * The 60s TTL is a load optimisation, but on its own it makes a privilege
67
+ * change take up to a minute to appear — which reads as "the change didn't
68
+ * work", and is exactly the confusion flair#941 is about: an operator grants
69
+ * admin, tests it, sees a denial, and starts changing other things. The write
70
+ * path knows precisely when a principal's admin status changed, so it says so
71
+ * and the grant is effective on the caller's very next request.
72
+ *
73
+ * Deliberately NOT a distributed invalidation: Harper runs N worker threads,
74
+ * each with its own module instance and its own cache, so a promotion applied
75
+ * on thread A still takes up to the TTL to be seen by thread B. Making that
76
+ * exact would mean a shared invalidation channel, which is a much larger change
77
+ * than the confusion warrants — the bound stays 60s, unchanged, and the common
78
+ * single-threaded/dev case becomes immediate. Called by resources/Agent.ts's
79
+ * write paths.
80
+ */
81
+ export function invalidateAdminCache() {
82
+ adminCacheExpiry = 0;
83
+ adminCache = new Set();
84
+ }
56
85
  export async function isAdmin(agentId) {
57
86
  return (await getAdminAgents()).has(agentId);
58
87
  }
@@ -195,10 +224,73 @@ export async function allowAdmin(context) {
195
224
  const a = await resolveAgentAuth(context);
196
225
  return a.kind === "internal" || (a.kind === "agent" && a.isAdmin);
197
226
  }
227
+ /**
228
+ * flair#936 — the `internal` verdict is TRUSTED and UNFILTERED, and it is also
229
+ * what a caller gets for supplying no context at all. Nothing distinguishes
230
+ * "I am flair's own maintenance code" from "I am an application developer who
231
+ * did not know a context was required": both spell it `new Memory()`, both
232
+ * succeed, and both return plausible data. It surfaces months later as memories
233
+ * that were never scoped.
234
+ *
235
+ * This does not change the verdict — every authorization outcome is byte-
236
+ * identical — it ends the SILENCE. A deliberate elevated call says so with
237
+ * `internalContext()` (resources/in-process.ts), whose `__flairInternal` marker
238
+ * is read here and nowhere else; anything else that lands on `internal` gets one
239
+ * warning per process, with the stack, so the condition is observable where it
240
+ * was previously invisible.
241
+ *
242
+ * MEASURED, and the reason this is worth having: flair itself has NO in-process
243
+ * caller that relies on the omission — every maintenance, migration, federation
244
+ * and boot path goes through the raw `databases.flair.*` table accessor, which
245
+ * bypasses this resolver entirely rather than defaulting through it. So a
246
+ * warning from this line is, in practice, always either an embedding
247
+ * application that forgot a context or a flair call site that should be naming
248
+ * its intent. Neither is noise.
249
+ *
250
+ * The marker is read off `context`, NOT off `c`: `c` is rebound to
251
+ * `context.request` on the line below, and internalContext() carries the marker
252
+ * on the outer object.
253
+ */
254
+ let warnedInternalByOmission = false;
255
+ /**
256
+ * Was this elevated call DELIBERATE — i.e. did the caller name the authority
257
+ * with `internalContext()` rather than land on it by leaving a context off?
258
+ *
259
+ * Exported because it is the whole decision, and a decision worth testing is
260
+ * worth testing directly: the warning itself is latched once per process, which
261
+ * makes any test of the side effect order-dependent (bun shares one module
262
+ * registry across the whole run, so whichever file resolves a context-less call
263
+ * first consumes the latch and every later assertion silently passes). Pinning
264
+ * the predicate instead is deterministic.
265
+ *
266
+ * Grants nothing and is never consulted for an authorization decision — the
267
+ * verdict is identical either way.
268
+ */
269
+ export function isDeliberateInternalCall(context) {
270
+ return context?.__flairInternal === true;
271
+ }
272
+ /** The advisory text, exported so its content is pinned without tripping the latch. */
273
+ export const INTERNAL_BY_OMISSION_WARNING = "[flair-auth] a resource was invoked with NO caller context and resolved to the trusted " +
274
+ "`internal` verdict: reads are UNFILTERED across every agent, writes are unattributed, and " +
275
+ "the admin-only gate passes. If this is your application's code, pass agentContext(<id>) from " +
276
+ "@tpsdev-ai/flair's in-process seam so the call is scoped and attributed. If the elevated " +
277
+ "authority is genuinely intended, say so with internalContext() and this warning will stop. " +
278
+ "Fires once per process.";
279
+ function noteInternalVerdict(context) {
280
+ if (warnedInternalByOmission)
281
+ return;
282
+ if (isDeliberateInternalCall(context))
283
+ return; // deliberate, and said so
284
+ warnedInternalByOmission = true;
285
+ const stack = (new Error().stack ?? "").split("\n").slice(2, 7).join("\n");
286
+ console.error(INTERNAL_BY_OMISSION_WARNING + "\n" + stack);
287
+ }
198
288
  export async function resolveAgentAuth(context) {
199
289
  const c = context?.request ?? context;
200
- if (!c)
290
+ if (!c) {
291
+ noteInternalVerdict(context);
201
292
  return { kind: "internal" };
293
+ }
202
294
  if (c.tpsAnonymous === true)
203
295
  return { kind: "anonymous" };
204
296
  if (c.tpsAgent) {
@@ -231,5 +323,6 @@ export async function resolveAgentAuth(context) {
231
323
  return { kind: "agent", agentId: auth.agentId, isAdmin: auth.isAdmin };
232
324
  return { kind: "anonymous" };
233
325
  }
326
+ noteInternalVerdict(context);
234
327
  return { kind: "internal" };
235
328
  }