@tpsdev-ai/flair 0.20.1 → 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 (104) hide show
  1. package/README.md +39 -11
  2. package/SECURITY.md +52 -20
  3. package/config.yaml +32 -5
  4. package/dist/cli.js +2235 -260
  5. package/dist/deploy.js +96 -15
  6. package/dist/doctor-client.js +312 -0
  7. package/dist/fleet-presence.js +98 -0
  8. package/dist/fleet-verify.js +291 -0
  9. package/dist/install/clients.js +18 -0
  10. package/dist/mcp-client-assertion.js +364 -0
  11. package/dist/probe.js +97 -0
  12. package/dist/rem/restore.js +1 -1
  13. package/dist/rem/runner.js +82 -12
  14. package/dist/resources/Admin.js +1 -1
  15. package/dist/resources/AdminConnectors.js +1 -1
  16. package/dist/resources/AdminDashboard.js +1 -1
  17. package/dist/resources/AdminIdp.js +1 -1
  18. package/dist/resources/AdminInstance.js +1 -1
  19. package/dist/resources/AdminMemory.js +2 -2
  20. package/dist/resources/AdminPrincipals.js +1 -1
  21. package/dist/resources/Agent.js +14 -0
  22. package/dist/resources/AgentCard.js +1 -1
  23. package/dist/resources/AgentSeed.js +1 -1
  24. package/dist/resources/AttentionQuery.js +356 -0
  25. package/dist/resources/Credential.js +11 -1
  26. package/dist/resources/Federation.js +121 -2
  27. package/dist/resources/Integration.js +1 -1
  28. package/dist/resources/MCPClientMetadata.js +88 -0
  29. package/dist/resources/Memory.js +127 -22
  30. package/dist/resources/MemoryBootstrap.js +441 -85
  31. package/dist/resources/MemoryGrant.js +1 -1
  32. package/dist/resources/MemoryReflect.js +105 -49
  33. package/dist/resources/MemoryUsage.js +104 -0
  34. package/dist/resources/OAuth.js +69 -5
  35. package/dist/resources/OrgEvent.js +11 -0
  36. package/dist/resources/OrgEventCatchup.js +1 -1
  37. package/dist/resources/Presence.js +268 -17
  38. package/dist/resources/RecordUsage.js +230 -0
  39. package/dist/resources/Relationship.js +108 -26
  40. package/dist/resources/SemanticSearch.js +88 -320
  41. package/dist/resources/SkillScan.js +15 -0
  42. package/dist/resources/Soul.js +14 -0
  43. package/dist/resources/WorkspaceLatest.js +1 -1
  44. package/dist/resources/WorkspaceState.js +12 -1
  45. package/dist/resources/agent-auth.js +61 -3
  46. package/dist/resources/agentcard-fields.js +2 -2
  47. package/dist/resources/auth-middleware.js +42 -14
  48. package/dist/resources/bm25-filter.js +1 -1
  49. package/dist/resources/bm25.js +13 -7
  50. package/dist/resources/collision-lib.js +157 -0
  51. package/dist/resources/dedup.js +2 -2
  52. package/dist/resources/ed25519-auth.js +2 -2
  53. package/dist/resources/embeddings-boot.js +149 -0
  54. package/dist/resources/embeddings-provider.js +364 -106
  55. package/dist/resources/entity-vocab.js +139 -0
  56. package/dist/resources/federation-nonce-store.js +195 -0
  57. package/dist/resources/health.js +97 -6
  58. package/dist/resources/instance-identity.js +53 -0
  59. package/dist/resources/mcp-client-metadata-fields.js +112 -0
  60. package/dist/resources/mcp-handler.js +1 -1
  61. package/dist/resources/mcp-tools.js +88 -10
  62. package/dist/resources/memory-bootstrap-lib.js +1 -1
  63. package/dist/resources/memory-read-scope.js +58 -71
  64. package/dist/resources/memory-reflect-lib.js +289 -0
  65. package/dist/resources/memory-visibility.js +37 -0
  66. package/dist/resources/migration-boot.js +143 -0
  67. package/dist/resources/migrations/dir-safety.js +71 -0
  68. package/dist/resources/migrations/embedding-stamp.js +178 -0
  69. package/dist/resources/migrations/envelope.js +43 -0
  70. package/dist/resources/migrations/export.js +38 -0
  71. package/dist/resources/migrations/ledger.js +55 -0
  72. package/dist/resources/migrations/lock.js +154 -0
  73. package/dist/resources/migrations/progress.js +31 -0
  74. package/dist/resources/migrations/registry.js +36 -0
  75. package/dist/resources/migrations/risk-policy.js +23 -0
  76. package/dist/resources/migrations/runner.js +456 -0
  77. package/dist/resources/migrations/snapshot.js +127 -0
  78. package/dist/resources/migrations/source-fields.js +93 -0
  79. package/dist/resources/migrations/space.js +73 -0
  80. package/dist/resources/migrations/state.js +64 -0
  81. package/dist/resources/migrations/status.js +39 -0
  82. package/dist/resources/migrations/synthetic-test-migration.js +93 -0
  83. package/dist/resources/migrations/types.js +9 -0
  84. package/dist/resources/presence-internal.js +29 -0
  85. package/dist/resources/provenance.js +50 -0
  86. package/dist/resources/rate-limiter.js +8 -1
  87. package/dist/resources/scoring.js +124 -5
  88. package/dist/resources/semantic-retrieval-core.js +317 -0
  89. package/dist/version-check.js +167 -0
  90. package/dist/version-handshake.js +122 -0
  91. package/package.json +4 -5
  92. package/schemas/agent.graphql +7 -0
  93. package/schemas/event.graphql +5 -0
  94. package/schemas/federation.graphql +12 -0
  95. package/schemas/memory.graphql +82 -0
  96. package/schemas/schema.graphql +5 -43
  97. package/schemas/workspace.graphql +3 -0
  98. package/dist/resources/IngestEvents.js +0 -162
  99. package/dist/resources/IssueTokens.js +0 -19
  100. package/dist/resources/ObsAgentSnapshot.js +0 -13
  101. package/dist/resources/ObsEventFeed.js +0 -13
  102. package/dist/resources/ObsOffice.js +0 -19
  103. package/dist/resources/ObservationCenter.js +0 -23
  104. package/ui/observation-center.html +0 -385
