@tpsdev-ai/flair 0.21.0 → 0.22.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.
Files changed (74) hide show
  1. package/README.md +10 -7
  2. package/SECURITY.md +24 -2
  3. package/config.yaml +32 -5
  4. package/dist/cli.js +1755 -213
  5. package/dist/deploy.js +3 -4
  6. package/dist/fleet-presence.js +98 -0
  7. package/dist/fleet-verify.js +291 -0
  8. package/dist/mcp-client-assertion.js +364 -0
  9. package/dist/probe.js +97 -0
  10. package/dist/rem/runner.js +82 -12
  11. package/dist/resources/AttentionQuery.js +356 -0
  12. package/dist/resources/Credential.js +11 -1
  13. package/dist/resources/Federation.js +23 -0
  14. package/dist/resources/MCPClientMetadata.js +88 -0
  15. package/dist/resources/Memory.js +52 -53
  16. package/dist/resources/MemoryBootstrap.js +422 -76
  17. package/dist/resources/MemoryReflect.js +105 -49
  18. package/dist/resources/MemoryUsage.js +104 -0
  19. package/dist/resources/OAuth.js +8 -1
  20. package/dist/resources/OrgEvent.js +11 -0
  21. package/dist/resources/Presence.js +218 -19
  22. package/dist/resources/RecordUsage.js +230 -0
  23. package/dist/resources/Relationship.js +98 -25
  24. package/dist/resources/SemanticSearch.js +85 -317
  25. package/dist/resources/SkillScan.js +15 -0
  26. package/dist/resources/WorkspaceState.js +11 -0
  27. package/dist/resources/agent-auth.js +60 -2
  28. package/dist/resources/auth-middleware.js +18 -9
  29. package/dist/resources/bm25.js +12 -6
  30. package/dist/resources/collision-lib.js +157 -0
  31. package/dist/resources/embeddings-boot.js +149 -0
  32. package/dist/resources/embeddings-provider.js +364 -106
  33. package/dist/resources/entity-vocab.js +139 -0
  34. package/dist/resources/health.js +97 -6
  35. package/dist/resources/mcp-client-metadata-fields.js +112 -0
  36. package/dist/resources/mcp-handler.js +1 -1
  37. package/dist/resources/mcp-tools.js +88 -10
  38. package/dist/resources/memory-reflect-lib.js +289 -0
  39. package/dist/resources/migration-boot.js +143 -0
  40. package/dist/resources/migrations/dir-safety.js +71 -0
  41. package/dist/resources/migrations/embedding-stamp.js +178 -0
  42. package/dist/resources/migrations/envelope.js +43 -0
  43. package/dist/resources/migrations/export.js +38 -0
  44. package/dist/resources/migrations/ledger.js +55 -0
  45. package/dist/resources/migrations/lock.js +154 -0
  46. package/dist/resources/migrations/progress.js +31 -0
  47. package/dist/resources/migrations/registry.js +36 -0
  48. package/dist/resources/migrations/risk-policy.js +23 -0
  49. package/dist/resources/migrations/runner.js +456 -0
  50. package/dist/resources/migrations/snapshot.js +127 -0
  51. package/dist/resources/migrations/source-fields.js +93 -0
  52. package/dist/resources/migrations/space.js +73 -0
  53. package/dist/resources/migrations/state.js +64 -0
  54. package/dist/resources/migrations/status.js +39 -0
  55. package/dist/resources/migrations/synthetic-test-migration.js +93 -0
  56. package/dist/resources/migrations/types.js +9 -0
  57. package/dist/resources/presence-internal.js +29 -0
  58. package/dist/resources/provenance.js +50 -0
  59. package/dist/resources/rate-limiter.js +8 -1
  60. package/dist/resources/scoring.js +124 -5
  61. package/dist/resources/semantic-retrieval-core.js +317 -0
  62. package/dist/version-handshake.js +122 -0
  63. package/package.json +4 -5
  64. package/schemas/event.graphql +5 -0
  65. package/schemas/memory.graphql +66 -0
  66. package/schemas/schema.graphql +5 -43
  67. package/schemas/workspace.graphql +3 -0
  68. package/dist/resources/IngestEvents.js +0 -162
  69. package/dist/resources/IssueTokens.js +0 -19
  70. package/dist/resources/ObsAgentSnapshot.js +0 -13
  71. package/dist/resources/ObsEventFeed.js +0 -13
  72. package/dist/resources/ObsOffice.js +0 -19
  73. package/dist/resources/ObservationCenter.js +0 -23
  74. package/ui/observation-center.html +0 -385
