@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.
- package/dist/cli.js +95 -22
- package/dist/deploy.js +123 -6
- package/dist/lib/auth-resolve.js +19 -0
- package/dist/lib/mcp-enable.js +107 -18
- package/dist/resources/Credential.js +55 -16
- package/dist/resources/Memory.js +43 -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/memory-visibility.js +44 -0
- package/dist/resources/record-type-kit.js +49 -0
- package/dist/version-check.js +45 -0
- package/docs/upgrade.md +31 -12
- package/package.json +7 -4
|
@@ -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
|
@@ -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
|
-
|
|
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));
|
|
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).
|
|
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);
|
|
@@ -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
|
+
}
|