@tpsdev-ai/flair 0.51.2 → 0.52.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.
Files changed (71) hide show
  1. package/README.md +10 -5
  2. package/dist/build-info.json +3 -3
  3. package/dist/cli.js +575 -547
  4. package/dist/doctor-client.js +35 -0
  5. package/dist/hook-install.js +74 -0
  6. package/dist/install/global-bin-path.js +14 -0
  7. package/dist/lib/auth-resolve.js +15 -0
  8. package/dist/lib/doctor-run.js +28 -15
  9. package/dist/lib/upgrade-exec-path.js +257 -0
  10. package/dist/lib/upgrade-plain-tree.js +558 -0
  11. package/dist/rem/promote-policy.js +204 -0
  12. package/dist/rem/restore.js +55 -15
  13. package/dist/rem/runner.js +203 -20
  14. package/dist/resources/AdminMemory.js +2 -1
  15. package/dist/resources/AgentSeed.js +26 -10
  16. package/dist/resources/Asset.js +203 -0
  17. package/dist/resources/AutoPromoteCandidates.js +2 -4
  18. package/dist/resources/Credential.js +14 -0
  19. package/dist/resources/Federation.js +80 -0
  20. package/dist/resources/Integration.js +12 -0
  21. package/dist/resources/Memory.js +158 -60
  22. package/dist/resources/MemoryBootstrap.js +63 -20
  23. package/dist/resources/MemoryCandidate.js +12 -0
  24. package/dist/resources/MemoryConsolidate.js +2 -1
  25. package/dist/resources/MemoryDedupStats.js +17 -2
  26. package/dist/resources/MemoryFeed.js +30 -0
  27. package/dist/resources/MemoryGrant.js +14 -0
  28. package/dist/resources/MemoryReflect.js +75 -17
  29. package/dist/resources/Message.js +190 -0
  30. package/dist/resources/OrgEvent.js +12 -0
  31. package/dist/resources/PromoteMemoryCandidate.js +76 -0
  32. package/dist/resources/RecordUsage.js +1 -1
  33. package/dist/resources/Relationship.js +12 -0
  34. package/dist/resources/SemanticSearch.js +45 -13
  35. package/dist/resources/Soul.js +54 -18
  36. package/dist/resources/WorkspaceState.js +12 -0
  37. package/dist/resources/auth-middleware.js +17 -44
  38. package/dist/resources/authority-field-guard.js +37 -0
  39. package/dist/resources/bm25-index-service.js +1 -1
  40. package/dist/resources/bm25-index.js +50 -11
  41. package/dist/resources/embedding-space-guard.js +238 -0
  42. package/dist/resources/embeddings-provider.js +32 -5
  43. package/dist/resources/federation-classify.js +23 -1
  44. package/dist/resources/health.js +11 -2
  45. package/dist/resources/hit-tracking.js +244 -0
  46. package/dist/resources/mcp-tools.js +272 -7
  47. package/dist/resources/memory-reflect-lib.js +111 -0
  48. package/dist/resources/migrations/embedding-stamp.js +22 -4
  49. package/dist/resources/owner-field-guard.js +62 -0
  50. package/dist/resources/promotion-stamp.js +29 -0
  51. package/dist/resources/record-owner-guard.js +71 -5
  52. package/dist/resources/record-types.js +30 -7
  53. package/dist/resources/relay-lib.js +205 -0
  54. package/dist/resources/relay-ops.js +294 -0
  55. package/dist/resources/skill-write.js +120 -0
  56. package/dist/resources/soul-adk-guard.js +68 -0
  57. package/dist/resources/soul-write-policy.js +63 -0
  58. package/dist/resources/table-helpers.js +2 -0
  59. package/dist/resources/usage-recording.js +3 -3
  60. package/dist/src/rem/promote-policy.js +204 -0
  61. package/docs/api-reference.md +374 -0
  62. package/docs/auth.md +52 -0
  63. package/docs/federation.md +4 -0
  64. package/docs/integrations.md +6 -6
  65. package/docs/mcp-clients.md +16 -1
  66. package/docs/releasing.md +11 -8
  67. package/docs/rem.md +20 -2
  68. package/docs/upgrade.md +47 -2
  69. package/package.json +6 -5
  70. package/schemas/memory.graphql +51 -2
  71. package/schemas/message.graphql +74 -0
