@tpsdev-ai/flair 0.22.1 → 0.23.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 +555 -110
- package/dist/doctor-client.js +176 -0
- package/dist/install/clients.js +46 -11
- package/dist/probe.js +15 -2
- package/dist/resources/Memory.js +80 -60
- package/dist/resources/OrgEvent.js +37 -26
- package/dist/resources/Relationship.js +52 -53
- package/dist/resources/Soul.js +16 -8
- package/dist/resources/WorkspaceState.js +58 -54
- package/dist/resources/mcp-handler.js +29 -5
- package/dist/resources/mcp-tools.js +50 -3
- package/dist/resources/provenance.js +60 -8
- package/dist/resources/record-type-kit.js +253 -0
- package/dist/resources/record-types.js +363 -0
- package/package.json +2 -2
- package/schemas/memory.graphql +2 -2
|
@@ -123,13 +123,20 @@ async function memoryStore(agent, args) {
|
|
|
123
123
|
// agentId is the RESOLVED agent — Memory.post also re-checks ownership via
|
|
124
124
|
// resolveAgentAuth, so a mismatched body agentId would 403 anyway; we set it
|
|
125
125
|
// to the verified id so the write is correctly owned.
|
|
126
|
-
|
|
126
|
+
const body = {
|
|
127
127
|
agentId: agent.agentId,
|
|
128
128
|
content: args?.content,
|
|
129
129
|
type: args?.type ?? "session",
|
|
130
130
|
durability: args?.durability ?? "standard",
|
|
131
131
|
tags: args?.tags,
|
|
132
|
-
}
|
|
132
|
+
};
|
|
133
|
+
// flair#718 authorship-provenance: forward the resolved OAuth client_id
|
|
134
|
+
// (never a tool argument — no forging) as claimedClient; Memory.post()
|
|
135
|
+
// folds it into provenance.claimed.client and strips it from the row.
|
|
136
|
+
// Omitted entirely when the token carried no client_id.
|
|
137
|
+
if (agent.clientId)
|
|
138
|
+
body.claimedClient = agent.clientId;
|
|
139
|
+
return unwrap(await h.post(body));
|
|
133
140
|
}
|
|
134
141
|
/**
|
|
135
142
|
* memory_update — id-targeted, dedup-BYPASSED overwrite/version path (memory-
|
|
@@ -175,12 +182,20 @@ async function memoryUpdate(agent, args) {
|
|
|
175
182
|
delete record.validFrom;
|
|
176
183
|
delete record.validTo;
|
|
177
184
|
delete record.archivedAt;
|
|
185
|
+
// flair#718 authorship-provenance — see memoryStore's comment: forward
|
|
186
|
+
// the resolved OAuth client_id (never forgeable via args) so the NEW
|
|
187
|
+
// version's provenance records which client authored this update.
|
|
188
|
+
if (agent.clientId)
|
|
189
|
+
record.claimedClient = agent.clientId;
|
|
178
190
|
h.isCollection = true;
|
|
179
191
|
return unwrap(await h.post(record));
|
|
180
192
|
}
|
|
181
193
|
const merged = { ...existing, content, updatedAt: new Date().toISOString() };
|
|
182
194
|
delete merged.embedding;
|
|
183
195
|
delete merged.embeddingModel;
|
|
196
|
+
// flair#718 authorship-provenance — see memoryStore's comment above.
|
|
197
|
+
if (agent.clientId)
|
|
198
|
+
merged.claimedClient = agent.clientId;
|
|
184
199
|
return unwrap(await h.put(merged));
|
|
185
200
|
}
|
|
186
201
|
async function memoryGet(agent, args) {
|
|
@@ -287,6 +302,38 @@ async function recordUsage(agent, args) {
|
|
|
287
302
|
: typeof args?.memoryId === "string" ? [args.memoryId] : undefined;
|
|
288
303
|
return unwrap(await h.post({ memoryIds, attribution: args?.attribution }));
|
|
289
304
|
}
|
|
305
|
+
/**
|
|
306
|
+
* Verb→tool-name overrides — the three naming quirks where the shipped tool
|
|
307
|
+
* name isn't record-types.ts's default `${toolPrefix}_${verb}` shape (see
|
|
308
|
+
* that module's `mcp` header doc, and RECORD_TYPES.<Table>.mcp itself,
|
|
309
|
+
* record-types slice 3, flair#520). Keyed by table name + verb; consumed by
|
|
310
|
+
* `mcpToolName()` below and by test/unit/mcp-surface-tripwire.test.ts to
|
|
311
|
+
* compute the expected tool name for every declared registry verb.
|
|
312
|
+
*
|
|
313
|
+
* Placement here (not in record-types.ts) is deliberate — per Kern/
|
|
314
|
+
* Sherlock's unanimous slice-3 verdict, the registry declares WHAT is
|
|
315
|
+
* exposed (capability: toolPrefix + verbs); this map declares HOW that
|
|
316
|
+
* capability's tool is actually named (presentation). Coupling names into
|
|
317
|
+
* the registry would mix the policy layer with the presentation layer,
|
|
318
|
+
* exactly what the registry's separation is meant to avoid.
|
|
319
|
+
*/
|
|
320
|
+
export const TOOL_NAME_OVERRIDES = {
|
|
321
|
+
Soul: { store: "soul_set" },
|
|
322
|
+
WorkspaceState: { store: "flair_workspace_set" },
|
|
323
|
+
OrgEvent: { store: "flair_orgevent" },
|
|
324
|
+
};
|
|
325
|
+
/**
|
|
326
|
+
* Resolve the actual `TOOLS` key for a declared (table, verb) pair: the
|
|
327
|
+
* `TOOL_NAME_OVERRIDES` entry if one exists, else the default
|
|
328
|
+
* `${toolPrefix}_${verb}` shape. `toolPrefix` is passed in (rather than
|
|
329
|
+
* looked up from RECORD_TYPES here) so this stays a pure naming function —
|
|
330
|
+
* both this module and the tripwire test call it with the registry's own
|
|
331
|
+
* `toolPrefix` value, keeping the naming rule in exactly one place.
|
|
332
|
+
*/
|
|
333
|
+
export function mcpToolName(table, toolPrefix, verb) {
|
|
334
|
+
const override = TOOL_NAME_OVERRIDES[table]?.[verb];
|
|
335
|
+
return override ?? `${toolPrefix}_${verb}`;
|
|
336
|
+
}
|
|
290
337
|
export const TOOLS = {
|
|
291
338
|
memory_search: {
|
|
292
339
|
def: {
|
|
@@ -489,7 +536,7 @@ export const TOOLS = {
|
|
|
489
536
|
impl: recordUsage,
|
|
490
537
|
},
|
|
491
538
|
};
|
|
492
|
-
/** The tool definitions for a tools/list response (exactly the
|
|
539
|
+
/** The tool definitions for a tools/list response (exactly the 12 curated tools). */
|
|
493
540
|
export function listToolDefs() {
|
|
494
541
|
return Object.values(TOOLS).map((t) => t.def);
|
|
495
542
|
}
|
|
@@ -1,5 +1,36 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Sanitize an optional, unverified `claimed.*` passthrough value (memory-
|
|
3
|
+
* provenance slice 1 + flair#718 authorship-provenance). Shared by BOTH
|
|
4
|
+
* `claimed.model` and `claimed.client` — same authority level, same
|
|
5
|
+
* discipline, one implementation so they can't drift:
|
|
6
|
+
*
|
|
7
|
+
* 1. Must be a `string` — anything else (number, object, array) is dropped.
|
|
8
|
+
* 2. Control characters (C0 + DEL, `\x00`-`\x1F`,`\x7F`) are stripped —
|
|
9
|
+
* this is caller-supplied, unverified data landing in a stored JSON
|
|
10
|
+
* blob; no newlines/nulls smuggled into logs or downstream renders.
|
|
11
|
+
* 3. Trimmed.
|
|
12
|
+
* 4. Length-capped at 200 chars (truncated, not rejected — a label this
|
|
13
|
+
* long is almost certainly malformed, but the write must never fail
|
|
14
|
+
* because of it).
|
|
15
|
+
* 5. Dropped (returns `undefined`) if empty after the above — an
|
|
16
|
+
* all-control-chars or all-whitespace input is treated as absent, not
|
|
17
|
+
* stamped as `""`.
|
|
18
|
+
*
|
|
19
|
+
* Sherlock flair#718 review: `claimed.model` previously had only a
|
|
20
|
+
* truthiness check (no cap, no sanitize) — folded into this same function
|
|
21
|
+
* "while touching the same code" per that review's non-blocking recommendation.
|
|
22
|
+
*/
|
|
23
|
+
function sanitizeClaim(value) {
|
|
24
|
+
if (typeof value !== "string")
|
|
25
|
+
return undefined;
|
|
26
|
+
const cleaned = value.replace(/[\x00-\x1F\x7F]/g, "").trim();
|
|
27
|
+
if (cleaned.length === 0)
|
|
28
|
+
return undefined;
|
|
29
|
+
return cleaned.length > 200 ? cleaned.slice(0, 200) : cleaned;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* ─── Write-time provenance stamp (memory-provenance slice 1; claimed.client
|
|
33
|
+
* added by flair#718 authorship-provenance) ──────────────────────────────────
|
|
3
34
|
*
|
|
4
35
|
* Foundational capture for an emergent-trust model: every write gets a
|
|
5
36
|
* structured, versioned `provenance` JSON blob recording what the server can
|
|
@@ -8,7 +39,7 @@
|
|
|
8
39
|
*
|
|
9
40
|
* { v: 1,
|
|
10
41
|
* verified: { agentId: <string|null>, timestamp: <ISO string> },
|
|
11
|
-
* claimed?: { model
|
|
42
|
+
* claimed?: { model?: <string>, client?: <string> } }
|
|
12
43
|
*
|
|
13
44
|
* - `verified.agentId` comes from the ALREADY-RESOLVED auth verdict
|
|
14
45
|
* (resolveAgentAuth) — never from anything the caller can forge on the
|
|
@@ -23,10 +54,25 @@
|
|
|
23
54
|
* `embeddingModel = getModelId()` stamp in resources/Memory.ts.
|
|
24
55
|
* - `claimed.model` is an OPTIONAL, UNVERIFIED passthrough: included only
|
|
25
56
|
* when the incoming write payload itself already carries a non-empty
|
|
26
|
-
* string `model` field
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
57
|
+
* string `model` field (sanitized via sanitizeClaim above). Never
|
|
58
|
+
* invented, never defaulted.
|
|
59
|
+
* - `claimed.client` (flair#718) is the SAME kind of OPTIONAL, UNVERIFIED
|
|
60
|
+
* passthrough, sourced from `content.claimedClient` (a deliberately
|
|
61
|
+
* distinct body-field name from the output key — see the write paths in
|
|
62
|
+
* resources/Memory.ts / resources/Relationship.ts, which strip this field
|
|
63
|
+
* from the row after calling buildProvenance so it is NEVER persisted
|
|
64
|
+
* outside this provenance blob). Records WHICH CLIENT authored a write
|
|
65
|
+
* under one shared principal (the personal deployment shape — see
|
|
66
|
+
* docs/auth.md "Deployment shapes"). `claimed` — never `verified` —
|
|
67
|
+
* because this is self-reported by an authenticated principal, not
|
|
68
|
+
* independently corroborated: it MUST grant zero authority anywhere
|
|
69
|
+
* (never read for access control, attribution weighting, or dedup
|
|
70
|
+
* decisions — Sherlock flair#718 binding refinement). On the native /mcp
|
|
71
|
+
* OAuth path, the caller is required to source this from the verified
|
|
72
|
+
* `client_id` token claim, never the user-controlled `client_name` — see
|
|
73
|
+
* resources/mcp-handler.ts's handleToolCall for that stamp site.
|
|
74
|
+
* - The `claimed` key is omitted entirely (not stamped as `{}`) when both
|
|
75
|
+
* `model` and `client` are absent.
|
|
30
76
|
*
|
|
31
77
|
* Originally introduced in resources/Memory.ts (Memory.post()/Memory.put());
|
|
32
78
|
* extracted here so Relationship.ts (and any future write path) can reuse the
|
|
@@ -43,8 +89,14 @@ export function buildProvenance(auth, createdAt, content) {
|
|
|
43
89
|
timestamp: createdAt,
|
|
44
90
|
},
|
|
45
91
|
};
|
|
46
|
-
|
|
47
|
-
|
|
92
|
+
const model = sanitizeClaim(content?.model);
|
|
93
|
+
const client = sanitizeClaim(content?.claimedClient);
|
|
94
|
+
if (model !== undefined || client !== undefined) {
|
|
95
|
+
provenance.claimed = {};
|
|
96
|
+
if (model !== undefined)
|
|
97
|
+
provenance.claimed.model = model;
|
|
98
|
+
if (client !== undefined)
|
|
99
|
+
provenance.claimed.client = client;
|
|
48
100
|
}
|
|
49
101
|
return JSON.stringify(provenance);
|
|
50
102
|
}
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* record-type-kit.ts — shared, parameterized building blocks for a flair
|
|
3
|
+
* table's agent-identity / read-scope / no-forge-attribution machinery
|
|
4
|
+
* (record-types slice 1: kit extraction, flair#520).
|
|
5
|
+
*
|
|
6
|
+
* ─── What this is ────────────────────────────────────────────────────────
|
|
7
|
+
* Five flair-owned tables — Memory, Relationship, WorkspaceState, OrgEvent,
|
|
8
|
+
* Soul — each independently hand-copied ~150-250 lines of near-identical
|
|
9
|
+
* auth/scoping code: call resolveAgentAuth(getContext()), branch on the
|
|
10
|
+
* three-way verdict (internal / agent / anonymous), 404 a non-owner by-id
|
|
11
|
+
* read (never 403, so ids can't be enumerated), stamp agentId/authorId from
|
|
12
|
+
* the verified identity rather than the request body ("no-forge
|
|
13
|
+
* attribution"). Every allowRead() docstring in those five files literally
|
|
14
|
+
* says "same pattern as X.ts" — this module makes that pattern real code
|
|
15
|
+
* instead of a comment convention that can drift.
|
|
16
|
+
*
|
|
17
|
+
* ─── What this is NOT (scope discipline for slice 1) ─────────────────────
|
|
18
|
+
* This is a PURE REFACTOR extracting what is IDENTICAL across the five
|
|
19
|
+
* existing tables today. It is deliberately conservative, not the full
|
|
20
|
+
* `applyRecordTypeKit(policy)` composition sketched in the record-types
|
|
21
|
+
* design doc's §5 (that shape is for slice 2/3's registry, generating a
|
|
22
|
+
* COMPLETE {allowRead, get, search, post, put, delete} bundle for a
|
|
23
|
+
* brand-new consumer type with no bespoke logic). The five EXISTING tables
|
|
24
|
+
* genuinely diverge in real, load-bearing ways that must be preserved
|
|
25
|
+
* byte-for-byte, not papered over by a one-size bundle:
|
|
26
|
+
* - search()'s query-merge shape differs per table (Memory detaches the
|
|
27
|
+
* transaction and falls through differently on a bare query object than
|
|
28
|
+
* WorkspaceState's in-place mutation than Relationship's nested-wrap);
|
|
29
|
+
* - the no-forge attribution idiom differs not just per table but per
|
|
30
|
+
* METHOD within a table (WorkspaceState.post() unconditionally stamps
|
|
31
|
+
* with no mismatch check at all, while WorkspaceState.put() rejects a
|
|
32
|
+
* mismatch and never stamps — see stampAttribution's mode doc below);
|
|
33
|
+
* - delete() diverges the most: Memory.delete() doesn't even use
|
|
34
|
+
* resolveAgentAuth (raw tpsAgent + isAdmin(), gated on the `permanent`
|
|
35
|
+
* durability flag, no ownership check at all for non-permanent rows);
|
|
36
|
+
* Soul has no delete() override; the other three each fetch the
|
|
37
|
+
* pre-existing record with a different super.get() call signature.
|
|
38
|
+
* - OrgEvent and Soul have NO get()/search() overrides at all (org events
|
|
39
|
+
* and souls are readable by any verified agent, unscoped by owner) —
|
|
40
|
+
* this module does not force them into a scope they never had.
|
|
41
|
+
* Each of those divergences stays inline in its resource file as visible,
|
|
42
|
+
* type-specific business logic. What THIS module extracts is only the
|
|
43
|
+
* genuinely-identical primitives underneath: the auth-gate three-way
|
|
44
|
+
* dispatch, the two read-scope condition/predicate shapes, the no-forge
|
|
45
|
+
* attribution idioms (named and parameterized, not merged into one), and
|
|
46
|
+
* the canonical error-response builders. `buildProvenance` itself
|
|
47
|
+
* (./provenance.ts) is already table-agnostic and is reused as-is — this
|
|
48
|
+
* module does not wrap it, it re-exports it for a single import surface.
|
|
49
|
+
*
|
|
50
|
+
* Design lineage: flair#520 design draft (issue comment, 2026-07-13),
|
|
51
|
+
* Kern's DESIGN REVIEW ("the makeAuthGate/makeReadScope/buildProvenance
|
|
52
|
+
* as-is extraction is the right factoring... each class keeps its
|
|
53
|
+
* type-specific business logic visibly, loses only the copied
|
|
54
|
+
* boilerplate"). `stampEmbedding` (the design's 4th cut-line) is OUT of
|
|
55
|
+
* scope here — no table this module composes writes an embedding today
|
|
56
|
+
* (only Memory does, and Memory's embedding logic is dedup-gate-entangled,
|
|
57
|
+
* not part of the five-table auth/scope/provenance duplication this slice
|
|
58
|
+
* targets); it belongs to the registry/new-capability slice.
|
|
59
|
+
*/
|
|
60
|
+
import { resolveAgentAuth, allowVerified } from "./agent-auth.js";
|
|
61
|
+
import { resolveReadScope } from "./memory-read-scope.js";
|
|
62
|
+
import { buildProvenance } from "./provenance.js";
|
|
63
|
+
export { buildProvenance };
|
|
64
|
+
// ─── Canonical error responses ─────────────────────────────────────────────
|
|
65
|
+
// Byte-for-byte the same bodies every one of the five files hand-rolled
|
|
66
|
+
// (Memory/Relationship/WorkspaceState/OrgEvent each defined their own
|
|
67
|
+
// FORBIDDEN/UNAUTH/NOT_FOUND consts identically; Soul only needed
|
|
68
|
+
// FORBIDDEN/UNAUTH). Header-key casing is normalized here to "Content-Type"
|
|
69
|
+
// — HTTP header names are case-insensitive (and Harper/undici's Headers
|
|
70
|
+
// object normalizes on read), so this is not an observable behavior change
|
|
71
|
+
// from the two files (Memory.ts, Relationship.ts) whose inline copies used
|
|
72
|
+
// a lowercase "content-type" key.
|
|
73
|
+
export const FORBIDDEN = (msg) => new Response(JSON.stringify({ error: msg }), { status: 403, headers: { "Content-Type": "application/json" } });
|
|
74
|
+
export const UNAUTH = () => new Response(JSON.stringify({ error: "authentication required" }), { status: 401, headers: { "Content-Type": "application/json" } });
|
|
75
|
+
export const NOT_FOUND = () => new Response(JSON.stringify({ error: "not found" }), { status: 404, headers: { "Content-Type": "application/json" } });
|
|
76
|
+
// ─── (a) Identity gating — makeAuthGate ────────────────────────────────────
|
|
77
|
+
/**
|
|
78
|
+
* Produces the `allowRead()` override every one of the five tables hand-
|
|
79
|
+
* wrote identically:
|
|
80
|
+
* `allowRead() { return allowVerified((this as any).getContext?.()); }`
|
|
81
|
+
* Self-authorizes now that the global gate is non-rejecting (the
|
|
82
|
+
* memory-soul-read-gate family fix): Harper routes `GET /<Table>/<id>` to
|
|
83
|
+
* get() and the collection describe (`GET /<Table>`) to a path outside
|
|
84
|
+
* search(), so neither was gated before that fix landed — closes the same
|
|
85
|
+
* P0 leak (an anonymous caller getting a 200 with full record content) for
|
|
86
|
+
* every table that composes this. Per-record/collection scoping is still
|
|
87
|
+
* each table's own get()/search() responsibility below this gate.
|
|
88
|
+
*
|
|
89
|
+
* `allowVerified()` itself (permit verified agents, admins/super_user, and
|
|
90
|
+
* trusted internal calls; deny anonymous HTTP) already lives in
|
|
91
|
+
* ./agent-auth.ts and is reused unmodified — this factory exists only to
|
|
92
|
+
* remove the identical one-line method body duplicated five times, not to
|
|
93
|
+
* change what it does.
|
|
94
|
+
*
|
|
95
|
+
* MUST be composed as a genuine prototype method, never a class-field
|
|
96
|
+
* assignment (`allowRead = makeAuthGate();`). Harper's Table.js has a code
|
|
97
|
+
* path (`relatedTable.prototype.allowRead.call(null, user, property,
|
|
98
|
+
* context)`, used when a query's `select` traverses a GraphQL relationship
|
|
99
|
+
* into one of these tables under Harper's native attribute-level RBAC) that
|
|
100
|
+
* reads `allowRead` off the CLASS PROTOTYPE directly, not off a resource
|
|
101
|
+
* instance. A class field is an own-instance property — invisible to that
|
|
102
|
+
* lookup — so a class-field assignment would silently fall back to Harper's
|
|
103
|
+
* default RBAC-based `allowRead` for that one relationship-traversal path
|
|
104
|
+
* while every direct/primary call (`this.allowRead(...)`, always
|
|
105
|
+
* instance-invoked) kept working, making the gap easy to miss. Every
|
|
106
|
+
* caller of this factory MUST wire it as `allowRead() { return
|
|
107
|
+
* gate.call(this); }` (see Memory.ts/Relationship.ts/WorkspaceState.ts/
|
|
108
|
+
* OrgEvent.ts/Soul.ts for the exact pattern), not `allowRead =
|
|
109
|
+
* makeAuthGate();`.
|
|
110
|
+
*/
|
|
111
|
+
export function makeAuthGate() {
|
|
112
|
+
return function allowRead() {
|
|
113
|
+
return allowVerified(this.getContext?.());
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
export async function resolveAuthGate(ctx, denyResponse) {
|
|
117
|
+
const auth = await resolveAgentAuth(ctx);
|
|
118
|
+
if (auth.kind === "anonymous")
|
|
119
|
+
return { kind: "denied", response: denyResponse };
|
|
120
|
+
if (auth.kind === "internal" || (auth.kind === "agent" && auth.isAdmin))
|
|
121
|
+
return { kind: "unfiltered" };
|
|
122
|
+
return { kind: "scoped", agentId: auth.agentId };
|
|
123
|
+
}
|
|
124
|
+
/** owner-only: `agentId === authAgentId`, full stop — the model
|
|
125
|
+
* Relationship.ts and WorkspaceState.ts hand-rolled identically for their
|
|
126
|
+
* get()/search() overrides (`{attribute: ownerField, comparator: "equals",
|
|
127
|
+
* value: authAgentId}` / `record[ownerField] !== authAgentId`). No
|
|
128
|
+
* visibility field, no org-open exception — a real, legitimate read-scope
|
|
129
|
+
* shape in its own right (per Kern/Sherlock's review), not a lesser
|
|
130
|
+
* version of Memory's model. */
|
|
131
|
+
function ownerOnlyReadScope(ownerField) {
|
|
132
|
+
return async (authAgentId) => ({
|
|
133
|
+
condition: { attribute: ownerField, comparator: "equals", value: authAgentId },
|
|
134
|
+
isAllowed: (record) => !!record && record[ownerField] === authAgentId,
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Resolve the read-scope resolver for a table, by mode:
|
|
139
|
+
* - "owner-only": own records only, scoped on `ownerField` (defaults to
|
|
140
|
+
* "agentId" — every current owner-only caller uses that field; the
|
|
141
|
+
* parameter exists so a future owner-only type using a different field,
|
|
142
|
+
* the way OrgEvent's WRITE path uses "authorId", isn't hardcoded out).
|
|
143
|
+
* - "open-within-org": delegates to the EXACT existing
|
|
144
|
+
* resolveReadScope()/PRIVATE_VISIBILITY semantics in
|
|
145
|
+
* ./memory-read-scope.ts — Memory's own module, untouched, not
|
|
146
|
+
* reimplemented here. `ownerField` is ignored for this mode:
|
|
147
|
+
* resolveReadScope() is hardcoded to "agentId" (the only table that
|
|
148
|
+
* uses this mode today), matching the task's instruction to delegate to
|
|
149
|
+
* that module "as the exact existing" implementation rather than
|
|
150
|
+
* generalizing it in this slice.
|
|
151
|
+
*
|
|
152
|
+
* The returned resolver is tagged with its own `.mode`/`.ownerField` (record-
|
|
153
|
+
* types slice 2, flair#520) — harmless own-properties on the function object,
|
|
154
|
+
* never read by any caller of the resolver itself in production. Pinned by
|
|
155
|
+
* test/unit/record-type-kit.test.ts directly (mock-free — this primitive
|
|
156
|
+
* touches no `databases` mock). The registry's own drift tripwire
|
|
157
|
+
* (test/unit/record-types-registry.test.ts) verifies the five resource files
|
|
158
|
+
* draw their kit parameters from RECORD_TYPES via a source-text scan instead
|
|
159
|
+
* of importing them (see that test file's header for why — importing
|
|
160
|
+
* multiple of Memory.ts/Relationship.ts/WorkspaceState.ts together risks a
|
|
161
|
+
* cross-file Harper-mock module-cache collision); this tagging exists so a
|
|
162
|
+
* FUTURE single-resource-file behavior test (the pattern every existing
|
|
163
|
+
* table's own read-gate test already uses in isolation) can also assert
|
|
164
|
+
* `<table>ReadScope.mode === RECORD_TYPES.<Table>.readScope` directly against
|
|
165
|
+
* a live, exported resolver without re-deriving it.
|
|
166
|
+
*/
|
|
167
|
+
export function makeReadScope(mode, ownerField = "agentId") {
|
|
168
|
+
const resolve = mode === "open-within-org"
|
|
169
|
+
? (authAgentId) => resolveReadScope(authAgentId)
|
|
170
|
+
: ownerOnlyReadScope(ownerField);
|
|
171
|
+
return Object.assign(resolve, { mode, ownerField });
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* By-id read-gate factory for get() overrides that scope a single-record
|
|
175
|
+
* read the same way search() scopes a collection read (Memory.ts,
|
|
176
|
+
* Relationship.ts, WorkspaceState.ts — NOT OrgEvent/Soul, which have no
|
|
177
|
+
* get() override at all and are intentionally left alone, see file header).
|
|
178
|
+
* Never distinguishes "doesn't exist" from "exists but not yours" — both
|
|
179
|
+
* return 404, never 403, so a denied caller can't use get() to enumerate
|
|
180
|
+
* other agents' record ids.
|
|
181
|
+
*
|
|
182
|
+
* `superGet` is a caller-supplied closure so the class's own `super.get()`
|
|
183
|
+
* (which cannot be referenced from outside the class body) stays exactly
|
|
184
|
+
* where it was.
|
|
185
|
+
*/
|
|
186
|
+
export function makeByIdReadGate(readScope) {
|
|
187
|
+
return async function byIdReadGate(target, superGet) {
|
|
188
|
+
// Collection / query reads arrive as a RequestTarget with
|
|
189
|
+
// `isCollection === true`, and are governed by search() (same owner
|
|
190
|
+
// scoping). Only a genuine by-id get is ownership-checked below.
|
|
191
|
+
if (!target || (typeof target === "object" && target.isCollection)) {
|
|
192
|
+
return this.search(target);
|
|
193
|
+
}
|
|
194
|
+
const ctx = this.getContext?.();
|
|
195
|
+
const auth = await resolveAgentAuth(ctx);
|
|
196
|
+
// Anonymous by-id read is already blocked at the allowRead() gate (403);
|
|
197
|
+
// this is defense-in-depth if get() is ever reached directly.
|
|
198
|
+
if (auth.kind === "anonymous")
|
|
199
|
+
return NOT_FOUND();
|
|
200
|
+
// Trusted internal call or admin agent — unfiltered, unchanged behavior.
|
|
201
|
+
if (auth.kind === "internal" || (auth.kind === "agent" && auth.isAdmin)) {
|
|
202
|
+
return superGet(target);
|
|
203
|
+
}
|
|
204
|
+
// Non-admin agent: scoped per the table's own read-scope model.
|
|
205
|
+
const record = await superGet(target);
|
|
206
|
+
if (!record)
|
|
207
|
+
return NOT_FOUND();
|
|
208
|
+
const scope = await readScope(auth.agentId);
|
|
209
|
+
if (!scope.isAllowed(record))
|
|
210
|
+
return NOT_FOUND();
|
|
211
|
+
return record;
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
export function stampAttribution(auth, content, field, mode, forbiddenMessage) {
|
|
215
|
+
if (auth.kind !== "agent")
|
|
216
|
+
return {}; // internal → always passthrough
|
|
217
|
+
if (auth.isAdmin) {
|
|
218
|
+
if (mode === "stamp-default")
|
|
219
|
+
content[field] ||= auth.agentId;
|
|
220
|
+
// every other mode: admin passthrough, untouched
|
|
221
|
+
return {};
|
|
222
|
+
}
|
|
223
|
+
// non-admin agent
|
|
224
|
+
switch (mode) {
|
|
225
|
+
case "validate-truthy":
|
|
226
|
+
if (content?.[field] && content[field] !== auth.agentId) {
|
|
227
|
+
return { denied: FORBIDDEN(forbiddenMessage) };
|
|
228
|
+
}
|
|
229
|
+
return {};
|
|
230
|
+
case "validate-strict":
|
|
231
|
+
if (content[field] !== auth.agentId) {
|
|
232
|
+
return { denied: FORBIDDEN(forbiddenMessage) };
|
|
233
|
+
}
|
|
234
|
+
return {};
|
|
235
|
+
case "stamp-default":
|
|
236
|
+
content[field] = auth.agentId;
|
|
237
|
+
return {};
|
|
238
|
+
case "stamp-strict":
|
|
239
|
+
if (content?.[field] && content[field] !== auth.agentId) {
|
|
240
|
+
return { denied: FORBIDDEN(forbiddenMessage) };
|
|
241
|
+
}
|
|
242
|
+
content[field] = auth.agentId;
|
|
243
|
+
return {};
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
// ─── (d) Provenance wiring ──────────────────────────────────────────────────
|
|
247
|
+
// buildProvenance itself is already table-agnostic (./provenance.ts) and is
|
|
248
|
+
// re-exported above, unmodified, for a single kit import surface. Per
|
|
249
|
+
// Kern/Sherlock's DESIGN REVIEW verdict (Q4): stamp the identical shape
|
|
250
|
+
// everywhere it's wired, never a table-specific format — and per this
|
|
251
|
+
// slice's explicit behavior-preservation bar, do NOT wire it onto
|
|
252
|
+
// WorkspaceState/OrgEvent, which don't stamp it today (only Memory and
|
|
253
|
+
// Relationship do). See Memory.ts/Relationship.ts for the call sites.
|