@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.
- package/README.md +39 -11
- package/SECURITY.md +52 -20
- package/config.yaml +32 -5
- package/dist/cli.js +2235 -260
- package/dist/deploy.js +96 -15
- package/dist/doctor-client.js +312 -0
- package/dist/fleet-presence.js +98 -0
- package/dist/fleet-verify.js +291 -0
- package/dist/install/clients.js +18 -0
- package/dist/mcp-client-assertion.js +364 -0
- package/dist/probe.js +97 -0
- package/dist/rem/restore.js +1 -1
- package/dist/rem/runner.js +82 -12
- package/dist/resources/Admin.js +1 -1
- package/dist/resources/AdminConnectors.js +1 -1
- package/dist/resources/AdminDashboard.js +1 -1
- package/dist/resources/AdminIdp.js +1 -1
- package/dist/resources/AdminInstance.js +1 -1
- package/dist/resources/AdminMemory.js +2 -2
- package/dist/resources/AdminPrincipals.js +1 -1
- package/dist/resources/Agent.js +14 -0
- package/dist/resources/AgentCard.js +1 -1
- package/dist/resources/AgentSeed.js +1 -1
- package/dist/resources/AttentionQuery.js +356 -0
- package/dist/resources/Credential.js +11 -1
- package/dist/resources/Federation.js +121 -2
- package/dist/resources/Integration.js +1 -1
- package/dist/resources/MCPClientMetadata.js +88 -0
- package/dist/resources/Memory.js +127 -22
- package/dist/resources/MemoryBootstrap.js +441 -85
- package/dist/resources/MemoryGrant.js +1 -1
- package/dist/resources/MemoryReflect.js +105 -49
- package/dist/resources/MemoryUsage.js +104 -0
- package/dist/resources/OAuth.js +69 -5
- package/dist/resources/OrgEvent.js +11 -0
- package/dist/resources/OrgEventCatchup.js +1 -1
- package/dist/resources/Presence.js +268 -17
- package/dist/resources/RecordUsage.js +230 -0
- package/dist/resources/Relationship.js +108 -26
- package/dist/resources/SemanticSearch.js +88 -320
- package/dist/resources/SkillScan.js +15 -0
- package/dist/resources/Soul.js +14 -0
- package/dist/resources/WorkspaceLatest.js +1 -1
- package/dist/resources/WorkspaceState.js +12 -1
- package/dist/resources/agent-auth.js +61 -3
- package/dist/resources/agentcard-fields.js +2 -2
- package/dist/resources/auth-middleware.js +42 -14
- package/dist/resources/bm25-filter.js +1 -1
- package/dist/resources/bm25.js +13 -7
- package/dist/resources/collision-lib.js +157 -0
- package/dist/resources/dedup.js +2 -2
- package/dist/resources/ed25519-auth.js +2 -2
- 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/federation-nonce-store.js +195 -0
- package/dist/resources/health.js +97 -6
- package/dist/resources/instance-identity.js +53 -0
- 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-bootstrap-lib.js +1 -1
- package/dist/resources/memory-read-scope.js +58 -71
- package/dist/resources/memory-reflect-lib.js +289 -0
- package/dist/resources/memory-visibility.js +37 -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 +456 -0
- package/dist/resources/migrations/snapshot.js +127 -0
- package/dist/resources/migrations/source-fields.js +93 -0
- package/dist/resources/migrations/space.js +73 -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-check.js +167 -0
- package/dist/version-handshake.js +122 -0
- package/package.json +4 -5
- package/schemas/agent.graphql +7 -0
- package/schemas/event.graphql +5 -0
- package/schemas/federation.graphql +12 -0
- package/schemas/memory.graphql +82 -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
|
@@ -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 (
|
|
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 (
|
|
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 (
|
|
7
|
+
* allowRead()=allowAdmin (defense-in-depth): see Admin.ts.
|
|
8
8
|
*/
|
|
9
9
|
export class AdminPrincipals extends Resource {
|
|
10
10
|
async allowRead() {
|
package/dist/resources/Agent.js
CHANGED
|
@@ -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
|
|
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 (
|
|
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 {
|
|
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
|
-
|
|
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
|
|
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
|