@tpsdev-ai/flair 0.21.0 → 0.22.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -7
- package/SECURITY.md +24 -2
- package/config.yaml +32 -5
- package/dist/cli.js +1811 -221
- package/dist/deploy.js +3 -4
- package/dist/fleet-presence.js +98 -0
- package/dist/fleet-verify.js +291 -0
- package/dist/mcp-client-assertion.js +364 -0
- package/dist/probe.js +97 -0
- package/dist/rem/runner.js +82 -12
- package/dist/resources/AttentionQuery.js +356 -0
- package/dist/resources/Credential.js +11 -1
- package/dist/resources/Federation.js +23 -0
- package/dist/resources/MCPClientMetadata.js +88 -0
- package/dist/resources/Memory.js +52 -53
- package/dist/resources/MemoryBootstrap.js +422 -76
- package/dist/resources/MemoryReflect.js +105 -49
- package/dist/resources/MemoryUsage.js +104 -0
- package/dist/resources/OAuth.js +8 -1
- package/dist/resources/OrgEvent.js +11 -0
- package/dist/resources/Presence.js +218 -19
- package/dist/resources/RecordUsage.js +230 -0
- package/dist/resources/Relationship.js +98 -25
- package/dist/resources/SemanticSearch.js +85 -317
- package/dist/resources/SkillScan.js +15 -0
- package/dist/resources/WorkspaceState.js +11 -0
- package/dist/resources/agent-auth.js +60 -2
- package/dist/resources/auth-middleware.js +18 -9
- package/dist/resources/bm25.js +12 -6
- package/dist/resources/collision-lib.js +157 -0
- package/dist/resources/embeddings-boot.js +149 -0
- package/dist/resources/embeddings-provider.js +364 -106
- package/dist/resources/entity-vocab.js +139 -0
- package/dist/resources/health.js +97 -6
- package/dist/resources/mcp-client-metadata-fields.js +112 -0
- package/dist/resources/mcp-handler.js +1 -1
- package/dist/resources/mcp-tools.js +88 -10
- package/dist/resources/memory-reflect-lib.js +289 -0
- package/dist/resources/migration-boot.js +143 -0
- package/dist/resources/migrations/dir-safety.js +71 -0
- package/dist/resources/migrations/embedding-stamp.js +178 -0
- package/dist/resources/migrations/envelope.js +43 -0
- package/dist/resources/migrations/export.js +38 -0
- package/dist/resources/migrations/ledger.js +55 -0
- package/dist/resources/migrations/lock.js +154 -0
- package/dist/resources/migrations/progress.js +31 -0
- package/dist/resources/migrations/registry.js +36 -0
- package/dist/resources/migrations/risk-policy.js +23 -0
- package/dist/resources/migrations/runner.js +455 -0
- package/dist/resources/migrations/snapshot.js +127 -0
- package/dist/resources/migrations/source-fields.js +93 -0
- package/dist/resources/migrations/space.js +167 -0
- package/dist/resources/migrations/state.js +64 -0
- package/dist/resources/migrations/status.js +39 -0
- package/dist/resources/migrations/synthetic-test-migration.js +93 -0
- package/dist/resources/migrations/types.js +9 -0
- package/dist/resources/presence-internal.js +29 -0
- package/dist/resources/provenance.js +50 -0
- package/dist/resources/rate-limiter.js +8 -1
- package/dist/resources/scoring.js +124 -5
- package/dist/resources/semantic-retrieval-core.js +317 -0
- package/dist/version-handshake.js +122 -0
- package/package.json +4 -5
- package/schemas/event.graphql +5 -0
- package/schemas/memory.graphql +66 -0
- package/schemas/schema.graphql +5 -43
- package/schemas/workspace.graphql +3 -0
- package/dist/resources/IngestEvents.js +0 -162
- package/dist/resources/IssueTokens.js +0 -19
- package/dist/resources/ObsAgentSnapshot.js +0 -13
- package/dist/resources/ObsEventFeed.js +0 -13
- package/dist/resources/ObsOffice.js +0 -19
- package/dist/resources/ObservationCenter.js +0 -23
- 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
|
+
}
|