@tpsdev-ai/flair 0.10.1 → 0.12.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 +318 -34
- package/dist/resources/Agent.js +20 -7
- package/dist/resources/AgentCard.js +6 -0
- package/dist/resources/AgentSeed.js +7 -1
- package/dist/resources/Credential.js +24 -13
- package/dist/resources/IngestEvents.js +7 -0
- package/dist/resources/Instance.js +13 -0
- package/dist/resources/Integration.js +64 -0
- package/dist/resources/Memory.js +53 -8
- package/dist/resources/MemoryBootstrap.js +8 -0
- package/dist/resources/MemoryConsolidate.js +7 -1
- package/dist/resources/MemoryFeed.js +8 -0
- package/dist/resources/MemoryGrant.js +79 -0
- package/dist/resources/MemoryMaintenance.js +1 -1
- package/dist/resources/MemoryReflect.js +7 -1
- package/dist/resources/MemoryReindex.js +7 -1
- package/dist/resources/OAuthClient.js +15 -0
- package/dist/resources/ObsAgentSnapshot.js +13 -0
- package/dist/resources/ObsEventFeed.js +13 -0
- package/dist/resources/ObsOffice.js +19 -0
- package/dist/resources/OrgEvent.js +37 -21
- package/dist/resources/OrgEventCatchup.js +8 -0
- package/dist/resources/OrgEventMaintenance.js +7 -0
- package/dist/resources/PairingToken.js +14 -0
- package/dist/resources/Peer.js +15 -0
- package/dist/resources/Presence.js +300 -0
- package/dist/resources/Relationship.js +13 -7
- package/dist/resources/SemanticSearch.js +25 -8
- package/dist/resources/Soul.js +18 -9
- package/dist/resources/SoulFeed.js +7 -0
- package/dist/resources/WorkspaceLatest.js +7 -0
- package/dist/resources/WorkspaceState.js +38 -28
- package/dist/resources/XAA.js +8 -0
- package/dist/resources/agent-auth.js +209 -0
- package/dist/resources/auth-middleware.js +89 -57
- package/package.json +1 -1
- package/schemas/schema.graphql +9 -0
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* POST /Presence — agent heartbeat writes its own presence.
|
|
3
|
+
* GET /Presence — public-safe presence roster for The Office Space.
|
|
4
|
+
*
|
|
5
|
+
* Extends the auto-generated Presence table resource (from schema.graphql).
|
|
6
|
+
* Overrides get() for public-safe roster and post() for Ed25519-authed
|
|
7
|
+
* heartbeat writes.
|
|
8
|
+
*
|
|
9
|
+
* Auth:
|
|
10
|
+
* GET — public (returns only allowlisted fields; safe for public renderer).
|
|
11
|
+
* POST — Ed25519 agent credential (TPS-Ed25519 header). Agent writes only its
|
|
12
|
+
* own record; cross-agent writes are rejected (403).
|
|
13
|
+
*
|
|
14
|
+
* Security (Sherlock):
|
|
15
|
+
* - Write: per-agent Ed25519 auth. Cross-agent → 403.
|
|
16
|
+
* - Read: field-allowlisted to public-safe set. No secrets, no admin data.
|
|
17
|
+
* - currentTask is agent-authored free text → cap length, escape on render.
|
|
18
|
+
*/
|
|
19
|
+
import { databases } from "@harperfast/harper";
|
|
20
|
+
import { resolveAgentAuth } from "./agent-auth.js";
|
|
21
|
+
// ─── Constants ────────────────────────────────────────────────────────────────
|
|
22
|
+
const WINDOW_MS = 30_000;
|
|
23
|
+
const CURRENT_TASK_MAX_LENGTH = 200;
|
|
24
|
+
const VALID_ACTIVITIES = new Set(["coding", "reviewing", "planning", "idle"]);
|
|
25
|
+
function idleThresholdMs() {
|
|
26
|
+
const env = process.env.PRESENCE_IDLE_THRESHOLD_MS;
|
|
27
|
+
return env ? Number(env) || 90_000 : 90_000;
|
|
28
|
+
}
|
|
29
|
+
function offlineThresholdMs() {
|
|
30
|
+
const env = process.env.PRESENCE_OFFLINE_THRESHOLD_MS;
|
|
31
|
+
return env ? Number(env) || 600_000 : 600_000;
|
|
32
|
+
}
|
|
33
|
+
// ─── Nonce replay protection ──────────────────────────────────────────────────
|
|
34
|
+
const nonceSeen = new Map();
|
|
35
|
+
function pruneNonces() {
|
|
36
|
+
const now = Date.now();
|
|
37
|
+
for (const [k, ts] of nonceSeen.entries()) {
|
|
38
|
+
if (now - ts > WINDOW_MS)
|
|
39
|
+
nonceSeen.delete(k);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
// ─── Crypto helpers ───────────────────────────────────────────────────────────
|
|
43
|
+
function b64ToArrayBuffer(b64) {
|
|
44
|
+
const std = b64.replace(/-/g, "+").replace(/_/g, "/");
|
|
45
|
+
const bin = atob(std);
|
|
46
|
+
const buf = new ArrayBuffer(bin.length);
|
|
47
|
+
const view = new Uint8Array(buf);
|
|
48
|
+
for (let i = 0; i < bin.length; i++)
|
|
49
|
+
view[i] = bin.charCodeAt(i);
|
|
50
|
+
return buf;
|
|
51
|
+
}
|
|
52
|
+
const keyCache = new Map();
|
|
53
|
+
async function importEd25519Key(publicKeyStr) {
|
|
54
|
+
if (keyCache.has(publicKeyStr))
|
|
55
|
+
return keyCache.get(publicKeyStr);
|
|
56
|
+
let raw;
|
|
57
|
+
if (/^[0-9a-f]{64}$/i.test(publicKeyStr)) {
|
|
58
|
+
const bytes = new Uint8Array(32);
|
|
59
|
+
for (let i = 0; i < 32; i++)
|
|
60
|
+
bytes[i] = parseInt(publicKeyStr.slice(i * 2, i * 2 + 2), 16);
|
|
61
|
+
raw = bytes.buffer;
|
|
62
|
+
}
|
|
63
|
+
else {
|
|
64
|
+
raw = b64ToArrayBuffer(publicKeyStr);
|
|
65
|
+
}
|
|
66
|
+
const key = await crypto.subtle.importKey("raw", raw, { name: "Ed25519" }, false, ["verify"]);
|
|
67
|
+
keyCache.set(publicKeyStr, key);
|
|
68
|
+
return key;
|
|
69
|
+
}
|
|
70
|
+
// ─── Status derivation (pure — exported for unit testing) ─────────────────────
|
|
71
|
+
export function derivePresenceStatus(now, lastHeartbeatAt, idleMs, offlineMs) {
|
|
72
|
+
const idle = idleMs ?? idleThresholdMs();
|
|
73
|
+
const offline = offlineMs ?? offlineThresholdMs();
|
|
74
|
+
if (lastHeartbeatAt == null || !Number.isFinite(lastHeartbeatAt))
|
|
75
|
+
return "offline";
|
|
76
|
+
const elapsed = now - lastHeartbeatAt;
|
|
77
|
+
if (elapsed < 0)
|
|
78
|
+
return "active";
|
|
79
|
+
if (elapsed < idle)
|
|
80
|
+
return "active";
|
|
81
|
+
if (elapsed < offline)
|
|
82
|
+
return "idle";
|
|
83
|
+
return "offline";
|
|
84
|
+
}
|
|
85
|
+
// ─── Public-safe field allowlist ──────────────────────────────────────────────
|
|
86
|
+
const ROSTER_ALLOWLIST = new Set([
|
|
87
|
+
"id",
|
|
88
|
+
"displayName",
|
|
89
|
+
"role",
|
|
90
|
+
"runtime",
|
|
91
|
+
"activity",
|
|
92
|
+
"presenceStatus",
|
|
93
|
+
"currentTask",
|
|
94
|
+
"lastHeartbeatAt",
|
|
95
|
+
]);
|
|
96
|
+
function sanitizeCurrentTask(task) {
|
|
97
|
+
if (typeof task !== "string")
|
|
98
|
+
return null;
|
|
99
|
+
const trimmed = task.trim();
|
|
100
|
+
if (trimmed.length === 0)
|
|
101
|
+
return null;
|
|
102
|
+
return trimmed.slice(0, CURRENT_TASK_MAX_LENGTH);
|
|
103
|
+
}
|
|
104
|
+
function pickAllowlisted(record) {
|
|
105
|
+
const out = {};
|
|
106
|
+
for (const key of Object.keys(record)) {
|
|
107
|
+
if (ROSTER_ALLOWLIST.has(key))
|
|
108
|
+
out[key] = record[key];
|
|
109
|
+
}
|
|
110
|
+
return out;
|
|
111
|
+
}
|
|
112
|
+
// ─── Resource ─────────────────────────────────────────────────────────────────
|
|
113
|
+
/**
|
|
114
|
+
* Extends the auto-generated Presence table resource (from schema.graphql) so
|
|
115
|
+
* Harper resolves the /Presence path without conflict. Overrides get() for the
|
|
116
|
+
* public-safe roster view and post() for Ed25519-authed heartbeat writes.
|
|
117
|
+
*/
|
|
118
|
+
export class Presence extends databases.flair.Presence {
|
|
119
|
+
/** Bypass Harper's role gate for GET (public-safe data only). */
|
|
120
|
+
allowRead() {
|
|
121
|
+
return true;
|
|
122
|
+
}
|
|
123
|
+
/** Bypass Harper's role gate for POST (Ed25519 auth handled internally). */
|
|
124
|
+
allowCreate() {
|
|
125
|
+
return true;
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* GET /Presence — public-safe presence roster.
|
|
129
|
+
*
|
|
130
|
+
* Joins Presence records with Agent metadata and derives presenceStatus.
|
|
131
|
+
* Only allowlisted fields are returned (no secrets, no admin data).
|
|
132
|
+
*/
|
|
133
|
+
async get() {
|
|
134
|
+
const now = Date.now();
|
|
135
|
+
const idleThreshold = idleThresholdMs();
|
|
136
|
+
const offlineThreshold = offlineThresholdMs();
|
|
137
|
+
const results = [];
|
|
138
|
+
try {
|
|
139
|
+
const presenceRows = databases.flair.Presence.search();
|
|
140
|
+
for await (const row of presenceRows) {
|
|
141
|
+
const agentId = row?.agentId;
|
|
142
|
+
if (!agentId)
|
|
143
|
+
continue;
|
|
144
|
+
let agent = null;
|
|
145
|
+
try {
|
|
146
|
+
agent = await databases.flair.Agent.get(agentId);
|
|
147
|
+
}
|
|
148
|
+
catch { /* agent may not exist or be deactivated */ }
|
|
149
|
+
const entry = {
|
|
150
|
+
id: agentId,
|
|
151
|
+
displayName: agent?.displayName ?? agent?.name ?? agentId,
|
|
152
|
+
role: agent?.role ?? "agent",
|
|
153
|
+
runtime: agent?.runtime ?? null,
|
|
154
|
+
activity: row?.activity ?? "idle",
|
|
155
|
+
presenceStatus: derivePresenceStatus(now, typeof row?.lastHeartbeatAt === "number"
|
|
156
|
+
? row.lastHeartbeatAt
|
|
157
|
+
: Number(row?.lastHeartbeatAt ?? 0), idleThreshold, offlineThreshold),
|
|
158
|
+
currentTask: sanitizeCurrentTask(row?.currentTask),
|
|
159
|
+
lastHeartbeatAt: typeof row?.lastHeartbeatAt === "number"
|
|
160
|
+
? row.lastHeartbeatAt
|
|
161
|
+
: Number(row?.lastHeartbeatAt ?? 0),
|
|
162
|
+
};
|
|
163
|
+
results.push(pickAllowlisted(entry));
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
catch (err) {
|
|
167
|
+
return new Response(JSON.stringify({ error: "presence_query_failed", detail: err?.message }), { status: 500, headers: { "Content-Type": "application/json" } });
|
|
168
|
+
}
|
|
169
|
+
return results;
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* POST /Presence — agent heartbeat.
|
|
173
|
+
*
|
|
174
|
+
* Auth: TPS-Ed25519 header required. agentId from signature, NOT from body.
|
|
175
|
+
* An agent may update ONLY its own presence; cross-agent writes → 403.
|
|
176
|
+
*
|
|
177
|
+
* Bypasses Harper's default table post handler — writes via databases.flair.Presence
|
|
178
|
+
* directly so we control auth flow end-to-end.
|
|
179
|
+
*/
|
|
180
|
+
async post(content, context) {
|
|
181
|
+
const ctx = this.getContext?.();
|
|
182
|
+
const request = ctx?.request ?? ctx;
|
|
183
|
+
// ── Parse Ed25519 auth header ────────────────────────────────────────────
|
|
184
|
+
const authHeader = request?.headers?.get?.("authorization") ??
|
|
185
|
+
request?.headers?.asObject?.authorization ??
|
|
186
|
+
"";
|
|
187
|
+
// If the middleware already verified and set tpsAgent, trust it
|
|
188
|
+
const middlewareAgent = request?.tpsAgent;
|
|
189
|
+
let agentId;
|
|
190
|
+
if (middlewareAgent) {
|
|
191
|
+
agentId = middlewareAgent;
|
|
192
|
+
}
|
|
193
|
+
else {
|
|
194
|
+
const m = authHeader.match(/^TPS-Ed25519\s+([^:]+):(\d+):([^:]+):(.+)$/);
|
|
195
|
+
if (!m) {
|
|
196
|
+
return new Response(JSON.stringify({ error: "Ed25519 agent auth required for heartbeat" }), { status: 401, headers: { "Content-Type": "application/json" } });
|
|
197
|
+
}
|
|
198
|
+
const [, headerAgentId, tsRaw, nonce, sigB64] = m;
|
|
199
|
+
const ts = Number(tsRaw);
|
|
200
|
+
const now = Date.now();
|
|
201
|
+
if (!Number.isFinite(ts) || Math.abs(now - ts) > WINDOW_MS) {
|
|
202
|
+
return new Response(JSON.stringify({ error: "timestamp_out_of_window" }), { status: 401, headers: { "Content-Type": "application/json" } });
|
|
203
|
+
}
|
|
204
|
+
pruneNonces();
|
|
205
|
+
const nonceKey = `${headerAgentId}:${nonce}`;
|
|
206
|
+
if (nonceSeen.has(nonceKey)) {
|
|
207
|
+
return new Response(JSON.stringify({ error: "nonce_replay_detected" }), { status: 401, headers: { "Content-Type": "application/json" } });
|
|
208
|
+
}
|
|
209
|
+
const agent = await databases.flair.Agent.get(headerAgentId).catch(() => null);
|
|
210
|
+
if (!agent) {
|
|
211
|
+
return new Response(JSON.stringify({ error: "unknown_agent" }), { status: 401, headers: { "Content-Type": "application/json" } });
|
|
212
|
+
}
|
|
213
|
+
try {
|
|
214
|
+
// request.url is just the path portion (e.g. "/Presence")
|
|
215
|
+
const pathname = (request.url ?? "/Presence").split("?")[0];
|
|
216
|
+
const payload = `${headerAgentId}:${tsRaw}:${nonce}:POST:${pathname}`;
|
|
217
|
+
const key = await importEd25519Key(agent.publicKey);
|
|
218
|
+
const sigBuf = b64ToArrayBuffer(sigB64);
|
|
219
|
+
const payloadBuf = new TextEncoder().encode(payload);
|
|
220
|
+
const ok = await crypto.subtle.verify({ name: "Ed25519" }, key, sigBuf, payloadBuf);
|
|
221
|
+
if (!ok) {
|
|
222
|
+
return new Response(JSON.stringify({ error: "invalid_signature" }), { status: 401, headers: { "Content-Type": "application/json" } });
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
catch (e) {
|
|
226
|
+
return new Response(JSON.stringify({ error: "signature_verification_failed", detail: e?.message }), { status: 401, headers: { "Content-Type": "application/json" } });
|
|
227
|
+
}
|
|
228
|
+
nonceSeen.set(nonceKey, ts);
|
|
229
|
+
agentId = headerAgentId;
|
|
230
|
+
}
|
|
231
|
+
// ── Validate body ────────────────────────────────────────────────────────
|
|
232
|
+
const { currentTask, activity } = content || {};
|
|
233
|
+
if (activity !== undefined && !VALID_ACTIVITIES.has(activity)) {
|
|
234
|
+
return new Response(JSON.stringify({
|
|
235
|
+
error: "invalid_activity",
|
|
236
|
+
detail: `activity must be one of: ${[...VALID_ACTIVITIES].join(", ")}`,
|
|
237
|
+
}), { status: 400, headers: { "Content-Type": "application/json" } });
|
|
238
|
+
}
|
|
239
|
+
// ── Write presence ───────────────────────────────────────────────────────
|
|
240
|
+
const now = Date.now();
|
|
241
|
+
try {
|
|
242
|
+
let existing = null;
|
|
243
|
+
try {
|
|
244
|
+
existing = await databases.flair.Presence.get(agentId);
|
|
245
|
+
}
|
|
246
|
+
catch { /* first heartbeat */ }
|
|
247
|
+
const record = {
|
|
248
|
+
agentId,
|
|
249
|
+
lastHeartbeatAt: now,
|
|
250
|
+
currentTask: sanitizeCurrentTask(currentTask),
|
|
251
|
+
activity: activity ?? (existing?.activity ?? "idle"),
|
|
252
|
+
};
|
|
253
|
+
if (existing) {
|
|
254
|
+
const merged = { ...existing, ...record };
|
|
255
|
+
await databases.flair.Presence.put(merged);
|
|
256
|
+
}
|
|
257
|
+
else {
|
|
258
|
+
await databases.flair.Presence.put(record);
|
|
259
|
+
}
|
|
260
|
+
return {
|
|
261
|
+
ok: true,
|
|
262
|
+
agentId,
|
|
263
|
+
lastHeartbeatAt: now,
|
|
264
|
+
presenceStatus: "active",
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
catch (e) {
|
|
268
|
+
return new Response(JSON.stringify({ error: "presence_write_failed", detail: e?.message }), { status: 500, headers: { "Content-Type": "application/json" } });
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
/**
|
|
272
|
+
* PUT/DELETE are not part of the agent heartbeat contract (writes go through
|
|
273
|
+
* POST, which carries its own Ed25519 auth). GET is intentionally public and
|
|
274
|
+
* allowCreate=true lets POST self-auth — but those bypasses must NOT extend to
|
|
275
|
+
* PUT/DELETE. The non-rejecting gate would otherwise let anonymous PUT/DELETE
|
|
276
|
+
* straight to Harper's default handler (the leak this closes). Require a verified
|
|
277
|
+
* non-anonymous principal, scoped to the agent's own record (Presence is keyed
|
|
278
|
+
* by agentId).
|
|
279
|
+
*/
|
|
280
|
+
async put(content, context) {
|
|
281
|
+
const auth = await resolveAgentAuth(this.getContext?.());
|
|
282
|
+
if (auth.kind === "anonymous") {
|
|
283
|
+
return new Response(JSON.stringify({ error: "authentication required" }), { status: 401, headers: { "Content-Type": "application/json" } });
|
|
284
|
+
}
|
|
285
|
+
if (auth.kind === "agent" && !auth.isAdmin && content?.agentId && content.agentId !== auth.agentId) {
|
|
286
|
+
return new Response(JSON.stringify({ error: "forbidden: cannot write presence for another agent" }), { status: 403, headers: { "Content-Type": "application/json" } });
|
|
287
|
+
}
|
|
288
|
+
return super.put(content, context);
|
|
289
|
+
}
|
|
290
|
+
async delete(id) {
|
|
291
|
+
const auth = await resolveAgentAuth(this.getContext?.());
|
|
292
|
+
if (auth.kind === "anonymous") {
|
|
293
|
+
return new Response(JSON.stringify({ error: "authentication required" }), { status: 401, headers: { "Content-Type": "application/json" } });
|
|
294
|
+
}
|
|
295
|
+
if (auth.kind === "agent" && !auth.isAdmin && id != null && String(id) !== auth.agentId) {
|
|
296
|
+
return new Response(JSON.stringify({ error: "forbidden: cannot delete presence for another agent" }), { status: 403, headers: { "Content-Type": "application/json" } });
|
|
297
|
+
}
|
|
298
|
+
return super.delete(id);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { databases } from "@harperfast/harper";
|
|
2
|
+
import { resolveAgentAuth } from "./agent-auth.js";
|
|
2
3
|
import { checkRateLimit, rateLimitResponse } from "./rate-limiter.js";
|
|
3
4
|
/**
|
|
4
5
|
* Relationship resource — entity-to-entity relationships with temporal validity.
|
|
@@ -13,15 +14,20 @@ import { checkRateLimit, rateLimitResponse } from "./rate-limiter.js";
|
|
|
13
14
|
*/
|
|
14
15
|
export class Relationship extends databases.flair.Relationship {
|
|
15
16
|
async search(query) {
|
|
16
|
-
const
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
17
|
+
const auth = await resolveAgentAuth(this.getContext?.());
|
|
18
|
+
// Anonymous HTTP must NOT read relationships (previously `!authAgent` was
|
|
19
|
+
// treated as unfiltered — the anonymous-read leak).
|
|
20
|
+
if (auth.kind === "anonymous") {
|
|
21
|
+
return new Response(JSON.stringify({ error: "authentication required" }), {
|
|
22
|
+
status: 401, headers: { "content-type": "application/json" },
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
// Trusted internal call or admin agent → unfiltered.
|
|
26
|
+
if (auth.kind === "internal" || (auth.kind === "agent" && auth.isAdmin)) {
|
|
21
27
|
return super.search(query);
|
|
22
28
|
}
|
|
23
|
-
// Non-admin: scope to own relationships
|
|
24
|
-
const agentCondition = { attribute: "agentId", comparator: "equals", value:
|
|
29
|
+
// Non-admin agent: scope to own relationships.
|
|
30
|
+
const agentCondition = { attribute: "agentId", comparator: "equals", value: auth.agentId };
|
|
25
31
|
if (!query?.conditions) {
|
|
26
32
|
return super.search({ conditions: [agentCondition], ...(query || {}) });
|
|
27
33
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Resource, databases } from "@harperfast/harper";
|
|
2
|
+
import { resolveAgentAuth, allowVerified } from "./agent-auth.js";
|
|
2
3
|
import { getEmbedding, getMode } from "./embeddings-provider.js";
|
|
3
4
|
import { patchRecord, withDetachedTxn } from "./table-helpers.js";
|
|
4
5
|
import { checkRateLimit, rateLimitResponse } from "./rate-limiter.js";
|
|
@@ -45,6 +46,15 @@ function distanceToSimilarity(distance) {
|
|
|
45
46
|
// so composite re-ranking has enough headroom to reorder results.
|
|
46
47
|
const CANDIDATE_MULTIPLIER = 5;
|
|
47
48
|
export class SemanticSearch extends Resource {
|
|
49
|
+
// Self-authorize via the Ed25519 agent verify instead of relying on the auth
|
|
50
|
+
// gate's admin super_user elevation (removed in the auth reshape). Any
|
|
51
|
+
// cryptographically-verified agent may search; per-agent RESULT scoping is
|
|
52
|
+
// enforced in post() below (an agent only sees its own + office-visible +
|
|
53
|
+
// granted memories). Without this, Harper's default denies the POST for the
|
|
54
|
+
// least-privilege flair_agent role (AccessViolation 403).
|
|
55
|
+
async allowCreate() {
|
|
56
|
+
return allowVerified(this.getContext?.());
|
|
57
|
+
}
|
|
48
58
|
async post(data) {
|
|
49
59
|
const { agentId: bodyAgentId, q, queryEmbedding, tag, subject, subjects, limit = 10, includeSuperseded = false, scoring = "composite", minScore = 0, since, asOf } = data || {};
|
|
50
60
|
// Authenticated identity lives on the Harper Resource context (getContext().request).
|
|
@@ -52,11 +62,18 @@ export class SemanticSearch extends Resource {
|
|
|
52
62
|
// silently returned undefined and the defense-in-depth scope check below
|
|
53
63
|
// was bypassed, letting a non-admin agent read another agent's memories
|
|
54
64
|
// by putting the victim's id in the body.
|
|
55
|
-
const
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
65
|
+
const auth = await resolveAgentAuth(this.getContext?.());
|
|
66
|
+
// Anonymous HTTP must NOT search. Previously the no-auth path fell through to
|
|
67
|
+
// honoring the body-supplied agentId (line below), so an unauthenticated
|
|
68
|
+
// caller could read any agent's memories by putting that id in the body.
|
|
69
|
+
if (auth.kind === "anonymous") {
|
|
70
|
+
return new Response(JSON.stringify({ error: "authentication required" }), {
|
|
71
|
+
status: 401, headers: { "Content-Type": "application/json" },
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
const authenticatedAgent = auth.kind === "agent" ? auth.agentId : undefined;
|
|
75
|
+
const callerIsAdmin = auth.kind === "agent" && auth.isAdmin;
|
|
76
|
+
// Rate limiting — use authenticated agent ID (internal calls have none).
|
|
60
77
|
if (authenticatedAgent) {
|
|
61
78
|
const bucket = q && !queryEmbedding ? "embedding" : "general";
|
|
62
79
|
const rl = checkRateLimit(authenticatedAgent, bucket);
|
|
@@ -70,14 +87,14 @@ export class SemanticSearch extends Resource {
|
|
|
70
87
|
: null;
|
|
71
88
|
// Enforce agentId = authenticated agent for non-admins. A mismatched body
|
|
72
89
|
// agentId is a cross-agent read attempt — reject outright. Admins can query
|
|
73
|
-
// any agentId (
|
|
90
|
+
// any agentId (bootstrap / consolidation).
|
|
74
91
|
if (authenticatedAgent && !callerIsAdmin && bodyAgentId && bodyAgentId !== authenticatedAgent) {
|
|
75
92
|
return new Response(JSON.stringify({
|
|
76
93
|
error: "forbidden: agentId must match authenticated agent",
|
|
77
94
|
}), { status: 403, headers: { "Content-Type": "application/json" } });
|
|
78
95
|
}
|
|
79
|
-
// Scope
|
|
80
|
-
//
|
|
96
|
+
// Scope: non-admin agent → own (+ granted). Admin agent or trusted internal
|
|
97
|
+
// call (no request) → honor the body-supplied agentId.
|
|
81
98
|
const agentId = (authenticatedAgent && !callerIsAdmin)
|
|
82
99
|
? authenticatedAgent
|
|
83
100
|
: bodyAgentId;
|
package/dist/resources/Soul.js
CHANGED
|
@@ -1,17 +1,26 @@
|
|
|
1
1
|
import { databases } from "@harperfast/harper";
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
2
|
+
import { resolveAgentAuth } from "./agent-auth.js";
|
|
3
|
+
const FORBIDDEN = (msg) => new Response(JSON.stringify({ error: msg }), { status: 403, headers: { "Content-Type": "application/json" } });
|
|
4
|
+
const UNAUTH = () => new Response(JSON.stringify({ error: "authentication required" }), { status: 401, headers: { "Content-Type": "application/json" } });
|
|
5
|
+
/**
|
|
6
|
+
* Deny anonymous; enforce per-agent write ownership for non-admin agents.
|
|
7
|
+
* The previous header-based check only fired when an agent WAS present (it read
|
|
8
|
+
* x-tps-agent), so an anonymous request — which carries no x-tps-agent — slipped
|
|
9
|
+
* through. With the non-rejecting gate, each write path self-enforces (resolveAgentAuth
|
|
10
|
+
* distinguishes internal/agent/anonymous). Mirrors the WorkspaceState pattern.
|
|
11
|
+
*/
|
|
12
|
+
async function enforceWriteAuth(self, data) {
|
|
13
|
+
const auth = await resolveAgentAuth(self.getContext?.());
|
|
14
|
+
if (auth.kind === "anonymous")
|
|
15
|
+
return UNAUTH();
|
|
16
|
+
if (auth.kind === "agent" && !auth.isAdmin && data?.agentId && data.agentId !== auth.agentId) {
|
|
17
|
+
return FORBIDDEN("forbidden: agentId must match authenticated agent");
|
|
9
18
|
}
|
|
10
19
|
return null;
|
|
11
20
|
}
|
|
12
21
|
export class Soul extends databases.flair.Soul {
|
|
13
22
|
async post(content, context) {
|
|
14
|
-
const denied =
|
|
23
|
+
const denied = await enforceWriteAuth(this, content);
|
|
15
24
|
if (denied)
|
|
16
25
|
return denied;
|
|
17
26
|
content.durability ||= "permanent";
|
|
@@ -20,7 +29,7 @@ export class Soul extends databases.flair.Soul {
|
|
|
20
29
|
return super.post(content, context);
|
|
21
30
|
}
|
|
22
31
|
async put(content, context) {
|
|
23
|
-
const denied =
|
|
32
|
+
const denied = await enforceWriteAuth(this, content);
|
|
24
33
|
if (denied)
|
|
25
34
|
return denied;
|
|
26
35
|
content.updatedAt = new Date().toISOString();
|
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
import { Resource, databases } from '@harperfast/harper';
|
|
2
|
+
import { allowVerified } from './agent-auth.js';
|
|
2
3
|
export class FeedSouls extends Resource {
|
|
4
|
+
// Self-authorize the subscription via the Ed25519 agent verify (auth reshape
|
|
5
|
+
// removes the gate's admin elevation). FeedSouls extends Resource (not a Table),
|
|
6
|
+
// so getContext().request is reachable in allow* — same pattern as SemanticSearch.
|
|
7
|
+
async allowRead() {
|
|
8
|
+
return allowVerified(this.getContext?.());
|
|
9
|
+
}
|
|
3
10
|
async *connect(target, incomingMessages) {
|
|
4
11
|
const subscription = await databases.flair.Soul.subscribe(target);
|
|
5
12
|
if (!incomingMessages) {
|
|
@@ -5,7 +5,14 @@
|
|
|
5
5
|
* Auth: requesting agent must match agentId in path (or be admin).
|
|
6
6
|
*/
|
|
7
7
|
import { Resource, databases } from "@harperfast/harper";
|
|
8
|
+
import { allowVerified } from "./agent-auth.js";
|
|
8
9
|
export class WorkspaceLatest extends Resource {
|
|
10
|
+
// Self-authorize via the Ed25519 agent verify (auth reshape removes the gate's
|
|
11
|
+
// admin elevation). Any verified agent may read; the path-vs-agent ownership
|
|
12
|
+
// check stays in get().
|
|
13
|
+
async allowRead() {
|
|
14
|
+
return allowVerified(this.getContext?.());
|
|
15
|
+
}
|
|
9
16
|
async get(pathInfo) {
|
|
10
17
|
const request = this.context?.request ?? this.request;
|
|
11
18
|
const callerAgent = request?.tpsAgent;
|
|
@@ -8,30 +8,30 @@
|
|
|
8
8
|
* Use this.getContext() to access request context (tpsAgent, tpsAgentIsAdmin).
|
|
9
9
|
*/
|
|
10
10
|
import { databases } from "@harperfast/harper";
|
|
11
|
+
import { resolveAgentAuth } from "./agent-auth.js";
|
|
12
|
+
const FORBIDDEN = (msg) => new Response(JSON.stringify({ error: msg }), { status: 403, headers: { "Content-Type": "application/json" } });
|
|
13
|
+
const UNAUTH = () => new Response(JSON.stringify({ error: "authentication required" }), { status: 401, headers: { "Content-Type": "application/json" } });
|
|
11
14
|
export class WorkspaceState extends databases.flair.WorkspaceState {
|
|
12
|
-
/**
|
|
13
|
-
*
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
const ctx = this.getContext?.();
|
|
17
|
-
const request = ctx?.request ?? ctx;
|
|
18
|
-
return {
|
|
19
|
-
agentId: request?.tpsAgent,
|
|
20
|
-
isAdmin: request?.tpsAgentIsAdmin ?? false,
|
|
21
|
-
};
|
|
15
|
+
/** Auth verdict from the request context. internal = trusted in-process call;
|
|
16
|
+
* agent = verified Ed25519; anonymous = HTTP with no valid agent → deny. */
|
|
17
|
+
_auth() {
|
|
18
|
+
return resolveAgentAuth(this.getContext?.());
|
|
22
19
|
}
|
|
23
20
|
/**
|
|
24
|
-
* Override search() to scope collection GETs to the authenticated agent's
|
|
25
|
-
*
|
|
21
|
+
* Override search() to scope collection GETs to the authenticated agent's own
|
|
22
|
+
* records. Internal calls + admin agents see all; anonymous is denied
|
|
23
|
+
* (previously `!authAgent` was treated as unfiltered — the anonymous-read leak).
|
|
26
24
|
*/
|
|
27
25
|
async search(query) {
|
|
28
|
-
const
|
|
29
|
-
if (
|
|
26
|
+
const auth = await this._auth();
|
|
27
|
+
if (auth.kind === "anonymous")
|
|
28
|
+
return UNAUTH();
|
|
29
|
+
if (auth.kind === "internal" || (auth.kind === "agent" && auth.isAdmin)) {
|
|
30
30
|
return super.search(query);
|
|
31
31
|
}
|
|
32
|
-
const agentIdCondition = { attribute: "agentId", comparator: "equals", value:
|
|
33
|
-
// Harper passes `query` as a request target object (
|
|
34
|
-
// Inject scope condition into its `.conditions` array
|
|
32
|
+
const agentIdCondition = { attribute: "agentId", comparator: "equals", value: auth.agentId };
|
|
33
|
+
// Harper passes `query` as a request target object (pathname, id, isCollection…).
|
|
34
|
+
// Inject the scope condition into its `.conditions` array.
|
|
35
35
|
if (query && typeof query === "object" && !Array.isArray(query)) {
|
|
36
36
|
const existing = query.conditions ?? [];
|
|
37
37
|
query.conditions = Array.isArray(existing)
|
|
@@ -45,31 +45,41 @@ export class WorkspaceState extends databases.flair.WorkspaceState {
|
|
|
45
45
|
return super.search(conditions);
|
|
46
46
|
}
|
|
47
47
|
async post(content) {
|
|
48
|
-
const
|
|
49
|
-
//
|
|
50
|
-
|
|
51
|
-
|
|
48
|
+
const auth = await this._auth();
|
|
49
|
+
// Anonymous must NOT write (previously the agentId check was skipped when
|
|
50
|
+
// there was no authenticated agent, so anonymous could write any record).
|
|
51
|
+
if (auth.kind === "anonymous")
|
|
52
|
+
return UNAUTH();
|
|
53
|
+
if (auth.kind === "agent" && !auth.isAdmin && content.agentId !== auth.agentId) {
|
|
54
|
+
return FORBIDDEN("forbidden: cannot write workspace state for another agent");
|
|
52
55
|
}
|
|
53
56
|
content.createdAt = new Date().toISOString();
|
|
54
57
|
content.timestamp ||= content.createdAt;
|
|
55
58
|
return super.post(content);
|
|
56
59
|
}
|
|
57
60
|
async put(content) {
|
|
58
|
-
const
|
|
59
|
-
if (
|
|
60
|
-
return
|
|
61
|
+
const auth = await this._auth();
|
|
62
|
+
if (auth.kind === "anonymous")
|
|
63
|
+
return UNAUTH();
|
|
64
|
+
if (auth.kind === "agent" && !auth.isAdmin && content.agentId !== auth.agentId) {
|
|
65
|
+
return FORBIDDEN("forbidden: cannot write workspace state for another agent");
|
|
61
66
|
}
|
|
62
67
|
return super.put(content);
|
|
63
68
|
}
|
|
64
69
|
async delete(id) {
|
|
65
|
-
const
|
|
66
|
-
|
|
70
|
+
const auth = await this._auth();
|
|
71
|
+
// Anonymous must NOT delete (previously `!agentId → super.delete` let anonymous
|
|
72
|
+
// delete any record).
|
|
73
|
+
if (auth.kind === "anonymous")
|
|
74
|
+
return UNAUTH();
|
|
75
|
+
if (auth.kind === "internal" || (auth.kind === "agent" && auth.isAdmin)) {
|
|
67
76
|
return super.delete(id);
|
|
77
|
+
}
|
|
68
78
|
const record = await this.get(id);
|
|
69
79
|
if (!record)
|
|
70
80
|
return super.delete(id);
|
|
71
|
-
if (
|
|
72
|
-
return
|
|
81
|
+
if (record.agentId !== auth.agentId) {
|
|
82
|
+
return FORBIDDEN("forbidden: cannot delete workspace state for another agent");
|
|
73
83
|
}
|
|
74
84
|
return super.delete(id);
|
|
75
85
|
}
|
package/dist/resources/XAA.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { databases } from "@harperfast/harper";
|
|
2
|
+
import { allowAdmin } from "./agent-auth.js";
|
|
2
3
|
import { createHash, randomBytes } from "node:crypto";
|
|
3
4
|
import { createRemoteJWKSet, jwtVerify } from "jose";
|
|
4
5
|
/**
|
|
@@ -252,6 +253,13 @@ export async function handleJwtBearerGrant(data) {
|
|
|
252
253
|
* Admin-only access.
|
|
253
254
|
*/
|
|
254
255
|
export class IdpConfig extends databases.flair.IdpConfig {
|
|
256
|
+
// Admin-only on every path (the "Admin-only" comment was previously unenforced —
|
|
257
|
+
// a pure put() override with no auth check, so the non-rejecting gate let
|
|
258
|
+
// anonymous through to Harper's handler). allowAdmin permits admin + internal.
|
|
259
|
+
allowRead() { return allowAdmin(this.getContext?.()); }
|
|
260
|
+
allowCreate() { return allowAdmin(this.getContext?.()); }
|
|
261
|
+
allowUpdate() { return allowAdmin(this.getContext?.()); }
|
|
262
|
+
allowDelete() { return allowAdmin(this.getContext?.()); }
|
|
255
263
|
async put(content) {
|
|
256
264
|
const now = nowISO();
|
|
257
265
|
content.enabled ??= true;
|