@tpsdev-ai/flair 0.20.1 → 0.21.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 (49) hide show
  1. package/README.md +29 -4
  2. package/SECURITY.md +28 -18
  3. package/dist/cli.js +465 -32
  4. package/dist/deploy.js +93 -11
  5. package/dist/doctor-client.js +312 -0
  6. package/dist/install/clients.js +18 -0
  7. package/dist/rem/restore.js +1 -1
  8. package/dist/resources/Admin.js +1 -1
  9. package/dist/resources/AdminConnectors.js +1 -1
  10. package/dist/resources/AdminDashboard.js +1 -1
  11. package/dist/resources/AdminIdp.js +1 -1
  12. package/dist/resources/AdminInstance.js +1 -1
  13. package/dist/resources/AdminMemory.js +2 -2
  14. package/dist/resources/AdminPrincipals.js +1 -1
  15. package/dist/resources/Agent.js +14 -0
  16. package/dist/resources/AgentCard.js +1 -1
  17. package/dist/resources/AgentSeed.js +1 -1
  18. package/dist/resources/Federation.js +98 -2
  19. package/dist/resources/IngestEvents.js +1 -1
  20. package/dist/resources/Integration.js +1 -1
  21. package/dist/resources/Memory.js +123 -17
  22. package/dist/resources/MemoryBootstrap.js +46 -36
  23. package/dist/resources/MemoryGrant.js +1 -1
  24. package/dist/resources/OAuth.js +61 -4
  25. package/dist/resources/OrgEventCatchup.js +1 -1
  26. package/dist/resources/Presence.js +55 -3
  27. package/dist/resources/Relationship.js +10 -1
  28. package/dist/resources/SemanticSearch.js +14 -14
  29. package/dist/resources/Soul.js +14 -0
  30. package/dist/resources/WorkspaceLatest.js +1 -1
  31. package/dist/resources/WorkspaceState.js +1 -1
  32. package/dist/resources/agent-auth.js +1 -1
  33. package/dist/resources/agentcard-fields.js +2 -2
  34. package/dist/resources/auth-middleware.js +24 -5
  35. package/dist/resources/bm25-filter.js +1 -1
  36. package/dist/resources/bm25.js +1 -1
  37. package/dist/resources/dedup.js +2 -2
  38. package/dist/resources/ed25519-auth.js +2 -2
  39. package/dist/resources/embeddings-provider.js +1 -1
  40. package/dist/resources/federation-nonce-store.js +195 -0
  41. package/dist/resources/instance-identity.js +53 -0
  42. package/dist/resources/memory-bootstrap-lib.js +1 -1
  43. package/dist/resources/memory-read-scope.js +58 -71
  44. package/dist/resources/memory-visibility.js +37 -0
  45. package/dist/version-check.js +167 -0
  46. package/package.json +2 -2
  47. package/schemas/agent.graphql +7 -0
  48. package/schemas/federation.graphql +12 -0
  49. package/schemas/memory.graphql +16 -0