@@ -21,7 +21,7 @@ import { allowAdmin } from "./agent-auth.js";
21
21
  * makes that legible: every memory shows where it came from, what it derived
22
22
  * from, what it superseded, and which peer it synced from.
23
23
  *
24
- * allowRead()=allowAdmin (ops-oox7 defense-in-depth): see Admin.ts — this
24
+ * allowRead()=allowAdmin (defense-in-depth): see Admin.ts — this
25
25
  * page surfaces full memory content across ALL agents, so the gap it closes
26
26
  * matters more here than on the other Admin* pages.
27
27
  */
@@ -31,7 +31,7 @@ export class AdminMemory extends Resource {
31
31
  }
32
32
  async get() {
33
33
  // Harper v5 does not populate this.request on Resource subclasses —
34
- // getContext() is the only reliable path (ops-sal4: the previous
34
+ // getContext() is the only reliable path (the previous
35
35
  // `(this as any).request` read was always undefined, so query params
36
36
  // (?id=, ?q=, ?subject=, ?limit=) were always ignored).
37
37
  const ctx = this.getContext?.();
@@ -4,7 +4,7 @@ import { allowAdmin } from "./agent-auth.js";
4
4
  /**
5
5
  * GET /AdminPrincipals — list all principals with kind, trust, status.
6
6
  *
7
- * allowRead()=allowAdmin (ops-oox7 defense-in-depth): see Admin.ts.
7
+ * allowRead()=allowAdmin (defense-in-depth): see Admin.ts.
8
8
  */
9
9
  export class AdminPrincipals extends Resource {
10
10
  async allowRead() {
@@ -1,5 +1,6 @@
1
1
  import { databases } from "@harperfast/harper";
2
2
  import { resolveAgentAuth, allowVerified, allowAdmin } from "./agent-auth.js";
3
+ import { localInstanceId } from "./instance-identity.js";
3
4
  /**
4
5
  * Agent resource — serves as the Principal table in 1.0.
5
6
  *
@@ -40,6 +41,14 @@ export class Agent extends databases.flair.Agent {
40
41
  }
41
42
  content.createdAt = now;
42
43
  content.updatedAt = now;
44
+ // Write-time originatorInstanceId stamp (federation-edge-hardening slice
45
+ // 1) — see resources/Memory.ts's stampOriginatorInstanceId doc for the
46
+ // full contract. No-op if already set (never fires for a genuine local
47
+ // write; a federation-synced record never reaches this method — the
48
+ // merge path writes via the raw table object, bypassing this class).
49
+ if (content.originatorInstanceId == null) {
50
+ content.originatorInstanceId = await localInstanceId();
51
+ }
43
52
  return super.post(content, context);
44
53
  }
45
54
  async put(content) {
@@ -64,6 +73,11 @@ export class Agent extends databases.flair.Agent {
64
73
  // Protect immutable fields
65
74
  delete content.createdAt;
66
75
  delete content.publicKey; // key rotation goes through dedicated endpoint
76
+ // Write-time originatorInstanceId stamp — see post() above / Memory.ts's
77
+ // stampOriginatorInstanceId doc. No-op if already set.
78
+ if (content.originatorInstanceId == null) {
79
+ content.originatorInstanceId = await localInstanceId();
80
+ }
67
81
  return super.put(content);
68
82
  }
69
83
  }
@@ -29,7 +29,7 @@ export class AgentCard extends Resource {
29
29
  return {
30
30
  name: String(agent.name ?? agent.id ?? agentId),
31
31
  // Only an explicit kind="description" soul publishes — no private-soul
32
- // fallback (ops-vz6j). See agentcard-fields.ts for the security rationale.
32
+ // fallback. See agentcard-fields.ts for the security rationale.
33
33
  description: selectPublicDescription(souls),
34
34
  url: String(agent.url ?? ""),
35
35
  version: String(agent.version ?? "1.0.0"),
@@ -40,7 +40,7 @@ export class AgentSeed extends Resource {
40
40
  }
41
41
  async post(data) {
42
42
  // Harper v5 does not populate this.request on Resource subclasses —
43
- // getContext() is the only reliable path (ops-sal4: the previous
43
+ // getContext() is the only reliable path (the previous
44
44
  // `(this as any).request` read was always undefined, so actorId was always
45
45
  // undefined and this belt-and-suspenders check fail-closed every request,
46
46
  // even from a real admin already verified by allowCreate()).
@@ -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,16 +1,42 @@
1
1
  import { Resource, databases, server } from "@harperfast/harper";
2
2
  import { randomBytes } from "node:crypto";
3
3
  import nacl from "tweetnacl";
4
- import { canonicalize, signBody, verifyBodySignature, signBodyFresh, verifyBodySignatureFresh, createNonceStore, generateNonce, } from "./federation-crypto.js";
4
+ import { allowAdmin } from "./agent-auth.js";
5
+ import { canonicalize, signBody, verifyBodySignature, signBodyFresh, verifyBodySignatureFresh, generateNonce, } from "./federation-crypto.js";
5
6
  import { initFederationCleanup } from "./federation-cleanup.js";
7
+ import { createPersistentNonceStore, initNonceStoreCleanup } from "./federation-nonce-store.js";
6
8
  import { classifyRecord } from "./federation-classify.js";
7
9
  export { classifyRecord } from "./federation-classify.js";
8
10
  // Module-level nonce store for federation anti-replay.
9
11
  // Shared across FederationPair + FederationSync — nonces are globally unique
10
12
  // (generated by signBodyFresh per request with 128-bit random nonces).
11
- const federationNonceStore = createNonceStore();
13
+ //
14
+ // Backed by the `Nonce` table (federation-edge-hardening slice 4) so a
15
+ // process restart doesn't wipe recently-seen nonces within the ±30s
16
+ // freshness window — see federation-nonce-store.ts for the full design.
17
+ // The NonceStore interface (has/set/evict) stays synchronous, so this swap
18
+ // requires NO change to verifyBodySignatureFresh or its 2 call sites below.
19
+ const federationNonceStore = createPersistentNonceStore();
12
20
  // Re-export for consumers that import from Federation.ts
13
21
  export { canonicalize, signBody, verifyBodySignature, signBodyFresh, verifyBodySignatureFresh, generateNonce };
22
+ // ─── Per-record signature enforcement mode (federation-edge-hardening slice 3b) ──
23
+ /**
24
+ * Whether FederationSync.post requires EVERY record to carry a valid
25
+ * per-record signature (§3a), skipping unsigned ones (`missing_signature`),
26
+ * vs. the default "verify-if-present" mode where an unsigned record still
27
+ * merges (relying on the batch-level signature already verified above it).
28
+ *
29
+ * Default OFF (verify-if-present) — pre-3a spokes don't sign individual
30
+ * records yet, and their batch is already authenticated. Turning this ON
31
+ * is an OPERATOR decision, never auto-flipped by this code: flip it only
32
+ * once every paired peer has been confirmed sending per-record signatures
33
+ * (e.g. by checking SyncLog for unsigned records / peer versions). Flipping
34
+ * it before every peer has upgraded silently starts dropping that peer's
35
+ * records instead of merging them.
36
+ */
37
+ function requireRecordSignatures() {
38
+ return (process.env.FLAIR_FEDERATION_REQUIRE_RECORD_SIGNATURES ?? "").toLowerCase() === "true";
39
+ }
14
40
  // ─── Conflict resolution ─────────────────────────────────────────────────────
15
41
  /**
16
42
  * Field-level Last-Write-Wins merge.
@@ -36,8 +62,21 @@ function mergeRecord(local, remote) {
36
62
  /**
37
63
  * GET /FederationInstance — return this instance's identity.
38
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).
39
75
  */
40
76
  export class FederationInstance extends Resource {
77
+ async allowRead() {
78
+ return allowAdmin(this.getContext?.());
79
+ }
41
80
  async get() {
42
81
  // Find or create instance identity
43
82
  let instance = null;
@@ -293,6 +332,61 @@ export class FederationSync extends Resource {
293
332
  recordSkip(decision.reason);
294
333
  continue;
295
334
  }
335
+ // ── Per-record signature verification (federation-edge-hardening slice 3b) ──
336
+ // classifyRecord's "merge" verdict above only proves the record is
337
+ // STRUCTURALLY eligible (known table, self-originated OR the sender
338
+ // is a hub, not stale, not a no-op) — that hub bypass trusts the
339
+ // BATCH-level signature (verifyBodySignatureFresh above, verified
340
+ // against the SENDER's pinned key). It does NOT prove the record's
341
+ // *claimed* originator actually produced it — a hub relaying on
342
+ // behalf of many spokes could otherwise forge a record under any
343
+ // originatorInstanceId it likes, and the receiver had no way to
344
+ // tell. This gate closes that hole: verify the record's own
345
+ // signature (§3a, set at push-time by the ORIGINATOR, never by
346
+ // whoever relayed it) against the ORIGINATOR's pinned instance key
347
+ // — never the sender's key.
348
+ //
349
+ // A bad/unverifiable signature skips ONLY this record, never the
350
+ // batch — rejecting the whole batch on one bad record would be a
351
+ // DoS vector (one forged/garbled record could blackhole every other
352
+ // legitimate record riding along in the same POST).
353
+ const originator = decision.originator;
354
+ if (record.signature) {
355
+ const originatorPublicKey = originator === instanceId
356
+ ? peer.publicKey
357
+ : (await databases.flair.Peer.get(originator))?.publicKey;
358
+ if (!originatorPublicKey) {
359
+ recordSkip("unknown_originator_key");
360
+ continue;
361
+ }
362
+ // CONTRACT — must match src/cli.ts runFederationSyncOnce's signing
363
+ // payload byte-for-byte: keys { v, table, id, data, updatedAt,
364
+ // originatorInstanceId }. canonicalize() sorts keys, so field ORDER
365
+ // doesn't matter, but the field SET and values do. `v: 1` versions
366
+ // the canonical form itself — bump it on BOTH sides together if the
367
+ // signed field set ever changes, so an old signature fails closed
368
+ // instead of silently mis-verifying under a new form.
369
+ const signatureValid = verifyBodySignature({
370
+ v: 1,
371
+ table: record.table,
372
+ id: record.id,
373
+ data: record.data,
374
+ updatedAt: record.updatedAt,
375
+ originatorInstanceId: originator,
376
+ signature: record.signature,
377
+ }, originatorPublicKey);
378
+ if (!signatureValid) {
379
+ recordSkip("invalid_signature");
380
+ continue;
381
+ }
382
+ }
383
+ else if (requireRecordSignatures()) {
384
+ // require-mode: unsigned records are no longer trusted on
385
+ // batch-level auth alone. Default mode (verify-if-present) falls
386
+ // through here and merges — see requireRecordSignatures() above.
387
+ recordSkip("missing_signature");
388
+ continue;
389
+ }
296
390
  const mergedData = mergeRecord(local, record);
297
391
  mergedData._originatorInstanceId = decision.originator;
298
392
  mergedData._syncedFrom = instanceId;
@@ -372,8 +466,17 @@ export class FederationSync extends Resource {
372
466
  }
373
467
  /**
374
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).
375
475
  */
376
476
  export class FederationPeers extends Resource {
477
+ async allowRead() {
478
+ return allowAdmin(this.getContext?.());
479
+ }
377
480
  async get() {
378
481
  const peers = [];
379
482
  try {
@@ -405,5 +508,21 @@ if (typeof setTimeout !== "undefined") {
405
508
  // Swallow — in test/resource-env the Harper databases may not be bound.
406
509
  // In production, initFederationCleanup handles its own error paths.
407
510
  });
511
+ // Nonce store hydration (slice 4) — load nonces persisted by a previous
512
+ // process (if any) before this instance starts guarding live traffic.
513
+ // Runs on the same deferred tick as the cleanup sweep above; there is a
514
+ // narrow window between process start and hydration completing during
515
+ // which a pre-restart nonce isn't yet visible — an accepted tradeoff
516
+ // consistent with this file's existing deferred-init pattern (see the
517
+ // cleanup sweep comment above).
518
+ federationNonceStore.hydrate().catch((err) => {
519
+ // Swallow — in test/resource-env the Harper databases may not be bound.
520
+ });
521
+ // Nonce table eviction sweep — runs on every instance (both hub and
522
+ // spoke can be the receiving/verifying side of a signed federation
523
+ // request), unlike PairingToken cleanup which is hub-only.
524
+ initNonceStoreCleanup().catch((err) => {
525
+ // Swallow — in test/resource-env the Harper databases may not be bound.
526
+ });
408
527
  }, 0);
409
528
  }
@@ -15,7 +15,7 @@ export class Integration extends databases.flair.Integration {
15
15
  }
16
16
  /**
17
17
  * Self-authorize now that the global gate is non-rejecting (memory-soul-
18
- * read-gate family fix, ops-oox7 — same pattern as Memory.ts/Soul.ts/
18
+ * read-gate family fix — same pattern as Memory.ts/Soul.ts/
19
19
  * WorkspaceState.ts/Relationship.ts). Closes the same P0 leak: Harper
20
20
  * routes `GET /Integration/<id>` to get() and the collection describe
21
21
  * (`GET /Integration`) outside search(), so neither was gated before this