@tpsdev-ai/flair 0.18.0 → 0.20.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 +1 -1
- 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 +9 -1
- package/dist/resources/AdminPrincipals.js +6 -0
- package/dist/resources/Integration.js +52 -2
- package/dist/resources/Memory.js +187 -21
- package/dist/resources/MemoryBootstrap.js +99 -22
- package/dist/resources/MemoryGrant.js +55 -2
- package/dist/resources/Presence.js +9 -36
- package/dist/resources/Relationship.js +49 -1
- package/dist/resources/SemanticSearch.js +78 -33
- package/dist/resources/Soul.js +12 -1
- package/dist/resources/WorkspaceState.js +56 -2
- package/dist/resources/agent-auth.js +10 -34
- package/dist/resources/auth-middleware.js +20 -53
- package/dist/resources/bm25-filter.js +9 -0
- package/dist/resources/dedup.js +24 -0
- package/dist/resources/ed25519-auth.js +119 -0
- package/dist/resources/memory-read-scope.js +112 -0
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -7123,7 +7123,7 @@ memory.command("add [content]").requiredOption("--agent <id>")
|
|
|
7123
7123
|
.option("--summary <text>", "agent-set multi-sentence dense compression (3-tier chain: subject → summary → content; ops-wkoh)")
|
|
7124
7124
|
.option("--subject <text>", "one-line title / entity this memory is about")
|
|
7125
7125
|
.option("--derived-from <csv>", "Comma-separated source Memory IDs this memory was distilled/reflected from (sets Memory.derivedFrom; used by the `rem rapid` reflection loop)")
|
|
7126
|
-
.option("--visibility <value>", "
|
|
7126
|
+
.option("--visibility <value>", "Writer-controlled sharing intent (sets Memory.visibility): 'private' (owner-only, never visible to a grant-holder) or 'shared' (visible to owner + any agent holding a read/search MemoryGrant). Omit to use the server's durability-keyed default: permanent/persistent -> shared, standard/ephemeral -> private (flair#509, ops-2dm3)")
|
|
7127
7127
|
.action(async (contentArg, opts) => {
|
|
7128
7128
|
const content = contentArg ?? opts.content;
|
|
7129
7129
|
if (!content) {
|
package/dist/resources/Admin.js
CHANGED
|
@@ -1,12 +1,24 @@
|
|
|
1
1
|
import { Resource } from "@harperfast/harper";
|
|
2
|
+
import { allowAdmin } from "./agent-auth.js";
|
|
2
3
|
/**
|
|
3
4
|
* GET /Admin — friendly redirect to /AdminDashboard.
|
|
4
5
|
*
|
|
5
6
|
* Operators bookmark or type the bare /Admin path. Without this resource
|
|
6
7
|
* they hit a 404 and assume the admin UI is broken. The dashboard is the
|
|
7
8
|
* canonical landing surface; redirect there.
|
|
9
|
+
*
|
|
10
|
+
* allowRead()=allowAdmin (ops-oox7 defense-in-depth): the /Admin* pathname
|
|
11
|
+
* gate in auth-middleware.ts only 401s when there's NO Authorization header
|
|
12
|
+
* at all (or Basic creds don't resolve to a real user) — a validly-verified
|
|
13
|
+
* TPS-Ed25519 agent that is NOT an admin, or a valid-but-non-super_user Basic
|
|
14
|
+
* user, currently sails through the middleware with no admin resource-level
|
|
15
|
+
* check at all. allowRead closes that gap the same way WorkspaceLatest.ts /
|
|
16
|
+
* MemoryReindex.ts already gate their own custom (non-@table) Resources.
|
|
8
17
|
*/
|
|
9
18
|
export class Admin extends Resource {
|
|
19
|
+
async allowRead() {
|
|
20
|
+
return allowAdmin(this.getContext?.());
|
|
21
|
+
}
|
|
10
22
|
async get() {
|
|
11
23
|
return new Response("", {
|
|
12
24
|
status: 302,
|
|
@@ -1,9 +1,15 @@
|
|
|
1
1
|
import { Resource, databases } from "@harperfast/harper";
|
|
2
2
|
import { layout, htmlResponse, esc } from "./admin-layout.js";
|
|
3
|
+
import { allowAdmin } from "./agent-auth.js";
|
|
3
4
|
/**
|
|
4
5
|
* GET /AdminConnectors — OAuth clients and active sessions.
|
|
6
|
+
*
|
|
7
|
+
* allowRead()=allowAdmin (ops-oox7 defense-in-depth): see Admin.ts.
|
|
5
8
|
*/
|
|
6
9
|
export class AdminConnectors extends Resource {
|
|
10
|
+
async allowRead() {
|
|
11
|
+
return allowAdmin(this.getContext?.());
|
|
12
|
+
}
|
|
7
13
|
async get() {
|
|
8
14
|
const clients = [];
|
|
9
15
|
try {
|
|
@@ -1,9 +1,18 @@
|
|
|
1
1
|
import { Resource, databases } from "@harperfast/harper";
|
|
2
2
|
import { layout, htmlResponse } from "./admin-layout.js";
|
|
3
|
+
import { allowAdmin } from "./agent-auth.js";
|
|
3
4
|
/**
|
|
4
5
|
* GET /AdminDashboard — admin home page with system overview.
|
|
6
|
+
*
|
|
7
|
+
* allowRead()=allowAdmin (ops-oox7 defense-in-depth): see Admin.ts for why
|
|
8
|
+
* this closes a real gap, not just belt-and-suspenders — a verified
|
|
9
|
+
* non-admin agent could otherwise read full instance stats (principal/agent/
|
|
10
|
+
* memory/relationship counts) with no admin check at all.
|
|
5
11
|
*/
|
|
6
12
|
export class AdminDashboard extends Resource {
|
|
13
|
+
async allowRead() {
|
|
14
|
+
return allowAdmin(this.getContext?.());
|
|
15
|
+
}
|
|
7
16
|
async get() {
|
|
8
17
|
let principalCount = 0;
|
|
9
18
|
let memoryCount = 0;
|
|
@@ -1,9 +1,15 @@
|
|
|
1
1
|
import { Resource, databases } from "@harperfast/harper";
|
|
2
2
|
import { layout, htmlResponse, esc } from "./admin-layout.js";
|
|
3
|
+
import { allowAdmin } from "./agent-auth.js";
|
|
3
4
|
/**
|
|
4
5
|
* GET /AdminIdp — enterprise IdP configuration management.
|
|
6
|
+
*
|
|
7
|
+
* allowRead()=allowAdmin (ops-oox7 defense-in-depth): see Admin.ts.
|
|
5
8
|
*/
|
|
6
9
|
export class AdminIdp extends Resource {
|
|
10
|
+
async allowRead() {
|
|
11
|
+
return allowAdmin(this.getContext?.());
|
|
12
|
+
}
|
|
7
13
|
async get() {
|
|
8
14
|
const idps = [];
|
|
9
15
|
try {
|
|
@@ -4,6 +4,7 @@ import { existsSync, readFileSync } from "node:fs";
|
|
|
4
4
|
import { join, dirname } from "node:path";
|
|
5
5
|
import { homedir } from "node:os";
|
|
6
6
|
import { fileURLToPath } from "node:url";
|
|
7
|
+
import { allowAdmin } from "./agent-auth.js";
|
|
7
8
|
/**
|
|
8
9
|
* Resolve the public URL operators reach this Flair on.
|
|
9
10
|
*
|
|
@@ -83,8 +84,13 @@ function resolveVersion() {
|
|
|
83
84
|
}
|
|
84
85
|
/**
|
|
85
86
|
* GET /AdminInstance — instance info, public key, version.
|
|
87
|
+
*
|
|
88
|
+
* allowRead()=allowAdmin (ops-oox7 defense-in-depth): see Admin.ts.
|
|
86
89
|
*/
|
|
87
90
|
export class AdminInstance extends Resource {
|
|
91
|
+
async allowRead() {
|
|
92
|
+
return allowAdmin(this.getContext?.());
|
|
93
|
+
}
|
|
88
94
|
async get() {
|
|
89
95
|
const version = resolveVersion();
|
|
90
96
|
// Pass the request through so URL resolution can derive from
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Resource, databases } from "@harperfast/harper";
|
|
2
2
|
import { layout, htmlResponse, esc } from "./admin-layout.js";
|
|
3
|
+
import { allowAdmin } from "./agent-auth.js";
|
|
3
4
|
/**
|
|
4
5
|
* GET /AdminMemory browse + search memories (list view)
|
|
5
6
|
* GET /AdminMemory?id=<id> per-memory detail view with full provenance pane
|
|
@@ -19,8 +20,15 @@ import { layout, htmlResponse, esc } from "./admin-layout.js";
|
|
|
19
20
|
* "memory that follows the agent across orchestrators" — the provenance pane
|
|
20
21
|
* makes that legible: every memory shows where it came from, what it derived
|
|
21
22
|
* from, what it superseded, and which peer it synced from.
|
|
23
|
+
*
|
|
24
|
+
* allowRead()=allowAdmin (ops-oox7 defense-in-depth): see Admin.ts — this
|
|
25
|
+
* page surfaces full memory content across ALL agents, so the gap it closes
|
|
26
|
+
* matters more here than on the other Admin* pages.
|
|
22
27
|
*/
|
|
23
28
|
export class AdminMemory extends Resource {
|
|
29
|
+
async allowRead() {
|
|
30
|
+
return allowAdmin(this.getContext?.());
|
|
31
|
+
}
|
|
24
32
|
async get() {
|
|
25
33
|
// Harper v5 does not populate this.request on Resource subclasses —
|
|
26
34
|
// getContext() is the only reliable path (ops-sal4: the previous
|
|
@@ -334,7 +342,7 @@ export class AdminMemory extends Resource {
|
|
|
334
342
|
<dl style="display:grid;grid-template-columns:200px 1fr;gap:6px;margin-top:8px;font-family:ui-monospace,SF Mono,monospace;font-size:0.85em">
|
|
335
343
|
<dt style="color:#666">id</dt><dd>${esc(m.id)}</dd>
|
|
336
344
|
<dt style="color:#666">contentHash</dt><dd>${esc(m.contentHash ?? "—")}</dd>
|
|
337
|
-
<dt style="color:#666">visibility</dt><dd>${esc(m.visibility ?? "
|
|
345
|
+
<dt style="color:#666">visibility</dt><dd>${esc(m.visibility ?? "shared (no field — pre-migration default)")}</dd>
|
|
338
346
|
</dl>
|
|
339
347
|
</div>
|
|
340
348
|
`;
|
|
@@ -1,9 +1,15 @@
|
|
|
1
1
|
import { Resource, databases } from "@harperfast/harper";
|
|
2
2
|
import { layout, htmlResponse, esc } from "./admin-layout.js";
|
|
3
|
+
import { allowAdmin } from "./agent-auth.js";
|
|
3
4
|
/**
|
|
4
5
|
* GET /AdminPrincipals — list all principals with kind, trust, status.
|
|
6
|
+
*
|
|
7
|
+
* allowRead()=allowAdmin (ops-oox7 defense-in-depth): see Admin.ts.
|
|
5
8
|
*/
|
|
6
9
|
export class AdminPrincipals extends Resource {
|
|
10
|
+
async allowRead() {
|
|
11
|
+
return allowAdmin(this.getContext?.());
|
|
12
|
+
}
|
|
7
13
|
async get() {
|
|
8
14
|
const principals = [];
|
|
9
15
|
try {
|
|
@@ -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
|
* Integration records are agent-owned. Auth: the non-rejecting gate annotates the
|
|
7
8
|
* request; this resource self-enforces (resolveAgentAuth → internal/agent/anonymous).
|
|
@@ -12,6 +13,50 @@ export class Integration extends databases.flair.Integration {
|
|
|
12
13
|
_auth() {
|
|
13
14
|
return resolveAgentAuth(this.getContext?.());
|
|
14
15
|
}
|
|
16
|
+
/**
|
|
17
|
+
* Self-authorize now that the global gate is non-rejecting (memory-soul-
|
|
18
|
+
* read-gate family fix, ops-oox7 — same pattern as Memory.ts/Soul.ts/
|
|
19
|
+
* WorkspaceState.ts/Relationship.ts). Closes the same P0 leak: Harper
|
|
20
|
+
* routes `GET /Integration/<id>` to get() and the collection describe
|
|
21
|
+
* (`GET /Integration`) outside search(), so neither was gated before this
|
|
22
|
+
* fix — an anonymous caller got a 200 with full record content. Per-record
|
|
23
|
+
* ownership scoping happens in get() below; the collection scope is still
|
|
24
|
+
* in search().
|
|
25
|
+
*/
|
|
26
|
+
allowRead() { return allowVerified(this.getContext?.()); }
|
|
27
|
+
/**
|
|
28
|
+
* Override get() to scope by-id reads the same way search() scopes
|
|
29
|
+
* collection reads (memory-soul-read-gate family fix). Never distinguishes
|
|
30
|
+
* "doesn't exist" from "exists but not yours" — both return 404, never
|
|
31
|
+
* 403, so a denied caller can't use get() to enumerate other agents'
|
|
32
|
+
* integration ids.
|
|
33
|
+
*/
|
|
34
|
+
async get(target) {
|
|
35
|
+
// Collection / query reads arrive as a RequestTarget with
|
|
36
|
+
// `isCollection === true`, and are governed by search() (same owner
|
|
37
|
+
// scoping). Only a genuine by-id get is ownership-checked below — see
|
|
38
|
+
// Memory.ts's get() for the full rationale (same bug class).
|
|
39
|
+
if (!target || (typeof target === "object" && target.isCollection)) {
|
|
40
|
+
return this.search(target);
|
|
41
|
+
}
|
|
42
|
+
const auth = await this._auth();
|
|
43
|
+
// Anonymous by-id read is already blocked at the allowRead() gate (403);
|
|
44
|
+
// this is defense-in-depth if get() is ever reached directly.
|
|
45
|
+
if (auth.kind === "anonymous") {
|
|
46
|
+
return NOT_FOUND();
|
|
47
|
+
}
|
|
48
|
+
// Trusted internal call or admin agent — unfiltered, unchanged behavior.
|
|
49
|
+
if (auth.kind === "internal" || (auth.kind === "agent" && auth.isAdmin)) {
|
|
50
|
+
return super.get(target);
|
|
51
|
+
}
|
|
52
|
+
// Non-admin agent: only its own integrations.
|
|
53
|
+
const record = await super.get(target);
|
|
54
|
+
if (!record)
|
|
55
|
+
return NOT_FOUND();
|
|
56
|
+
if (record.agentId !== auth.agentId)
|
|
57
|
+
return NOT_FOUND();
|
|
58
|
+
return record;
|
|
59
|
+
}
|
|
15
60
|
async search(query) {
|
|
16
61
|
const auth = await this._auth();
|
|
17
62
|
if (auth.kind === "anonymous")
|
|
@@ -66,7 +111,12 @@ export class Integration extends databases.flair.Integration {
|
|
|
66
111
|
if (auth.kind === "internal" || (auth.kind === "agent" && auth.isAdmin)) {
|
|
67
112
|
return super.delete(id);
|
|
68
113
|
}
|
|
69
|
-
|
|
114
|
+
// Use super.get(id), NOT this.get(id): the new get() override above 404s
|
|
115
|
+
// (a truthy Response) for a non-owner id, which would otherwise defeat
|
|
116
|
+
// the `if (!record)` check below and mis-route a genuinely-missing
|
|
117
|
+
// record into the FORBIDDEN branch instead of a clean super.delete(id)
|
|
118
|
+
// no-op. Mirrors Memory.ts's delete() — same rationale, same fix.
|
|
119
|
+
const record = await super.get(id);
|
|
70
120
|
if (!record)
|
|
71
121
|
return super.delete(id);
|
|
72
122
|
if (record.agentId !== auth.agentId) {
|
package/dist/resources/Memory.js
CHANGED
|
@@ -1,12 +1,26 @@
|
|
|
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 {
|
|
7
|
+
import { resolveReadScope } from "./memory-read-scope.js";
|
|
8
|
+
import { DEDUP_COSINE_THRESHOLD_DEFAULT, DEDUP_LEXICAL_THRESHOLD_DEFAULT, DEDUP_MIN_CONTENT_LENGTH, computeMatchConfidence, cosineSimilarity, isConservativeMatch, } from "./dedup.js";
|
|
8
9
|
const FORBIDDEN = (msg) => new Response(JSON.stringify({ error: msg }), { status: 403, headers: { "Content-Type": "application/json" } });
|
|
9
10
|
const UNAUTH = () => new Response(JSON.stringify({ error: "authentication required" }), { status: 401, headers: { "Content-Type": "application/json" } });
|
|
11
|
+
const NOT_FOUND = () => new Response(JSON.stringify({ error: "not found" }), { status: 404, headers: { "Content-Type": "application/json" } });
|
|
12
|
+
/**
|
|
13
|
+
* Owner ids a non-admin agent may READ (resolveAllowedOwners) and the full
|
|
14
|
+
* read-scope condition + private-exclusion predicate (resolveReadScope) now
|
|
15
|
+
* live in ./memory-read-scope.ts — the ONE centralized helper every
|
|
16
|
+
* cross-agent Memory read path (search()/get() here, SemanticSearch.ts,
|
|
17
|
+
* MemoryBootstrap.ts, auth-middleware.ts's by-id guard) resolves its scope
|
|
18
|
+
* through, so the scoping rule cannot drift per-path again (ops-nzxa: a
|
|
19
|
+
* SemanticSearch inline `visibility === "office"` OR-clause leaked office
|
|
20
|
+
* memories to any authenticated agent because the rule had scattered). See
|
|
21
|
+
* that module's doc for the migration invariant (no-visibility-field reads
|
|
22
|
+
* as "shared", never "private").
|
|
23
|
+
*/
|
|
10
24
|
/**
|
|
11
25
|
* ─── Server-side conservative-duplicate gate (memory-integrity fix) ──────────
|
|
12
26
|
*
|
|
@@ -72,7 +86,59 @@ async function findConservativeDedupMatch(ctx, agentId, contentText, embedding,
|
|
|
72
86
|
}
|
|
73
87
|
if (!top)
|
|
74
88
|
return null;
|
|
75
|
-
|
|
89
|
+
// ─── ops-ume4: Harper's cosine-sort query omits $distance for a SINGLETON
|
|
90
|
+
// result set ─────────────────────────────────────────────────────────────
|
|
91
|
+
// Initial working theory was a per-agentId HNSW "cold-start" (first-ever
|
|
92
|
+
// query cold, second query warm) and the initially-recommended fix was a
|
|
93
|
+
// same-query retry. Empirically FALSIFIED: a plain retry of the identical
|
|
94
|
+
// query, 8x with 300ms delays (2.4s total), never recovered a `$distance`
|
|
95
|
+
// for a genuinely singleton candidate set (exactly one record matching
|
|
96
|
+
// `agentId equals X AND archived not_equal true`). The actual trigger,
|
|
97
|
+
// confirmed by direct probing: when this query's post-filter result set
|
|
98
|
+
// has exactly ONE matching record, `$distance` comes back `undefined` for
|
|
99
|
+
// it — regardless of how many prior queries have run for that agentId,
|
|
100
|
+
// how long you wait, or how many other agentIds/records already exist in
|
|
101
|
+
// the table. The moment a SECOND matching record exists, `$distance` is
|
|
102
|
+
// populated correctly on the very first query ever issued for that
|
|
103
|
+
// agentId — no warm-up needed. In practice the singleton case is exactly
|
|
104
|
+
// an agent's SECOND-ever memory (compared against their first) — the most
|
|
105
|
+
// common real-world trigger for this bug, and why it looked "permanent
|
|
106
|
+
// per-agent for the first near-dup query."
|
|
107
|
+
//
|
|
108
|
+
// Also NOT a query-shape/conditions issue: the `{operator:"or"}` wrap
|
|
109
|
+
// SemanticSearch.ts uses elsewhere is unrelated, and neither raising
|
|
110
|
+
// `limit` past 1 nor changing the conditions shape changes the result —
|
|
111
|
+
// confirmed empirically. Harper's SORT ordering is correct even in the
|
|
112
|
+
// singleton case (the right record comes back as `top`); only the
|
|
113
|
+
// numeric `$distance` annotation is missing. Also confirmed: selecting
|
|
114
|
+
// "embedding" directly on THIS sort-by-embedding query does not help
|
|
115
|
+
// either — it comes back as a bare scalar (Harper appears to special-case
|
|
116
|
+
// the sort attribute in `select`), not the stored vector.
|
|
117
|
+
//
|
|
118
|
+
// Fix: when `$distance` is undefined, fetch the ONE candidate's full
|
|
119
|
+
// record by id (a plain point lookup — not a vector-sort query, so
|
|
120
|
+
// unaffected by the quirk above) and compute cosine similarity ourselves
|
|
121
|
+
// in JS from its real stored `embedding` vector against this write's own
|
|
122
|
+
// `embedding` (this function's parameter), via the same math Harper would
|
|
123
|
+
// have used (dedup.ts's cosineSimilarity, Harper-free and unit-tested).
|
|
124
|
+
// This sidesteps the underlying engine quirk entirely rather than
|
|
125
|
+
// depending on its timing, and works identically whether this is the
|
|
126
|
+
// agent's first query ever or its thousandth. Never suppresses the write
|
|
127
|
+
// either way: if the candidate's embedding is somehow also missing (e.g.
|
|
128
|
+
// a legacy record written before embeddings existed), `cosineSimilarity`
|
|
129
|
+
// returns 0 — the same safe "no match" signal the pre-fix `?? 1`
|
|
130
|
+
// fallback produced.
|
|
131
|
+
let cosine;
|
|
132
|
+
if (top.$distance !== undefined) {
|
|
133
|
+
cosine = 1 - top.$distance;
|
|
134
|
+
}
|
|
135
|
+
else {
|
|
136
|
+
console.error("Memory.findConservativeDedupMatch: $distance undefined on a singleton cosine result (ops-ume4) — " +
|
|
137
|
+
"falling back to a manual cosine computation from the candidate's stored embedding", { agentId, candidateId: top.id });
|
|
138
|
+
const fullCandidate = await withDetachedTxn(ctx, () => databases.flair.Memory.get(top.id));
|
|
139
|
+
const candidateEmbedding = Array.isArray(fullCandidate?.embedding) ? fullCandidate.embedding : [];
|
|
140
|
+
cosine = cosineSimilarity(embedding, candidateEmbedding);
|
|
141
|
+
}
|
|
76
142
|
const confidence = computeMatchConfidence(contentText, top.content, cosine);
|
|
77
143
|
if (!isConservativeMatch(confidence.cosine, confidence.lexical, cosineThreshold, lexicalThreshold)) {
|
|
78
144
|
return null;
|
|
@@ -230,7 +296,81 @@ async function closeSupersededIfNeeded(ctx, content, methodLabel) {
|
|
|
230
296
|
"(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 });
|
|
231
297
|
}
|
|
232
298
|
}
|
|
299
|
+
/**
|
|
300
|
+
* ─── Durability-keyed default visibility (ops-2dm3 Layer 1, part A) ─────────
|
|
301
|
+
*
|
|
302
|
+
* Writer intent: an explicit `visibility` on the write ALWAYS overrides this
|
|
303
|
+
* (callers check `content.visibility == null` before calling this). When
|
|
304
|
+
* unset, the default is keyed off durability — a durable write (the agent
|
|
305
|
+
* chose to make this stick around) defaults to shared; anything else
|
|
306
|
+
* defaults to private. "absent" durability (not yet defaulted by the caller)
|
|
307
|
+
* falls into the private branch, matching the spec's
|
|
308
|
+
* "standard|ephemeral|absent → private".
|
|
309
|
+
*/
|
|
310
|
+
function defaultVisibilityForDurability(durability) {
|
|
311
|
+
return durability === "permanent" || durability === "persistent" ? "shared" : "private";
|
|
312
|
+
}
|
|
233
313
|
export class Memory extends databases.flair.Memory {
|
|
314
|
+
/**
|
|
315
|
+
* Self-authorize now that the global gate is non-rejecting. Closes the P0
|
|
316
|
+
* leak: Harper routes `GET /Memory/<id>` to get() and the collection
|
|
317
|
+
* describe (`GET /Memory`) to a path outside search() — neither was gated
|
|
318
|
+
* before this fix, so an anonymous caller got a 200 with full record
|
|
319
|
+
* content / schema even though search() (and the write paths) correctly
|
|
320
|
+
* 401/403'd. Per-record ownership/grant scoping happens in get() below;
|
|
321
|
+
* the collection scope is still in search().
|
|
322
|
+
*
|
|
323
|
+
* allowCreate/allowUpdate/allowDelete are deliberately NOT added here:
|
|
324
|
+
* post()/put()/delete() already self-enforce per-agent ownership inline
|
|
325
|
+
* (resolveAgentAuth + explicit agentId checks in post()/put(), and the
|
|
326
|
+
* isAdmin durability check in delete()). Adding allow* on top of that,
|
|
327
|
+
* unverified, risks regressing owner writes/deletes on a P0 security fix
|
|
328
|
+
* that is scoped to the read leak — left as-is on purpose.
|
|
329
|
+
*/
|
|
330
|
+
allowRead() { return allowVerified(this.getContext?.()); }
|
|
331
|
+
/**
|
|
332
|
+
* Override get() to scope by-id reads the same way search() scopes
|
|
333
|
+
* collection reads (memory-soul-read-gate fix). Never distinguishes
|
|
334
|
+
* "doesn't exist" from "exists but not yours" — both return 404, never
|
|
335
|
+
* 403, so a denied caller can't use get() to enumerate other agents'
|
|
336
|
+
* memory ids.
|
|
337
|
+
*/
|
|
338
|
+
async get(target) {
|
|
339
|
+
// Collection / query reads — the `GET /Memory/?<query>` form and the bare
|
|
340
|
+
// collection — arrive as a RequestTarget with `isCollection === true`, and
|
|
341
|
+
// are governed by search() (same owner/grant scoping). Only a genuine by-id
|
|
342
|
+
// get is ownership-checked below. Without this guard, get() would receive
|
|
343
|
+
// the query's RequestTarget, super.get() would return the (truthy) result
|
|
344
|
+
// set, the single-record check would find no `.agentId` on it, and a valid
|
|
345
|
+
// authenticated self-query would 404 (regression caught by the auth-
|
|
346
|
+
// middleware e2e "TPS-Ed25519 on GET /Memory/?agentId=X → 200"). A by-id
|
|
347
|
+
// get (RequestTarget with isCollection false, or a bare id) falls through.
|
|
348
|
+
if (!target || (typeof target === "object" && target.isCollection)) {
|
|
349
|
+
return this.search(target);
|
|
350
|
+
}
|
|
351
|
+
const ctx = this.getContext?.();
|
|
352
|
+
const auth = await resolveAgentAuth(ctx);
|
|
353
|
+
// Anonymous by-id read is already blocked at the allowRead() gate (403);
|
|
354
|
+
// this is defense-in-depth if get() is ever reached directly.
|
|
355
|
+
if (auth.kind === "anonymous") {
|
|
356
|
+
return NOT_FOUND();
|
|
357
|
+
}
|
|
358
|
+
// Trusted internal call or admin agent — unfiltered, unchanged behavior.
|
|
359
|
+
if (auth.kind === "internal" || (auth.kind === "agent" && auth.isAdmin)) {
|
|
360
|
+
return super.get(target);
|
|
361
|
+
}
|
|
362
|
+
// Non-admin agent: only its own memories (any visibility), or a granted
|
|
363
|
+
// owner's SHARED memories — never that owner's private ones (ops-2dm3
|
|
364
|
+
// Layer 1 private-exclusion). Centralized in resolveReadScope() so this
|
|
365
|
+
// and search() below cannot drift.
|
|
366
|
+
const record = await super.get(target);
|
|
367
|
+
if (!record)
|
|
368
|
+
return NOT_FOUND();
|
|
369
|
+
const scope = await resolveReadScope(auth.agentId);
|
|
370
|
+
if (!scope.isAllowed(record))
|
|
371
|
+
return NOT_FOUND();
|
|
372
|
+
return record;
|
|
373
|
+
}
|
|
234
374
|
/**
|
|
235
375
|
* Override search() to scope collection GETs by authenticated agent.
|
|
236
376
|
*
|
|
@@ -256,23 +396,12 @@ export class Memory extends databases.flair.Memory {
|
|
|
256
396
|
if (auth.kind === "internal" || (auth.kind === "agent" && auth.isAdmin)) {
|
|
257
397
|
return super.search(query);
|
|
258
398
|
}
|
|
259
|
-
// Non-admin agent: scope to own + granted owners
|
|
399
|
+
// Non-admin agent: scope to own (any visibility) + granted owners' SHARED
|
|
400
|
+
// memories only (ops-2dm3 Layer 1 private-exclusion). Centralized in
|
|
401
|
+
// resolveReadScope() so get() above and search() here cannot drift.
|
|
260
402
|
const authAgent = auth.agentId;
|
|
261
|
-
const
|
|
262
|
-
|
|
263
|
-
for await (const grant of databases.flair.MemoryGrant.search({
|
|
264
|
-
conditions: [{ attribute: "granteeId", comparator: "equals", value: authAgent }],
|
|
265
|
-
})) {
|
|
266
|
-
if (grant.ownerId && (grant.scope === "read" || grant.scope === "search")) {
|
|
267
|
-
allowedOwners.push(grant.ownerId);
|
|
268
|
-
}
|
|
269
|
-
}
|
|
270
|
-
}
|
|
271
|
-
catch { /* MemoryGrant table not yet populated — ignore */ }
|
|
272
|
-
// Build the agentId scope condition
|
|
273
|
-
const agentIdCondition = allowedOwners.length === 1
|
|
274
|
-
? { attribute: "agentId", comparator: "equals", value: allowedOwners[0] }
|
|
275
|
-
: { conditions: allowedOwners.map(id => ({ attribute: "agentId", comparator: "equals", value: id })), operator: "or" };
|
|
403
|
+
const scope = await resolveReadScope(authAgent);
|
|
404
|
+
const agentIdCondition = scope.condition;
|
|
276
405
|
// Harper passes `query` as a RequestTarget (extends URLSearchParams) or a
|
|
277
406
|
// conditions array. For URL-based GET /Memory?... calls, URL params are no
|
|
278
407
|
// longer translated to conditions here — callers should use
|
|
@@ -321,6 +450,16 @@ export class Memory extends databases.flair.Memory {
|
|
|
321
450
|
content.createdAt = new Date().toISOString();
|
|
322
451
|
content.updatedAt = content.createdAt;
|
|
323
452
|
content.archived = content.archived ?? false;
|
|
453
|
+
// ─── Default visibility (durability-keyed) — ops-2dm3 Layer 1, part A ────
|
|
454
|
+
// post() only ever creates a NEW record — patchRecord/supersede-close/
|
|
455
|
+
// retrievalCount bumps all route through put() instead (see put()'s
|
|
456
|
+
// pre-existing-record guard below), so there is no "don't overwrite an
|
|
457
|
+
// existing record's visibility" concern here. Explicit visibility on the
|
|
458
|
+
// write ALWAYS overrides; only stamp the default when the caller left it
|
|
459
|
+
// unset. permanent|persistent → shared; standard|ephemeral|absent → private.
|
|
460
|
+
if (content.visibility === undefined || content.visibility === null) {
|
|
461
|
+
content.visibility = defaultVisibilityForDurability(content.durability);
|
|
462
|
+
}
|
|
324
463
|
// Validate derivedFrom source IDs exist (best-effort, non-blocking)
|
|
325
464
|
if (Array.isArray(content.derivedFrom) && content.derivedFrom.length > 0) {
|
|
326
465
|
const now = content.createdAt;
|
|
@@ -438,6 +577,26 @@ export class Memory extends databases.flair.Memory {
|
|
|
438
577
|
// Set defaults that post() sets — put() is also used for new records via CLI
|
|
439
578
|
content.archived = content.archived ?? false;
|
|
440
579
|
content.createdAt = content.createdAt ?? now;
|
|
580
|
+
// Fetch the pre-existing record (if any) ONCE — reused below both to
|
|
581
|
+
// decide whether this PUT is a fresh create (dedup gate applies, default
|
|
582
|
+
// visibility stamped) or an update/patch (dedup-bypassed, visibility left
|
|
583
|
+
// untouched). See the dedup-gate block further down for why an existing
|
|
584
|
+
// id skips the gate; the SAME "does a record already exist" check gates
|
|
585
|
+
// the visibility default (ops-2dm3 Layer 1 part A): patchRecord/supersede-
|
|
586
|
+
// close/retrievalCount bumps all route through put() with a MERGED
|
|
587
|
+
// `{...existing, ...patch}` payload, and must never have their stored
|
|
588
|
+
// visibility overwritten by a default recomputed from that merged content
|
|
589
|
+
// — only a genuinely NEW id gets the default stamped.
|
|
590
|
+
const preExisting = content.id
|
|
591
|
+
? await databases.flair.Memory.get(content.id).catch(() => null)
|
|
592
|
+
: null;
|
|
593
|
+
// ─── Default visibility (durability-keyed) — ops-2dm3 Layer 1, part A ────
|
|
594
|
+
// Explicit visibility on the write ALWAYS overrides; only stamp the
|
|
595
|
+
// default when the caller left it unset AND this is a fresh record.
|
|
596
|
+
// permanent|persistent → shared; standard|ephemeral|absent → private.
|
|
597
|
+
if (!preExisting && (content.visibility === undefined || content.visibility === null)) {
|
|
598
|
+
content.visibility = defaultVisibilityForDurability(content.durability);
|
|
599
|
+
}
|
|
441
600
|
// supersedes: optional reference to the ID of the memory this one
|
|
442
601
|
// replaces. Validates shape + cross-agent-write authorization (shared
|
|
443
602
|
// with post() — see validateAndAuthorizeSupersedes doc for why PUT needs
|
|
@@ -481,7 +640,6 @@ export class Memory extends databases.flair.Memory {
|
|
|
481
640
|
delete content.lexicalThreshold;
|
|
482
641
|
}
|
|
483
642
|
else if (content.id) {
|
|
484
|
-
const preExisting = await databases.flair.Memory.get(content.id).catch(() => null);
|
|
485
643
|
if (!preExisting) {
|
|
486
644
|
dedupMatch = await runDedupGate(ctx, content);
|
|
487
645
|
}
|
|
@@ -523,7 +681,15 @@ export class Memory extends databases.flair.Memory {
|
|
|
523
681
|
return buildWriteResponse(content, result, dedupMatch);
|
|
524
682
|
}
|
|
525
683
|
async delete(id) {
|
|
526
|
-
|
|
684
|
+
// Use super.get(id), NOT this.get(id): the new get() override above 404s
|
|
685
|
+
// (a truthy Response) for a non-owner/non-granted id, which would
|
|
686
|
+
// otherwise short-circuit the `record.durability === "permanent"` check
|
|
687
|
+
// below (a Response has no .durability) and silently bypass the
|
|
688
|
+
// admin-only permanent-delete guard for cross-agent deletes. This keeps
|
|
689
|
+
// delete()'s own pre-existing ownership/admin logic exactly as it was
|
|
690
|
+
// before the read-gate fix — the read-scoping override must not leak
|
|
691
|
+
// into delete()'s internal record lookup.
|
|
692
|
+
const record = await super.get(id);
|
|
527
693
|
if (!record)
|
|
528
694
|
return super.delete(id);
|
|
529
695
|
if (record.durability === "permanent") {
|