@@ -51,6 +51,21 @@
51
51
  * resources — they answer a different question, "may you attribute a NEW record
52
52
  * to someone else", which this guard does not address.
53
53
  *
54
+ * ─── The owner field is immutable to a non-admin ─────────────────────────────
55
+ *
56
+ * Owning a row grants writing its ordinary fields, not rewriting the field that
57
+ * decides who owns it. Left alone, a caller that legitimately owns a row could
58
+ * use a later write to re-point that field at another principal — handing the
59
+ * row away, or attributing its content to someone else after the fact, past the
60
+ * create-time no-forge check each resource applies only on creation. So
61
+ * `isForbiddenOwnerFieldChange` refuses any non-admin write whose owner value
62
+ * differs from the stored one, comparing against STORED STATE exactly as above.
63
+ * It cannot run in the middleware — Harper's middleware Request has no parsed
64
+ * body — so it is enforced at the resource layer via a single shared delegate
65
+ * (resources/owner-field-guard.ts) called by each principal-owning resource's
66
+ * put() and patch(), the same shape resources/Agent.ts uses for the principal
67
+ * table's admin-status field.
68
+ *
54
69
  * ─── Keeping this honest as the codebase grows ───────────────────────────────
55
70
  *
56
71
  * OWNER_FIELDS below is static and PR-reviewed, matching the posture of
@@ -69,12 +84,18 @@
69
84
  * being absent on the day someone adds one.
70
85
  */
