@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.
@@ -4,6 +4,7 @@ import { getEmbedding } from "./embeddings-provider.js";
4
4
  import { isAdmin, FLAIR_AGENT_USERNAME } from "./agent-auth.js";
5
5
  import { WINDOW_MS, isNonceReplay, recordNonce, importEd25519Key, b64ToArrayBuffer, parseTpsEd25519Header } from "./ed25519-auth.js";
6
6
  import { resolveReadScope } from "./memory-read-scope.js";
7
+ import { isForbiddenOwnerMutation, resolveGuardedRecord } from "./record-owner-guard.js";
7
8
  // --- Admin credentials ---
8
9
  // Admin auth is sourced exclusively from Harper's own environment variables
9
10
  // (HDB_ADMIN_PASSWORD / FLAIR_ADMIN_PASSWORD). No filesystem token file.
@@ -350,6 +351,37 @@ server.http(async (request, nextLayer) => {
350
351
  // ── Server-side permission guards ──────────────────────────────────────────
351
352
  const method = request.method.toUpperCase();
352
353
  const isMutation = method === "POST" || method === "PUT" || method === "PATCH" || method === "DELETE";
354
+ // ── THE record-ownership rule, for every table, on every mutating verb ─────
355
+ //
356
+ // One enforcement point rather than one per resource. See
357
+ // resources/record-owner-guard.ts for why this is not written into each
358
+ // resource's put(): Harper maps verbs to methods one-to-one, so a rule living
359
+ // in put() is enforced on PUT alone, and nearly every resource wrote its rules
360
+ // there. Doing this per-resource would be N chances to get one wrong and would
361
+ // still leave the next resource broken by default.
362
+ //
363
+ // Ownership is read from the STORED record named by the path — never from the
364
+ // request body, which is the caller's claim about who owns the row and was how
365
+ // a body that simply omitted the field passed unchecked.
366
+ //
367
+ // Deliberately scoped to records that ALREADY EXIST: creation is left to each
368
+ // resource's own no-forge attribution. That keeps this incapable of breaking a
369
+ // create, a self-write, or a legitimate cross-agent field like MemoryGrant's
370
+ // granteeId — it can only narrow mutation of another agent's stored row.
371
+ if (isMutation && !request.tpsAgentIsAdmin) {
372
+ const guarded = resolveGuardedRecord(url.pathname);
373
+ if (guarded) {
374
+ try {
375
+ const record = await databases.flair[guarded.table]?.get(guarded.id);
376
+ if (isForbiddenOwnerMutation(record, guarded.ownerField, agentId)) {
377
+ return new Response(JSON.stringify({
378
+ error: `forbidden: cannot modify ${guarded.table} owned by another principal`,
379
+ }), { status: 403, headers: { "Content-Type": "application/json" } });
380
+ }
381
+ }
382
+ catch { /* unreadable row → fall through to the resource's own rules */ }
383
+ }
384
+ }
353
385
  if (isMutation) {
354
386
  // OrgEvent: authorId must match authenticated agent
355
387
  if ((url.pathname === "/OrgEvent" || url.pathname.startsWith("/OrgEvent/")) &&
@@ -419,19 +451,59 @@ server.http(async (request, nextLayer) => {
419
451
  catch { }
420
452
  }
421
453
  }
422
- // Soul PUT: only owner or admin
423
- if (url.pathname.startsWith("/Soul") && (method === "PUT" || method === "POST")) {
454
+ // Soul mutations: only the owner or an admin may write a soul entry.
455
+ //
456
+ // This guard had two independent holes, either of which alone let one agent
457
+ // rewrite another's identity data:
458
+ //
459
+ // 1. THE VERB LIST enumerated PUT and POST only. Its three siblings above
460
+ // (OrgEvent, WorkspaceState, Memory) all include PATCH, and Memory
461
+ // includes DELETE; this one did neither. Harper routes PATCH to a
462
+ // resource method that carries no ownership check of its own
463
+ // (Soul.ts's enforceWriteAuth covers post()/put()), and DELETE reached
464
+ // the table with no per-record check at all. Both were live.
465
+ //
466
+ // 2. IT COMPARED THE BODY, not the target. `body.agentId` is the owner
467
+ // the CALLER claims, and the check only fired when that field was
468
+ // present and mismatched — so a body omitting it, which a partial
469
+ // write naturally does, was compared against nothing and passed
470
+ // whatever record the URL pointed at. The resource-level check behind
471
+ // it is "validate-truthy" attribution, which by design also passes an
472
+ // ABSENT owner field, so nothing downstream caught it either. (A PUT
473
+ // happened to fail anyway, but on `agentId: String!` schema
474
+ // validation — a 400 for the wrong reason, not an authorization
475
+ // decision, and not a defence to rely on.)
476
+ //
477
+ // Closing one hole leaves the other reachable through the remaining verbs,
478
+ // so both are closed here: every mutating verb is covered, and ownership is
479
+ // resolved from the STORED RECORD named by the path. That is the same shape
480
+ // as the Memory ownership guard below, which is the resource in this file
481
+ // that already had it right — worth copying rather than reinventing.
482
+ //
483
+ // The path test stays `startsWith("/Soul")` so sibling routes keep the
484
+ // body check they already had; the record lookup is scoped to the real
485
+ // `/Soul/<id>` collection so it never resolves an unrelated id.
486
+ if (url.pathname.startsWith("/Soul") &&
487
+ (method === "POST" || method === "PUT" || method === "PATCH" || method === "DELETE")) {
424
488
  if (!request.tpsAgentIsAdmin) {
425
- let bodyAgentId = null;
426
- try {
427
- const clone = request.clone();
428
- const body = await clone.json();
429
- bodyAgentId = body?.agentId ?? null;
430
- }
431
- catch { }
432
- if (bodyAgentId && bodyAgentId !== agentId) {
433
- return new Response(JSON.stringify({ error: "forbidden: non-admin cannot modify another agent's soul" }), { status: 403 });
489
+ // (a) A PRESENT, mismatched body agentId is a forged attribution.
490
+ if (method !== "DELETE") {
491
+ let bodyAgentId = null;
492
+ try {
493
+ const clone = request.clone();
494
+ const body = await clone.json();
495
+ bodyAgentId = body?.agentId ?? null;
496
+ }
497
+ catch { }
498
+ if (bodyAgentId && bodyAgentId !== agentId) {
499
+ return new Response(JSON.stringify({ error: "forbidden: non-admin cannot modify another agent's soul" }), { status: 403 });
500
+ }
434
501
  }
502
+ // (b) The owner of the record actually being written — the half that
503
+ // catches a body simply leaving agentId out — is now the shared
504
+ // record-ownership rule at the top of this block, which applies it to
505
+ // every table on every mutating verb. Deliberately NOT repeated here:
506
+ // one condition enforced in two places is how the two drift apart.
435
507
  }
436
508
  }
437
509
  // Memory promotion guard: only admin can approve or set durability=permanent
@@ -453,21 +525,22 @@ server.http(async (request, nextLayer) => {
453
525
  catch { }
454
526
  }
455
527
  }
456
- // Memory PUT/DELETE: ownership check (non-admin can only modify their own memories)
528
+ // Memory DELETE: permanent memories are admin-only to purge.
529
+ //
530
+ // The OWNERSHIP half of this guard — which was the model the shared
531
+ // record-ownership rule above was built from, and the only one in this file
532
+ // that already covered PATCH — now lives there and covers every table. What
533
+ // remains here is the part that is NOT about ownership: durability. An agent
534
+ // owning a permanent memory still may not purge it.
457
535
  if (((url.pathname === "/Memory" || url.pathname.startsWith("/Memory/") || url.pathname === "/memory" || url.pathname.startsWith("/memory/"))) &&
458
- (method === "PUT" || method === "DELETE" || method === "PATCH")) {
536
+ method === "DELETE") {
459
537
  if (!request.tpsAgentIsAdmin) {
460
538
  try {
461
539
  const pathParts = url.pathname.split("/").filter(Boolean);
462
540
  const memId = pathParts[1] ? decodeURIComponent(pathParts[1]) : null;
463
541
  if (memId) {
464
542
  const record = await databases.flair.Memory.get(memId);
465
- if (record && record.agentId && record.agentId !== agentId) {
466
- return new Response(JSON.stringify({
467
- error: `forbidden: cannot modify memory owned by ${record.agentId}`
468
- }), { status: 403 });
469
- }
470
- if (method === "DELETE" && record?.durability === "permanent") {
543
+ if (record?.durability === "permanent") {
471
544
  return new Response(JSON.stringify({
472
545
  error: "forbidden: only admins can purge permanent memories"
473
546
  }), { status: 403 });
@@ -0,0 +1,382 @@
1
+ /**
2
+ * ─── Public in-process API (flair#956) ───────────────────────────────────────
3
+ *
4
+ * The facade that hides four internal implementation details a Harper engineer
5
+ * should never have to learn:
6
+ *
7
+ * 1. A deep import path into our dist/
8
+ * 2. That server.resources is keyed by REST path with no leading slash
9
+ * 3. That the registry entry wraps the class in .Resource
10
+ * 4. That creates need collectionResource() while reads do not
11
+ *
12
+ * And collapses the agentId double-pass (context + body) into one.
13
+ *
14
+ * ```ts
15
+ * import { Flair } from "@tpsdev-ai/flair";
16
+ * const flair = new Flair(server);
17
+ * const planner = flair.as("planner");
18
+ * await planner.memory.write("deploy runs at 0200 UTC");
19
+ * ```
20
+ *
21
+ * The facade does NOT hide the security boundary. In-process identity is
22
+ * asserted, not verified — co-location IS the grant. flair.as(id) requires a
23
+ * non-empty id (runtime throw). flair.admin and flair.internal are separate,
24
+ * greppable properties. The docs say plainly: build the context from your own
25
+ * server-side state, never from request data.
26
+ *
27
+ * ── Internal implementation ─────────────────────────────────────────────────
28
+ * Every operation delegates to the existing primitives in ./in-process.js
29
+ * (agentContext, adminContext, internalContext, collectionResource). The facade
30
+ * is additive — existing code using the raw seam continues to work.
31
+ */
32
+ import { agentContext, adminContext, internalContext, collectionResource, InProcessContextError, } from "./in-process.js";
33
+ // ─── Re-export for the "./server" entry point ────────────────────────────────
34
+ export { agentContext, adminContext, internalContext, collectionResource, InProcessContextError };
35
+ // ─── Resource resolution ─────────────────────────────────────────────────────
36
+ /**
37
+ * Resolve a Flair resource class from the Harper server registry.
38
+ * Throws with a helpful message listing available resources if not found.
39
+ */
40
+ function resolveResource(server, name) {
41
+ const entry = server.resources.get?.(name) ?? server.resources.getMatch?.(name);
42
+ if (!entry?.Resource) {
43
+ const keys = [...server.resources.keys()].sort();
44
+ const available = keys.length > 0 ? keys.join(", ") : "(none)";
45
+ throw new Error(`Flair is not loaded in this Harper instance.\n` +
46
+ `The '${name}' resource was not found in the registry.\n` +
47
+ `Available: [${available}]\n` +
48
+ `Make sure @tpsdev-ai/flair is installed as a component of this instance.`);
49
+ }
50
+ return entry.Resource;
51
+ }
52
+ // ─── AgentHandle ─────────────────────────────────────────────────────────────
53
+ /**
54
+ * A handle that carries agent identity and scopes every operation to that agent.
55
+ *
56
+ * Returned by {@link Flair.as}. The agentId is validated at construction time
57
+ * (runtime, not types) — missing, empty, blank, or non-string throws
58
+ * {@link InProcessContextError}.
59
+ *
60
+ * **Security:** in-process identity is asserted, not verified. Build the
61
+ * agentId from your own server-side state, never from request data.
62
+ */
63
+ export class AgentHandle {
64
+ agentId;
65
+ #server;
66
+ #ctx;
67
+ constructor(server, agentId) {
68
+ this.#server = server;
69
+ this.agentId = agentId;
70
+ // Throws InProcessContextError on missing/empty/blank id — see
71
+ // resources/in-process.ts's safety-design block for why this is
72
+ // not merely defensive.
73
+ this.#ctx = agentContext(agentId);
74
+ }
75
+ /** Memory operations scoped to this agent. */
76
+ get memory() {
77
+ return new AgentMemory(this.#server, this.#ctx, this.agentId);
78
+ }
79
+ /**
80
+ * Semantic search scoped to this agent.
81
+ *
82
+ * ```ts
83
+ * const hits = await planner.recall("deploy schedule", { limit: 5 });
84
+ * ```
85
+ */
86
+ async recall(query, opts) {
87
+ const Cls = resolveResource(this.#server, "SemanticSearch");
88
+ const h = new Cls(undefined, this.#ctx);
89
+ const body = { q: query, limit: opts?.limit ?? 5 };
90
+ if (opts?.includeTrust === true)
91
+ body.includeTrust = true;
92
+ if (opts?.abstain === true)
93
+ body.abstain = true;
94
+ if (opts?.scoring)
95
+ body.scoring = opts.scoring;
96
+ if (opts?.minScore !== undefined)
97
+ body.minScore = opts.minScore;
98
+ if (opts?.since)
99
+ body.since = opts.since;
100
+ if (opts?.asOf)
101
+ body.asOf = opts.asOf;
102
+ if (opts?.tag)
103
+ body.tag = opts.tag;
104
+ if (opts?.subject)
105
+ body.subject = opts.subject;
106
+ if (opts?.subjects)
107
+ body.subjects = opts.subjects;
108
+ return unwrap(await h.post(body));
109
+ }
110
+ }
111
+ // ─── AgentMemory (per-agent memory operations) ───────────────────────────────
112
+ class AgentMemory {
113
+ #server;
114
+ #ctx;
115
+ #agentId;
116
+ constructor(server, ctx, agentId) {
117
+ this.#server = server;
118
+ this.#ctx = ctx;
119
+ this.#agentId = agentId;
120
+ }
121
+ /**
122
+ * Write a memory as this agent.
123
+ *
124
+ * The agentId is stamped from the handle's context — the caller never
125
+ * passes it, and any agentId in opts is overwritten. This collapses the
126
+ * double-pass (context + body) into one.
127
+ */
128
+ async write(content, opts) {
129
+ const Cls = resolveResource(this.#server, "Memory");
130
+ const h = await collectionResource(Cls, this.#ctx);
131
+ const body = {
132
+ agentId: this.#agentId,
133
+ content,
134
+ };
135
+ if (opts?.durability)
136
+ body.durability = opts.durability;
137
+ if (opts?.visibility)
138
+ body.visibility = opts.visibility;
139
+ if (opts?.tags)
140
+ body.tags = opts.tags;
141
+ if (opts?.type)
142
+ body.type = opts.type;
143
+ if (opts?.id)
144
+ body.id = opts.id;
145
+ return unwrap(await h.post(body));
146
+ }
147
+ /** Get a memory by id, scoped to this agent. */
148
+ async get(id) {
149
+ const Cls = resolveResource(this.#server, "Memory");
150
+ const h = new Cls(undefined, this.#ctx);
151
+ return unwrap(await h.get(id));
152
+ }
153
+ /**
154
+ * Search memories scoped to this agent.
155
+ *
156
+ * Delegates to Memory.search() which applies the agent's read scope
157
+ * (own memories + granted owners' shared memories).
158
+ */
159
+ async search(opts) {
160
+ const Cls = resolveResource(this.#server, "Memory");
161
+ const h = new Cls(undefined, this.#ctx);
162
+ const conditions = [];
163
+ if (opts?.tags) {
164
+ for (const tag of opts.tags) {
165
+ conditions.push({ search_attribute: "tags", search_type: "contains", search_value: tag });
166
+ }
167
+ }
168
+ if (opts?.type) {
169
+ conditions.push({ search_attribute: "type", search_type: "equals", search_value: opts.type });
170
+ }
171
+ if (opts?.durability) {
172
+ conditions.push({ search_attribute: "durability", search_type: "equals", search_value: opts.durability });
173
+ }
174
+ if (opts?.visibility) {
175
+ conditions.push({ search_attribute: "visibility", search_type: "equals", search_value: opts.visibility });
176
+ }
177
+ const query = conditions.length > 0 ? { conditions, operator: "and" } : undefined;
178
+ return unwrap(await h.search(query));
179
+ }
180
+ }
181
+ // ─── AdminHandle ─────────────────────────────────────────────────────────────
182
+ /**
183
+ * Flair-admin operations — unfiltered reads, cross-agent writes.
184
+ *
185
+ * **This is a root shell.** Every call site is greppable via
186
+ * `git grep "flair.admin"`. Use for provisioning and maintenance only,
187
+ * never as a request handler's default.
188
+ *
189
+ * The admin agentId is validated at construction time (same guard as
190
+ * {@link AgentHandle}).
191
+ */
192
+ export class AdminHandle {
193
+ agentId;
194
+ #server;
195
+ #ctx;
196
+ constructor(server, agentId) {
197
+ this.#server = server;
198
+ this.agentId = agentId;
199
+ this.#ctx = adminContext(agentId);
200
+ }
201
+ /**
202
+ * Register an agent through the Agent resource (full Principal shape).
203
+ *
204
+ * ```ts
205
+ * await flair.admin.registerAgent("planner", { publicKey: "pending" });
206
+ * ```
207
+ */
208
+ async registerAgent(id, opts) {
209
+ const Cls = resolveResource(this.#server, "Agent");
210
+ const h = await collectionResource(Cls, this.#ctx);
211
+ const body = {
212
+ id,
213
+ name: id,
214
+ displayName: opts?.displayName ?? id,
215
+ publicKey: opts?.publicKey ?? "pending",
216
+ runtime: opts?.runtime ?? "headless",
217
+ };
218
+ if (opts?.admin === true)
219
+ body.admin = true;
220
+ return unwrap(await h.post(body));
221
+ }
222
+ /** Memory operations with admin authority (unfiltered reads, cross-agent writes). */
223
+ get memory() {
224
+ return new AdminMemory(this.#server, this.#ctx, this.agentId);
225
+ }
226
+ }
227
+ // ─── AdminMemory ─────────────────────────────────────────────────────────────
228
+ class AdminMemory {
229
+ #server;
230
+ #ctx;
231
+ #agentId;
232
+ constructor(server, ctx, agentId) {
233
+ this.#server = server;
234
+ this.#ctx = ctx;
235
+ this.#agentId = agentId;
236
+ }
237
+ /** Read any memory by id, unfiltered. */
238
+ async get(id) {
239
+ const Cls = resolveResource(this.#server, "Memory");
240
+ const h = new Cls(undefined, this.#ctx);
241
+ return unwrap(await h.get(id));
242
+ }
243
+ /**
244
+ * Write a memory attributed to another agent.
245
+ *
246
+ * ```ts
247
+ * await flair.admin.memory.write("researcher", "provisioned memory", { visibility: "shared" });
248
+ * ```
249
+ */
250
+ async write(asAgentId, content, opts) {
251
+ const Cls = resolveResource(this.#server, "Memory");
252
+ // Use adminContext for the acting admin, but stamp the target agentId
253
+ // on the body so the memory is owned by the target agent.
254
+ const h = await collectionResource(Cls, this.#ctx);
255
+ const body = {
256
+ agentId: asAgentId,
257
+ content,
258
+ };
259
+ if (opts?.durability)
260
+ body.durability = opts.durability;
261
+ if (opts?.visibility)
262
+ body.visibility = opts.visibility;
263
+ if (opts?.tags)
264
+ body.tags = opts.tags;
265
+ if (opts?.type)
266
+ body.type = opts.type;
267
+ if (opts?.id)
268
+ body.id = opts.id;
269
+ return unwrap(await h.post(body));
270
+ }
271
+ }
272
+ // ─── InternalHandle ──────────────────────────────────────────────────────────
273
+ /**
274
+ * Trusted, unattributed, unfiltered operations — Flair's `internal` verdict.
275
+ *
276
+ * Reads see every agent's private records; writes are owned by nobody.
277
+ * This exists for work that is genuinely infrastructure: provisioning a
278
+ * principal, a migration, a maintenance sweep.
279
+ *
280
+ * Every call site is greppable via `git grep "flair.internal"`.
281
+ */
282
+ export class InternalHandle {
283
+ #server;
284
+ #ctx;
285
+ constructor(server) {
286
+ this.#server = server;
287
+ this.#ctx = internalContext();
288
+ }
289
+ /** Raw Agent table access for provisioning. */
290
+ get agentTable() {
291
+ return new InternalAgentTable(this.#server, this.#ctx);
292
+ }
293
+ }
294
+ // ─── InternalAgentTable ──────────────────────────────────────────────────────
295
+ class InternalAgentTable {
296
+ #server;
297
+ #ctx;
298
+ constructor(server, ctx) {
299
+ this.#server = server;
300
+ this.#ctx = ctx;
301
+ }
302
+ /** Write directly to the Agent resource (bypasses admin gate via internal context). */
303
+ async put(record) {
304
+ const Cls = resolveResource(this.#server, "Agent");
305
+ const h = await collectionResource(Cls, this.#ctx);
306
+ return unwrap(await h.post(record));
307
+ }
308
+ }
309
+ // ─── Flair (the facade) ──────────────────────────────────────────────────────
310
+ /**
311
+ * The public in-process API for Flair embedded in a Harper app.
312
+ *
313
+ * One handle per Harper instance. Resolves resources lazily on first use.
314
+ *
315
+ * ```ts
316
+ * import { Flair } from "@tpsdev-ai/flair";
317
+ * const flair = new Flair(server);
318
+ * const planner = flair.as("planner");
319
+ * await planner.memory.write("deploy runs at 0200 UTC");
320
+ * ```
321
+ *
322
+ * **Security:** In-process identity is asserted, not verified — co-location
323
+ * IS the grant. Build the agentId from your own server-side state, never
324
+ * from request data. `flair.as(id)` requires a non-empty id (runtime throw).
325
+ * `flair.admin` and `flair.internal` are separate, greppable properties for
326
+ * deliberate escalation.
327
+ */
328
+ export class Flair {
329
+ #server;
330
+ constructor(server) {
331
+ this.#server = server;
332
+ }
333
+ /**
334
+ * Return a handle that acts as the given agent.
335
+ *
336
+ * The agentId is runtime-validated: missing, empty, blank, or non-string
337
+ * throws {@link InProcessContextError}. Build it from your own server-side
338
+ * state, never from request data.
339
+ */
340
+ as(agentId) {
341
+ return new AgentHandle(this.#server, agentId);
342
+ }
343
+ /**
344
+ * Admin operations — unfiltered reads, cross-agent writes.
345
+ *
346
+ * **This is a root shell.** Every call site is greppable via
347
+ * `git grep "flair.admin"`. Use for provisioning and maintenance only.
348
+ */
349
+ get admin() {
350
+ // AdminHandle requires an agentId for attribution. We use a sentinel
351
+ // that makes the admin identity visible in audit logs. The caller
352
+ // should use a real admin agent id when possible.
353
+ return new AdminHandle(this.#server, "_admin");
354
+ }
355
+ /**
356
+ * Internal operations — trusted, unattributed, unfiltered.
357
+ *
358
+ * Every call site is greppable via `git grep "flair.internal"`.
359
+ * Use for infrastructure work only: provisioning, migrations, maintenance.
360
+ */
361
+ get internal() {
362
+ return new InternalHandle(this.#server);
363
+ }
364
+ }
365
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
366
+ /**
367
+ * Unwrap a handler return value into a plain object.
368
+ * Handlers may return a `Response` (the 401/403/400 guards) — surface its
369
+ * JSON body so the caller sees the structured error rather than an opaque object.
370
+ */
371
+ async function unwrap(value) {
372
+ if (value && typeof value === "object" && typeof value.json === "function" && "status" in value) {
373
+ try {
374
+ const body = await value.json();
375
+ return { error: body?.error ?? "request failed", status: value.status, ...body };
376
+ }
377
+ catch {
378
+ return { error: "request failed", status: value.status };
379
+ }
380
+ }
381
+ return value;
382
+ }
@@ -208,6 +208,15 @@ export function internalContext() {
208
208
  * Reads do not need this — `Cls.get(id, context)` and `Cls.search(query,
209
209
  * context)` (Harper's static resource methods) already thread the context and
210
210
  * start their own transaction.
211
+ *
212
+ * **They do still need the CONTEXT.** Only the collection binding is
213
+ * unnecessary for a read, not the identity. `Cls.search(query)` with the second
214
+ * argument left off does not read "as nobody" — outside a Harper request scope
215
+ * (a boot hook, a timer, a queue worker, a detached promise) it resolves to the
216
+ * same trusted `internal` verdict this module exists to keep out of reach, and
217
+ * returns every agent's private records unfiltered. On that path the resource's
218
+ * own `allow*` gate is not consulted at all, so nothing else stands in the way.
219
+ * Pass the context to every call, read or write (flair#936).
211
220
  */
212
221
  export async function collectionResource(Cls, context) {
213
222
  if (context == null || typeof context !== "object") {
@@ -26,6 +26,7 @@
26
26
  import { databases } from "harper";
27
27
  import { randomBytes } from "node:crypto";
28
28
  import { TOOLS, listToolDefs } from "./mcp-tools.js";
29
+ import { agentRecordIsAdmin } from "./agent-admin.js";
29
30
  // The MCP protocol revision we implement (initialize handshake).
30
31
  const PROTOCOL_VERSION = "2025-06-18";
31
32
  const JSON_HEADERS = { "content-type": "application/json" };
@@ -157,14 +158,23 @@ async function jitProvisionPrincipal(sub) {
157
158
  return principalId;
158
159
  }
159
160
  /**
160
- * Is this Principal a flair admin? Reads the Agent record's `admin`/`role`
161
- * fields. A MCP-OAuth agent is NON-admin unless an operator has explicitly
162
- * marked its Agent record admin — the MCP surface never elevates on its own.
161
+ * Is this Principal a flair admin? A MCP-OAuth agent is NON-admin unless an
162
+ * operator has explicitly marked its Agent record admin the MCP surface never
163
+ * elevates on its own.
164
+ *
165
+ * flair#941: this used to OR the two admin fields together while the primary
166
+ * HTTP gate (resources/agent-auth.ts's isAdmin) read only `role`, so the same
167
+ * record could be an administrator here and an ordinary agent there. It now
168
+ * resolves through the one shared predicate, so both surfaces answer
169
+ * identically. A record carrying `admin: true` alone — which no flair write
170
+ * path produces, and which was never an admin on the HTTP gate — is no longer
171
+ * an admin here either; see resources/agent-admin.ts for the remedy. This
172
+ * surface is gated behind FLAIR_MCP_OAUTH and is default-OFF.
163
173
  */
164
174
  async function isAgentAdmin(principalId) {
165
175
  try {
166
176
  const agent = await databases.flair.Agent.get(principalId);
167
- return agent?.admin === true || agent?.role === "admin";
177
+ return agentRecordIsAdmin(agent);
168
178
  }
169
179
  catch {
170
180
  return false;
@@ -2,7 +2,12 @@ function presenceDelegationContext(auth) {
2
2
  const agentAuth = auth.kind === "agent"
3
3
  ? { agentId: auth.agentId, isAdmin: auth.isAdmin }
4
4
  : { agentId: "internal", isAdmin: true };
5
- return { request: { _flairAgentAuth: agentAuth } };
5
+ // `__flairInternal` marks the `internal` branch as DELIBERATE (flair#936) —
6
+ // an "internal" verdict relayed here is a trusted in-process call this file
7
+ // has already established, not a caller who forgot to pass a context. It is
8
+ // read only by resources/agent-auth.ts's accidental-omission warning; it
9
+ // grants nothing and is never consulted for an authorization decision.
10
+ return { request: { _flairAgentAuth: agentAuth }, __flairInternal: true };
6
11
  }
7
12
  /**
8
13
  * The full presence roster (one row per agent — bounded, per the K&S