@tpsdev-ai/flair 0.35.0 → 0.36.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.
@@ -1,6 +1,7 @@
1
1
  import { databases } from "harper";
2
2
  import { resolveAgentAuth, allowVerified } from "./agent-auth.js";
3
3
  import { checkRateLimit, rateLimitResponse } from "./rate-limiter.js";
4
+ import { stampAttribution, UNAUTH } from "./record-type-kit.js";
4
5
  /**
5
6
  * Credential resource — authentication surfaces for Principals.
6
7
  *
@@ -23,6 +24,50 @@ export class Credential extends databases.flair.Credential {
23
24
  * top of this for the paths they do see.
24
25
  */
25
26
  allowRead() { return allowVerified(this.getContext?.()); }
27
+ /**
28
+ * Self-authorize creation (same posture as allowRead). Without this gate
29
+ * Harper routes POST /Credential to the base class's post() — which has
30
+ * no cross-principal check — letting an unauthenticated caller create
31
+ * credentials for any principal.
32
+ */
33
+ allowCreate() { return allowVerified(this.getContext?.()); }
34
+ /**
35
+ * Create a credential. No-forge attribution via stampAttribution
36
+ * ("stamp-default" mode): a non-admin agent's credential is ALWAYS
37
+ * attributed to the authenticated identity — we never trust
38
+ * content.principalId from the body. An admin may create on behalf of
39
+ * another principal (content.principalId honored if present, else
40
+ * defaults to the admin's own id). Internal in-process callers keep
41
+ * whatever principalId they pass.
42
+ *
43
+ * Previously Credential had no post() override — the base class's post()
44
+ * ran with no cross-principal check, so any verified caller could create
45
+ * a credential for any principal by setting principalId in the body.
46
+ */
47
+ async post(content) {
48
+ const auth = await resolveAgentAuth(this.getContext?.());
49
+ if (auth.kind === "anonymous")
50
+ return UNAUTH();
51
+ // No-forge attribution: stamp principalId from the authenticated
52
+ // identity. "stamp-default" unconditionally overwrites for non-admin;
53
+ // admin may supply their own value (defaults to admin's id if absent).
54
+ stampAttribution(auth, content, "principalId", "stamp-default", "forbidden: unreachable for stamp-default");
55
+ const rl = checkRateLimit(auth.kind === "agent" ? auth.agentId : "internal");
56
+ if (!rl.allowed)
57
+ return rateLimitResponse(rl.retryAfterMs, "credential");
58
+ // Validate kind
59
+ const validKinds = ["webauthn", "bearer-token", "ed25519", "idp"];
60
+ if (!content.kind || !validKinds.includes(content.kind)) {
61
+ return new Response(JSON.stringify({ error: `kind must be one of: ${validKinds.join(", ")}` }), {
62
+ status: 400, headers: { "content-type": "application/json" },
63
+ });
64
+ }
65
+ const now = new Date().toISOString();
66
+ content.status = content.status || "active";
67
+ content.createdAt = content.createdAt || now;
68
+ content.updatedAt = now;
69
+ return super.post(content);
70
+ }
26
71
  async search(query) {
27
72
  const auth = await resolveAgentAuth(this.getContext?.());
28
73
  // Anonymous HTTP must NOT read credentials. (Previously `!authAgent` was
@@ -71,22 +116,19 @@ export class Credential extends databases.flair.Credential {
71
116
  return safe;
72
117
  }
73
118
  async put(content) {
74
- const ctx = this.getContext?.();
75
- const request = ctx?.request ?? ctx;
76
- const authAgent = request?.tpsAgent;
77
- const isAdminAgent = request?.tpsAgentIsAdmin ?? false;
78
- if (!authAgent) {
119
+ const auth = await resolveAgentAuth(this.getContext?.());
120
+ if (auth.kind === "anonymous") {
79
121
  return new Response(JSON.stringify({ error: "authentication required" }), {
80
122
  status: 401, headers: { "content-type": "application/json" },
81
123
  });
82
124
  }
83
- // Only admins can create credentials for other principals
84
- if (!isAdminAgent && content.principalId && content.principalId !== authAgent) {
125
+ // Only admins can update credentials for other principals
126
+ if (auth.kind === "agent" && !auth.isAdmin && content.principalId && content.principalId !== auth.agentId) {
85
127
  return new Response(JSON.stringify({ error: "only admin principals can manage other principals' credentials" }), {
86
128
  status: 403, headers: { "content-type": "application/json" },
87
129
  });
88
130
  }
89
- const rl = checkRateLimit(authAgent);
131
+ const rl = checkRateLimit(auth.kind === "agent" ? auth.agentId : "internal");
90
132
  if (!rl.allowed)
91
133
  return rateLimitResponse(rl.retryAfterMs, "credential");
92
134
  // Validate kind
@@ -97,25 +139,22 @@ export class Credential extends databases.flair.Credential {
97
139
  });
98
140
  }
99
141
  const now = new Date().toISOString();
100
- content.principalId = content.principalId || authAgent;
142
+ content.principalId = content.principalId || (auth.kind === "agent" ? auth.agentId : content.principalId);
101
143
  content.status = content.status || "active";
102
144
  content.createdAt = content.createdAt || now;
103
145
  content.updatedAt = now;
104
146
  return super.put(content);
105
147
  }
106
148
  async delete(_) {
107
- const ctx = this.getContext?.();
108
- const request = ctx?.request ?? ctx;
109
- const authAgent = request?.tpsAgent;
110
- const isAdminAgent = request?.tpsAgentIsAdmin ?? false;
111
- if (!authAgent) {
149
+ const auth = await resolveAgentAuth(this.getContext?.());
150
+ if (auth.kind === "anonymous") {
112
151
  return new Response(JSON.stringify({ error: "authentication required" }), {
113
152
  status: 401, headers: { "content-type": "application/json" },
114
153
  });
115
154
  }
116
- if (!isAdminAgent) {
155
+ if (auth.kind === "agent" && !auth.isAdmin) {
117
156
  const existing = await super.get();
118
- if (existing?.principalId && existing.principalId !== authAgent) {
157
+ if (existing?.principalId && existing.principalId !== auth.agentId) {
119
158
  return new Response(JSON.stringify({ error: "only admin principals can revoke other principals' credentials" }), {
120
159
  status: 403, headers: { "content-type": "application/json" },
121
160
  });
@@ -7,7 +7,7 @@ import { scanFields, isStrictMode } from "./content-safety.js";
7
7
  import { invalidEntitiesResponse } from "./entity-vocab.js";
8
8
  import { checkRateLimit, rateLimitResponse } from "./rate-limiter.js";
9
9
  import { DEDUP_COSINE_THRESHOLD_DEFAULT, DEDUP_LEXICAL_THRESHOLD_DEFAULT, DEDUP_MIN_CONTENT_LENGTH, computeMatchConfidence, cosineSimilarity, isConservativeMatch, } from "./dedup.js";
10
- import { buildProvenance, makeAuthGate, makeReadScope, makeByIdReadGate, resolveAuthGate, stampAttribution, FORBIDDEN, UNAUTH, } from "./record-type-kit.js";
10
+ import { buildProvenance, makeAuthGate, makeReadScope, makeByIdReadGate, makeScopedSearch, resolveAuthGate, stampAttribution, FORBIDDEN, UNAUTH, } from "./record-type-kit.js";
11
11
  import { RECORD_TYPES } from "./record-types.js";
12
12
  import { attachTrust } from "./trust-block.js";
13
13
  import { recordCitations } from "./usage-recording.js";
@@ -53,6 +53,7 @@ function wantsTrust(target, opts) {
53
53
  */
54
54
  export const memoryReadScope = makeReadScope(RECORD_TYPES.Memory.readScope, RECORD_TYPES.Memory.ownerField);
55
55
  const memoryByIdReadGate = makeByIdReadGate(memoryReadScope);
56
+ const memoryScopedSearch = makeScopedSearch(memoryReadScope);
56
57
  // See makeAuthGate's doc (record-type-kit.ts): must be wired as a genuine
57
58
  // prototype method below, never a class-field assignment — Harper's
58
59
  // relationship-traversal RBAC path reads allowRead off the prototype.
@@ -540,25 +541,12 @@ export class Memory extends databases.flair.Memory {
540
541
  // from RECORD_TYPES.Memory — see this file's header — delegating
541
542
  // "open-within-org" to memory-read-scope.ts's resolveReadScope()
542
543
  // unchanged) so get() above and search() here cannot drift.
543
- const scope = await memoryReadScope(gate.agentId);
544
- const agentIdCondition = scope.condition;
545
- // Harper passes `query` as a RequestTarget (extends URLSearchParams) or a
546
- // conditions array. For URL-based GET /Memory?... calls, URL params are no
547
- // longer translated to conditions here callers should use
548
- // POST /Memory/search_by_conditions with an explicit conditions array.
549
- // For programmatic calls with a conditions array, we wrap with the agentId scope.
550
- if (query && typeof query === "object" && !Array.isArray(query)) {
551
- if (Array.isArray(query.conditions) && query.conditions.length > 0) {
552
- query.conditions = [agentIdCondition, ...query.conditions];
553
- return withDetachedTxn(ctx, () => super.search(query));
554
- }
555
- // Fallback: no conditions array present — just scope and pass through
556
- }
557
- // Fallback: plain array or no query (internal calls)
558
- const conditions = Array.isArray(query) && query.length > 0
559
- ? [agentIdCondition, ...query]
560
- : [agentIdCondition];
561
- return withDetachedTxn(ctx, () => super.search(conditions));
544
+ //
545
+ // The scope condition is nested as the outermost AND block via
546
+ // makeScopedSearch (record-type-kit.ts) same correct composition
547
+ // MemoryCandidate.search() already applies so a caller-supplied
548
+ // `operator: "or"` cannot boolean-inject past the owner scope.
549
+ return memoryScopedSearch(gate.agentId, query, (q) => withDetachedTxn(ctx, () => super.search(q)));
562
550
  }
563
551
  async post(content, context) {
564
552
  // Rate limiting — use authenticated agent ID, not client-supplied body field
@@ -1,16 +1,51 @@
1
1
  import { Resource, databases } from "harper";
2
- import { allowVerified } from "./agent-auth.js";
2
+ import { allowVerified, resolveAgentAuth } from "./agent-auth.js";
3
3
  import { computeContentHash, findExistingMemoryByContentHash } from "./memory-feed-lib.js";
4
+ import { FORBIDDEN, UNAUTH, stampAttribution } from "./record-type-kit.js";
4
5
  export class FeedMemories extends Resource {
5
6
  // Self-authorize via the Ed25519 agent verify (the auth reshape removes the
6
- // gate's admin elevation). NOTE: post() trusts content.agentId from the body —
7
- // closing that create-spoofing gap is tracked with the table-resource
8
- // create-ownership work (Memory.allowCreate), not in this auth-coverage pass.
7
+ // gate's admin elevation).
9
8
  async allowCreate() {
10
9
  return allowVerified(this.getContext?.());
11
10
  }
12
11
  async post(content) {
13
- const agentId = String(content?.agentId ?? "");
12
+ const ctx = this.getContext?.();
13
+ const auth = await resolveAgentAuth(ctx);
14
+ // Anonymous HTTP must NOT write.
15
+ if (auth.kind === "anonymous") {
16
+ return UNAUTH();
17
+ }
18
+ // No-forge attribution: use the kit's stampAttribution to stamp agentId
19
+ // from the authenticated principal, never from the body.
20
+ //
21
+ // Mode choice: stamp-strict (reject 403 on mismatch) over stamp-default
22
+ // (silent overwrite). This endpoint is the ingestion path — callers are MCP
23
+ // clients and agent-side tool calls. The defect this fix addresses was
24
+ // trusting a body-supplied identity; the correction is to always stamp from
25
+ // the authenticated principal.
26
+ //
27
+ // Deciding point (adjudicated on PR #1071): a silent overwrite means a
28
+ // buggy client never learns it is buggy — it keeps sending the wrong
29
+ // agentId and keeps getting 200. A strict rejection surfaces the mismatch
30
+ // so the caller can fix it. The concern about breaking callers that
31
+ // harmlessly echo agentId back was checked: a full search of the repo and
32
+ // workspace for FeedMemories and /FeedMemories returns only the resource
33
+ // definition and its own tests — no SDK wrappers, no CLI commands, no
34
+ // internal callers construct requests with a body-supplied agentId. A
35
+ // caller echoing the correct agentId (matching the principal) passes
36
+ // through stamp-strict unchanged; a caller echoing a wrong one is exactly
37
+ // the bug this slice exists to prevent.
38
+ const attr = stampAttribution(auth, content, 'agentId', 'stamp-strict', 'forbidden: cannot attribute a feed memory to another agent');
39
+ if (attr.denied)
40
+ return attr.denied;
41
+ // Guard against body-supplied id targeting another agent's record.
42
+ if (content?.id) {
43
+ const existingRecord = await databases.flair.Memory.get(content.id);
44
+ if (existingRecord && existingRecord.agentId !== content.agentId) {
45
+ return FORBIDDEN("forbidden: cannot write a feed memory owned by another agent");
46
+ }
47
+ }
48
+ const agentId = content.agentId;
14
49
  const body = String(content?.content ?? "");
15
50
  if (!agentId || !body) {
16
51
  return new Response(JSON.stringify({ error: "agentId and content are required" }), {
@@ -31,7 +31,7 @@ import { existsSync, readFileSync } from "node:fs";
31
31
  import { dirname, join } from "node:path";
32
32
  import { fileURLToPath } from "node:url";
33
33
  import { createRequire } from "node:module";
34
- import { resolveAgentAuth, verifyAgentRequest } from "./agent-auth.js";
34
+ import { resolveAgentAuth, verifyAgentRequest, isPrincipalDeactivated } from "./agent-auth.js";
35
35
  import { agentRecordIsAdmin } from "./agent-admin.js";
36
36
  import { WINDOW_MS, isNonceReplay, recordNonce, importEd25519Key, b64ToArrayBuffer, parseTpsEd25519Header } from "./ed25519-auth.js";
37
37
  // ─── Constants ────────────────────────────────────────────────────────────────
@@ -449,6 +449,13 @@ export class Presence extends databases.flair.Presence {
449
449
  return new Response(JSON.stringify({ error: "signature_verification_failed", detail: e?.message }), { status: 401, headers: { "Content-Type": "application/json" } });
450
450
  }
451
451
  recordNonce(headerAgentId, nonce, ts);
452
+ // Deactivation guard — same predicate as the middleware Basic branches.
453
+ // A deactivated principal must not be allowed to heartbeat, even when
454
+ // the Ed25519 signature is valid.
455
+ const agentRecord = await databases.flair.Agent.get(headerAgentId).catch(() => null);
456
+ if (isPrincipalDeactivated(agentRecord)) {
457
+ return new Response(JSON.stringify({ error: "principal_deactivated" }), { status: 401 });
458
+ }
452
459
  agentId = headerAgentId;
453
460
  }
454
461
  // ── Validate body ────────────────────────────────────────────────────────
@@ -10,7 +10,7 @@
10
10
  import { databases } from "harper";
11
11
  import { resolveAgentAuth } from "./agent-auth.js";
12
12
  import { invalidEntitiesResponse } from "./entity-vocab.js";
13
- import { makeAuthGate, makeReadScope, makeByIdReadGate, resolveAuthGate, stampAttribution, FORBIDDEN, UNAUTH, } from "./record-type-kit.js";
13
+ import { makeAuthGate, makeReadScope, makeByIdReadGate, makeScopedSearch, resolveAuthGate, stampAttribution, FORBIDDEN, UNAUTH, } from "./record-type-kit.js";
14
14
  import { RECORD_TYPES } from "./record-types.js";
15
15
  // Parameterized from RECORD_TYPES.WorkspaceState (record-types slice 2,
16
16
  // flair#520) rather than a hand-typed "owner-only" literal — the registry is
@@ -21,6 +21,7 @@ import { RECORD_TYPES } from "./record-types.js";
21
21
  // runtime consumer.
22
22
  export const workspaceReadScope = makeReadScope(RECORD_TYPES.WorkspaceState.readScope, RECORD_TYPES.WorkspaceState.ownerField);
23
23
  const workspaceByIdReadGate = makeByIdReadGate(workspaceReadScope);
24
+ const workspaceScopedSearch = makeScopedSearch(workspaceReadScope);
24
25
  // See makeAuthGate's doc (record-type-kit.ts): must be wired as a genuine
25
26
  // prototype method below, never a class-field assignment — Harper's
26
27
  // relationship-traversal RBAC path reads allowRead off the prototype.
@@ -78,21 +79,11 @@ export class WorkspaceState extends databases.flair.WorkspaceState {
78
79
  return gate.response;
79
80
  if (gate.kind === "unfiltered")
80
81
  return super.search(query);
81
- const scope = await workspaceReadScope(gate.agentId);
82
- const agentIdCondition = scope.condition;
83
- // Harper passes `query` as a request target object (pathname, id, isCollection…).
84
- // Inject the scope condition into its `.conditions` array.
85
- if (query && typeof query === "object" && !Array.isArray(query)) {
86
- const existing = query.conditions ?? [];
87
- query.conditions = Array.isArray(existing)
88
- ? [agentIdCondition, ...existing]
89
- : [agentIdCondition, existing];
90
- return super.search(query);
91
- }
92
- const conditions = Array.isArray(query) && query.length > 0
93
- ? [agentIdCondition, ...query]
94
- : [agentIdCondition];
95
- return super.search(conditions);
82
+ // Non-admin agent: scope to own records only. The scope condition is
83
+ // nested as the outermost AND block via makeScopedSearch (record-type-kit.ts)
84
+ // same correct composition MemoryCandidate.search() already applies so a
85
+ // caller-supplied `operator: "or"` cannot boolean-inject past the owner scope.
86
+ return workspaceScopedSearch(gate.agentId, query, (q) => super.search(q));
96
87
  }
97
88
  async post(content) {
98
89
  const auth = await this._auth();
@@ -25,6 +25,33 @@ import { ADMIN_ROLE, agentRecordIsAdmin } from "./agent-admin.js";
25
25
  * provisions it (ensureFlairAgentUser); they MUST agree on the name.
26
26
  */
27
27
  export const FLAIR_AGENT_USERNAME = "flair-agent";
28
+ // ─── Principal deactivation guard (authz hardening slice 1) ─────────────────
29
+ //
30
+ // ONE predicate, called from BOTH verify paths (Ed25519 in doVerify + the
31
+ // gate's own verify, and Basic/agent-auth in resolveAgentAuth) so the
32
+ // deactivation check cannot drift between them. Two independent checks would
33
+ // inevitably diverge — one path tightened, the other forgotten — and this is
34
+ // exactly the kind of control that must not.
35
+ //
36
+ // KNOWN LIMIT (slice 1b, NOT this slice): Harper's Bearer validation never
37
+ // calls Agent.get(principalId), so already-issued Bearer tokens SURVIVE
38
+ // deactivation. This slice buys "deactivation stops new authentications" and
39
+ // nothing more. Existing tokens live until expiry or explicit revocation.
40
+ // Do not read this as "deactivation now works" — it works for NEW auth only.
41
+ /**
42
+ * Single shared predicate: is this principal deactivated?
43
+ *
44
+ * Called from BOTH verify paths (Ed25519 and Basic/agent-auth) so the
45
+ * deactivation check cannot drift. A nonexistent agent is not "deactivated" —
46
+ * it will fail on other checks (missing publicKey, unknown user, etc.).
47
+ */
48
+ export function isPrincipalDeactivated(agent) {
49
+ if (agent == null)
50
+ return false;
51
+ if (agent.status === undefined)
52
+ return false;
53
+ return agent.status !== "active";
54
+ }
28
55
  // ─── Crypto + replay-guard helpers ────────────────────────────────────────────
29
56
  // WINDOW_MS, isNonceReplay/recordNonce (the ONE shared nonce store), and
30
57
  // importEd25519Key all live in ./ed25519-auth.ts — the single
@@ -103,6 +130,10 @@ async function doVerify(request) {
103
130
  const agent = await databases.flair.Agent.get(agentId).catch(() => null);
104
131
  if (!agent?.publicKey)
105
132
  return null;
133
+ // Deactivation guard — ONE predicate, same check as the Basic/agent-auth path
134
+ // in resolveAgentAuth below. A deactivated principal cannot authenticate.
135
+ if (isPrincipalDeactivated(agent))
136
+ return null;
106
137
  // Canonical signed payload: id:ts:nonce:METHOD:pathname+search (must match the
107
138
  // TPS CLI signer exactly — changing this breaks every agent's auth).
108
139
  const url = new URL(request.url, "http://localhost");
@@ -310,10 +341,18 @@ export async function resolveAgentAuth(context) {
310
341
  const credentialed = hasCredentialEvidence(c);
311
342
  const user = context?.user ?? c.user;
312
343
  if (credentialed && user?.role?.permission?.super_user === true) {
313
- return { kind: "agent", agentId: String(user.username ?? "admin"), isAdmin: true };
344
+ const agentId = String(user.username ?? "admin");
345
+ const agent = await databases.flair.Agent.get(agentId).catch(() => null);
346
+ if (isPrincipalDeactivated(agent))
347
+ return { kind: "anonymous" };
348
+ return { kind: "agent", agentId, isAdmin: true };
314
349
  }
315
350
  if (credentialed && user?.username && user.username !== FLAIR_AGENT_USERNAME) {
316
- return { kind: "agent", agentId: String(user.username), isAdmin: false };
351
+ const agentId = String(user.username);
352
+ const agent = await databases.flair.Agent.get(agentId).catch(() => null);
353
+ if (isPrincipalDeactivated(agent))
354
+ return { kind: "anonymous" };
355
+ return { kind: "agent", agentId, isAdmin: false };
317
356
  }
318
357
  // A raw request with headers is present → verify it; an HTTP request that
319
358
  // yields no agent is anonymous (NOT internal).
@@ -1,7 +1,7 @@
1
1
  import { patchRecord } from "./table-helpers.js";
2
2
  import { server, databases } from "harper";
3
3
  import { getEmbedding } from "./embeddings-provider.js";
4
- import { isAdmin, FLAIR_AGENT_USERNAME } from "./agent-auth.js";
4
+ import { isAdmin, isPrincipalDeactivated, FLAIR_AGENT_USERNAME } from "./agent-auth.js";
5
5
  import { WINDOW_MS, isNonceReplay, recordNonce, importEd25519Key, b64ToArrayBuffer, parseTpsEd25519Header } from "./ed25519-auth.js";
6
6
  import { resolveReadScope } from "./memory-read-scope.js";
7
7
  import { isForbiddenOwnerMutation, resolveGuardedRecord } from "./record-owner-guard.js";
@@ -163,15 +163,24 @@ server.http(async (request, nextLayer) => {
163
163
  // ambient elevation alone. (The root-cause gate lives in resolveAgentAuth; see
164
164
  // agent-auth.ts hasCredentialEvidence.)
165
165
  if (header && request.user?.role?.permission?.super_user === true) {
166
- request.tpsAgent = request.user.username ?? "admin";
167
- request.tpsAgentIsAdmin = true;
168
- try {
169
- request.headers.set("x-tps-agent", request.tpsAgent);
170
- if (request.headers.asObject)
171
- request.headers.asObject["x-tps-agent"] = request.tpsAgent;
166
+ const username = request.user.username ?? "admin";
167
+ // Deactivation guard — same predicate as the Ed25519 path.
168
+ // A deactivated principal must not receive a tpsAgent annotation, even
169
+ // when Harper's ambient auth already verified the credential.
170
+ const agentRecord = await databases.flair.Agent.get(username).catch(() => null);
171
+ if (!isPrincipalDeactivated(agentRecord)) {
172
+ request.tpsAgent = username;
173
+ request.tpsAgentIsAdmin = true;
174
+ try {
175
+ request.headers.set("x-tps-agent", request.tpsAgent);
176
+ if (request.headers.asObject)
177
+ request.headers.asObject["x-tps-agent"] = request.tpsAgent;
178
+ }
179
+ catch { /* frozen headers — annotation on request object still applies */ }
180
+ return nextLayer(request);
172
181
  }
173
- catch { /* frozen headers annotation on request object still applies */ }
174
- return nextLayer(request);
182
+ // Deactivated fall through. The request continues through the
183
+ // middleware chain (Basic block → Ed25519 → anonymous) without tpsAgent.
175
184
  }
176
185
  // Skip re-entry: if we already swapped auth to Basic, pass through
177
186
  if (request._tpsAuthVerified)
@@ -191,18 +200,23 @@ server.http(async (request, nextLayer) => {
191
200
  // with exact HDB_ADMIN_PASSWORD. Non-match falls through to Path 2.
192
201
  const adminPass = getAdminPass();
193
202
  if (adminPass !== null && user === "admin" && pass === adminPass) {
194
- // Mark as verified and set Harper user directly
195
- request._tpsAuthVerified = true;
196
- try {
197
- request.user = await server.getUser("admin", null, request);
203
+ // Deactivation guard same predicate, called before tpsAgent is stamped.
204
+ const agentRecord = await databases.flair.Agent.get("admin").catch(() => null);
205
+ if (!isPrincipalDeactivated(agentRecord)) {
206
+ // Mark as verified and set Harper user directly
207
+ request._tpsAuthVerified = true;
208
+ try {
209
+ request.user = await server.getUser("admin", null, request);
210
+ }
211
+ catch { /* fallback: let original Basic header pass through */ }
212
+ request.headers.set("x-tps-agent", "admin");
213
+ if (request.headers.asObject)
214
+ request.headers.asObject["x-tps-agent"] = "admin";
215
+ request.tpsAgent = "admin";
216
+ request.tpsAgentIsAdmin = true;
217
+ return nextLayer(request);
198
218
  }
199
- catch { /* fallback: let original Basic header pass through */ }
200
- request.headers.set("x-tps-agent", "admin");
201
- if (request.headers.asObject)
202
- request.headers.asObject["x-tps-agent"] = "admin";
203
- request.tpsAgent = "admin";
204
- request.tpsAgentIsAdmin = true;
205
- return nextLayer(request);
219
+ // Deactivated fall through to anonymous (end of Basic block).
206
220
  }
207
221
  // Path 2: Harper super_user check — any user with super_user:true
208
222
  let harperUser = null;
@@ -211,14 +225,19 @@ server.http(async (request, nextLayer) => {
211
225
  }
212
226
  catch { /* fall through — invalid creds, non-existent user, etc. */ }
213
227
  if (harperUser?.role?.permission?.super_user === true) {
214
- request._tpsAuthVerified = true;
215
- request.user = harperUser;
216
- request.headers.set("x-tps-agent", user);
217
- if (request.headers.asObject)
218
- request.headers.asObject["x-tps-agent"] = user;
219
- request.tpsAgent = user;
220
- request.tpsAgentIsAdmin = true;
221
- return nextLayer(request);
228
+ // Deactivation guard — same predicate, called before tpsAgent is stamped.
229
+ const agentRecord = await databases.flair.Agent.get(user).catch(() => null);
230
+ if (!isPrincipalDeactivated(agentRecord)) {
231
+ request._tpsAuthVerified = true;
232
+ request.user = harperUser;
233
+ request.headers.set("x-tps-agent", user);
234
+ if (request.headers.asObject)
235
+ request.headers.asObject["x-tps-agent"] = user;
236
+ request.tpsAgent = user;
237
+ request.tpsAgentIsAdmin = true;
238
+ return nextLayer(request);
239
+ }
240
+ // Deactivated — fall through to anonymous (end of Basic block).
222
241
  }
223
242
  // Path 3: flair_pair_initiator — restricted to /FederationPair only.
224
243
  // Bootstrap credentials (pair-bootstrap-<id>) may only be used on this
@@ -231,14 +250,19 @@ server.http(async (request, nextLayer) => {
231
250
  catch { /* fall through */ }
232
251
  if (pairUser?.role?.role === "flair_pair_initiator" &&
233
252
  pairUser?.active === true) {
234
- request._tpsAuthVerified = true;
235
- request.user = pairUser;
236
- request.headers.set("x-tps-agent", user);
237
- if (request.headers.asObject)
238
- request.headers.asObject["x-tps-agent"] = user;
239
- request.tpsAgent = user;
240
- request.tpsAgentIsAdmin = false;
241
- return nextLayer(request);
253
+ // Deactivation guard — same predicate, called before tpsAgent is stamped.
254
+ const agentRecord = await databases.flair.Agent.get(user).catch(() => null);
255
+ if (!isPrincipalDeactivated(agentRecord)) {
256
+ request._tpsAuthVerified = true;
257
+ request.user = pairUser;
258
+ request.headers.set("x-tps-agent", user);
259
+ if (request.headers.asObject)
260
+ request.headers.asObject["x-tps-agent"] = user;
261
+ request.tpsAgent = user;
262
+ request.tpsAgentIsAdmin = false;
263
+ return nextLayer(request);
264
+ }
265
+ // Deactivated — fall through to anonymous (end of Basic block).
242
266
  }
243
267
  }
244
268
  }
@@ -297,6 +321,11 @@ server.http(async (request, nextLayer) => {
297
321
  const agent = await databases.flair.Agent.get(agentId);
298
322
  if (!agent)
299
323
  return new Response(JSON.stringify({ error: "unknown_agent" }), { status: 401 });
324
+ // Deactivation guard — same predicate as the per-resource verify path in
325
+ // agent-auth.ts. A deactivated principal cannot authenticate.
326
+ if (isPrincipalDeactivated(agent)) {
327
+ return new Response(JSON.stringify({ error: "principal_deactivated" }), { status: 401 });
328
+ }
300
329
  try {
301
330
  const payload = `${agentId}:${tsRaw}:${nonce}:${request.method}:${url.pathname}${url.search}`;
302
331
  const key = await importEd25519Key(agent.publicKey);
@@ -211,6 +211,55 @@ export function makeByIdReadGate(readScope) {
211
211
  return record;
212
212
  };
213
213
  }
214
+ // ─── (c) Scoped search — makeScopedSearch ────────────────────────────────
215
+ /**
216
+ * Produces a scoped search() override that nests the caller's conditions
217
+ * inside the agent-scope condition as the OUTERMOST `and` block, so a
218
+ * caller-supplied `operator: "or"` cannot boolean-inject past the owner
219
+ * scope.
220
+ *
221
+ * This is the correct composition that MemoryCandidate.search() already
222
+ * applies (the only resource that got it right). Memory.search() and
223
+ * WorkspaceState.search() both used a flat prepend — `[agentCondition,
224
+ * ...query.conditions]` — which lets a caller-supplied `query.operator`
225
+ * survive and turn the scope condition into one OR-branch.
226
+ *
227
+ * Every table that composes this gets the correct nesting by default;
228
+ * a fifth table added later cannot accidentally reintroduce the flat-
229
+ * prepend bug.
230
+ *
231
+ * `superSearch` is a caller-supplied closure so the class's own
232
+ * `super.search()` (which cannot be referenced from outside the class
233
+ * body) stays exactly where it was.
234
+ */
235
+ export function makeScopedSearch(readScope) {
236
+ return async function scopedSearch(agentId, query, superSearch) {
237
+ const scope = await readScope(agentId);
238
+ const agentCondition = scope.condition;
239
+ // Object with a conditions array — nest caller's conditions inside the
240
+ // scope condition as the outermost AND block so a caller-supplied
241
+ // `operator: "or"` cannot boolean-inject past the owner scope.
242
+ if (query && typeof query === "object" && !Array.isArray(query)) {
243
+ if (Array.isArray(query.conditions) && query.conditions.length > 0) {
244
+ return superSearch({
245
+ ...query,
246
+ conditions: [agentCondition, { conditions: query.conditions, operator: query.operator || "and" }],
247
+ operator: "and",
248
+ });
249
+ }
250
+ // No (or empty) conditions array — just scope, preserving other query
251
+ // properties but NOT a caller-supplied operator (force "and").
252
+ const { conditions: _c, operator: _o, ...rest } = query;
253
+ return superSearch({ ...rest, conditions: [agentCondition], operator: "and" });
254
+ }
255
+ // Plain array or no query — wrap in an object with operator "and" so
256
+ // the scope condition is always the outermost AND.
257
+ const conditions = Array.isArray(query) && query.length > 0
258
+ ? [agentCondition, { conditions: query, operator: "and" }]
259
+ : [agentCondition];
260
+ return superSearch({ conditions, operator: "and" });
261
+ };
262
+ }
214
263
  export function stampAttribution(auth, content, field, mode, forbiddenMessage) {
215
264
  if (auth.kind !== "agent")
216
265
  return {}; // internal → always passthrough
package/docs/upgrade.md CHANGED
@@ -78,18 +78,23 @@ actually running:
78
78
  instead of retrying in a loop — see [Downgrade](#downgrade) for the
79
79
  restore procedure.
80
80
 
81
- ### Pre-upgrade snapshot (opt-in)
81
+ ### Pre-upgrade snapshot (opt-in for same-engine, unconditional on engine change)
82
82
 
83
83
  flair#637 added a **physical**, byte-exact snapshot of `~/.flair/data` — the whole
84
84
  directory (RocksDB files, keys, config, `admin-pass`), not just the logical records a
85
- `flair backup` JSON export covers. As of 2026-07-08 this is **opt-in**: pass
86
- `--snapshot` to `flair upgrade` to take one before the package swap. It's off by
87
- default — matching how Harper's own upgrade CLI behaves (it recommends a backup before
88
- proceeding, but never auto-tars your data directory for you) — because the
89
- tested-downgrade guarantee below already covers the failure mode a snapshot exists
90
- for, and the old opt-out default meant every upgrade paid the cost (the data dir can
91
- be 800MB+; keep-last-3 retention meant up to ~2.5GB of snapshots sitting around)
92
- whether or not you wanted it.
85
+ `flair backup` JSON export covers. As of 2026-07-08 this is **opt-in** for same-engine
86
+ upgrades: pass `--snapshot` to `flair upgrade` to take one before the package swap.
87
+ It's off by default — matching how Harper's own upgrade CLI behaves (it recommends a
88
+ backup before proceeding, but never auto-tars your data directory for you) — because
89
+ the downgrade-boot test (see below) covers same-engine downgrades, and the old opt-out
90
+ default meant every upgrade paid the cost (the data dir can be 800MB+; keep-last-3
91
+ retention meant up to ~2.5GB of snapshots sitting around) whether or not you wanted it.
92
+
93
+ **When the engine (Harper) version changes** (flair#1047), the snapshot is
94
+ **unconditional** — the tested-downgrade guarantee does not hold across engine version
95
+ boundaries, and the backwards-boot refusal + snapshot recovery path is the invariant
96
+ that applies. Opting out requires `--no-engine-snapshot` and prints what is being
97
+ given up.
93
98
 
94
99
  ```bash
95
100
  flair upgrade --snapshot
@@ -489,6 +494,20 @@ current build, writes a memory and a presence row, stops it *without* wiping the
489
494
  directory, then boots the last **npm-published** `@tpsdev-ai/flair` against that exact
490
495
  same directory and confirms it comes up healthy and can read both rows back.
491
496
 
497
+ **The guarantee is now restated (flair#1050):** there is never a silent bad outcome.
498
+ Either the old binary boots and serves the corpus correctly, **or** it refuses to start
499
+ with a message naming what wrote the store, what is running, and how to recover — and a
500
+ pre-upgrade snapshot exists to recover *from*. The first branch (clean boot) holds for
501
+ same-engine upgrades; the second (refusal + snapshot) applies when the engine version
502
+ changes, which is the case where downgrade was never ours to guarantee.
503
+
504
+ **First known engine-version break:** Harper 5.1 → 5.2 (2026-08). 5.2.0 creates the
505
+ `hdb_secret` store on first boot against an existing data directory, and the older binary
506
+ will not start against it. Harper's 5.2.0 release notes document no rollback procedure.
507
+ The backwards-boot refusal (flair#1049) catches this: the old binary refuses to start,
508
+ naming both versions and the data directory, with recovery instructions. A pre-upgrade
509
+ snapshot exists at the named path. Restoring it returns the store to a working state.
510
+
492
511
  **As observed when this suite was added (2026-07-08):** the npm-published baseline
493
512
  (0.21.0) boots cleanly against data written by a HEAD build roughly 14 commits ahead of
494
513
  it (several security-hardening and CLI-behavior changes, no Flair schema migration, and
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tpsdev-ai/flair",
3
- "version": "0.35.0",
3
+ "version": "0.36.0",
4
4
  "packageManager": "bun@1.3.10",
5
5
  "description": "Identity, memory, and soul for AI agents. Cryptographic identity (Ed25519), semantic memory with local embeddings, and persistent personality — all in a single process.",
6
6
  "type": "module",