71
86
  export const OWNER_FIELDS = Object.freeze({
87
+ Asset: "agentId",
72
88
  Credential: "principalId",
73
89
  Integration: "agentId",
74
90
  Memory: "agentId",
75
91
  MemoryCandidate: "agentId",
76
92
  MemoryGrant: "ownerId",
77
93
  MemoryUsage: "agentId",
94
+ // The Message envelope's owning principal is the SENDER (`from`), not
95
+ // `agentId` — the per-table owner field, matching federation-classify's
96
+ // PRINCIPAL_OWNER_FIELD. `from` is not one of OWNER_COLUMN_NAMES, so the
97
+ // coverage test verifies it against the schema block directly.
98
+ Message: "from",
78
99
  OAuthAuthCode: "principalId",
79
100
  OAuthToken: "principalId",
80
101
  OrgEvent: "authorId",
@@ -134,16 +155,61 @@ export function resolveGuardedRecord(pathname) {
134
155
  * Decide whether a caller may mutate an already-stored record.
135
156
  *
136
157
  * Pure, so the decision is testable without a Harper instance — the middleware
137
- * supplies the record it loaded. A record that does not exist, or that carries
138
- * no owner value, is NOT refused here: the first is a create (or a 404 the
139
- * resource will produce), and the second is a row with nothing to own, neither
140
- * of which this rule is about.
158
+ * supplies the record it loaded. A record that does not EXIST is not refused:
159
+ * that is a create (or a 404 the resource will produce), which this rule leaves
160
+ * to each resource's own attribution check.
161
+ *
162
+ * A record that exists but carries NO owner value is refused for a non-admin.
163
+ * Nobody owns such a row, so no non-admin agent id can match it, and the safe
164
+ * reading of "no owner" is "not yours" rather than "everyone's" — the guard
165
+ * fails closed. For the tables whose owner column is required by schema this is
166
+ * unreachable in practice, but a table with a nullable owner column (or a row
167
+ * that predates one becoming required) must not become a shared write surface.
141
168
  */
142
169
  export function isForbiddenOwnerMutation(record, ownerField, callerAgentId) {
143
170
  if (!record)
144
171
  return false;
145
172
  const owner = record[ownerField];
146
173
  if (owner == null || owner === "")
147
- return false;
174
+ return true;
148
175
  return owner !== callerAgentId;
149
176
  }
177
+ /**
178
+ * Decide whether a caller may CHANGE the owner field of an already-stored record.
179
+ *
180
+ * The ownership rule above answers "may you write this row"; it does not answer
181
+ * "may you rewrite the field that decides who owns it". Those are different
182
+ * questions, and a caller that legitimately owns a row today can still use a
183
+ * write to re-point that row at another principal — handing the row away, or
184
+ * attributing its content to someone else after the fact, past the create-time
185
+ * no-forge attribution each resource enforces only on creation.
186
+ *
187
+ * So the owner field is immutable to a non-admin: any write whose owner value
188
+ * differs from the stored one is refused. This mirrors resources/Agent.ts's
189
+ * shared write-authorization helper, which compares the RESULTING value against
190
+ * the stored one across both put() and patch() — a merged-vs-stored comparison,
191
+ * so a partial write that never names the field, or a no-op restatement of the
192
+ * current owner, is not a spurious denial.
193
+ *
194
+ * Pure and body-aware: `requested` is the request content (the caller's claim),
195
+ * `record` is the stored row (the ground truth). A create (`record` absent) is
196
+ * not this rule's business — attribution on creation belongs to the resource.
197
+ * Enforced at the RESOURCE layer (see resources/owner-field-guard.ts), because
198
+ * Harper's middleware Request exposes no parsed body — the same reason Agent.ts
199
+ * enforces its analogous rule in the resource rather than the middleware.
200
+ */
201
+ export function isForbiddenOwnerFieldChange(record, requested, ownerField,
202
+ // Deliberately caller-agnostic: the rule is "does this write CHANGE the owner
203
+ // field", which is decided purely by requested-vs-stored and never depends on
204
+ // WHO is asking (the caller's authority is decided one layer up, in
205
+ // owner-field-guard.ts). The parameter is kept only for signature symmetry
206
+ // with isForbiddenOwnerMutation; it is intentionally unused.
207
+ _callerAgentId) {
208
+ if (!record)
209
+ return false;
210
+ if (requested == null || typeof requested !== "object")
211
+ return false;
212
+ if (!Object.prototype.hasOwnProperty.call(requested, ownerField))
213
+ return false;
214
+ return requested[ownerField] !== record[ownerField];
215
+ }
@@ -107,12 +107,10 @@
107
107
  * The stamped attribute name is always `ownerField` above — no type uses a
108
108
  * different field for attribution vs. ownership scoping.
109
109
  *
110
- * `provenance` — true only for Memory and Relationship (the two tables that
110
+ * `provenance` — true for Memory, Relationship and Soul (the tables that
111
111
  * call `buildProvenance()` today and carry a nullable `provenance: String`
112
- * schema field). WorkspaceState/OrgEvent/Soul are `false`: not an oversight
113
- * nobody wired it, and none of the three declare the schema field, so
114
- * flipping this to `true` without a schema migration would be a lie the
115
- * registry tells about what actually gets stamped.
112
+ * schema field). Soul additionally stamps its authenticated source class.
113
+ * WorkspaceState and OrgEvent do not declare or stamp provenance.
116
114
  *
117
115
  * `embedding` — set only for Memory (`content` is the embedded field; the
118
116
  * type has its own exposed semantic-search tool today — `memory_search` /
@@ -254,6 +252,28 @@
254
252
  // `makeReadScope()`/`stampAttribution()` (which expect the narrow literal
255
253
  // unions, not the wide ones) with no runtime cast or non-null assertion.
256
254
  export const RECORD_TYPES = {
255
+ // Asset — binary blobs (images/screenshots) owned by an agent, linked to a
256
+ // Memory. images-in-Flair slice 1 (storage): owner-only read scope (standard
257
+ // review bar — an agent's assets are private to it, like Relationship/
258
+ // WorkspaceState), no embedding (binary, no recall surface), no `mcp`
259
+ // (absent = no MCP exposure; serving assets over /mcp is a later reviewed
260
+ // slice). `federation: "excluded"` is MANDATORY for any new type per this
261
+ // file's header (Sherlock DESIGN REVIEW Q6): Federation.ts's push table
262
+ // list is unchanged, so Asset blobs never leave the instance — a Memory that
263
+ // does federate carries only the assetId reference, not the bytes.
264
+ // Lifecycle: orphans retained until the owner deletes the Asset row; no
265
+ // Memory-delete GC this slice. Slice 2 serving 404s dangling refs; GC
266
+ // sweep + memoryId exist-and-owned validation deferred to slice 2.
267
+ Asset: {
268
+ table: "Asset",
269
+ ownerField: "agentId",
270
+ identity: "gated",
271
+ readScope: "owner-only",
272
+ attribution: { post: "stamp-default", put: "validate-strict" },
273
+ provenance: false,
274
+ remEligible: false,
275
+ federation: "excluded",
276
+ },
257
277
  Memory: {
258
278
  table: "Memory",
259
279
  ownerField: "agentId",
@@ -310,7 +330,7 @@ export const RECORD_TYPES = {
310
330
  // intentionally readable by any verified agent. See header doc.
311
331
  readScope: "none",
312
332
  attribution: { post: "validate-truthy", put: "validate-truthy" },
313
- provenance: false,
333
+ provenance: true,
314
334
  remEligible: false,
315
335
  federation: "included",
316
336
  mcp: { toolPrefix: "soul", readVerbs: ["get"], writeVerbs: ["store"] },
@@ -363,8 +383,11 @@ export const RECORD_TYPES = {
363
383
  // record_usage — usage-signal resource (RecordUsage.ts), not a table verb
364
384
  // memory_basement — archive action (flair#1472), not a table verb
365
385
  // memory_restore — un-archive action (flair#1472), not a table verb
386
+ // skill_store — skill-tagged Memory write (flair#1542), not a table verb
387
+ // skill_search — skill-tagged Memory recall (flair#1546), rides SemanticSearch
388
+ // skill_get — full skill read by id (flair#1546), rides Memory.get
366
389
  //
367
- export const COMPOSITE_MCP_TOOLS = ["bootstrap", "attention", "record_usage", "memory_basement", "memory_restore"];
390
+ export const COMPOSITE_MCP_TOOLS = ["bootstrap", "attention", "record_usage", "memory_basement", "memory_restore", "skill_store", "skill_search", "skill_get"];
368
391
  // ─── Runtime immutability ───────────────────────────────────────────────────
369
392
  // Belt-and-suspenders backstop for the "static, compiled" invariant (see
370
393
  // header doc). TypeScript's `as const satisfies` already gives compile-time
@@ -0,0 +1,205 @@
1
+ /**
2
+ * relay-lib.ts — Flair Relay S1 pure logic (no Harper imports).
3
+ *
4
+ * Extracted from the Message resource the same way federation-classify.ts is
5
+ * extracted from Federation.ts: the load-bearing decisions (canonical signing
6
+ * body, contentHash, inbox cap + per-sender sub-cap, and the absorbing
7
+ * state machine) are pure functions that can be unit-tested without spinning
8
+ * a Harper instance, and are the single source of truth shared by the
9
+ * Message resource (send/inbox/consume/sweep) and the federation apply path.
10
+ *
11
+ * Design + review amendments: Flair Relay S1 (flair#1521) §4 (envelope +
12
+ * delivery) and §12 (binding review amendments — P0-3 absorbing state, P0-4
13
+ * contentHash, P0(S) per-sender sub-caps + server-resolved orgScope, P1-5 seq).
14
+ */
15
+ import { createHash } from "node:crypto";
16
+ import { canonicalize, signBody, verifyBodySignature } from "./federation-crypto.js";
17
+ /**
18
+ * `consumed` is ABSORBING (§12 P0-3): once a message is consumed, no later
19
+ * write — a deadline sweep, a redelivered original, a federation merge — may
20
+ * regress it. The sender must never be told "failed" about a message the
21
+ * recipient actually consumed. That inversion is the exact bug this design
22
+ * exists to kill.
23
+ */
24
+ export const ABSORBING_STATE = "consumed";
25
+ // ─── Caps (§12 P0(S) — DoS lever) ───────────────────────────────────────────
26
+ //
27
+ // A global cap alone lets one hostile sender pin a recipient's whole inbox and
28
+ // lock everyone else out. The per-sender sub-cap bounds any single sender well
29
+ // below the global cap, so a flooding sender only ever fills its own slice.
30
+ // Env-overridable for operators; defaults are deliberately generous.
31
+ export const DEFAULT_INBOX_CAP = 1000;
32
+ export const DEFAULT_PER_SENDER_CAP = 100;
33
+ export function inboxCap() {
34
+ return positiveIntEnv(process.env.FLAIR_RELAY_INBOX_CAP, DEFAULT_INBOX_CAP);
35
+ }
36
+ export function perSenderCap() {
37
+ return positiveIntEnv(process.env.FLAIR_RELAY_PER_SENDER_CAP, DEFAULT_PER_SENDER_CAP);
38
+ }
39
+ function positiveIntEnv(raw, fallback) {
40
+ if (raw === undefined)
41
+ return fallback;
42
+ const n = Number(raw);
43
+ return Number.isInteger(n) && n > 0 ? n : fallback;
44
+ }
45
+ /**
46
+ * Server-resolved org sentinel for an instance that has never federated (no
47
+ * Instance row). The org scope is always the instance itself (single-tenant:
48
+ * "an org/instance is one shared knowledge base") — resolved server-side,
49
+ * NEVER trusted from the message body. Callers pass `localInstanceId() ?? this`.
50
+ */
51
+ export const LOCAL_ORG_SENTINEL = "local";
52
+ // ─── Canonical bodies ───────────────────────────────────────────────────────
53
+ //
54
+ // TWO field sets, both pure functions of the envelope so sender and server
55
+ // compute byte-identical inputs:
56
+ //
57
+ // contentHash — a stable fingerprint of the message CONTENT. Excludes `id`
58
+ // and `createdAt` (a retry may restamp either) so an identical resend
59
+ // dedups; includes `seq` so distinct messages in a thread never collide.
60
+ //
61
+ // signed body — everything the signature covers: the content PLUS `id`,
62
+ // `createdAt` and `contentHash`. Excludes only the mutable server/lifecycle
63
+ // fields (`state`, `deliveredAt`, `consumedAt`, `failureReason`) and the
64
+ // `signature` itself.
65
+ // `inReplyTo`/`parentContentHash` are the reply-linkage of the envelope
66
+ // (design §4.2). They land in S1 (though "understood" reply semantics are S3)
67
+ // and are CONTENT — two messages that differ only in what they reply to are
68
+ // distinct, so they belong in the contentHash (dedup key) AND under the
69
+ // signature (SIGNED_BODY_FIELDS), never trusted unsigned.
70
+ export const CONTENT_HASH_FIELDS = [
71
+ "from",
72
+ "to",
73
+ "threadId",
74
+ "seq",
75
+ "kind",
76
+ "body",
77
+ "orgScope",
78
+ "deadline",
79
+ "inReplyTo",
80
+ "parentContentHash",
81
+ "senderModel",
82
+ "senderProvider",
83
+ "senderRunId",
84
+ ];
85
+ export const SIGNED_BODY_FIELDS = [
86
+ "id",
87
+ "from",
88
+ "to",
89
+ "threadId",
90
+ "seq",
91
+ "kind",
92
+ "body",
93
+ "orgScope",
94
+ "deadline",
95
+ "inReplyTo",
96
+ "parentContentHash",
97
+ "createdAt",
98
+ "contentHash",
99
+ "senderModel",
100
+ "senderProvider",
101
+ "senderRunId",
102
+ ];
103
+ /**
104
+ * Pick only the named keys whose value is neither undefined nor null. Dropping
105
+ * null/undefined (rather than emitting them) keeps canonicalization stable
106
+ * across "field absent" vs "field explicitly null" — both sign the same bytes.
107
+ */
108
+ function pick(obj, fields) {
109
+ const out = {};
110
+ for (const f of fields) {
111
+ const v = obj[f];
112
+ if (v !== undefined && v !== null)
113
+ out[f] = v;
114
+ }
115
+ return out;
116
+ }
117
+ /** sha256 (hex) over the canonical content — retry-dedup key + integrity check. */
118
+ export function computeContentHash(msg) {
119
+ const canonical = canonicalize(pick(msg, CONTENT_HASH_FIELDS));
120
+ return createHash("sha256").update(canonical).digest("hex");
121
+ }
122
+ /** The exact object the ed25519 signature is taken over (no `signature` key). */
123
+ export function signedBody(msg) {
124
+ return pick(msg, SIGNED_BODY_FIELDS);
125
+ }
126
+ /**
127
+ * Seal a message for sending: stamp contentHash over the content, then sign the
128
+ * full signed body (which now includes that contentHash) with the sender's
129
+ * ed25519 secret key. Sender-side + test helper — the server never signs.
130
+ */
131
+ export function sealMessage(msg, secretKey) {
132
+ const contentHash = computeContentHash(msg);
133
+ const withHash = { ...msg, contentHash };
134
+ const signature = signBody(signedBody(withHash), secretKey);
135
+ return { ...withHash, signature };
136
+ }
137
+ /**
138
+ * Server-side envelope verification:
139
+ * 1. signature present,
140
+ * 2. the sender-supplied contentHash actually matches the content (a second
141
+ * integrity check under the signature — §12 P0-4),
142
+ * 3. the ed25519 signature verifies over the signed body against the
143
+ * sender's pinned public key.
144
+ * Any tampered signed field flips (3); a forged contentHash flips (2).
145
+ */
146
+ export function verifyMessageSignature(msg, publicKeyB64url) {
147
+ if (!msg.signature)
148
+ return { ok: false, reason: "missing_signature" };
149
+ const expectedHash = computeContentHash(msg);
150
+ if (msg.contentHash !== expectedHash)
151
+ return { ok: false, reason: "content_hash_mismatch" };
152
+ const body = { ...signedBody(msg), signature: msg.signature };
153
+ if (!verifyBodySignature(body, publicKeyB64url))
154
+ return { ok: false, reason: "invalid_signature" };
155
+ return { ok: true };
156
+ }
157
+ /**
158
+ * Backpressure that fails LOUD: the send is rejected synchronously when the
159
+ * recipient inbox is at the global cap OR this sender is at its sub-cap. The
160
+ * sub-cap is checked first so a hostile sender is told exactly why, and a
161
+ * recipient at the global cap because of ONE sender never masks a well-behaved
162
+ * sender that still has room (their sub-cap is independent).
163
+ */
164
+ export function capDecision(counts, caps = { inbox: inboxCap(), perSender: perSenderCap() }) {
165
+ if (counts.senderUnconsumed >= caps.perSender) {
166
+ return { ok: false, reason: "inbox_full", scope: "sender" };
167
+ }
168
+ if (counts.recipientUnconsumed >= caps.inbox) {
169
+ return { ok: false, reason: "inbox_full", scope: "recipient" };
170
+ }
171
+ return { ok: true };
172
+ }
173
+ // ─── Absorbing state machine (§12 P0-3) ─────────────────────────────────────
174
+ /** A message is still in the inbox (redeliverable) until consumed or failed. */
175
+ export function isUnconsumed(state) {
176
+ return state === "submitted" || state === "delivered";
177
+ }
178
+ /**
179
+ * Reconcile a would-be new state against the local one under the absorbing
180
+ * rule. `consumed` always wins (over anything, in either position); `failed`
181
+ * yields only to `consumed`. This is the single guard both the deadline sweep
182
+ * and the federation merge path consult, so the invariant cannot drift between
183
+ * them.
184
+ */
185
+ export function reconcileState(localState, incomingState) {
186
+ if (localState === "consumed" || incomingState === "consumed")
187
+ return "consumed";
188
+ if (localState === "failed")
189
+ return "failed";
190
+ return incomingState;
191
+ }
192
+ /**
193
+ * Deadline sweep decision for one row at time `now`. Fails an unconsumed row
194
+ * whose deadline has passed; NEVER touches a consumed row (absorbing) and
195
+ * never re-fails an already-failed one, and never fails a row with no deadline.
196
+ */
197
+ export function sweepDecision(row, now) {
198
+ if (!isUnconsumed(row.state))
199
+ return { fail: false };
200
+ if (!row.deadline)
201
+ return { fail: false };
202
+ if (new Date(row.deadline).getTime() > now.getTime())
203
+ return { fail: false };
204
+ return { fail: true, failureReason: "deadline" };
205
+ }