@tpsdev-ai/flair 0.51.2 → 0.53.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/README.md +10 -5
- package/dist/build-info.json +3 -3
- package/dist/cli.js +1037 -566
- package/dist/doctor-client.js +35 -0
- package/dist/hook-install.js +74 -0
- package/dist/install/global-bin-path.js +14 -0
- package/dist/lib/auth-resolve.js +15 -0
- package/dist/lib/doctor-run.js +28 -15
- package/dist/lib/launchd-repair.js +198 -0
- package/dist/lib/stabilize-mqtt-network.js +123 -0
- package/dist/lib/upgrade-exec-path.js +257 -0
- package/dist/lib/upgrade-plain-tree.js +558 -0
- package/dist/rem/promote-policy.js +204 -0
- package/dist/rem/restore.js +55 -15
- package/dist/rem/runner.js +203 -20
- package/dist/resources/AdminMemory.js +2 -1
- package/dist/resources/AgentSeed.js +26 -10
- package/dist/resources/Asset.js +203 -0
- package/dist/resources/AutoPromoteCandidates.js +2 -4
- package/dist/resources/Credential.js +14 -0
- package/dist/resources/Federation.js +80 -0
- package/dist/resources/Integration.js +12 -0
- package/dist/resources/Memory.js +158 -60
- package/dist/resources/MemoryBootstrap.js +63 -20
- package/dist/resources/MemoryCandidate.js +12 -0
- package/dist/resources/MemoryConsolidate.js +2 -1
- package/dist/resources/MemoryDedupStats.js +17 -2
- package/dist/resources/MemoryFeed.js +30 -0
- package/dist/resources/MemoryGrant.js +14 -0
- package/dist/resources/MemoryReflect.js +75 -17
- package/dist/resources/Message.js +190 -0
- package/dist/resources/OrgEvent.js +12 -0
- package/dist/resources/PromoteMemoryCandidate.js +76 -0
- package/dist/resources/RecordUsage.js +1 -1
- package/dist/resources/Relationship.js +12 -0
- package/dist/resources/SemanticSearch.js +45 -13
- package/dist/resources/Soul.js +54 -18
- package/dist/resources/WorkspaceState.js +12 -0
- package/dist/resources/auth-middleware.js +17 -44
- package/dist/resources/authority-field-guard.js +37 -0
- package/dist/resources/bm25-index-service.js +1 -1
- package/dist/resources/bm25-index.js +50 -11
- package/dist/resources/embedding-space-guard.js +238 -0
- package/dist/resources/embeddings-provider.js +32 -5
- package/dist/resources/federation-classify.js +23 -1
- package/dist/resources/health.js +11 -2
- package/dist/resources/hit-tracking.js +244 -0
- package/dist/resources/mcp-tools.js +272 -7
- package/dist/resources/memory-reflect-lib.js +111 -0
- package/dist/resources/migrations/embedding-stamp.js +22 -4
- package/dist/resources/owner-field-guard.js +62 -0
- package/dist/resources/promotion-stamp.js +29 -0
- package/dist/resources/record-owner-guard.js +71 -5
- package/dist/resources/record-types.js +30 -7
- package/dist/resources/relay-lib.js +205 -0
- package/dist/resources/relay-ops.js +294 -0
- package/dist/resources/skill-write.js +120 -0
- package/dist/resources/soul-adk-guard.js +68 -0
- package/dist/resources/soul-write-policy.js +63 -0
- package/dist/resources/table-helpers.js +2 -0
- package/dist/resources/usage-recording.js +3 -3
- package/dist/src/rem/promote-policy.js +204 -0
- package/docs/api-reference.md +374 -0
- package/docs/auth.md +52 -0
- package/docs/federation.md +4 -0
- package/docs/integrations.md +6 -6
- package/docs/mcp-clients.md +16 -1
- package/docs/releasing.md +11 -8
- package/docs/rem.md +20 -2
- package/docs/upgrade.md +47 -2
- package/package.json +6 -5
- package/schemas/memory.graphql +51 -2
- package/schemas/message.graphql +74 -0
- package/templates/launchd/start-flair-with-admin-pass.sh +73 -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
|
|
138
|
-
*
|
|
139
|
-
*
|
|
140
|
-
*
|
|
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
|
|
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
|
|
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).
|
|
113
|
-
*
|
|
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:
|
|
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
|
+
}
|