@tpsdev-ai/flair 0.17.0 → 0.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/resources/Admin.js +12 -0
- package/dist/resources/AdminConnectors.js +6 -0
- package/dist/resources/AdminDashboard.js +9 -0
- package/dist/resources/AdminIdp.js +6 -0
- package/dist/resources/AdminInstance.js +6 -0
- package/dist/resources/AdminMemory.js +8 -0
- package/dist/resources/AdminPrincipals.js +6 -0
- package/dist/resources/Integration.js +52 -2
- package/dist/resources/Memory.js +404 -45
- package/dist/resources/MemoryGrant.js +55 -2
- package/dist/resources/Relationship.js +49 -1
- package/dist/resources/SemanticSearch.js +41 -3
- package/dist/resources/Soul.js +12 -1
- package/dist/resources/WorkspaceState.js +56 -2
- package/dist/resources/dedup.js +62 -0
- package/dist/resources/health.js +17 -0
- package/dist/resources/mcp-tools.js +75 -5
- package/dist/resources/rerank-provider.js +361 -0
- package/package.json +2 -1
package/dist/resources/Memory.js
CHANGED
|
@@ -1,10 +1,317 @@
|
|
|
1
1
|
import { databases } from "@harperfast/harper";
|
|
2
2
|
import { patchRecord, withDetachedTxn } from "./table-helpers.js";
|
|
3
|
-
import { isAdmin, resolveAgentAuth } from "./agent-auth.js";
|
|
3
|
+
import { isAdmin, resolveAgentAuth, allowVerified } from "./agent-auth.js";
|
|
4
4
|
import { getEmbedding, getModelId } from "./embeddings-provider.js";
|
|
5
5
|
import { scanFields, isStrictMode } from "./content-safety.js";
|
|
6
6
|
import { checkRateLimit, rateLimitResponse } from "./rate-limiter.js";
|
|
7
|
+
import { DEDUP_COSINE_THRESHOLD_DEFAULT, DEDUP_LEXICAL_THRESHOLD_DEFAULT, DEDUP_MIN_CONTENT_LENGTH, computeMatchConfidence, isConservativeMatch, } from "./dedup.js";
|
|
8
|
+
const FORBIDDEN = (msg) => new Response(JSON.stringify({ error: msg }), { status: 403, headers: { "Content-Type": "application/json" } });
|
|
9
|
+
const UNAUTH = () => new Response(JSON.stringify({ error: "authentication required" }), { status: 401, headers: { "Content-Type": "application/json" } });
|
|
10
|
+
const NOT_FOUND = () => new Response(JSON.stringify({ error: "not found" }), { status: 404, headers: { "Content-Type": "application/json" } });
|
|
11
|
+
/**
|
|
12
|
+
* Owner ids a non-admin agent may READ: itself, plus any owner who has
|
|
13
|
+
* granted it a "read" or "search" scoped MemoryGrant. Shared by search()
|
|
14
|
+
* (collection scoping) and get() (per-id ownership check, memory-soul-read-
|
|
15
|
+
* gate fix) so the two paths cannot drift apart — this was previously
|
|
16
|
+
* computed inline only inside search().
|
|
17
|
+
*/
|
|
18
|
+
async function resolveAllowedOwners(authAgentId) {
|
|
19
|
+
const allowedOwners = [authAgentId];
|
|
20
|
+
try {
|
|
21
|
+
for await (const grant of databases.flair.MemoryGrant.search({
|
|
22
|
+
conditions: [{ attribute: "granteeId", comparator: "equals", value: authAgentId }],
|
|
23
|
+
})) {
|
|
24
|
+
if (grant.ownerId && (grant.scope === "read" || grant.scope === "search")) {
|
|
25
|
+
allowedOwners.push(grant.ownerId);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
catch { /* MemoryGrant table not yet populated — ignore */ }
|
|
30
|
+
return allowedOwners;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* ─── Server-side conservative-duplicate gate (memory-integrity fix) ──────────
|
|
34
|
+
*
|
|
35
|
+
* NEVER SUPPRESSES A WRITE. This gate only computes a SIGNAL — the caller
|
|
36
|
+
* (Memory.post / Memory.put) always proceeds to write the new record. When a
|
|
37
|
+
* conservative match is found, the signal is attached to the RESPONSE only
|
|
38
|
+
* (deduplicated/matchedId/matchConfidence). There must be no code path where
|
|
39
|
+
* finding a match causes the write to be skipped: that was the #526 bug (two
|
|
40
|
+
* topically-close but DISTINCT findings — one about replication
|
|
41
|
+
* route-directionality, one about DDL/schema replication — and the SECOND was
|
|
42
|
+
* silently dropped because the old client-side gate returned the existing
|
|
43
|
+
* record instead of writing).
|
|
44
|
+
*
|
|
45
|
+
* Conservative match = raw cosine >= cosineThreshold AND Jaccard token-overlap
|
|
46
|
+
* >= lexicalThreshold, checked ONLY against the SINGLE top-cosine candidate
|
|
47
|
+
* (scoped to the same agentId as the write — never cross-agent). If that one
|
|
48
|
+
* candidate fails either gate, there is no match; we do not fall back to the
|
|
49
|
+
* 2nd-most-similar candidate.
|
|
50
|
+
*
|
|
51
|
+
* Previously this lived client-side (packages/flair-client/src/client.ts,
|
|
52
|
+
* pre-fix) and only ran for callers that opted into `dedup:true` over HTTP
|
|
53
|
+
* PUT — the Model-2 /mcp handler (resources/mcp-tools.ts), which calls
|
|
54
|
+
* Memory.post() directly, got ZERO dedup checking. Moving the gate here makes
|
|
55
|
+
* it apply uniformly regardless of transport (HTTP PUT vs in-process post()).
|
|
56
|
+
*
|
|
57
|
+
* NOTE on HTTP verbs: the Memory schema only exposes PUT over HTTP (a raw
|
|
58
|
+
* HTTP POST /Memory returns "Memory does not have a post method implemented"
|
|
59
|
+
* — see src/cli.ts's `flair test` command and commit 2fa6d22 / ops-pj5).
|
|
60
|
+
* `Memory.post()` IS reachable, but only via an in-process resource
|
|
61
|
+
* instantiation (as resources/mcp-tools.ts does) — never via the real HTTP
|
|
62
|
+
* POST route. Because flair-client's write() (used by flair-mcp, the CLI, and
|
|
63
|
+
* every other integration package) issues an HTTP PUT, the actual
|
|
64
|
+
* field-observed bug (#526) flows through Memory.put(), not Memory.post().
|
|
65
|
+
* The gate below is therefore a SHARED helper invoked from both post() and
|
|
66
|
+
* put() — anchored in the same place the design calls out (Memory.post), but
|
|
67
|
+
* wired into put() too so the write path real callers actually use is
|
|
68
|
+
* protected. See memory-integrity-fix report for the full writeup of this
|
|
69
|
+
* deviation from a literal "gate lives only in Memory.post" reading.
|
|
70
|
+
*/
|
|
71
|
+
async function findConservativeDedupMatch(ctx, agentId, contentText, embedding, cosineThreshold, lexicalThreshold) {
|
|
72
|
+
if (!agentId || !embedding || embedding.length === 0)
|
|
73
|
+
return null;
|
|
74
|
+
try {
|
|
75
|
+
const query = {
|
|
76
|
+
sort: { attribute: "embedding", target: embedding, distance: "cosine" },
|
|
77
|
+
conditions: [
|
|
78
|
+
{ attribute: "agentId", comparator: "equals", value: agentId },
|
|
79
|
+
{ attribute: "archived", comparator: "not_equal", value: true },
|
|
80
|
+
],
|
|
81
|
+
select: ["id", "content", "$distance"],
|
|
82
|
+
limit: 1,
|
|
83
|
+
};
|
|
84
|
+
let top = null;
|
|
85
|
+
// Detach ctx.transaction around this search — same rationale as
|
|
86
|
+
// Memory.search()/SemanticSearch.ts: a drained search generator can leave
|
|
87
|
+
// a CLOSED transaction in ctx's chain that the subsequent WRITE
|
|
88
|
+
// (super.post/super.put, right after this gate runs) would otherwise
|
|
89
|
+
// inherit. Detaching here protects that write, not this read.
|
|
90
|
+
const results = withDetachedTxn(ctx, () => databases.flair.Memory.search(query));
|
|
91
|
+
for await (const record of results) {
|
|
92
|
+
top = record;
|
|
93
|
+
break; // single top-cosine candidate only — never fall back further
|
|
94
|
+
}
|
|
95
|
+
if (!top)
|
|
96
|
+
return null;
|
|
97
|
+
const cosine = 1 - (top.$distance ?? 1);
|
|
98
|
+
const confidence = computeMatchConfidence(contentText, top.content, cosine);
|
|
99
|
+
if (!isConservativeMatch(confidence.cosine, confidence.lexical, cosineThreshold, lexicalThreshold)) {
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
return { matchedId: top.id, cosine: confidence.cosine, lexical: confidence.lexical };
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
// Dedup-check failures (embedding engine down, search error, etc.) must
|
|
106
|
+
// NEVER block or alter the write — treat as "no match found".
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Run the dedup gate for a create-shaped write. Mutates `content.embedding` /
|
|
112
|
+
* `content.embeddingModel` when it computes a fresh embedding, so the
|
|
113
|
+
* existing "generate embedding if missing" step later in post()/put() reuses
|
|
114
|
+
* it instead of recomputing. Always strips the client-forwarded hint fields
|
|
115
|
+
* (dedup / dedupThreshold / lexicalThreshold) so they never persist onto the
|
|
116
|
+
* stored record — they are passthrough tuning hints, not schema fields.
|
|
117
|
+
*
|
|
118
|
+
* Returns the match signal, or null (no match / gate not applicable).
|
|
119
|
+
*/
|
|
120
|
+
async function runDedupGate(ctx, content) {
|
|
121
|
+
const cosineThreshold = typeof content.dedupThreshold === "number" ? content.dedupThreshold : DEDUP_COSINE_THRESHOLD_DEFAULT;
|
|
122
|
+
const lexicalThreshold = typeof content.lexicalThreshold === "number" ? content.lexicalThreshold : DEDUP_LEXICAL_THRESHOLD_DEFAULT;
|
|
123
|
+
delete content.dedup;
|
|
124
|
+
delete content.dedupThreshold;
|
|
125
|
+
delete content.lexicalThreshold;
|
|
126
|
+
if (typeof content.content !== "string" || content.content.length < DEDUP_MIN_CONTENT_LENGTH) {
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
let embedding = Array.isArray(content.embedding) ? content.embedding : null;
|
|
130
|
+
if (!embedding) {
|
|
131
|
+
try {
|
|
132
|
+
embedding = await getEmbedding(content.content);
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
embedding = null;
|
|
136
|
+
}
|
|
137
|
+
if (embedding) {
|
|
138
|
+
content.embedding = embedding;
|
|
139
|
+
content.embeddingModel = getModelId();
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
if (!embedding)
|
|
143
|
+
return null;
|
|
144
|
+
return findConservativeDedupMatch(ctx, content.agentId, content.content, embedding, cosineThreshold, lexicalThreshold);
|
|
145
|
+
}
|
|
146
|
+
/** Build the final write response: always `written: true`, always includes
|
|
147
|
+
* `id`, and layers the dedup collision signal on top when present. Never a
|
|
148
|
+
* code path where a match suppresses these base fields. */
|
|
149
|
+
function buildWriteResponse(content, result, dedupMatch) {
|
|
150
|
+
const base = result && typeof result === "object" && !Array.isArray(result) ? result : {};
|
|
151
|
+
const response = {
|
|
152
|
+
id: content.id,
|
|
153
|
+
...base,
|
|
154
|
+
written: true,
|
|
155
|
+
deduplicated: !!dedupMatch,
|
|
156
|
+
};
|
|
157
|
+
if (dedupMatch) {
|
|
158
|
+
response.matchedId = dedupMatch.matchedId;
|
|
159
|
+
response.matchConfidence = { cosine: dedupMatch.cosine, lexical: dedupMatch.lexical };
|
|
160
|
+
}
|
|
161
|
+
return response;
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Read-modify-write close of a superseded record, with the SAME transaction
|
|
165
|
+
* detachment discipline as findConservativeDedupMatch (each discrete Harper
|
|
166
|
+
* call individually wrapped — see withDetachedTxn's doc for why a single
|
|
167
|
+
* wrap around a multi-await async function would not protect the later
|
|
168
|
+
* call). Does NOT swallow failures (ops-a4t5 fix) — throws so the caller can
|
|
169
|
+
* log it. Never called before the new record is already written.
|
|
170
|
+
*/
|
|
171
|
+
async function closeSupersededRecord(ctx, oldId, patch) {
|
|
172
|
+
const existing = await withDetachedTxn(ctx, () => databases.flair.Memory.get(oldId));
|
|
173
|
+
if (!existing) {
|
|
174
|
+
throw new Error(`supersede-close: record ${oldId} not found`);
|
|
175
|
+
}
|
|
176
|
+
await withDetachedTxn(ctx, () => databases.flair.Memory.put({ ...existing, ...patch }));
|
|
177
|
+
}
|
|
178
|
+
/** Does an agent hold a "write" grant from `ownerId`? Same MemoryGrant lookup
|
|
179
|
+
* pattern as Memory.search()/SemanticSearch.ts (read/search scopes) — reused
|
|
180
|
+
* here for the "write" scope that gates cross-agent supersede. */
|
|
181
|
+
async function hasWriteGrant(granteeId, ownerId) {
|
|
182
|
+
try {
|
|
183
|
+
for await (const grant of databases.flair.MemoryGrant.search({
|
|
184
|
+
conditions: [
|
|
185
|
+
{ attribute: "granteeId", comparator: "equals", value: granteeId },
|
|
186
|
+
{ attribute: "ownerId", comparator: "equals", value: ownerId },
|
|
187
|
+
],
|
|
188
|
+
})) {
|
|
189
|
+
if (grant.scope === "write")
|
|
190
|
+
return true;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
catch {
|
|
194
|
+
/* MemoryGrant table not yet populated — no grant */
|
|
195
|
+
}
|
|
196
|
+
return false;
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* Shared by post() AND put(): the Memory schema only exposes a working HTTP
|
|
200
|
+
* PUT route (a raw HTTP POST /Memory 404s with "Memory does not have a post
|
|
201
|
+
* method implemented" — see src/cli.ts's `flair test` command / commit
|
|
202
|
+
* 2fa6d22 / ops-pj5). Memory.post() IS reachable, but only via an in-process
|
|
203
|
+
* resource instantiation (resources/mcp-tools.ts does this). Since
|
|
204
|
+
* flair-client's write()/update() — used by flair-mcp, the CLI, and every
|
|
205
|
+
* other integration package — issue HTTP PUT, `supersedes` must be fully
|
|
206
|
+
* handled (validated, authorized, and closed) from BOTH entry points for the
|
|
207
|
+
* real-world write path to actually get the fix, not just the in-process one.
|
|
208
|
+
*
|
|
209
|
+
* Validates the `supersedes` field's shape and, for a cross-agent supersede,
|
|
210
|
+
* requires a "write" MemoryGrant from the target's owner (reuses the existing
|
|
211
|
+
* agent-auth/grant machinery — no parallel auth logic). Returns a Response to
|
|
212
|
+
* short-circuit with (400/403), or null to continue.
|
|
213
|
+
*/
|
|
214
|
+
async function validateAndAuthorizeSupersedes(content, auth) {
|
|
215
|
+
if (content.supersedes !== undefined && typeof content.supersedes !== "string") {
|
|
216
|
+
return new Response(JSON.stringify({ error: "supersedes must be a string (memory ID)" }), {
|
|
217
|
+
status: 400, headers: { "Content-Type": "application/json" },
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
if (content.supersedes && auth.kind === "agent" && !auth.isAdmin) {
|
|
221
|
+
const target = await databases.flair.Memory.get(content.supersedes).catch(() => null);
|
|
222
|
+
if (target && target.agentId !== auth.agentId) {
|
|
223
|
+
if (!(await hasWriteGrant(auth.agentId, target.agentId))) {
|
|
224
|
+
return FORBIDDEN("forbidden: cannot supersede a memory owned by another agent without a write grant");
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
return null;
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* Close the superseded record — called AFTER the new record has already been
|
|
232
|
+
* written (write-new-BEFORE-close-old, ops-a4t5 fix). Safe failure state is
|
|
233
|
+
* two active records (recoverable), never a tombstoned-old-with-lost-new.
|
|
234
|
+
* Failure is logged (observable), never silently swallowed. No-op if
|
|
235
|
+
* `content.supersedes` is not set.
|
|
236
|
+
*/
|
|
237
|
+
async function closeSupersededIfNeeded(ctx, content, methodLabel) {
|
|
238
|
+
if (!content.supersedes)
|
|
239
|
+
return;
|
|
240
|
+
try {
|
|
241
|
+
await closeSupersededRecord(ctx, content.supersedes, {
|
|
242
|
+
validTo: content.validFrom ?? content.createdAt,
|
|
243
|
+
updatedAt: content.createdAt ?? content.updatedAt,
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
catch (err) {
|
|
247
|
+
// Constant format string + structured data: memory ids are agent-controlled,
|
|
248
|
+
// so interpolating them into console.error's format position (with a trailing
|
|
249
|
+
// `err` arg) would let an id containing %s/%o consume/hide the real error
|
|
250
|
+
// (semgrep unsafe-formatstring). Keep all dynamic values in the data object.
|
|
251
|
+
console.error("Memory.closeSuperseded: failed to close superseded record after writing new record " +
|
|
252
|
+
"(ops-a4t5 — observable, not silent; new record is safely written, old record remains active until retried)", { method: methodLabel, supersededId: content.supersedes, newRecordId: content.id, err });
|
|
253
|
+
}
|
|
254
|
+
}
|
|
7
255
|
export class Memory extends databases.flair.Memory {
|
|
256
|
+
/**
|
|
257
|
+
* Self-authorize now that the global gate is non-rejecting. Closes the P0
|
|
258
|
+
* leak: Harper routes `GET /Memory/<id>` to get() and the collection
|
|
259
|
+
* describe (`GET /Memory`) to a path outside search() — neither was gated
|
|
260
|
+
* before this fix, so an anonymous caller got a 200 with full record
|
|
261
|
+
* content / schema even though search() (and the write paths) correctly
|
|
262
|
+
* 401/403'd. Per-record ownership/grant scoping happens in get() below;
|
|
263
|
+
* the collection scope is still in search().
|
|
264
|
+
*
|
|
265
|
+
* allowCreate/allowUpdate/allowDelete are deliberately NOT added here:
|
|
266
|
+
* post()/put()/delete() already self-enforce per-agent ownership inline
|
|
267
|
+
* (resolveAgentAuth + explicit agentId checks in post()/put(), and the
|
|
268
|
+
* isAdmin durability check in delete()). Adding allow* on top of that,
|
|
269
|
+
* unverified, risks regressing owner writes/deletes on a P0 security fix
|
|
270
|
+
* that is scoped to the read leak — left as-is on purpose.
|
|
271
|
+
*/
|
|
272
|
+
allowRead() { return allowVerified(this.getContext?.()); }
|
|
273
|
+
/**
|
|
274
|
+
* Override get() to scope by-id reads the same way search() scopes
|
|
275
|
+
* collection reads (memory-soul-read-gate fix). Never distinguishes
|
|
276
|
+
* "doesn't exist" from "exists but not yours" — both return 404, never
|
|
277
|
+
* 403, so a denied caller can't use get() to enumerate other agents'
|
|
278
|
+
* memory ids.
|
|
279
|
+
*/
|
|
280
|
+
async get(target) {
|
|
281
|
+
// Collection / query reads — the `GET /Memory/?<query>` form and the bare
|
|
282
|
+
// collection — arrive as a RequestTarget with `isCollection === true`, and
|
|
283
|
+
// are governed by search() (same owner/grant scoping). Only a genuine by-id
|
|
284
|
+
// get is ownership-checked below. Without this guard, get() would receive
|
|
285
|
+
// the query's RequestTarget, super.get() would return the (truthy) result
|
|
286
|
+
// set, the single-record check would find no `.agentId` on it, and a valid
|
|
287
|
+
// authenticated self-query would 404 (regression caught by the auth-
|
|
288
|
+
// middleware e2e "TPS-Ed25519 on GET /Memory/?agentId=X → 200"). A by-id
|
|
289
|
+
// get (RequestTarget with isCollection false, or a bare id) falls through.
|
|
290
|
+
if (!target || (typeof target === "object" && target.isCollection)) {
|
|
291
|
+
return this.search(target);
|
|
292
|
+
}
|
|
293
|
+
const ctx = this.getContext?.();
|
|
294
|
+
const auth = await resolveAgentAuth(ctx);
|
|
295
|
+
// Anonymous by-id read is already blocked at the allowRead() gate (403);
|
|
296
|
+
// this is defense-in-depth if get() is ever reached directly.
|
|
297
|
+
if (auth.kind === "anonymous") {
|
|
298
|
+
return NOT_FOUND();
|
|
299
|
+
}
|
|
300
|
+
// Trusted internal call or admin agent — unfiltered, unchanged behavior.
|
|
301
|
+
if (auth.kind === "internal" || (auth.kind === "agent" && auth.isAdmin)) {
|
|
302
|
+
return super.get(target);
|
|
303
|
+
}
|
|
304
|
+
// Non-admin agent: only its own memories, or an owner's memories it holds
|
|
305
|
+
// a read/search MemoryGrant for (same owner-set as search() — shared
|
|
306
|
+
// resolveAllowedOwners helper so the two paths cannot drift).
|
|
307
|
+
const record = await super.get(target);
|
|
308
|
+
if (!record)
|
|
309
|
+
return NOT_FOUND();
|
|
310
|
+
const allowedOwners = await resolveAllowedOwners(auth.agentId);
|
|
311
|
+
if (!allowedOwners.includes(record.agentId))
|
|
312
|
+
return NOT_FOUND();
|
|
313
|
+
return record;
|
|
314
|
+
}
|
|
8
315
|
/**
|
|
9
316
|
* Override search() to scope collection GETs by authenticated agent.
|
|
10
317
|
*
|
|
@@ -32,17 +339,7 @@ export class Memory extends databases.flair.Memory {
|
|
|
32
339
|
}
|
|
33
340
|
// Non-admin agent: scope to own + granted owners.
|
|
34
341
|
const authAgent = auth.agentId;
|
|
35
|
-
const allowedOwners =
|
|
36
|
-
try {
|
|
37
|
-
for await (const grant of databases.flair.MemoryGrant.search({
|
|
38
|
-
conditions: [{ attribute: "granteeId", comparator: "equals", value: authAgent }],
|
|
39
|
-
})) {
|
|
40
|
-
if (grant.ownerId && (grant.scope === "read" || grant.scope === "search")) {
|
|
41
|
-
allowedOwners.push(grant.ownerId);
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
catch { /* MemoryGrant table not yet populated — ignore */ }
|
|
342
|
+
const allowedOwners = await resolveAllowedOwners(authAgent);
|
|
46
343
|
// Build the agentId scope condition
|
|
47
344
|
const agentIdCondition = allowedOwners.length === 1
|
|
48
345
|
? { attribute: "agentId", comparator: "equals", value: allowedOwners[0] }
|
|
@@ -78,20 +375,17 @@ export class Memory extends databases.flair.Memory {
|
|
|
78
375
|
// resolveAgentAuth (reads the gate's tpsAgent annotation) — NOT context.user
|
|
79
376
|
// .username, which is the fallback "admin" super_user while de-elevation is
|
|
80
377
|
// dormant and would wrongly 403 every agent's own write. internal/admin → pass.
|
|
378
|
+
let auth;
|
|
81
379
|
{
|
|
82
|
-
|
|
380
|
+
auth = await resolveAgentAuth(ctx);
|
|
83
381
|
// Anonymous HTTP must NOT write. Pre-flip the global gate rejected no-auth
|
|
84
382
|
// upstream; with the non-rejecting gate, each write path self-enforces (same
|
|
85
383
|
// rule search() applies to reads).
|
|
86
384
|
if (auth.kind === "anonymous") {
|
|
87
|
-
return
|
|
88
|
-
status: 401, headers: { "Content-Type": "application/json" },
|
|
89
|
-
});
|
|
385
|
+
return UNAUTH();
|
|
90
386
|
}
|
|
91
387
|
if (auth.kind === "agent" && !auth.isAdmin && content?.agentId && content.agentId !== auth.agentId) {
|
|
92
|
-
return
|
|
93
|
-
status: 403, headers: { "Content-Type": "application/json" },
|
|
94
|
-
});
|
|
388
|
+
return FORBIDDEN("forbidden: cannot write memory owned by another agent");
|
|
95
389
|
}
|
|
96
390
|
}
|
|
97
391
|
content.durability ||= "standard";
|
|
@@ -111,23 +405,16 @@ export class Memory extends databases.flair.Memory {
|
|
|
111
405
|
catch { }
|
|
112
406
|
}
|
|
113
407
|
}
|
|
114
|
-
// supersedes: optional reference to the ID of the memory this one
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
408
|
+
// supersedes: optional reference to the ID of the memory this one
|
|
409
|
+
// replaces. Validates shape + cross-agent-write authorization (shared
|
|
410
|
+
// with put() — see validateAndAuthorizeSupersedes doc).
|
|
411
|
+
const supersedesError = await validateAndAuthorizeSupersedes(content, auth);
|
|
412
|
+
if (supersedesError)
|
|
413
|
+
return supersedesError;
|
|
120
414
|
// Temporal validity: validFrom defaults to now, validTo left null for active facts.
|
|
121
|
-
// When a memory supersedes another, close the superseded memory's validity window.
|
|
122
415
|
if (!content.validFrom) {
|
|
123
416
|
content.validFrom = content.createdAt;
|
|
124
417
|
}
|
|
125
|
-
if (content.supersedes) {
|
|
126
|
-
patchRecord(databases.flair.Memory, content.supersedes, {
|
|
127
|
-
validTo: content.validFrom,
|
|
128
|
-
updatedAt: content.createdAt,
|
|
129
|
-
}).catch(() => { });
|
|
130
|
-
}
|
|
131
418
|
if (content.durability === "ephemeral" && !content.expiresAt) {
|
|
132
419
|
const ttlHours = Number(process.env.FLAIR_EPHEMERAL_TTL_HOURS || 24);
|
|
133
420
|
content.expiresAt = new Date(Date.now() + ttlHours * 3600_000).toISOString();
|
|
@@ -147,7 +434,22 @@ export class Memory extends databases.flair.Memory {
|
|
|
147
434
|
content._safetyFlags = safety.flags;
|
|
148
435
|
}
|
|
149
436
|
}
|
|
150
|
-
//
|
|
437
|
+
// Server-side conservative-duplicate gate (memory-integrity fix). A
|
|
438
|
+
// supersede write is an intentional version-link, not an ambiguous "is
|
|
439
|
+
// this a duplicate of something else" situation — bypass the gate for it
|
|
440
|
+
// (this also gives memory_update's preserveHistory mode dedup-bypass for
|
|
441
|
+
// free, without a separate flag). NEVER suppresses the write either way.
|
|
442
|
+
let dedupMatch = null;
|
|
443
|
+
if (!content.supersedes) {
|
|
444
|
+
dedupMatch = await runDedupGate(ctx, content);
|
|
445
|
+
}
|
|
446
|
+
else {
|
|
447
|
+
delete content.dedup;
|
|
448
|
+
delete content.dedupThreshold;
|
|
449
|
+
delete content.lexicalThreshold;
|
|
450
|
+
}
|
|
451
|
+
// Generate embedding from content text (no-op if the dedup gate above
|
|
452
|
+
// already computed one for this content).
|
|
151
453
|
if (content.content && !content.embedding) {
|
|
152
454
|
const vec = await getEmbedding(content.content);
|
|
153
455
|
if (vec) {
|
|
@@ -155,7 +457,16 @@ export class Memory extends databases.flair.Memory {
|
|
|
155
457
|
content.embeddingModel = getModelId();
|
|
156
458
|
}
|
|
157
459
|
}
|
|
158
|
-
|
|
460
|
+
// ── Write the new record FIRST ──────────────────────────────────────────
|
|
461
|
+
const result = await super.post(content);
|
|
462
|
+
// ── THEN close the superseded record (ops-a4t5 fix) ─────────────────────
|
|
463
|
+
// Write-new-BEFORE-close-old: the previous order (close-old via a fire-
|
|
464
|
+
// and-forget `.catch(()=>{})` BEFORE the new write) could tombstone the
|
|
465
|
+
// old record and then lose the new one if the write failed afterward.
|
|
466
|
+
// Now the safe failure state is two active records (recoverable), never
|
|
467
|
+
// a lost write — and the failure is logged, never silently swallowed.
|
|
468
|
+
await closeSupersededIfNeeded(ctx, content, "post");
|
|
469
|
+
return buildWriteResponse(content, result, dedupMatch);
|
|
159
470
|
}
|
|
160
471
|
async put(content) {
|
|
161
472
|
// Reindex migration bypass: admin-only escape hatch used by the
|
|
@@ -181,19 +492,16 @@ export class Memory extends databases.flair.Memory {
|
|
|
181
492
|
// write memories it owns, via resolveAgentAuth (gate annotation), not
|
|
182
493
|
// context.user.username (the dormant-de-elevation fallback is "admin").
|
|
183
494
|
// The _reindex admin path above bypasses this.
|
|
495
|
+
const ctx = this.getContext?.();
|
|
496
|
+
let auth;
|
|
184
497
|
{
|
|
185
|
-
|
|
186
|
-
const auth = await resolveAgentAuth(octx);
|
|
498
|
+
auth = await resolveAgentAuth(ctx);
|
|
187
499
|
// Anonymous HTTP must NOT write (non-rejecting gate → self-enforce here).
|
|
188
500
|
if (auth.kind === "anonymous") {
|
|
189
|
-
return
|
|
190
|
-
status: 401, headers: { "Content-Type": "application/json" },
|
|
191
|
-
});
|
|
501
|
+
return UNAUTH();
|
|
192
502
|
}
|
|
193
503
|
if (auth.kind === "agent" && !auth.isAdmin && content?.agentId && content.agentId !== auth.agentId) {
|
|
194
|
-
return
|
|
195
|
-
status: 403, headers: { "Content-Type": "application/json" },
|
|
196
|
-
});
|
|
504
|
+
return FORBIDDEN("forbidden: cannot write memory owned by another agent");
|
|
197
505
|
}
|
|
198
506
|
}
|
|
199
507
|
const now = new Date().toISOString();
|
|
@@ -201,6 +509,16 @@ export class Memory extends databases.flair.Memory {
|
|
|
201
509
|
// Set defaults that post() sets — put() is also used for new records via CLI
|
|
202
510
|
content.archived = content.archived ?? false;
|
|
203
511
|
content.createdAt = content.createdAt ?? now;
|
|
512
|
+
// supersedes: optional reference to the ID of the memory this one
|
|
513
|
+
// replaces. Validates shape + cross-agent-write authorization (shared
|
|
514
|
+
// with post() — see validateAndAuthorizeSupersedes doc for why PUT needs
|
|
515
|
+
// this too: it's the only HTTP-reachable create path).
|
|
516
|
+
const supersedesError = await validateAndAuthorizeSupersedes(content, auth);
|
|
517
|
+
if (supersedesError)
|
|
518
|
+
return supersedesError;
|
|
519
|
+
if (content.supersedes && !content.validFrom) {
|
|
520
|
+
content.validFrom = content.createdAt;
|
|
521
|
+
}
|
|
204
522
|
// Content safety scan on updated content + summary (ops-i2jb).
|
|
205
523
|
if (content.content || content.summary) {
|
|
206
524
|
const safety = scanFields(content, ["content", "summary"]);
|
|
@@ -219,7 +537,36 @@ export class Memory extends databases.flair.Memory {
|
|
|
219
537
|
content._safetyFlags = null;
|
|
220
538
|
}
|
|
221
539
|
}
|
|
222
|
-
//
|
|
540
|
+
// Server-side conservative-duplicate gate (memory-integrity fix). PUT is
|
|
541
|
+
// an upsert: only run the gate for a FRESH create (target id does not yet
|
|
542
|
+
// exist) that is NOT a supersede-link write. An update of an EXISTING id
|
|
543
|
+
// (memory_update's default same-id overwrite path) is an intentional,
|
|
544
|
+
// explicit overwrite, and a supersede-link write is an intentional
|
|
545
|
+
// version-link — neither is an ambiguous "is this a duplicate of
|
|
546
|
+
// something else" write, so both are dedup-bypassed automatically, no
|
|
547
|
+
// separate flag needed. NEVER suppresses the write either way.
|
|
548
|
+
let dedupMatch = null;
|
|
549
|
+
if (content.supersedes) {
|
|
550
|
+
delete content.dedup;
|
|
551
|
+
delete content.dedupThreshold;
|
|
552
|
+
delete content.lexicalThreshold;
|
|
553
|
+
}
|
|
554
|
+
else if (content.id) {
|
|
555
|
+
const preExisting = await databases.flair.Memory.get(content.id).catch(() => null);
|
|
556
|
+
if (!preExisting) {
|
|
557
|
+
dedupMatch = await runDedupGate(ctx, content);
|
|
558
|
+
}
|
|
559
|
+
else {
|
|
560
|
+
delete content.dedup;
|
|
561
|
+
delete content.dedupThreshold;
|
|
562
|
+
delete content.lexicalThreshold;
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
else {
|
|
566
|
+
dedupMatch = await runDedupGate(ctx, content);
|
|
567
|
+
}
|
|
568
|
+
// Re-generate embedding if content changed (no-op if the dedup gate above
|
|
569
|
+
// already computed one for this content).
|
|
223
570
|
if (content.content && !content.embedding) {
|
|
224
571
|
const vec = await getEmbedding(content.content);
|
|
225
572
|
if (vec) {
|
|
@@ -240,10 +587,22 @@ export class Memory extends databases.flair.Memory {
|
|
|
240
587
|
if (content.promotionStatus === "approved") {
|
|
241
588
|
content.durability = "permanent";
|
|
242
589
|
}
|
|
243
|
-
|
|
590
|
+
// ── Write the new/updated record FIRST ──────────────────────────────────
|
|
591
|
+
const result = await super.put(content);
|
|
592
|
+
// ── THEN close the superseded record (ops-a4t5 fix; see post()) ────────
|
|
593
|
+
await closeSupersededIfNeeded(ctx, content, "put");
|
|
594
|
+
return buildWriteResponse(content, result, dedupMatch);
|
|
244
595
|
}
|
|
245
596
|
async delete(id) {
|
|
246
|
-
|
|
597
|
+
// Use super.get(id), NOT this.get(id): the new get() override above 404s
|
|
598
|
+
// (a truthy Response) for a non-owner/non-granted id, which would
|
|
599
|
+
// otherwise short-circuit the `record.durability === "permanent"` check
|
|
600
|
+
// below (a Response has no .durability) and silently bypass the
|
|
601
|
+
// admin-only permanent-delete guard for cross-agent deletes. This keeps
|
|
602
|
+
// delete()'s own pre-existing ownership/admin logic exactly as it was
|
|
603
|
+
// before the read-gate fix — the read-scoping override must not leak
|
|
604
|
+
// into delete()'s internal record lookup.
|
|
605
|
+
const record = await super.get(id);
|
|
247
606
|
if (!record)
|
|
248
607
|
return super.delete(id);
|
|
249
608
|
if (record.durability === "permanent") {
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { databases } from "@harperfast/harper";
|
|
2
|
-
import { resolveAgentAuth } from "./agent-auth.js";
|
|
2
|
+
import { resolveAgentAuth, allowVerified } from "./agent-auth.js";
|
|
3
3
|
const FORBIDDEN = (msg) => new Response(JSON.stringify({ error: msg }), { status: 403, headers: { "Content-Type": "application/json" } });
|
|
4
4
|
const UNAUTH = () => new Response(JSON.stringify({ error: "authentication required" }), { status: 401, headers: { "Content-Type": "application/json" } });
|
|
5
|
+
const NOT_FOUND = () => new Response(JSON.stringify({ error: "not found" }), { status: 404, headers: { "Content-Type": "application/json" } });
|
|
5
6
|
/**
|
|
6
7
|
* MemoryGrant — an agent (ownerId) grants another (granteeId) scoped access to its
|
|
7
8
|
* memories. Self-authorizes now that the global gate is non-rejecting (previously a
|
|
@@ -16,6 +17,52 @@ export class MemoryGrant extends databases.flair.MemoryGrant {
|
|
|
16
17
|
_auth() {
|
|
17
18
|
return resolveAgentAuth(this.getContext?.());
|
|
18
19
|
}
|
|
20
|
+
/**
|
|
21
|
+
* Self-authorize now that the global gate is non-rejecting (memory-soul-
|
|
22
|
+
* read-gate family fix, ops-oox7 — same pattern as Memory.ts/Soul.ts/
|
|
23
|
+
* WorkspaceState.ts/Relationship.ts/Integration.ts). Closes the same P0
|
|
24
|
+
* leak: Harper routes `GET /MemoryGrant/<id>` to get() and the collection
|
|
25
|
+
* describe (`GET /MemoryGrant`) outside search(), so neither was gated
|
|
26
|
+
* before this fix — an anonymous caller got a 200 with full grant content.
|
|
27
|
+
* Per-record owner/grantee scoping happens in get() below; the collection
|
|
28
|
+
* scope is still in search().
|
|
29
|
+
*/
|
|
30
|
+
allowRead() { return allowVerified(this.getContext?.()); }
|
|
31
|
+
/**
|
|
32
|
+
* Override get() to scope by-id reads the same way search() scopes
|
|
33
|
+
* collection reads (memory-soul-read-gate family fix). A grant is visible
|
|
34
|
+
* to either party (ownerId OR granteeId), mirroring search()'s owner-OR-
|
|
35
|
+
* grantee scope. Never distinguishes "doesn't exist" from "exists but not
|
|
36
|
+
* yours" — both return 404, never 403, so a denied caller can't use get()
|
|
37
|
+
* to enumerate other agents' grant ids.
|
|
38
|
+
*/
|
|
39
|
+
async get(target) {
|
|
40
|
+
// Collection / query reads arrive as a RequestTarget with
|
|
41
|
+
// `isCollection === true`, and are governed by search() (same owner/
|
|
42
|
+
// grantee scoping). Only a genuine by-id get is ownership-checked below
|
|
43
|
+
// — see Memory.ts's get() for the full rationale (same bug class).
|
|
44
|
+
if (!target || (typeof target === "object" && target.isCollection)) {
|
|
45
|
+
return this.search(target);
|
|
46
|
+
}
|
|
47
|
+
const auth = await this._auth();
|
|
48
|
+
// Anonymous by-id read is already blocked at the allowRead() gate (403);
|
|
49
|
+
// this is defense-in-depth if get() is ever reached directly.
|
|
50
|
+
if (auth.kind === "anonymous") {
|
|
51
|
+
return NOT_FOUND();
|
|
52
|
+
}
|
|
53
|
+
// Trusted internal call or admin agent — unfiltered, unchanged behavior.
|
|
54
|
+
if (auth.kind === "internal" || (auth.kind === "agent" && auth.isAdmin)) {
|
|
55
|
+
return super.get(target);
|
|
56
|
+
}
|
|
57
|
+
// Non-admin agent: visible if it's the owner OR the grantee (parity with
|
|
58
|
+
// search()'s owner-OR-grantee scope).
|
|
59
|
+
const record = await super.get(target);
|
|
60
|
+
if (!record)
|
|
61
|
+
return NOT_FOUND();
|
|
62
|
+
if (record.ownerId !== auth.agentId && record.granteeId !== auth.agentId)
|
|
63
|
+
return NOT_FOUND();
|
|
64
|
+
return record;
|
|
65
|
+
}
|
|
19
66
|
async search(query) {
|
|
20
67
|
const auth = await this._auth();
|
|
21
68
|
if (auth.kind === "anonymous")
|
|
@@ -59,7 +106,13 @@ export class MemoryGrant extends databases.flair.MemoryGrant {
|
|
|
59
106
|
if (auth.kind === "internal" || (auth.kind === "agent" && auth.isAdmin)) {
|
|
60
107
|
return super.delete(id, context);
|
|
61
108
|
}
|
|
62
|
-
|
|
109
|
+
// Use super.get(id), NOT this.get(id): the new get() override above 404s
|
|
110
|
+
// (a truthy Response) for a non-owner/non-grantee id, which would
|
|
111
|
+
// otherwise defeat the `if (!record)` check below and mis-route a
|
|
112
|
+
// genuinely-missing record into the FORBIDDEN branch instead of a clean
|
|
113
|
+
// super.delete(id, context) no-op. Mirrors Memory.ts's delete() — same
|
|
114
|
+
// rationale, same fix.
|
|
115
|
+
const record = await super.get(id);
|
|
63
116
|
if (!record)
|
|
64
117
|
return super.delete(id, context);
|
|
65
118
|
if (record.ownerId !== auth.agentId) {
|