@tpsdev-ai/flair 0.34.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/config.yaml +17 -0
- package/dist/cli.js +136 -49
- package/dist/doctor-client.js +13 -1
- package/dist/engine-version.js +239 -0
- package/dist/resources/AdminInstance.js +7 -7
- 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/mcp-oauth.js +47 -0
- 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);
|
|
@@ -20,6 +20,46 @@
|
|
|
20
20
|
import * as harper from "harper";
|
|
21
21
|
import { mcpOAuthEnabled, mcpAuthConfig } from "./mcp-oauth-flag.js";
|
|
22
22
|
import { checkMcpRateLimit } from "./rate-limit.js";
|
|
23
|
+
/**
|
|
24
|
+
* Boot guard (flair#1021): when FLAIR_MCP_OAUTH is on, the @harperfast/oauth
|
|
25
|
+
* component MUST be declared in config.yaml. Without it the authorization
|
|
26
|
+
* server's routes never mount — discovery, authorize, token, JWKS all 404.
|
|
27
|
+
* The /mcp route would still register, but every request fails closed against
|
|
28
|
+
* a non-existent auth server. This guard fails loudly so the operator sees the
|
|
29
|
+
* error at boot instead of a silently broken deployment.
|
|
30
|
+
*
|
|
31
|
+
* The error names the actor (the operator who set the flag), the state
|
|
32
|
+
* (component absent from config.yaml), and the remedy (add the declaration).
|
|
33
|
+
* It does NOT suggest a concrete issuer — the issuer is derived at runtime
|
|
34
|
+
* from FLAIR_MCP_ISSUER / FLAIR_PUBLIC_URL and must not be hardcoded.
|
|
35
|
+
*/
|
|
36
|
+
export function assertHarperOAuthComponentDeclared(harperNs) {
|
|
37
|
+
// Harper exposes parsed config via its runtime namespace. We check for the
|
|
38
|
+
// @harperfast/oauth key; absence means the component's routes were never
|
|
39
|
+
// registered.
|
|
40
|
+
const h = harperNs ?? harper;
|
|
41
|
+
const hc = h.app?.config ?? h.config;
|
|
42
|
+
const component = hc?.get?.("@harperfast/oauth") ?? hc?.["@harperfast/oauth"];
|
|
43
|
+
if (!component) {
|
|
44
|
+
throw new Error("FLAIR_MCP_OAUTH is enabled but the @harperfast/oauth component is not declared in config.yaml. " +
|
|
45
|
+
"The authorization server cannot start without it — discovery, authorize, token, and JWKS endpoints will all 404, " +
|
|
46
|
+
"and /mcp will reject every request.\n" +
|
|
47
|
+
"Add this entry to your config.yaml:\n" +
|
|
48
|
+
"\n" +
|
|
49
|
+
' "@harperfast/oauth":\n' +
|
|
50
|
+
" providers:\n" +
|
|
51
|
+
' default:\n' +
|
|
52
|
+
' authorizationEndpoint: "/OAuthAuthorize"\n' +
|
|
53
|
+
' tokenEndpoint: "/OAuthToken"\n' +
|
|
54
|
+
' revocationEndpoint: "/OAuthRevoke"\n' +
|
|
55
|
+
' registrationEndpoint: "/OAuthRegister"\n' +
|
|
56
|
+
' jwksUri: "/.well-known/jwks.json"\n' +
|
|
57
|
+
' discoveryEndpoint: "/.well-known/oauth-authorization-server"\n' +
|
|
58
|
+
"\n" +
|
|
59
|
+
"Then set FLAIR_MCP_ISSUER (or FLAIR_PUBLIC_URL) to your instance's public origin " +
|
|
60
|
+
"and add the corresponding mcp.* block to the component config.");
|
|
61
|
+
}
|
|
62
|
+
}
|
|
23
63
|
/**
|
|
24
64
|
* Initial value: no route has been registered yet, which is literally true until
|
|
25
65
|
* `registerMcpOAuthRoute` runs — a reader between module load and registration
|
|
@@ -86,6 +126,13 @@ export async function registerMcpOAuthRoute(deps = {}) {
|
|
|
86
126
|
reason: "Set FLAIR_MCP_OAUTH=1 (and an issuer) to serve MCP over HTTP.",
|
|
87
127
|
});
|
|
88
128
|
}
|
|
129
|
+
// Boot guard (flair#1021): fail loudly if the operator enabled the flag but
|
|
130
|
+
// the @harperfast/oauth component is absent from config.yaml. Without it the
|
|
131
|
+
// authorization server's routes never mount — discovery, authorize, token,
|
|
132
|
+
// JWKS all 404 — and the /mcp guard has nothing to validate against.
|
|
133
|
+
if (!deps.skipComponentGuard) {
|
|
134
|
+
assertHarperOAuthComponentDeclared(deps.harper);
|
|
135
|
+
}
|
|
89
136
|
const config = mcpAuthConfig();
|
|
90
137
|
if (!config) {
|
|
91
138
|
// Flag on but issuer unset → we cannot safely pin iss/aud. Do NOT mount an
|
|
@@ -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
|