@@ -0,0 +1,356 @@
1
+ /**
2
+ * AttentionQuery.ts — entity-scoped attention query (flair#677).
3
+ *
4
+ * POST /AttentionQuery — "what touches entity E in the last N days?", grouped
5
+ * by source across the five surfaces the attention plane spans: Memory,
6
+ * Relationship, WorkspaceState, Presence, OrgEvent. Spec:
7
+ * FLAIR-ATTENTION-PLANE.md ("Phase 1 — the query" + the K&S verdict's
8
+ * refinements). Builds on the entity vocabulary + `entities[]` fields shipped
9
+ * in flair#675/#676 (resources/entity-vocab.ts, schemas/*.graphql).
10
+ *
11
+ * Deliberately NOT built here (separate follow-up, FLAIR-ATTENTION-PLANE.md
12
+ * Phase 2): collision surfacing (joining a CALLER's own current task/
13
+ * WorkspaceState against teammates' for overlap), semantic/embedding
14
+ * matching, or a Relationship write path. This is read-only, exact-match,
15
+ * index-pushdown across the five sources — nothing more.
16
+ *
17
+ * ─── Per-source read-scoping (binding, per the K&S verdict) ──────────────────
18
+ *
19
+ * - Memory: through resolveReadScope() (resources/memory-read-scope.ts) — the
20
+ * SAME centralized open-within-org-minus-private rule Memory.search()/
21
+ * SemanticSearch.ts/MemoryBootstrap.ts use. No new scoping logic.
22
+ * - Relationship: mirrors Relationship.ts's OWN search() scoping (own agentId
23
+ * for non-admin, unfiltered for admin/internal) — the entity condition is
24
+ * ANDed with that same rule, never replacing it. Relationship carries no
25
+ * visibility field yet (unlike Memory's open-within-org reframe), so the
26
+ * conservative, least-surprising choice is "the same rows a direct
27
+ * `POST /Relationship` search from this caller would already return."
28
+ * - WorkspaceState: Sherlock's Option 1 (K&S verdict, binding). WorkspaceState
29
+ * is normally strictly per-agent-scoped (403 cross-agent via the exported
30
+ * `WorkspaceState` resource class — see resources/WorkspaceState.ts). This
31
+ * query needs teammates' WorkspaceState, so it reads the RAW generated table
32
+ * object directly (`(databases as any).flair.WorkspaceState`), the same
33
+ * "internal path" idiom already used elsewhere in this codebase (e.g.
34
+ * OrgEvent.post()/put() call `(databases as any).flair.OrgEvent.put(...)`
35
+ * directly rather than through the exported subclass) — NEVER through the
36
+ * exported `WorkspaceState` class, which would just re-apply the per-agent
37
+ * 403 for the caller's own identity. This does NOT broaden WorkspaceState's
38
+ * general read model (a plain `GET /WorkspaceState` from a non-admin agent
39
+ * still 403s cross-agent, unchanged) — it is a narrow, server-computed join
40
+ * scoped to one caller-supplied, validated entity string + a bounded day
41
+ * window, returning only rows that matched. The exposed field set mirrors
42
+ * exactly what FLAIR-ATTENTION-PLANE.md's Phase 2 write-up already names as
43
+ * the intended surfaced shape (`summary`, `taskId`, `filesChanged`,
44
+ * `entities`) plus bare identifiers (`id`, `agentId`, `ref`, `phase`,
45
+ * `timestamp`) — never `metadata` (an undocumented free-form JSON blob with
46
+ * no spec-blessed exposure here).
47
+ * - Presence: via the exported `Presence` resource's get() (preserves its
48
+ * verified-agent currentTask content gate, #592) — never the raw table.
49
+ * Presence.get()'s gate keys off a fresh TPS-Ed25519 SIGNATURE
50
+ * (verifyAgentRequest), not an annotation, specifically to close the
51
+ * authorizeLocal-forged-identity vector (flair#610) — so this resource
52
+ * cannot just re-run resolveAgentAuth's verdict through it. Instead it
53
+ * pre-seeds verifyAgentRequest's OWN per-request memoization cache
54
+ * (`request._flairAgentAuth` — see resources/agent-auth.ts's
55
+ * verifyAgentRequest doc: "memoized... including null... we verify once and
56
+ * cache the result on the request") with the verdict THIS request already
57
+ * established via resolveAgentAuth. This is not a forgery: by the time this
58
+ * helper runs, `auth.kind` is already constrained to "agent" (a real
59
+ * Ed25519 signature verified upstream by auth-middleware.ts/agent-auth.ts)
60
+ * or "internal" (a trusted in-process call, unauthenticated-HTTP calls
61
+ * never reach this point — see the top-level anonymous gate below) — it
62
+ * relays an already-established fact instead of re-running a doomed second
63
+ * signature check (the original request's nonce was already consumed by
64
+ * auth-middleware.ts, so re-verifying the SAME raw request would collide
65
+ * with the shared nonce store and spuriously read as a replay).
66
+ * - OrgEvent: OrgEvent's own read model has no per-agent scoping override at
67
+ * all (resources/OrgEvent.ts only gates allowRead() to allowVerified() —
68
+ * any verified agent/admin already reads every OrgEvent org-wide). The raw
69
+ * table is queried directly purely to avoid instantiating an unneeded
70
+ * Resource subclass; the effective access is identical either way.
71
+ *
72
+ * No source here EVER receives a global/unscoped entity scan: every query
73
+ * carries EITHER the source's own read-scope condition (Memory,
74
+ * Relationship, OrgEvent's already-org-open model) OR a narrowly-targeted,
75
+ * validated single-entity + bounded-window condition (WorkspaceState), OR
76
+ * goes through the source's own content-gated resource (Presence).
77
+ */
78
+ import { Resource, databases } from "@harperfast/harper";
79
+ import { resolveAgentAuth, allowVerified } from "./agent-auth.js";
80
+ import { isValidEntity } from "./entity-vocab.js";
81
+ import { resolveReadScope } from "./memory-read-scope.js";
82
+ import { withDetachedTxn } from "./table-helpers.js";
83
+ import { checkRateLimit, rateLimitResponse } from "./rate-limiter.js";
84
+ import { wrapUntrusted } from "./content-safety.js";
85
+ import { getPresenceRoster } from "./presence-internal.js";
86
+ // ─── Tunables ─────────────────────────────────────────────────────────────────
87
+ export const DEFAULT_WINDOW_DAYS = 7;
88
+ const MAX_WINDOW_DAYS = 365;
89
+ // Per-source result cap. The attention view is meant to surface "what's
90
+ // touching this entity right now", not paginate a full history — a hot
91
+ // entity (e.g. a widely-referenced repo) is capped rather than returning an
92
+ // unbounded payload. Each source is independently capped (not a shared total).
93
+ const MAX_RESULTS_PER_SOURCE = 25;
94
+ const JSON_HEADERS = { "Content-Type": "application/json" };
95
+ function badRequest(error, detail) {
96
+ return new Response(JSON.stringify({ error, ...(detail ? { detail } : {}) }), {
97
+ status: 400,
98
+ headers: JSON_HEADERS,
99
+ });
100
+ }
101
+ const UNAUTH = () => new Response(JSON.stringify({ error: "authentication required" }), { status: 401, headers: JSON_HEADERS });
102
+ function parseQueryInput(data) {
103
+ const entity = data?.entity;
104
+ if (typeof entity !== "string" || entity.length === 0) {
105
+ return badRequest("invalid_entity", "entity is required (a vocabulary string, e.g. 'repo:owner/name')");
106
+ }
107
+ // Exact match on the full type:value string — the SAME validator every
108
+ // write path (Memory/WorkspaceState/OrgEvent) gates `entities` writes
109
+ // through (resources/entity-vocab.ts). Rejects anything not drawn from the
110
+ // closed, documented type set / grammar — never a prefix/regex match, so
111
+ // this stays a plain indexed equality lookup, never a scan.
112
+ if (!isValidEntity(entity)) {
113
+ return badRequest("invalid_entity", `'${entity}' is not a well-formed vocabulary string (type:value, closed type set)`);
114
+ }
115
+ let days = DEFAULT_WINDOW_DAYS;
116
+ if (data?.days !== undefined && data?.days !== null) {
117
+ const n = Number(data.days);
118
+ if (!Number.isFinite(n) || !Number.isInteger(n) || n <= 0) {
119
+ return badRequest("invalid_days", "days must be a positive integer");
120
+ }
121
+ days = Math.min(n, MAX_WINDOW_DAYS);
122
+ }
123
+ const sinceIso = new Date(Date.now() - days * 24 * 3600_000).toISOString();
124
+ return { entity, days, sinceIso };
125
+ }
126
+ async function queryMemory(ctx, entity, auth) {
127
+ const conditions = [
128
+ { attribute: "entities", comparator: "equals", value: entity },
129
+ { attribute: "archived", comparator: "not_equal", value: true },
130
+ ];
131
+ // Same centralized scope every other cross-agent Memory read path uses
132
+ // (Memory.search()/SemanticSearch.ts/MemoryBootstrap.ts) — own records (any
133
+ // visibility) + any other agent's non-private record, org-open. Admin/
134
+ // internal callers are unfiltered, matching those same call sites.
135
+ if (auth.kind === "agent" && !auth.isAdmin) {
136
+ const scope = await resolveReadScope(auth.agentId);
137
+ conditions.unshift(scope.condition);
138
+ }
139
+ const query = {
140
+ conditions,
141
+ select: [
142
+ "id", "agentId", "content", "summary", "subject", "tags", "durability",
143
+ "visibility", "createdAt", "updatedAt", "entities", "_safetyFlags",
144
+ ],
145
+ };
146
+ const rows = withDetachedTxn(ctx, () => databases.flair.Memory.search(query));
147
+ const callerAgent = auth.kind === "agent" ? auth.agentId : undefined;
148
+ const hits = [];
149
+ for await (const r of rows) {
150
+ const isFlagged = Array.isArray(r._safetyFlags) && r._safetyFlags.length > 0;
151
+ const fromOther = callerAgent !== undefined && r.agentId !== callerAgent;
152
+ hits.push({
153
+ id: r.id,
154
+ agentId: r.agentId,
155
+ content: isFlagged ? wrapUntrusted(r.content, fromOther ? r.agentId : undefined) : r.content,
156
+ summary: r.summary ?? null,
157
+ subject: r.subject ?? null,
158
+ tags: Array.isArray(r.tags) ? r.tags : null,
159
+ durability: r.durability ?? null,
160
+ visibility: r.visibility ?? null,
161
+ createdAt: r.createdAt,
162
+ updatedAt: r.updatedAt ?? null,
163
+ entities: Array.isArray(r.entities) ? r.entities : null,
164
+ });
165
+ }
166
+ hits.sort((a, b) => (b.createdAt || "").localeCompare(a.createdAt || ""));
167
+ return hits.slice(0, MAX_RESULTS_PER_SOURCE);
168
+ }
169
+ async function queryRelationship(ctx, entity, auth) {
170
+ const entityCondition = {
171
+ operator: "or",
172
+ conditions: [
173
+ { attribute: "subject", comparator: "equals", value: entity },
174
+ { attribute: "object", comparator: "equals", value: entity },
175
+ ],
176
+ };
177
+ const conditions = [entityCondition];
178
+ // Mirror Relationship.ts's search(): non-admin agents are scoped to their
179
+ // own agentId; admin/internal calls are unfiltered. Never broader than what
180
+ // a direct POST /Relationship search from this same caller would return.
181
+ if (auth.kind === "agent" && !auth.isAdmin) {
182
+ conditions.unshift({ attribute: "agentId", comparator: "equals", value: auth.agentId });
183
+ }
184
+ const rows = withDetachedTxn(ctx, () => databases.flair.Relationship.search({ conditions }));
185
+ const hits = [];
186
+ for await (const r of rows) {
187
+ hits.push({
188
+ id: r.id,
189
+ agentId: r.agentId,
190
+ subject: r.subject,
191
+ predicate: r.predicate,
192
+ object: r.object,
193
+ confidence: typeof r.confidence === "number" ? r.confidence : null,
194
+ validFrom: r.validFrom ?? null,
195
+ validTo: r.validTo ?? null,
196
+ createdAt: r.createdAt,
197
+ updatedAt: r.updatedAt ?? null,
198
+ });
199
+ }
200
+ hits.sort((a, b) => (b.updatedAt || b.createdAt || "").localeCompare(a.updatedAt || a.createdAt || ""));
201
+ return hits.slice(0, MAX_RESULTS_PER_SOURCE);
202
+ }
203
+ async function queryWorkspaceState(ctx, entity, sinceIso) {
204
+ const conditions = [
205
+ { attribute: "entities", comparator: "equals", value: entity },
206
+ { attribute: "timestamp", comparator: "greater_than_equal", value: sinceIso },
207
+ ];
208
+ // INTERNAL server-side path (Sherlock Option 1, binding): the RAW generated
209
+ // table object, never the exported `WorkspaceState` resource class — that
210
+ // class's search() re-applies strict per-agent scoping keyed off THIS
211
+ // caller's own identity, which would just filter every teammate's row back
212
+ // out. See this file's module doc for the full rationale + why this is not
213
+ // a broadening of WorkspaceState's general (still per-agent, still 403)
214
+ // read model.
215
+ const query = {
216
+ conditions,
217
+ select: ["id", "agentId", "ref", "phase", "taskId", "summary", "filesChanged", "timestamp", "entities"],
218
+ };
219
+ const rows = withDetachedTxn(ctx, () => databases.flair.WorkspaceState.search(query));
220
+ const hits = [];
221
+ for await (const r of rows) {
222
+ hits.push({
223
+ id: r.id,
224
+ agentId: r.agentId,
225
+ ref: r.ref,
226
+ phase: r.phase ?? null,
227
+ taskId: r.taskId ?? null,
228
+ summary: r.summary ?? null,
229
+ filesChanged: Array.isArray(r.filesChanged) ? r.filesChanged : null,
230
+ timestamp: r.timestamp,
231
+ entities: Array.isArray(r.entities) ? r.entities : null,
232
+ });
233
+ }
234
+ hits.sort((a, b) => (b.timestamp || "").localeCompare(a.timestamp || ""));
235
+ return hits.slice(0, MAX_RESULTS_PER_SOURCE);
236
+ }
237
+ // Presence roster fetch (the synthetic delegation-context trick) now lives in
238
+ // resources/presence-internal.ts, shared with MemoryBootstrap.ts's collision
239
+ // surfacing (flair#681) — see that file's module doc for the full security
240
+ // rationale (why this isn't a forgery, why a fresh signature re-verify isn't
241
+ // possible). This file's own "Presence" doc section above still applies; it
242
+ // just no longer duplicates the code.
243
+ async function queryPresence(auth, entity) {
244
+ const roster = await getPresenceRoster(auth); // fail-open (empty) — never fail the whole attention query
245
+ // Bounded scan: Presence carries exactly one row per agent (org-wide agent
246
+ // count is small), so a free-text substring match here is the pushdown-free
247
+ // exception the K&S verdict explicitly blesses ("Presence is a bounded
248
+ // scan, one row/agent, tiny") — unlike the other four sources, currentTask
249
+ // is unstructured prose with no indexed entities field to push down on.
250
+ const hits = [];
251
+ for (const row of roster) {
252
+ if (typeof row?.currentTask !== "string" || row.currentTask.length === 0)
253
+ continue;
254
+ if (!row.currentTask.includes(entity))
255
+ continue;
256
+ hits.push({
257
+ agentId: row.id,
258
+ displayName: row.displayName,
259
+ activity: row.activity,
260
+ currentTask: row.currentTask,
261
+ presenceStatus: row.presenceStatus,
262
+ lastHeartbeatAt: row.lastHeartbeatAt,
263
+ });
264
+ }
265
+ hits.sort((a, b) => {
266
+ const av = typeof a.lastHeartbeatAt === "number" ? a.lastHeartbeatAt : 0;
267
+ const bv = typeof b.lastHeartbeatAt === "number" ? b.lastHeartbeatAt : 0;
268
+ return bv - av;
269
+ });
270
+ return hits.slice(0, MAX_RESULTS_PER_SOURCE);
271
+ }
272
+ async function queryOrgEvent(ctx, entity, sinceIso) {
273
+ const conditions = [
274
+ { attribute: "entities", comparator: "equals", value: entity },
275
+ { attribute: "createdAt", comparator: "greater_than_equal", value: sinceIso },
276
+ ];
277
+ const query = {
278
+ conditions,
279
+ select: ["id", "authorId", "kind", "scope", "summary", "detail", "targetIds", "createdAt", "expiresAt", "entities"],
280
+ };
281
+ const rows = withDetachedTxn(ctx, () => databases.flair.OrgEvent.search(query));
282
+ const now = Date.now();
283
+ const hits = [];
284
+ for await (const r of rows) {
285
+ // Skip expired events (mirrors OrgEventCatchup.ts's in-process expiry filter).
286
+ if (r.expiresAt && new Date(r.expiresAt).getTime() < now)
287
+ continue;
288
+ hits.push({
289
+ id: r.id,
290
+ authorId: r.authorId,
291
+ kind: r.kind,
292
+ scope: r.scope ?? null,
293
+ summary: r.summary,
294
+ detail: r.detail ?? null,
295
+ targetIds: Array.isArray(r.targetIds) ? r.targetIds : null,
296
+ createdAt: r.createdAt,
297
+ entities: Array.isArray(r.entities) ? r.entities : null,
298
+ });
299
+ }
300
+ hits.sort((a, b) => (b.createdAt || "").localeCompare(a.createdAt || ""));
301
+ return hits.slice(0, MAX_RESULTS_PER_SOURCE);
302
+ }
303
+ // ─── Resource ─────────────────────────────────────────────────────────────────
304
+ export class AttentionQuery extends Resource {
305
+ // Self-authorize (allowVerified: any verified agent/admin/internal call;
306
+ // anonymous denied). Per-source scoping is applied inside post() — see the
307
+ // module doc above for each source's rule.
308
+ async allowCreate() {
309
+ return allowVerified(this.getContext?.());
310
+ }
311
+ async post(data) {
312
+ const ctx = this.getContext?.();
313
+ const auth = await resolveAgentAuth(ctx);
314
+ if (auth.kind === "anonymous")
315
+ return UNAUTH();
316
+ if (auth.kind === "agent") {
317
+ const rl = checkRateLimit(auth.agentId, "general");
318
+ if (!rl.allowed)
319
+ return rateLimitResponse(rl.retryAfterMs, "attention");
320
+ }
321
+ const parsed = parseQueryInput(data);
322
+ if (parsed instanceof Response)
323
+ return parsed;
324
+ const { entity, days, sinceIso } = parsed;
325
+ // Sequential, not Promise.all: each helper wraps its table read in
326
+ // withDetachedTxn (save/clear/call/restore ctx.transaction around a
327
+ // SYNCHRONOUS search() call — see table-helpers.ts's doc). Running five
328
+ // of these concurrently over the SAME shared `ctx` would risk one call's
329
+ // save/restore interleaving with another's in-flight generator iteration
330
+ // in ways Harper's transaction chaining isn't documented to tolerate.
331
+ // Every other multi-table resource in this codebase (Memory.ts,
332
+ // SemanticSearch.ts, MemoryBootstrap.ts) sequences its cross-table reads
333
+ // for the same reason — this query is an internal/coordination read, not
334
+ // a latency-critical hot path, so the small sequential cost is the safe
335
+ // trade.
336
+ const memory = await queryMemory(ctx, entity, auth);
337
+ const relationship = await queryRelationship(ctx, entity, auth);
338
+ const workspaceState = await queryWorkspaceState(ctx, entity, sinceIso);
339
+ const presence = await queryPresence(auth, entity);
340
+ const orgEvent = await queryOrgEvent(ctx, entity, sinceIso);
341
+ return {
342
+ entity,
343
+ windowDays: days,
344
+ since: sinceIso,
345
+ groups: { memory, relationship, workspaceState, presence, orgEvent },
346
+ counts: {
347
+ memory: memory.length,
348
+ relationship: relationship.length,
349
+ workspaceState: workspaceState.length,
350
+ presence: presence.length,
351
+ orgEvent: orgEvent.length,
352
+ total: memory.length + relationship.length + workspaceState.length + presence.length + orgEvent.length,
353
+ },
354
+ };
355
+ }
356
+ }
@@ -1,5 +1,5 @@
1
1
  import { databases } from "@harperfast/harper";