@@ -35,6 +35,27 @@ function nowISO() {
35
35
  function futureISO(ms) {
36
36
  return new Date(Date.now() + ms).toISOString();
37
37
  }
38
+ /**
39
+ * 302 redirect — deliberately NOT `Response.redirect()`.
40
+ *
41
+ * FOUND while writing the real-Harper integration test for #604 (this exact
42
+ * gap is why the coverage doc above calls OAuthAuthorize.post() untestable
43
+ * without a running Harper instance — no test had ever hit this path over
44
+ * real HTTP before). Pre-existing on main, unrelated to the #604 auth fix:
45
+ * per the Fetch spec, `Response.redirect(url, status)` constructs a Response
46
+ * whose Headers guard is set to "immutable". Harper's own HTTP response
47
+ * pipeline mutates the handler's returned Response's headers afterward (e.g.
48
+ * CORS/instrumentation headers) — against an immutable-guarded Headers this
49
+ * throws `TypeError: immutable`, turning EVERY real POST /OAuthAuthorize
50
+ * (both "deny" and "approve") into a 500, verified via curl against a built
51
+ * origin/main with no other changes. A manually-constructed Response with
52
+ * the same status/Location header has the default (mutable) "response"
53
+ * guard, so Harper's later mutation succeeds — identical bytes on the wire,
54
+ * no behavior change, just a mutable Headers object underneath.
55
+ */
56
+ function redirectTo(url, status = 302) {
57
+ return new Response(null, { status, headers: { Location: url } });
58
+ }
38
59
  // ─── Discovery metadata ──────────────────────────────────────────────────────
39
60
  export class OAuthMetadata extends Resource {
40
61
  // OAuth discovery metadata is intentionally public — RFC 8414 § 3 requires
@@ -123,7 +144,7 @@ export class OAuthAuthorize extends Resource {
123
144
  // In 1.0, this returns a simple HTML consent page.
124
145
  // The user (Nathan) approves or denies, which POSTs back.
125
146
  // Harper v5 does not populate this.request on Resource subclasses —
126
- // getContext() is the only reliable path (ops-sal4: the previous
147
+ // getContext() is the only reliable path (the previous
127
148
  // `(this as any).request` read was always undefined, so query params were
128
149
  // always empty).
129
150
  const ctx = this.getContext?.();
@@ -197,13 +218,13 @@ ${scope.split(" ").map((s) => `<div class="scope">${s}</div>`).join("")}
197
218
  }
198
219
  if (action === "deny") {
199
220
  const params = new URLSearchParams({ error: "access_denied", state });
200
- return Response.redirect(`${redirectUri}?${params}`, 302);
221
+ return redirectTo(`${redirectUri}?${params}`);
201
222
  }
202
223
  // Determine authenticated principal. Harper v5 does not populate
203
224
  // this.request on Resource subclasses, so `(this as any).request?.tpsAgent`
204
225
  // was always undefined and this ALWAYS fell back to the hardcoded "admin" —
205
226
  // every approved consent grant was minted for the admin principal
206
- // regardless of who actually approved it (ops-sal4 identity spoof).
227
+ // regardless of who actually approved it (identity spoof).
207
228
  // resolveAgentAuth(getContext()) resolves Basic super_user/admin auth to
208
229
  // { kind: "agent", agentId: username, isAdmin: true } (see agent-auth.ts).
209
230
  // We do NOT silently fall back to "admin" on an unresolved principal — if
@@ -215,6 +236,42 @@ ${scope.split(" ").map((s) => `<div class="scope">${s}</div>`).join("")}
215
236
  // admin, or should any verified (non-admin) agent be able to approve its
216
237
  // own consent grant? The previous code always resolved to "admin" for
217
238
  // every caller, so this is a genuine policy decision, not just a bug fix.
239
+ //
240
+ // SECURITY FIX (#604 — authorizeLocal escalation): resolveAgentAuth's
241
+ // `context.user` fallback (agent-auth.ts, the super_user / username
242
+ // branches) ALSO matches Harper's `authorizeLocal` (config true) ambient
243
+ // super_user injection for ANY credential-less LOOPBACK request.
244
+ // /OAuthAuthorize sits on auth-middleware.ts's public early-return
245
+ // passthrough (any method), so a bare local POST never gets OUR
246
+ // middleware's tpsAgent/tpsAnonymous annotation either — resolveAgentAuth
247
+ // falls straight through to Harper's raw `context.user`, which
248
+ // authorizeLocal can forge with NO signature and NO password. A
249
+ // credential-less loopback POST therefore resolved to
250
+ // { kind: "agent", agentId: "admin", isAdmin: true } and minted a REAL
251
+ // admin authorization code, exchangeable at OAuthToken for a Bearer
252
+ // token — full local privilege escalation with zero credentials.
253
+ // authorizeLocal injects request.user=super_user ONLY when there is NO
254
+ // Authorization header present (see Presence.ts's get() doc for the
255
+ // fuller writeup of this exact mechanism) — a genuinely
256
+ // password-verified Basic admin/super_user, or a genuinely
257
+ // Ed25519-signed agent request, both carry a real header. Requiring one
258
+ // be present before trusting resolveAgentAuth's verdict closes the
259
+ // forged-approver path without touching resolveAgentAuth itself (the
260
+ // root-cause fix is a separate, larger follow-up — see #604): a
261
+ // credential-less local caller now gets 401 and no code is minted; a
262
+ // genuinely-authenticated approver (real Basic password, or a real
263
+ // TPS-Ed25519 signature verified by resolveAgentAuth's own fallback) is
264
+ // unaffected.
265
+ const ctx = this.getContext?.();
266
+ const request = ctx?.request ?? ctx;
267
+ const authHeader = request?.headers?.get?.("authorization") ??
268
+ request?.headers?.asObject?.authorization ??
269
+ "";
270
+ if (!authHeader) {
271
+ return new Response(JSON.stringify({ error: "authentication required" }), {
272
+ status: 401, headers: { "content-type": "application/json" },
273
+ });
274
+ }
218
275
  const auth = await resolveAgentAuth(this.getContext?.());
219
276
  if (auth.kind !== "agent") {
220
277
  return new Response(JSON.stringify({ error: "authentication required" }), {
@@ -238,7 +295,7 @@ ${scope.split(" ").map((s) => `<div class="scope">${s}</div>`).join("")}
238
295
  createdAt: now,
239
296
  });
240
297
  const params = new URLSearchParams({ code, state });
241
- return Response.redirect(`${redirectUri}?${params}`, 302);
298
+ return redirectTo(`${redirectUri}?${params}`);
242
299
  }
243
300
  }
244
301
  // ─── Token endpoint ──────────────────────────────────────────────────────────
@@ -23,7 +23,7 @@ export class OrgEventCatchup extends Resource {
23
23
  async get(pathInfo) {
24
24
  // Harper v5 does not populate this.request on Resource subclasses —
25
25
  // getContext() is the only reliable path to the gate's tpsAgent/
26
- // tpsAgentIsAdmin annotations (ops-sal4: the previous `(this as
26
+ // tpsAgentIsAdmin annotations (the previous `(this as
27
27
  // any).request` read was always undefined, so the ownership check below
28
28
  // never ran — fail-open cross-agent read).
29
29
  const auth = await resolveAgentAuth(this.getContext?.());
@@ -8,6 +8,8 @@
8
8
  *
9
9
  * Auth:
10
10
  * GET — public (returns only allowlisted fields; safe for public renderer).
11
+ * currentTask is additionally content-gated to verified agents only
12
+ * (#592) — anonymous callers get the roster with currentTask=null.
11
13
  * POST — Ed25519 agent credential (TPS-Ed25519 header). Agent writes only its
12
14
  * own record; cross-agent writes are rejected (403).
13
15
  *
@@ -15,9 +17,17 @@
15
17
  * - Write: per-agent Ed25519 auth. Cross-agent → 403.
16
18
  * - Read: field-allowlisted to public-safe set. No secrets, no admin data.
17
19
  * - currentTask is agent-authored free text → cap length, escape on render.
20
+ * - currentTask CONTENT gate (#592): the roster (id/displayName/role/
21
+ * runtime/activity/presenceStatus/lastHeartbeatAt) is genuinely
22
+ * public-safe and stays world-readable, but currentTask is free text that
23
+ * the coordination convention (`presence set --task "investigating
24
+ * <host>: <symptom>"`) has put customer names and preprod hostnames in.
25
+ * sanitizeCurrentTask() only trims/caps length — it does not redact
26
+ * content. get() additionally gates currentTask itself to verified
27
+ * in-org agents only; see get()'s inline comment.
18
28
  */
19
29
  import { databases } from "@harperfast/harper";
20
- import { resolveAgentAuth } from "./agent-auth.js";
30
+ import { resolveAgentAuth, verifyAgentRequest } from "./agent-auth.js";
21
31
  import { WINDOW_MS, isNonceReplay, recordNonce, importEd25519Key, b64ToArrayBuffer } from "./ed25519-auth.js";
22
32
  // ─── Constants ────────────────────────────────────────────────────────────────
23
33
  const CURRENT_TASK_MAX_LENGTH = 200;
@@ -32,7 +42,7 @@ function offlineThresholdMs() {
32
42
  }
33
43
  // ─── Nonce replay + crypto helpers ─────────────────────────────────────────────
34
44
  // WINDOW_MS, isNonceReplay/recordNonce (the ONE shared nonce store), and
35
- // importEd25519Key all live in ./ed25519-auth.ts (bd ops-c4op) — the single
45
+ // importEd25519Key all live in ./ed25519-auth.ts — the single
36
46
  // shared implementation imported by auth-middleware.ts, agent-auth.ts, and
37
47
  // Presence.ts so a nonce recorded via any one of the three call sites is
38
48
  // visible to the other two, and the crypto/decoder logic can't drift.
@@ -98,8 +108,47 @@ export class Presence extends databases.flair.Presence {
98
108
  *
99
109
  * Joins Presence records with Agent metadata and derives presenceStatus.
100
110
  * Only allowlisted fields are returned (no secrets, no admin data).
111
+ *
112
+ * currentTask CONTENT gate (#592): Harper routes every GET — collection
113
+ * (`GET /Presence`) AND single-record (`GET /Presence/<id>`) — through this
114
+ * SAME method (REST.js: `resource.get(target, request)`, one call site for
115
+ * both; this override ignores `target` and always returns the full roster
116
+ * array), so gating once here, before the loop, covers every read path with
117
+ * no separate return site to miss.
118
+ *
119
+ * The gate keys off a valid TPS-Ed25519 SIGNATURE (verifyAgentRequest),
120
+ * NOT resolveAgentAuth()/allowVerified — and that distinction is the whole
121
+ * fix. /Presence is a public-passthrough in auth-middleware.ts: the
122
+ * middleware early-returns WITHOUT annotating tpsAgent/tpsAnonymous, so a
123
+ * resource-level resolver never sees a gate annotation for this path. Worse,
124
+ * Harper's `authorizeLocal` (config default true) auto-authorizes any
125
+ * *credential-less* loopback request as super_user — it injects request.user
126
+ * ONLY "when there is no Authorization header" (node .../server/http.js). So a
127
+ * bare, unauthenticated `GET /Presence` from loopback (exactly the anonymous
128
+ * caller the issue is about, and what the integration test exercises against
129
+ * a real spawned Harper) arrives with request.user = super_user and NO
130
+ * signature. resolveAgentAuth() would classify that as `kind:"agent"` (its
131
+ * super_user branch) and leak currentTask — which it did, against real
132
+ * Harper, even though mocked unit tests using tpsAgent annotations passed.
133
+ * A TPS-Ed25519 signature, by contrast, cannot be manufactured by
134
+ * authorizeLocal (it requires the Authorization header, which suppresses the
135
+ * super_user injection), so verifyAgentRequest() cleanly separates a real
136
+ * in-org agent from an anonymous/loopback/Basic-admin caller. Only a valid
137
+ * agent signature gets currentTask; everything else (anonymous, loopback
138
+ * super_user, Basic-admin, internal in-process) gets currentTask=null.
139
+ * allowRead() is UNCHANGED (still `true`) — the roster itself stays public;
140
+ * only the free-text field is gated, per the issue's field-level option.
101
141
  */
102
142
  async get() {
143
+ // Extract the raw request the same way post() does (getContext().request
144
+ // is populated for GET; fall back to the context itself). verifyAgentRequest
145
+ // returns the agent for a valid TPS-Ed25519 signature, else null — see the
146
+ // gate rationale above. Memoized per-request, so this is a no-op if any
147
+ // other path already verified the same request.
148
+ const ctx = this.getContext?.();
149
+ const request = ctx?.request ?? ctx;
150
+ const agentAuth = request ? await verifyAgentRequest(request) : null;
151
+ const includeCurrentTask = agentAuth !== null;
103
152
  const now = Date.now();
104
153
  const idleThreshold = idleThresholdMs();
105
154
  const offlineThreshold = offlineThresholdMs();
@@ -124,7 +173,10 @@ export class Presence extends databases.flair.Presence {
124
173
  presenceStatus: derivePresenceStatus(now, typeof row?.lastHeartbeatAt === "number"
125
174
  ? row.lastHeartbeatAt
126
175
  : Number(row?.lastHeartbeatAt ?? 0), idleThreshold, offlineThreshold),
127
- currentTask: sanitizeCurrentTask(row?.currentTask),
176
+ // Anonymous/unverified readers get `null` here (key stays present,
177
+ // schema-stable) instead of the sanitized task text — see the
178
+ // currentTask CONTENT gate doc above get().
179
+ currentTask: includeCurrentTask ? sanitizeCurrentTask(row?.currentTask) : null,
128
180
  lastHeartbeatAt: typeof row?.lastHeartbeatAt === "number"
129
181
  ? row.lastHeartbeatAt
130
182
  : Number(row?.lastHeartbeatAt ?? 0),
@@ -1,6 +1,7 @@
1
1
  import { databases } from "@harperfast/harper";
2
2
  import { resolveAgentAuth, allowVerified } from "./agent-auth.js";
3
3
  import { checkRateLimit, rateLimitResponse } from "./rate-limiter.js";
4
+ import { localInstanceId } from "./instance-identity.js";
4
5
  const NOT_FOUND = () => new Response(JSON.stringify({ error: "not found" }), { status: 404, headers: { "Content-Type": "application/json" } });
5
6
  /**
6
7
  * Relationship resource — entity-to-entity relationships with temporal validity.
@@ -16,7 +17,7 @@ const NOT_FOUND = () => new Response(JSON.stringify({ error: "not found" }), { s
16
17
  export class Relationship extends databases.flair.Relationship {
17
18
  /**
18
19
  * Self-authorize now that the global gate is non-rejecting (memory-soul-
19
- * read-gate family fix, ops-oox7 — same pattern as Memory.ts/Soul.ts/
20
+ * read-gate family fix — same pattern as Memory.ts/Soul.ts/
20
21
  * WorkspaceState.ts). Closes the same P0 leak: Harper routes
21
22
  * `GET /Relationship/<id>` to get() and the collection describe
22
23
  * (`GET /Relationship`) outside search(), so neither was gated before this
@@ -124,6 +125,14 @@ export class Relationship extends databases.flair.Relationship {
124
125
  content.validFrom = content.validFrom || now;
125
126
  // validTo left as null/undefined for active relationships
126
127
  content.confidence = content.confidence ?? 1.0;
128
+ // Write-time originatorInstanceId stamp (federation-edge-hardening slice
129
+ // 1) — see resources/Memory.ts's stampOriginatorInstanceId doc for the
130
+ // full contract. No-op if already set (never fires for a genuine local
131
+ // write; a federation-synced record never reaches this method — the
132
+ // merge path writes via the raw table object, bypassing this class).
133
+ if (content.originatorInstanceId == null) {
134
+ content.originatorInstanceId = await localInstanceId();
135
+ }
127
136
  return super.put(content);
128
137
  }
129
138
  async delete(_) {
@@ -11,7 +11,7 @@ import { isRerankEnabled, getRerankTopN, getRerankBudgetMs, getRerankMinCandidat
11
11
  // relevance floor) lives in ./scoring.ts — a Harper-free module so it can be
12
12
  // unit-tested directly (see test/unit/temporal-scoring.test.ts).
13
13
  import { compositeScore } from "./scoring.js";
14
- // BM25 + union-RRF hybrid retrieval (ops-i39b / FLAIR-BM25-HYBRID-RETRIEVAL).
14
+ // BM25 + union-RRF hybrid retrieval (FLAIR-BM25-HYBRID-RETRIEVAL).
15
15
  // Harper-free modules so the BM25 scoring, the candidate-union RRF, and the
16
16
  // SECURITY conditions-filter are unit-tested against the shipped code.
17
17
  import { buildBM25, fuseRrfNormalized, hybridEnabled, SEM_LIMIT } from "./bm25.js";
@@ -81,10 +81,10 @@ export class SemanticSearch extends Resource {
81
81
  ? authenticatedAgent
82
82
  : bodyAgentId;
83
83
  // Read-scope: own (any visibility) + granted owners' SHARED memories only
84
- // (ops-2dm3 Layer 1). Centralized in resolveReadScope() — this used to be
84
+ // (Layer 1). Centralized in resolveReadScope() — this used to be
85
85
  // an inline grant-resolution loop here PLUS a `visibility === "office"`
86
86
  // global OR-clause below that leaked ANY authenticated agent's read of
87
- // ANY other agent's office-visible memories (ops-nzxa). Both are gone;
87
+ // ANY other agent's office-visible memories. Both are gone;
88
88
  // this is the ONE scoping resolution for this endpoint now.
89
89
  const scope = agentId ? await resolveReadScope(agentId) : null;
90
90
  // Generate query embedding
@@ -162,13 +162,13 @@ export class SemanticSearch extends Resource {
162
162
  // from CANDIDATE_MULTIPLIER so composite re-ranking keeps its existing
163
163
  // headroom. Scoped to the legacy (non-hybrid) vector path below — the
164
164
  // hybrid path's candidate pool is already governed by CANDIDATE_MULTIPLIER
165
- // (semantic leg) + SEM_LIMIT (BM25 leg) via RRF union (ops-i39b); the
165
+ // (semantic leg) + SEM_LIMIT (BM25 leg) via RRF union; the
166
166
  // reranker still applies to its output further down regardless of which
167
167
  // path produced `filteredResults`.
168
168
  const rerankOn = isRerankEnabled();
169
169
  const rerankTopN = getRerankTopN();
170
170
  if (hybrid) {
171
- // ─── BM25 + union-RRF hybrid path (ops-i39b) ─────────────────────────
171
+ // ─── BM25 + union-RRF hybrid path ────────────────────────────────────
172
172
  // 1. Semantic candidates via HNSW (unchanged fetch). 2. BM25 lexical pass
173
173
  // over the SCOPED corpus. 3. SECURITY: the BM25 candidate set is filtered
174
174
  // by the SAME conditions[] + temporal filters BEFORE fusion (the corpus
@@ -206,7 +206,7 @@ export class SemanticSearch extends Resource {
206
206
  continue;
207
207
  if (asOf && record.validTo && record.validTo <= asOf)
208
208
  continue;
209
- // ops-9rc6: unconditional past-validTo exclusion (see legacy HNSW
209
+ // Unconditional past-validTo exclusion (see legacy HNSW
210
210
  // loop below for the full rationale) — applies regardless of asOf.
211
211
  if (record.validTo && Date.parse(record.validTo) < Date.now())
212
212
  continue;
@@ -315,7 +315,7 @@ export class SemanticSearch extends Resource {
315
315
  continue;
316
316
  if (asOf && record.validTo && record.validTo <= asOf)
317
317
  continue;
318
- // ops-9rc6: a past validTo ALWAYS means the record has been closed out
318
+ // A past validTo ALWAYS means the record has been closed out
319
319
  // (server supersede path — Memory.ts closeSupersededRecord — sets
320
320
  // validTo without necessarily setting `archived`). Unconditional, not
321
321
  // gated on `asOf`, so a server-superseded record can't resurface in
@@ -330,9 +330,9 @@ export class SemanticSearch extends Resource {
330
330
  semanticScore = distanceToSimilarity(record.$distance);
331
331
  }
332
332
  else {
333
- // ─── ops-syzm: Harper's cosine-sort query omits $distance for a
333
+ // ─── Harper's cosine-sort query omits $distance for a
334
334
  // SINGLETON post-filter result set — the SAME quirk root-caused and
335
- // fixed for the dedup path in ops-ume4 (resources/Memory.ts
335
+ // fixed for the dedup path (resources/Memory.ts
336
336
  // findConservativeDedupMatch / resources/dedup.ts cosineSimilarity).
337
337
  // Sort ORDER is still correct; only the numeric `$distance`
338
338
  // annotation is missing on that one record, regardless of the
@@ -340,7 +340,7 @@ export class SemanticSearch extends Resource {
340
340
  // just limit=1 — the trigger is the post-filter MATCH COUNT, not the
341
341
  // requested limit).
342
342
  //
343
- // ops-2dm3 Layer 1 made this common: the no-grants agent scope used
343
+ // Layer 1 made this common: the no-grants agent scope used
344
344
  // to ALWAYS be a compound `{operator:"or", conditions:[{agentId},
345
345
  // {visibility=="office"}]}` condition; resolveReadScope() now emits
346
346
  // a PLAIN single `{agentId==X}` condition for the common (no-grants)
@@ -354,8 +354,8 @@ export class SemanticSearch extends Resource {
354
354
  //
355
355
  // Fix: point-lookup the record by id (a plain get(), unaffected by
356
356
  // the sort-query quirk — selecting "embedding" directly on the SAME
357
- // sort-by-embedding query comes back as a bare scalar per ops-ume4's
358
- // findings) and compute cosine similarity ourselves in JS from its
357
+ // sort-by-embedding query comes back as a bare scalar, per the same
358
+ // investigation above) and compute cosine similarity ourselves in JS from its
359
359
  // real stored `embedding` vector against this query's `qEmb`, via
360
360
  // the same math as the ume4 fallback (dedup.ts's cosineSimilarity).
361
361
  // Only done on this (rare) undefined-$distance branch — never adds
@@ -405,7 +405,7 @@ export class SemanticSearch extends Resource {
405
405
  continue;
406
406
  if (asOf && record.validTo && record.validTo <= asOf)
407
407
  continue;
408
- // ops-9rc6: unconditional past-validTo exclusion (see legacy HNSW
408
+ // Unconditional past-validTo exclusion (see legacy HNSW
409
409
  // loop above for the full rationale) — applies regardless of asOf.
410
410
  if (record.validTo && Date.parse(record.validTo) < Date.now())
411
411
  continue;
@@ -449,7 +449,7 @@ export class SemanticSearch extends Resource {
449
449
  // Re-scores query+candidate TOGETHER (cross-attention the pooled embedding
450
450
  // can't do) and reorders before the final slice. Reorders whatever
451
451
  // `filteredResults` the retrieval stage above produced — legacy HNSW-only
452
- // OR the BM25+union-RRF hybrid path (ops-i39b) — since both converge into
452
+ // OR the BM25+union-RRF hybrid path — since both converge into
453
453
  // the same `results`/`filteredResults` shape before this point; hybrid
454
454
  // retrieves+fuses, the reranker only reorders the fused set. Still gated
455
455
  // on `qEmb` (an embedding was actually generated); the pure keyword-only
@@ -1,5 +1,6 @@
1
1
  import { databases } from "@harperfast/harper";
2
2
  import { resolveAgentAuth, allowVerified } from "./agent-auth.js";
3
+ import { localInstanceId } from "./instance-identity.js";
3
4
  const FORBIDDEN = (msg) => new Response(JSON.stringify({ error: msg }), { status: 403, headers: { "Content-Type": "application/json" } });
4
5
  const UNAUTH = () => new Response(JSON.stringify({ error: "authentication required" }), { status: 401, headers: { "Content-Type": "application/json" } });
5
6
  /**
@@ -37,6 +38,14 @@ export class Soul extends databases.flair.Soul {
37
38
  content.durability ||= "permanent";
38
39
  content.createdAt = new Date().toISOString();
39
40
  content.updatedAt = content.createdAt;
41
+ // Write-time originatorInstanceId stamp (federation-edge-hardening slice
42
+ // 1) — see resources/Memory.ts's stampOriginatorInstanceId doc for the
43
+ // full contract. No-op if already set (never fires for a genuine local
44
+ // write; a federation-synced record never reaches this method — the
45
+ // merge path writes via the raw table object, bypassing this class).
46
+ if (content.originatorInstanceId == null) {
47
+ content.originatorInstanceId = await localInstanceId();
48
+ }
40
49
  return super.post(content, context);
41
50
  }
42
51
  async put(content, context) {
@@ -44,6 +53,11 @@ export class Soul extends databases.flair.Soul {
44
53
  if (denied)
45
54
  return denied;
46
55
  content.updatedAt = new Date().toISOString();
56
+ // Write-time originatorInstanceId stamp — see post() above / Memory.ts's
57
+ // stampOriginatorInstanceId doc. No-op if already set.
58
+ if (content.originatorInstanceId == null) {
59
+ content.originatorInstanceId = await localInstanceId();
60
+ }
47
61
  return super.put(content, context);
48
62
  }
49
63
  }
@@ -16,7 +16,7 @@ export class WorkspaceLatest extends Resource {
16
16
  async get(pathInfo) {
17
17
  // Harper v5 does not populate this.context / this.request on Resource
18
18
  // subclasses — getContext() is the only reliable path to the gate's
19
- // tpsAgent/tpsAgentIsAdmin annotations (ops-sal4: the previous
19
+ // tpsAgent/tpsAgentIsAdmin annotations (the previous
20
20
  // `(this as any).context?.request ?? (this as any).request` read was always
21
21
  // undefined, so the ownership check below never ran — fail-open cross-agent
22
22
  // read).
@@ -20,7 +20,7 @@ export class WorkspaceState extends databases.flair.WorkspaceState {
20
20
  }
21
21
  /**
22
22
  * Self-authorize now that the global gate is non-rejecting (memory-soul-
23
- * read-gate family fix, ops-oox7 — applying the Memory.ts/Soul.ts pattern
23
+ * read-gate family fix — applying the Memory.ts/Soul.ts pattern
24
24
  * to WorkspaceState/Relationship/Integration/MemoryGrant). Closes the same
25
25
  * P0 leak: Harper routes `GET /WorkspaceState/<id>` to get() and the
26
26
  * collection describe (`GET /WorkspaceState`) to a path outside search(),
@@ -26,7 +26,7 @@ import { WINDOW_MS, isNonceReplay, recordNonce, importEd25519Key, b64ToArrayBuff
26
26
  export const FLAIR_AGENT_USERNAME = "flair-agent";
27
27
  // ─── Crypto + replay-guard helpers ────────────────────────────────────────────
28
28
  // WINDOW_MS, isNonceReplay/recordNonce (the ONE shared nonce store), and
29
- // importEd25519Key all live in ./ed25519-auth.ts (bd ops-c4op) — the single
29
+ // importEd25519Key all live in ./ed25519-auth.ts — the single
30
30
  // shared implementation imported by auth-middleware.ts, agent-auth.ts, and
31
31
  // Presence.ts so a nonce recorded via any one of the three call sites is
32
32
  // visible to the other two, and the crypto/decoder logic can't drift.
@@ -9,7 +9,7 @@
9
9
  //
10
10
  // Kept free of any @harperfast/harper import so the real logic is unit-testable
11
11
  // without spinning up Harper (avoids the simulator-pattern that let the
12
- // description-fallback leak ship untested — ops-vz6j).
12
+ // description-fallback leak ship untested).
13
13
  export function readSoulKind(entry) {
14
14
  return String(entry.kind ?? entry.key ?? "").trim().toLowerCase();
15
15
  }
@@ -20,7 +20,7 @@ export function readSoulContent(entry) {
20
20
  * The public description is ONLY an explicit `kind="description"` soul.
21
21
  *
22
22
  * There is deliberately no fallback to "the first soul with any content": that
23
- * was the ops-vz6j leak — an agent with no description soul would publish an
23
+ * was the description-fallback leak — an agent with no description soul would publish an
24
24
  * arbitrary private soul on the unauthenticated card. Absent an explicit
25
25
  * description, publish nothing.
26
26
  */
@@ -38,7 +38,7 @@ function getAdminPass() {
38
38
  // an admin — one implementation guarantees they can't diverge.
39
39
  // ─── Crypto + replay-guard helpers ────────────────────────────────────────────
40
40
  // WINDOW_MS, isNonceReplay/recordNonce (the ONE shared nonce store), and
41
- // importEd25519Key all live in ./ed25519-auth.ts (bd ops-c4op) — the single
41
+ // importEd25519Key all live in ./ed25519-auth.ts — the single
42
42
  // shared implementation imported by auth-middleware.ts, agent-auth.ts, and
43
43
  // Presence.ts so a nonce recorded via any one of the three call sites is
44
44
  // visible to the other two, and the crypto/decoder logic can't drift.
@@ -97,9 +97,28 @@ server.http(async (request, nextLayer) => {
97
97
  // instances (rockit-local works only because authorizeLocal=true).
98
98
  url.pathname === "/ObservationCenter" ||
99
99
  // Presence roster is public-safe (field-allowlisted); GET serves the
100
- // Office Space renderer without auth. POST handles Ed25519 auth internally
101
- // (allowCreate=true on the Resource).
102
- url.pathname === "/Presence")
100
+ // Office Space renderer without auth. Scoped to GET only (#604): the
101
+ // exact-path match used to match ANY method, so a bare `PUT /Presence`
102
+ // (collection-level, no id — Harper routes it to the same .put() as
103
+ // by-id PUT) early-returned here too, skipping this middleware entirely.
104
+ // A credential-less loopback PUT then reached Presence.put()'s
105
+ // resolveAgentAuth() call with NO tpsAnonymous/tpsAgent annotation, which
106
+ // fell through to raw `context.user` — populated by Harper's
107
+ // `authorizeLocal` (config true) ambient super_user injection for ANY
108
+ // credential-less loopback request — so the ownership check saw an
109
+ // "admin" caller (isAdmin=true) and let the write through unauthenticated
110
+ // (`super.put()`, no signature, no password). Mirrors the A2A GET-only
111
+ // pattern above: POST/PUT/DELETE now always transit the general
112
+ // middleware path below, which marks a genuinely headerless request
113
+ // tpsAnonymous BEFORE Harper's ambient elevation lands (resolveAgentAuth
114
+ // checks tpsAnonymous first — see agent-auth.ts's resolution order), so
115
+ // the ownership check in Presence.put()/delete() correctly denies it.
116
+ // POST (the heartbeat) is unaffected in practice: it already prefers
117
+ // request.tpsAgent when the middleware set it, and falls back to its own
118
+ // Ed25519 header parse otherwise — transiting the general path now just
119
+ // means a genuinely headerless POST gets marked anonymous (still 401)
120
+ // instead of skipping straight to that fallback parse.
121
+ (request.method === "GET" && url.pathname === "/Presence"))
103
122
  return nextLayer(request);
104
123
  // If Harper has already authorized this request (e.g. Basic admin, or
105
124
  // authorizeLocal=true on localhost), trust Harper's auth decision and pass
@@ -477,7 +496,7 @@ server.http(async (request, nextLayer) => {
477
496
  if (memId) {
478
497
  const record = await databases.flair.Memory.get(memId);
479
498
  if (record && record.agentId && record.agentId !== agentId) {
480
- // Centralized read-scope (ops-2dm3 Layer 1): a grant only covers
499
+ // Centralized read-scope (Layer 1): a grant only covers
481
500
  // the owner's SHARED memories, never their private ones. This
482
501
  // used to be a `visibility === "office"` bypass (any authenticated
483
502
  // agent, no grant needed) — that's gone; the private-exclusion is
@@ -72,7 +72,7 @@ export function passesRecordFilters(record, f = {}) {
72
72
  return false;
73
73
  if (f.asOf && record?.validTo && record.validTo <= f.asOf)
74
74
  return false;
75
- // ops-9rc6: a past validTo ALWAYS means the record has been closed out —
75
+ // A past validTo ALWAYS means the record has been closed out —
76
76
  // either by the server supersede path (Memory.ts closeSupersededRecord sets
77
77
  // validTo without necessarily setting `archived`) or any other writer. This
78
78
  // is unconditional (not gated on `f.asOf`) so a server-superseded record
@@ -1,5 +1,5 @@
1
1
  // ─── BM25 + union-RRF hybrid retrieval (Harper-free, unit-testable) ──────────
2
- // Per spec FLAIR-BM25-HYBRID-RETRIEVAL (Kern-approved, ops-i39b). This module is
2
+ // Per spec FLAIR-BM25-HYBRID-RETRIEVAL (Kern-approved). This module is
3
3
  // deliberately Harper-free — same rationale as ./scoring.ts — so the BM25
4
4
  // scoring, the candidate-union RRF fusion, and the security conditions-filter
5
5
  // can be unit-tested directly against the SHIPPED code without a live Harper.
@@ -30,10 +30,10 @@ export const DEDUP_MIN_CONTENT_LENGTH = 20;
30
30
  * Cosine similarity of two equal-length embedding vectors, computed directly
31
31
  * in JS. Used as a fallback for Harper's HNSW cosine-sort query omitting a
32
32
  * computed `$distance` on its top candidate when the query's post-filter
33
- * result set contains exactly ONE matching record (ops-ume4, found in
33
+ * result set contains exactly ONE matching record (found in
34
34
  * resources/Memory.ts's findConservativeDedupMatch; the identical quirk is
35
35
  * also fixed in resources/SemanticSearch.ts's scoring loop for the same
36
- * reason — see ops-syzm there). A mismatched length, empty vector, or
36
+ * reason). A mismatched length, empty vector, or
37
37
  * zero-magnitude side yields 0 (no signal, never treated as "identical" by a
38
38
  * degenerate computation).
39
39
  */
@@ -11,8 +11,8 @@
11
11
  * `importEd25519Key`. Three independent replay windows meant a nonce
12
12
  * recorded as "seen" via one path was invisible to the other two — a
13
13
  * defense-in-depth gap, and a drift hazard (any future fix to one copy
14
- * silently didn't apply to the other two). bd ops-c4op consolidates all
15
- * three into this one module so there is exactly ONE replay guard and ONE
14
+ * silently didn't apply to the other two). Consolidating all three into
15
+ * this one module means there is exactly ONE replay guard and ONE
16
16
  * key-import implementation, imported by all 3 sites.
17
17
  *
18
18
  * `b64ToArrayBuffer` was already unified into resources/b64.ts in a prior
@@ -65,7 +65,7 @@ async function ensureInit() {
65
65
  // Not initialized — init with modelsDir pointing at a USER-WRITABLE
66
66
  // location. On a sudo/root-owned global install the Flair package dir
67
67
  // (process.cwd()) is root-owned, so a model download into <cwd>/models
68
- // fails with EACCES and semantic search silently dies (ops-am0v). The
68
+ // fails with EACCES and semantic search silently dies. The
69
69
  // model — and everything else Flair writes — must live under ~/.flair.
70
70
  //
71
71
  // NOTE: import.meta.dirname and __dirname are both undefined in Harper v5's