@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.
- package/dist/resources/Credential.js +55 -16
- package/dist/resources/Memory.js +8 -20
- package/dist/resources/MemoryFeed.js +40 -5
- package/dist/resources/Presence.js +8 -1
- package/dist/resources/WorkspaceState.js +7 -16
- package/dist/resources/agent-auth.js +41 -2
- package/dist/resources/auth-middleware.js +65 -36
- package/dist/resources/record-type-kit.js +49 -0
- package/docs/upgrade.md +28 -9
- package/package.json +1 -1
|
@@ -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
|
|
75
|
-
|
|
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
|
|
84
|
-
if (!
|
|
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(
|
|
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 ||
|
|
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
|
|
108
|
-
|
|
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 (!
|
|
155
|
+
if (auth.kind === "agent" && !auth.isAdmin) {
|
|
117
156
|
const existing = await super.get();
|
|
118
|
-
if (existing?.principalId && existing.principalId !==
|
|
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
|
});
|
package/dist/resources/Memory.js
CHANGED
|
@@ -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
|
-
|
|
544
|
-
|
|
545
|
-
//
|
|
546
|
-
//
|
|
547
|
-
//
|
|
548
|
-
|
|
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).
|
|
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
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
//
|
|
84
|
-
//
|
|
85
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
-
|
|
174
|
-
|
|
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
|
-
//
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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
|
-
|
|
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
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
request.
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
request.
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
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
|
|
86
|
-
`--snapshot` to `flair upgrade` to take one before the package swap.
|
|
87
|
-
default — matching how Harper's own upgrade CLI behaves (it recommends a
|
|
88
|
-
proceeding, but never auto-tars your data directory for you) — because
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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.
|
|
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",
|