@tpsdev-ai/flair 0.35.0 → 0.37.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
  });
@@ -6,8 +6,9 @@ import { getEmbedding, getModelId } from "./embeddings-provider.js";
6
6
  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
+ import { assertValidVisibility } from "./memory-visibility.js";
9
10
  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";
11
+ import { buildProvenance, makeAuthGate, makeReadScope, makeByIdReadGate, makeScopedSearch, resolveAuthGate, stampAttribution, FORBIDDEN, UNAUTH, } from "./record-type-kit.js";
11
12
  import { RECORD_TYPES } from "./record-types.js";
12
13
  import { attachTrust } from "./trust-block.js";
13
14
  import { recordCitations } from "./usage-recording.js";
@@ -53,6 +54,7 @@ function wantsTrust(target, opts) {
53
54
  */
54
55
  export const memoryReadScope = makeReadScope(RECORD_TYPES.Memory.readScope, RECORD_TYPES.Memory.ownerField);
55
56
  const memoryByIdReadGate = makeByIdReadGate(memoryReadScope);
57
+ const memoryScopedSearch = makeScopedSearch(memoryReadScope);
56
58
  // See makeAuthGate's doc (record-type-kit.ts): must be wired as a genuine
57
59
  // prototype method below, never a class-field assignment — Harper's
58
60
  // relationship-traversal RBAC path reads allowRead off the prototype.
@@ -540,25 +542,12 @@ export class Memory extends databases.flair.Memory {
540
542
  // from RECORD_TYPES.Memory — see this file's header — delegating
541
543
  // "open-within-org" to memory-read-scope.ts's resolveReadScope()
542
544
  // 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));
545
+ //
546
+ // The scope condition is nested as the outermost AND block via
547
+ // makeScopedSearch (record-type-kit.ts) same correct composition
548
+ // MemoryCandidate.search() already applies so a caller-supplied
549
+ // `operator: "or"` cannot boolean-inject past the owner scope.
550
+ return memoryScopedSearch(gate.agentId, query, (q) => withDetachedTxn(ctx, () => super.search(q)));
562
551
  }
563
552
  async post(content, context) {
564
553
  // Rate limiting — use authenticated agent ID, not client-supplied body field
@@ -612,6 +601,23 @@ export class Memory extends databases.flair.Memory {
612
601
  // existing record's visibility" concern here. Explicit visibility on the
613
602
  // write ALWAYS overrides; only stamp the default when the caller left it
614
603
  // unset. permanent|persistent → shared; standard|ephemeral|absent → private.
604
+ // ── flair#1009: refuse an unrecognised visibility BEFORE defaulting ──
605
+ // isPrivateVisibility() is an exact match on "private", so on the READ
606
+ // side every other value (a typo, a wrong case, a retired tier like
607
+ // "office") resolves to non-private and is readable by every agent on the
608
+ // instance. #1006 closed that at the CLI flag and the MCP tool argument;
609
+ // REST and the in-process API reach here without passing either.
610
+ //
611
+ // Refusing, rather than dropping the key: dropping it falls through to the
612
+ // durability-keyed default below, which for a permanent or persistent
613
+ // write is "shared" - the same widening, arrived at silently. A misspelled
614
+ // argument must not decide who can read a memory.
615
+ {
616
+ const visibilityError = assertValidVisibility(content.visibility);
617
+ if (visibilityError) {
618
+ return new Response(JSON.stringify({ error: "invalid_visibility", message: visibilityError }), { status: 400, headers: { "content-type": "application/json" } });
619
+ }
620
+ }
615
621
  if (content.visibility === undefined || content.visibility === null) {
616
622
  content.visibility = defaultVisibilityForDurability(content.durability);
617
623
  }
@@ -796,6 +802,23 @@ export class Memory extends databases.flair.Memory {
796
802
  // Explicit visibility on the write ALWAYS overrides; only stamp the
797
803
  // default when the caller left it unset AND this is a fresh record.
798
804
  // permanent|persistent → shared; standard|ephemeral|absent → private.
805
+ // ── flair#1009: refuse an unrecognised visibility BEFORE defaulting ──
806
+ // isPrivateVisibility() is an exact match on "private", so on the READ
807
+ // side every other value (a typo, a wrong case, a retired tier like
808
+ // "office") resolves to non-private and is readable by every agent on the
809
+ // instance. #1006 closed that at the CLI flag and the MCP tool argument;
810
+ // REST and the in-process API reach here without passing either.
811
+ //
812
+ // Refusing, rather than dropping the key: dropping it falls through to the
813
+ // durability-keyed default below, which for a permanent or persistent
814
+ // write is "shared" - the same widening, arrived at silently. A misspelled
815
+ // argument must not decide who can read a memory.
816
+ {
817
+ const visibilityError = assertValidVisibility(content.visibility);
818
+ if (visibilityError) {
819
+ return new Response(JSON.stringify({ error: "invalid_visibility", message: visibilityError }), { status: 400, headers: { "content-type": "application/json" } });
820
+ }
821
+ }
799
822
  if (!preExisting && (content.visibility === undefined || content.visibility === null)) {
800
823
  content.visibility = defaultVisibilityForDurability(content.durability);
801
824
  }
@@ -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);
@@ -29,9 +29,53 @@
29
29
  * missing/null/anything-other-than-'private' all count as non-private.
30
30
  */
31
31
  export const PRIVATE_VISIBILITY = "private";
32
+ export const SHARED_VISIBILITY = "shared";
33
+ /** The only values a WRITER may supply. Deliberately not derived from the read
34
+ * predicate below — see the asymmetry note on assertValidVisibility. */
35
+ export const WRITABLE_VISIBILITIES = [PRIVATE_VISIBILITY, SHARED_VISIBILITY];
32
36
  /** True only when visibility is the literal string "private". Null, undefined,
33
37
  * "shared", or any other value are all non-private (see migration invariant
34
38
  * above) — never invert this to an allowlist of "shared". */
35
39
  export function isPrivateVisibility(visibility) {
36
40
  return visibility === PRIVATE_VISIBILITY;
37
41
  }
42
+ /**
43
+ * Reject a visibility a writer supplied that is not one of the two valid values.
44
+ * Returns an error message, or null when the value is acceptable.
45
+ *
46
+ * ── Why this is NOT the inverse of isPrivateVisibility (flair#1009) ──────────
47
+ *
48
+ * The read predicate above must stay "is this exactly 'private'", because a row
49
+ * written before the field existed has no visibility and must keep reading as it
50
+ * always did. That is a MIGRATION rule about stored data, and it is correct.
51
+ *
52
+ * The consequence is that on the read side every unrecognised value — a typo, a
53
+ * wrong case, a retired tier — resolves to non-private and is readable by every
54
+ * agent on the instance. #1006 closed that at the two writer-intent boundaries
55
+ * (the CLI flag, the MCP tool argument); REST and the in-process API still
56
+ * accepted anything, so `PUT /Memory/<id> {"visibility":"prvate"}` wrote a
57
+ * memory the caller believed was owner-only and everyone could read.
58
+ *
59
+ * So the two directions need different rules, and conflating them breaks one or
60
+ * the other:
61
+ * - READING an unknown value must be permissive, or old rows break.
62
+ * - WRITING an unknown value must be refused, or a typo silently widens who
63
+ * can read a memory.
64
+ *
65
+ * Refusing is also the only safe option at write time. Silently dropping the key
66
+ * would fall back to the durability-keyed default, which for a permanent or
67
+ * persistent write is `shared` — the same wrong outcome, arrived at quietly.
68
+ *
69
+ * `undefined`/`null` are accepted: omitting the field is how a caller asks for
70
+ * the durability-keyed default, and that is a documented, intentional path.
71
+ */
72
+ export function assertValidVisibility(visibility) {
73
+ if (visibility === undefined || visibility === null)
74
+ return null;
75
+ if (typeof visibility === "string" && WRITABLE_VISIBILITIES.includes(visibility)) {
76
+ return null;
77
+ }
78
+ return (`visibility must be ${WRITABLE_VISIBILITIES.map((v) => `"${v}"`).join(" or ")} ` +
79
+ `(got: ${JSON.stringify(visibility)}). Omit it to use the durability-keyed default: ` +
80
+ `permanent/persistent -> shared, standard/ephemeral -> private.`);
81
+ }