2
- import { resolveAgentAuth } from "./agent-auth.js";
2
+ import { resolveAgentAuth, allowVerified } from "./agent-auth.js";
3
3
  import { checkRateLimit, rateLimitResponse } from "./rate-limiter.js";
4
4
  /**
5
5
  * Credential resource — authentication surfaces for Principals.
@@ -13,6 +13,16 @@ import { checkRateLimit, rateLimitResponse } from "./rate-limiter.js";
13
13
  * are never returned in responses).
14
14
  */
15
15
  export class Credential extends databases.flair.Credential {
16
+ /**
17
+ * Self-authorize (mirrors Soul.ts/Relationship.ts/WorkspaceState.ts). Harper
18
+ * routes some request shapes (e.g. collection-describe `GET /Credential`)
19
+ * OUTSIDE get()/search() entirely, so those in-method checks alone don't
20
+ * cover every path — Credential was the one sibling in this sensitive class
21
+ * missing this gate (#556/#557 swept the others). Deny anonymous/unverified
22
+ * here; get()/search() below still enforce per-agent ownership scoping on
23
+ * top of this for the paths they do see.
24
+ */
25
+ allowRead() { return allowVerified(this.getContext?.()); }
16
26
  async search(query) {
17
27
  const auth = await resolveAgentAuth(this.getContext?.());
18
28
  // Anonymous HTTP must NOT read credentials. (Previously `!authAgent` was
@@ -1,6 +1,7 @@
1
1
  import { Resource, databases, server } from "@harperfast/harper";
2
2
  import { randomBytes } from "node:crypto";
3
3
  import nacl from "tweetnacl";
4
+ import { allowAdmin } from "./agent-auth.js";
4
5
  import { canonicalize, signBody, verifyBodySignature, signBodyFresh, verifyBodySignatureFresh, generateNonce, } from "./federation-crypto.js";
5
6
  import { initFederationCleanup } from "./federation-cleanup.js";
6
7
  import { createPersistentNonceStore, initNonceStoreCleanup } from "./federation-nonce-store.js";
@@ -61,8 +62,21 @@ function mergeRecord(local, remote) {
61
62
  /**
62
63
  * GET /FederationInstance — return this instance's identity.
63
64
  * Used by peers during pairing and by the admin UI.
65
+ *
66
+ * allowRead()=allowAdmin (defense-in-depth, authorizeLocal-escalation-class
67
+ * follow-up to #601/#604/#609/#612 — flair#614's backstop found this one had
68
+ * NO allow* at all, so Harper's own default — `user?.role.permission.
69
+ * super_user`, satisfiable only by a genuine admin OR authorizeLocal's forged
70
+ * loopback super_user — was silently standing in). Same idiom as
71
+ * AdminInstance.ts/AdminDashboard.ts: this is an admin-view endpoint (peers
72
+ * never call it during pairing — FederationPair.post() reads the Instance
73
+ * table directly server-side to hand a peer our identity; this HTTP GET is
74
+ * CLI tooling only).
64
75
  */
65
76
  export class FederationInstance extends Resource {
77
+ async allowRead() {
78
+ return allowAdmin(this.getContext?.());
79
+ }
66
80
  async get() {
67
81
  // Find or create instance identity
68
82
  let instance = null;
@@ -452,8 +466,17 @@ export class FederationSync extends Resource {
452
466
  }
453
467
  /**
454
468
  * GET /FederationPeers — list known peers (admin view).
469
+ *
470
+ * allowRead()=allowAdmin (defense-in-depth, authorizeLocal-escalation-class
471
+ * follow-up to #601/#604/#609/#612 — flair#614's backstop found this one had
472
+ * NO allow* at all). The docstring already called this "(admin view)" — this
473
+ * closes the gap between the documented/intended access model and what was
474
+ * actually enforced (nothing).
455
475
  */
456
476
  export class FederationPeers extends Resource {
477
+ async allowRead() {
478
+ return allowAdmin(this.getContext?.());
479
+ }
457
480
  async get() {
458
481
  const peers = [];
459
482
  try {
@@ -0,0 +1,88 @@
1
+ import { Resource, databases } from "@harperfast/harper";
2
+ import { mcpIssuer } from "./mcp-oauth-flag.js";
3
+ import { agentPublicKeyToJwk, buildCimdDocument } from "./mcp-client-metadata-fields.js";
4
+ /**
5
+ * MCPClientMetadata — serves a Client ID Metadata Document (CIMD) for a
6
+ * Flair agent at `GET /MCPClientMetadata/{agentId}`.
7
+ *
8
+ * This is the "publish" half of RFC 7523 private_key_jwt / client_credentials
9
+ * agent-auth (see docs/notes/mcp-agent-auth-consumer.md and
10
+ * HarperFast/oauth#159, whose full chain is now shipped in the published
11
+ * @harperfast/oauth@2.2.0: assertion verification #160/PR #165, CIMD-first
12
+ * client resolution #161/#167, token-endpoint grant + issuance rate
13
+ * limiting #162/#163 via PRs #170/#171). An authorization server that
14
+ * treats an agent's `client_id` as this URL fetches this document to learn
15
+ * the agent's JWKS (its EXISTING Ed25519 identity key) instead of doing a
16
+ * DCR registration — no registration state to replicate across Fabric
17
+ * nodes, matching Flair's stateless posture.
18
+ *
19
+ * Public, unauthenticated — mirrors AgentCard.ts. CIMD documents are meant
20
+ * to be fetched by an AS with no prior trust relationship (same posture as
21
+ * an A2A agent card / OIDC client metadata); this document exposes ONLY the
22
+ * agent's already-public identity key (the same value a verified caller
23
+ * already reads via `GET /Agent/{id}`), never anything secret.
24
+ *
25
+ * The document served here is proven against the REAL published plugin:
26
+ * test/unit/mcp-client-credentials-live-package.test.ts feeds it through
27
+ * 2.2.0's actual `resolveCimdClient` fetch+validate pipeline, and
28
+ * test/integration/mcp-client-credentials-e2e.test.ts serves it from a live
29
+ * spawned Harper. Note the plugin's CIMD fetch enforces an UNCONDITIONAL
30
+ * SSRF gate (https only, no private/loopback/link-local DNS answers, no
31
+ * override knob) — so an AS can only ever consume this document when it is
32
+ * served from a genuinely public HTTPS host. See the doc note's
33
+ * "SSRF/loopback boundary" section.
34
+ *
35
+ * Deployment coordination: the AS's `clientIdMetadataDocuments.allowedHosts`
36
+ * config MUST include this route's host (derived from `FLAIR_MCP_ISSUER` /
37
+ * `FLAIR_PUBLIC_URL`, same env vars as `mcpIssuer()` below) — merely being
38
+ * reachable is not enough; the allowlist gate is AS-side config and
39
+ * fail-closed. See docs/notes/mcp-agent-auth-consumer.md.
40
+ */
41
+ export class MCPClientMetadata extends Resource {
42
+ allowRead() {
43
+ return true;
44
+ }
45
+ async get(pathInfo) {
46
+ const agentId = (typeof pathInfo === "string" ? pathInfo : null) ??
47
+ this.getId?.() ??
48
+ null;
49
+ if (!agentId) {
50
+ return new Response(JSON.stringify({ error: "agentId required in path: GET /MCPClientMetadata/{agentId}" }), { status: 400, headers: { "Content-Type": "application/json" } });
51
+ }
52
+ const issuer = mcpIssuer();
53
+ if (!issuer) {
54
+ // Fail closed rather than guess a client_id URL — CIMD requires the
55
+ // document's client_id to equal the URL it was fetched from, byte for
56
+ // byte, so a floating/unpinned origin would produce a document that
57
+ // never validates.
58
+ return new Response(JSON.stringify({
59
+ error: "mcp_issuer_not_configured",
60
+ message: "FLAIR_MCP_ISSUER (or FLAIR_PUBLIC_URL) must be set to publish a stable client_id URL.",
61
+ }), { status: 501, headers: { "Content-Type": "application/json" } });
62
+ }
63
+ const agent = await databases.flair.Agent.get(agentId).catch(() => null);
64
+ if (!agent?.publicKey) {
65
+ return new Response(JSON.stringify({ error: "agent_not_found_or_no_key", agentId }), {
66
+ status: 404,
67
+ headers: { "Content-Type": "application/json" },
68
+ });
69
+ }
70
+ let jwk;
71
+ try {
72
+ jwk = agentPublicKeyToJwk(String(agent.publicKey), agentId);
73
+ }
74
+ catch (err) {
75
+ return new Response(JSON.stringify({ error: "invalid_agent_key", message: err?.message ?? String(err) }), { status: 500, headers: { "Content-Type": "application/json" } });
76
+ }
77
+ const clientId = `${issuer.replace(/\/+$/, "")}/MCPClientMetadata/${agentId}`;
78
+ const doc = buildCimdDocument({
79
+ clientId,
80
+ clientName: String(agent.name ?? agent.displayName ?? agentId),
81
+ jwk,
82
+ });
83
+ return new Response(JSON.stringify(doc), {
84
+ status: 200,
85
+ headers: { "Content-Type": "application/json" },
86
+ });
87
+ }
88
+ }