@uuaid/agent-commons 0.0.0 → 0.2.0-alpha.4

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 (78) hide show
  1. package/CONFORMANCE.md +26 -0
  2. package/LICENSE +201 -0
  3. package/NOTICE +22 -0
  4. package/PROVENANCE.json +24 -0
  5. package/README.md +404 -5
  6. package/bin/agent-commons.mjs +76 -0
  7. package/examples/Dockerfile +12 -0
  8. package/examples/agent.mjs +17 -0
  9. package/examples/global-policy.json +9 -0
  10. package/examples/run-cohort.mjs +68 -0
  11. package/package.json +31 -8
  12. package/profiles/agentnet.chat.json +11 -0
  13. package/profiles/local.json +11 -0
  14. package/profiles/zilligon.com.json +11 -0
  15. package/profiles/zilligons.com.json +11 -0
  16. package/src/carrier.mjs +84 -0
  17. package/src/channels.d.mts +75 -0
  18. package/src/channels.mjs +158 -0
  19. package/src/cohort.mjs +61 -0
  20. package/src/collaboration.mjs +875 -0
  21. package/src/config.mjs +40 -0
  22. package/src/continuity.d.mts +24 -0
  23. package/src/continuity.mjs +601 -0
  24. package/src/governance-scope.d.mts +46 -0
  25. package/src/governance-scope.mjs +139 -0
  26. package/src/governance.mjs +355 -0
  27. package/src/index.d.ts +111 -0
  28. package/src/index.mjs +15 -0
  29. package/src/loop.mjs +71 -0
  30. package/src/offline.d.mts +7 -0
  31. package/src/offline.mjs +29 -0
  32. package/src/pillar.mjs +59 -0
  33. package/src/profiles.d.mts +12 -0
  34. package/src/profiles.mjs +92 -0
  35. package/src/relay/auth.mjs +294 -0
  36. package/src/relay/board.mjs +223 -0
  37. package/src/relay/claims.mjs +25 -0
  38. package/src/relay/cli.mjs +49 -0
  39. package/src/relay/client.mjs +437 -0
  40. package/src/relay/docs/reconnect-v1.md +27 -0
  41. package/src/relay/docs/working-rules-v1.md +42 -0
  42. package/src/relay/enroll.mjs +232 -0
  43. package/src/relay/export-cli.mjs +47 -0
  44. package/src/relay/governance.mjs +1328 -0
  45. package/src/relay/mailbox.mjs +103 -0
  46. package/src/relay/records.mjs +146 -0
  47. package/src/relay/render.mjs +38 -0
  48. package/src/relay/roster.mjs +134 -0
  49. package/src/relay/routes.mjs +87 -0
  50. package/src/relay/schema.mjs +1081 -0
  51. package/src/relay/seed.mjs +89 -0
  52. package/src/relay/serve.mjs +116 -0
  53. package/src/relay/server.mjs +633 -0
  54. package/src/relay/signing.mjs +693 -0
  55. package/src/relay/test-mode.mjs +73 -0
  56. package/src/runtime.mjs +359 -0
  57. package/src/spaces/manifest.mjs +245 -0
  58. package/src/spaces/pillar-group.mjs +12 -0
  59. package/src/spaces/policy.mjs +229 -0
  60. package/src/spaces/store.mjs +325 -0
  61. package/src/store.mjs +73 -0
  62. package/src/transport.d.mts +76 -0
  63. package/src/transport.mjs +267 -0
  64. package/src/trust.mjs +104 -0
  65. package/src/uuaid-namespace.d.mts +6 -0
  66. package/src/uuaid-namespace.mjs +48 -0
  67. package/src/vendor/pillar/blob/blob.mjs +508 -0
  68. package/src/vendor/pillar/crypto/e2e.mjs +97 -0
  69. package/src/vendor/pillar/groups-v2.mjs +1108 -0
  70. package/src/vendor/pillar/groups.mjs +175 -0
  71. package/src/vendor/pillar/identity/jcs.mjs +34 -0
  72. package/src/vendor/pillar/identity/keychain.mjs +241 -0
  73. package/src/vendor/pillar/identity/tier.mjs +130 -0
  74. package/src/vendor/pillar/net/carrier-client.mjs +219 -0
  75. package/src/vendor/pillar/net/delegation.mjs +209 -0
  76. package/src/vendor/pillar/net/envelope.mjs +258 -0
  77. package/src/vendor/pillar/net/refusals.mjs +129 -0
  78. package/src/vendor/pillar/net/seed-tier.mjs +185 -0
@@ -0,0 +1,325 @@
1
+ /**
2
+ * The space store (design §8). Additive; imports the vendored pillar engine
3
+ * ONLY through ./pillar-group.mjs (C9). Computes no engine-vs-client hash
4
+ * (C13/V-7). Never opens engine.db (F9); it only hands back the path.
5
+ *
6
+ * Layout under <commonsHome>/spaces/ (all dirs 0700, all files 0600, atomic):
7
+ * groups-v2/grp-<32 hex>.json roster record, via the vendored RosterStore
8
+ * grp-<32 hex>/adapter.json adapter state (this file)
9
+ * grp-<32 hex>/engine.db this space's own engine store (path only)
10
+ * dispatcher.json per-carrier cursors
11
+ */
12
+ import { randomBytes } from "node:crypto";
13
+ import {
14
+ chmodSync, closeSync, fsyncSync, mkdirSync, openSync,
15
+ readFileSync, renameSync, writeFileSync,
16
+ } from "node:fs";
17
+ import { join } from "node:path";
18
+ import { RosterStore, isGroupIdV2 } from "./pillar-group.mjs";
19
+
20
+ export const ADAPTER_V = "commons-space-adapter/1";
21
+ export const DISPATCHER_V = "commons-space-dispatcher/1";
22
+ export const PENDING_TTL_MS = 60 * 60 * 1000; // S-3: a pending removal lives at most 1 hour
23
+
24
+ const HEX64 = /^[0-9a-f]{64}$/;
25
+ const HARD_REASONS = ["outsider", "policy-mismatch"]; // A-4, P-9 (HS-1)
26
+ const SOFT_REASONS = ["reconsent"]; // P-12 (HS-1)
27
+
28
+ // Exact key sets, one per level. A strict set at every level is what refuses a
29
+ // `policy`/`agents` key anywhere (S-4: policy is derived, never stored) and a
30
+ // `body` inside a heldRefs entry (L-6: metadata only) — neither is in a set.
31
+ const ADAPTER_KEYS = ["v", "groupId", "channels", "profile", "consent", "outsiderScan", "hardStop", "softStop", "pendingRemovals", "heldRefs"];
32
+ const CHANNELS_KEYS = ["content", "control"];
33
+ const PROFILE_KEYS = ["id", "hash"];
34
+ const CONSENT_KEYS = ["manifestVersion", "charterHash", "carriers"];
35
+ const OUTSIDER_SCAN_KEYS = ["version", "hash"];
36
+ const STOP_KEYS = ["reason", "at"];
37
+ const PENDING_KEYS = ["uuaid", "key", "manifestVersion", "queuedAt", "outboxId"];
38
+ const HELDREF_KEYS = ["ref", "groupId", "from", "msgId", "receivedAt"];
39
+ const DISPATCHER_KEYS = ["v", "cursors"];
40
+
41
+ const plain = (o) => o !== null && typeof o === "object" && !Array.isArray(o);
42
+ const nonEmptyString = (s) => typeof s === "string" && s.length > 0;
43
+ const hex64 = (s) => typeof s === "string" && HEX64.test(s);
44
+ const safeInt = (n) => Number.isSafeInteger(n);
45
+ const posInt = (n) => safeInt(n) && n >= 1;
46
+ const nonNegInt = (n) => safeInt(n) && n >= 0;
47
+
48
+ function exactKeys(obj, keys) {
49
+ if (!plain(obj)) return false;
50
+ const own = Object.keys(obj);
51
+ if (own.length !== keys.length) return false;
52
+ return keys.every((k) => Object.hasOwn(obj, k));
53
+ }
54
+
55
+ /** A named error, so a caller (and a pole) can tell a bad shape from an absent file. */
56
+ export class SpaceStoreError extends Error {
57
+ constructor(name, message) { super(message); this.name = name; }
58
+ }
59
+ const badId = (id) => new SpaceStoreError("GroupIdError", `not a v2 group id: ${String(id)}`);
60
+ const badAdapter = (why) => new SpaceStoreError("AdapterStateError", `adapter.json: ${why}`);
61
+ const badDispatcher = (why) => new SpaceStoreError("DispatcherStateError", `dispatcher.json: ${why}`);
62
+
63
+ function validateStop(stop, reasons, label) {
64
+ if (stop === null) return;
65
+ if (!exactKeys(stop, STOP_KEYS)) throw badAdapter(`${label}: expected null or {reason, at}`);
66
+ if (!reasons.includes(stop.reason)) throw badAdapter(`${label}.reason: not one of ${reasons.join("|")}`);
67
+ if (!safeInt(stop.at)) throw badAdapter(`${label}.at: not a safe integer`);
68
+ }
69
+
70
+ /**
71
+ * Strict shape check of an adapter state. Throws AdapterStateError on any
72
+ * deviation. When `id` is given, the record's groupId must match it.
73
+ */
74
+ export function validateAdapterState(state, id) {
75
+ if (!exactKeys(state, ADAPTER_KEYS)) throw badAdapter("top-level key set is not exact (a policy/agents key is refused here, S-4)");
76
+ if (state.v !== ADAPTER_V) throw badAdapter(`v: expected ${ADAPTER_V}`);
77
+ if (!isGroupIdV2(state.groupId)) throw badAdapter("groupId: not a v2 group id");
78
+ if (id !== undefined && state.groupId !== id) throw badAdapter("groupId does not match the space it is stored under");
79
+ if (!exactKeys(state.channels, CHANNELS_KEYS)) throw badAdapter("channels: expected {content, control}");
80
+ if (!nonEmptyString(state.channels.content)) throw badAdapter("channels.content: not a non-empty string");
81
+ if (!nonEmptyString(state.channels.control)) throw badAdapter("channels.control: not a non-empty string");
82
+ if (!exactKeys(state.profile, PROFILE_KEYS)) throw badAdapter("profile: expected {id, hash}");
83
+ if (!nonEmptyString(state.profile.id)) throw badAdapter("profile.id: not a non-empty string");
84
+ if (!hex64(state.profile.hash)) throw badAdapter("profile.hash: not a 64-hex sha256");
85
+ if (!exactKeys(state.consent, CONSENT_KEYS)) throw badAdapter("consent: expected {manifestVersion, charterHash, carriers}");
86
+ if (!posInt(state.consent.manifestVersion)) throw badAdapter("consent.manifestVersion: not a positive safe integer");
87
+ if (!hex64(state.consent.charterHash)) throw badAdapter("consent.charterHash: not a 64-hex sha256");
88
+ if (!Array.isArray(state.consent.carriers)) throw badAdapter("consent.carriers: not an array");
89
+ if (!state.consent.carriers.every(nonEmptyString)) throw badAdapter("consent.carriers: every entry must be a non-empty string");
90
+ if (!exactKeys(state.outsiderScan, OUTSIDER_SCAN_KEYS) || !posInt(state.outsiderScan.version) || !hex64(state.outsiderScan.hash)) throw badAdapter("outsiderScan: expected {version: positive safe integer, hash: 64-hex}");
91
+ validateStop(state.hardStop, HARD_REASONS, "hardStop");
92
+ validateStop(state.softStop, SOFT_REASONS, "softStop");
93
+ if (!Array.isArray(state.pendingRemovals)) throw badAdapter("pendingRemovals: not an array");
94
+ for (const e of state.pendingRemovals) {
95
+ if (!exactKeys(e, PENDING_KEYS)) throw badAdapter("pendingRemovals entry: expected {uuaid, key, manifestVersion, queuedAt, outboxId}");
96
+ if (!nonEmptyString(e.uuaid)) throw badAdapter("pendingRemovals.uuaid: not a non-empty string");
97
+ if (!hex64(e.key)) throw badAdapter("pendingRemovals.key: not a 64-hex public key");
98
+ if (!posInt(e.manifestVersion)) throw badAdapter("pendingRemovals.manifestVersion: not a positive safe integer");
99
+ if (!safeInt(e.queuedAt)) throw badAdapter("pendingRemovals.queuedAt: not a safe integer");
100
+ if (!nonEmptyString(e.outboxId)) throw badAdapter("pendingRemovals.outboxId: not a non-empty string");
101
+ }
102
+ if (!Array.isArray(state.heldRefs)) throw badAdapter("heldRefs: not an array");
103
+ for (const e of state.heldRefs) {
104
+ if (!exactKeys(e, HELDREF_KEYS)) throw badAdapter("heldRefs entry: expected exactly {ref, groupId, from, msgId, receivedAt} (a body is refused here, L-6)");
105
+ if (!nonEmptyString(e.ref)) throw badAdapter("heldRefs.ref: not a non-empty string");
106
+ if (!isGroupIdV2(e.groupId)) throw badAdapter("heldRefs.groupId: not a v2 group id");
107
+ if (!nonEmptyString(e.from)) throw badAdapter("heldRefs.from: not a non-empty string");
108
+ if (!nonEmptyString(e.msgId)) throw badAdapter("heldRefs.msgId: not a non-empty string");
109
+ if (!safeInt(e.receivedAt)) throw badAdapter("heldRefs.receivedAt: not a safe integer");
110
+ }
111
+ }
112
+
113
+ function validateDispatcher(d) {
114
+ if (!exactKeys(d, DISPATCHER_KEYS)) throw badDispatcher("top-level key set is not exact");
115
+ if (d.v !== DISPATCHER_V) throw badDispatcher(`v: expected ${DISPATCHER_V}`);
116
+ if (!plain(d.cursors)) throw badDispatcher("cursors: not an object");
117
+ for (const [url, seq] of Object.entries(d.cursors)) {
118
+ if (!nonEmptyString(url)) throw badDispatcher("cursors: a carrier url key is empty");
119
+ if (!nonNegInt(seq)) throw badDispatcher(`cursors[${url}]: not a non-negative safe integer`);
120
+ }
121
+ }
122
+
123
+ /** temp 0600 + wx, fsync, rename — the RosterStore.save pattern (groups-v2.mjs:414-426). */
124
+ function atomicWrite0600(filePath, text) {
125
+ const tmp = `${filePath}.tmp-${process.pid}-${randomBytes(4).toString("hex")}`;
126
+ writeFileSync(tmp, text, { mode: 0o600, flag: "wx" });
127
+ const fd = openSync(tmp, "r");
128
+ try { fsyncSync(fd); } finally { closeSync(fd); }
129
+ renameSync(tmp, filePath);
130
+ }
131
+
132
+ /**
133
+ * S-3 load-time pruning. Re-checks every pendingRemovals entry against the
134
+ * roster record VERIFIED IN THE SAME CALL. An entry is kept only if (a) the
135
+ * verified head shows the row removed with the same key, (b) the verified chain
136
+ * carries the manifest at manifestVersion showing that row removed and
137
+ * manifestVersion <= head.version, and (c) now - queuedAt <= 1 hour and
138
+ * queuedAt <= now. Anything else is dropped, with one reason per entry.
139
+ * `now` is injectable for tests.
140
+ *
141
+ * Contract: `rec` MUST be the output of `loadRoster(id)` (i.e. of the vendored
142
+ * `RosterStore.load`), so it is a record whose chain verified from version 1 in
143
+ * that same call. This function does not verify `rec` — it trusts it — and an
144
+ * unverified or hand-built chain is accepted and read literally.
145
+ *
146
+ * Every clause is written KEEP-ONLY-IF, never drop-if: a keep widens policy
147
+ * (P-7 puts pending rows into `agents`), so anything this function cannot
148
+ * positively prove in-window is dropped. That includes a clock it cannot
149
+ * compare with: a `now` that is not a safe integer (undefined, NaN, a string,
150
+ * a fraction, a value past 2^53-1) drops every entry as `bad-clock`, and an
151
+ * entry whose own `queuedAt` is not a safe integer drops as `bad-entry`,
152
+ * because each comparison against such a value is false or coerced and a
153
+ * negated clause would read that as "in window".
154
+ */
155
+ export function prunePendingRemovals(entries, rec, now) {
156
+ const kept = [];
157
+ const dropped = [];
158
+ const head = rec && Array.isArray(rec.chain) && rec.chain.length > 0 ? rec.chain[rec.chain.length - 1] : null;
159
+ for (const e of entries) {
160
+ let reason = null;
161
+ if (!rec || !head) {
162
+ reason = "roster-absent"; // no verified roster -> nothing grants a keep
163
+ } else {
164
+ // (a) verified head
165
+ const hrow = head.members?.find((r) => r.uuaid === e.uuaid);
166
+ if (!hrow) reason = "head-no-row";
167
+ else if (hrow.status !== "removed") reason = "head-not-removed";
168
+ else if (hrow.key !== e.key) reason = "head-key-mismatch";
169
+ // (b) verified chain carries the manifest at manifestVersion
170
+ if (reason === null) {
171
+ const man = rec.chain.find((m) => m.version === e.manifestVersion);
172
+ if (!man) reason = "chain-manifest-absent";
173
+ // Unreachable on a VERIFIED record: verifyChain (groups-v2.mjs:281) forces
174
+ // contiguous versions 1..N via verifyNext's prev/version checks, so a manifest
175
+ // present in the chain can never exceed the head. Kept because (i) the brief
176
+ // names "manifestVersion <= head.version" as a keep-condition and a reviewer
177
+ // reads the code against that text, (ii) it is a guard against a future change
178
+ // to the vendored verifyChain, and (iii) this function is exported and trusts
179
+ // its `rec` argument, so a hand-built out-of-order chain does reach it (pinned
180
+ // by a unit test).
181
+ else if (e.manifestVersion > head.version) reason = "manifest-ahead-of-head";
182
+ else {
183
+ const mrow = man.members?.find((r) => r.uuaid === e.uuaid);
184
+ if (!mrow || mrow.status !== "removed") reason = "chain-not-removed";
185
+ }
186
+ }
187
+ // (c) the 1-hour window, and no future stamp. KEEP-ONLY-IF: the clause is
188
+ // written as the positive keep condition, so a `now` that cannot be compared
189
+ // (undefined, NaN, a string, a fraction, a value past 2^53-1) drops as
190
+ // `bad-clock` instead of keeping. Written as "drop if X", both comparisons are
191
+ // false for such a `now`, which reads as "in window" and keeps a removed member
192
+ // authorized past the 1-hour bound. A safe integer is required, not merely a
193
+ // finite one: `Number.isFinite(now0 + 0.5)` is true, yet a fractional clock
194
+ // compares loosely against an integer stamp and cannot pin the window exactly.
195
+ // The entry's own stamp is held to the same bar — this function is exported and
196
+ // trusts its arguments, so an in-memory entry (a later slice's policy writer) can
197
+ // reach it with a string `queuedAt` that `<=` and `-` would silently coerce.
198
+ if (reason === null && !Number.isSafeInteger(now)) reason = "bad-clock";
199
+ else if (reason === null && !Number.isSafeInteger(e.queuedAt)) reason = "bad-entry";
200
+ else if (reason === null && !(e.queuedAt <= now && now - e.queuedAt <= PENDING_TTL_MS)) {
201
+ reason = e.queuedAt > now ? "future-stamp" : "expired";
202
+ }
203
+ }
204
+ if (reason === null) kept.push(e);
205
+ else dropped.push({ entry: e, reason });
206
+ }
207
+ return { kept, dropped };
208
+ }
209
+
210
+ export class SpaceStore {
211
+ /** @param {string} commonsHome the engine home chosen by the operator */
212
+ constructor(commonsHome) {
213
+ if (!nonEmptyString(commonsHome)) throw new SpaceStoreError("BadHomeError", "commonsHome: not a non-empty string");
214
+ this.commonsHome = commonsHome;
215
+ this.spacesHome = join(commonsHome, "spaces");
216
+ // The vendored RosterStore makes groups-v2/ (0700) and writes 0600 atomically itself.
217
+ this._roster = new RosterStore(this.spacesHome);
218
+ }
219
+
220
+ _ensureSpaces() {
221
+ mkdirSync(this.spacesHome, { recursive: true, mode: 0o700 });
222
+ chmodSync(this.spacesHome, 0o700);
223
+ }
224
+
225
+ _assertId(id) { if (!isGroupIdV2(id)) throw badId(id); }
226
+
227
+ /** spaces/grp-<id>/ — validated before any fs call (P7). */
228
+ groupDir(id) { this._assertId(id); return join(this.spacesHome, id); }
229
+ /** spaces/grp-<id>/adapter.json */
230
+ adapterPath(id) { return join(this.groupDir(id), "adapter.json"); }
231
+ /** spaces/grp-<id>/engine.db — path only; F9 gives one engine store per space. Never opened here. */
232
+ engineDbPath(id) { return join(this.groupDir(id), "engine.db"); }
233
+ /** spaces/dispatcher.json */
234
+ dispatcherPath() { return join(this.spacesHome, "dispatcher.json"); }
235
+ /** spaces/groups-v2/grp-<id>.json (the vendored roster record path). */
236
+ rosterPath(id) { this._assertId(id); return this._roster.path(id); }
237
+
238
+ // ── roster (S-1, F13) ──────────────────────────────────────────────────
239
+
240
+ /** Delegates to RosterStore.save, which refuses an unverified record (S-1). */
241
+ saveRoster(rec) {
242
+ // Verify before create: RosterStore.save runs checkStoreRecord and throws BEFORE
243
+ // its own mkdir, so an unverified record never makes spaces/ exist.
244
+ this._roster.save(rec); // throws if the chain does not verify / head mismatches
245
+ this._ensureSpaces(); // tighten spaces/ to 0700 once the record is known good
246
+ }
247
+
248
+ /** The verified record, or null. Any read, parse or verify failure reads as ABSENT (F13). */
249
+ loadRoster(id) {
250
+ this._assertId(id);
251
+ try { return this._roster.load(id); } catch { return null; }
252
+ }
253
+
254
+ // ── adapter.json ───────────────────────────────────────────────────────
255
+
256
+ /** Validates the strict shape, then writes 0600 atomically under grp-<id>/. */
257
+ saveAdapter(id, state) {
258
+ this._assertId(id);
259
+ validateAdapterState(state, id);
260
+ this._ensureSpaces(); // tighten a pre-existing spaces/ to 0700 before writing under it
261
+ const dir = this.groupDir(id);
262
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
263
+ chmodSync(dir, 0o700);
264
+ atomicWrite0600(this.adapterPath(id), JSON.stringify(state));
265
+ }
266
+
267
+ /**
268
+ * Loads adapter state and runs S-3 pruning against the roster verified in the
269
+ * same call. A MISSING file reads as null (no adapter state yet). A CORRUPT or
270
+ * wrong-shape file THROWS — it never defaults, because defaulting would turn a
271
+ * lost hardStop into "no stop" and widen authority (CL-1, HS-1).
272
+ * @returns {null | { state: object, dropped: Array<{entry, reason}> }}
273
+ */
274
+ loadAdapter(id, { now = Date.now() } = {}) {
275
+ this._assertId(id);
276
+ // The `= Date.now()` default catches only `undefined`. An explicitly passed
277
+ // NaN or "abc" flows through to prunePendingRemovals, whose keep-only-if clause
278
+ // drops every entry as `bad-clock` — a non-finite clock fails closed here too.
279
+ const p = this.adapterPath(id);
280
+ // Only ENOENT means "absent". Read inside try/catch instead of existsSync,
281
+ // which reports EACCES and ENOTDIR as absent too — so a grp dir at mode 000, or a
282
+ // grp path replaced by a regular file, would read a latched hardStop as "no state"
283
+ // and widen authority (CL-1, HS-1).
284
+ let raw;
285
+ try { raw = readFileSync(p, "utf-8"); }
286
+ catch (e) {
287
+ if (e.code === "ENOENT") return null;
288
+ throw badAdapter(`unreadable: ${e.code}: ${e.message}`);
289
+ }
290
+ let parsed;
291
+ try { parsed = JSON.parse(raw); } catch (e) { throw badAdapter(`not valid JSON: ${e.message}`); }
292
+ validateAdapterState(parsed, id); // throws AdapterStateError on a bad shape
293
+ const rec = this.loadRoster(id); // verified in THIS call
294
+ const { kept, dropped } = prunePendingRemovals(parsed.pendingRemovals, rec, now);
295
+ const state = { ...parsed, pendingRemovals: kept };
296
+ return { state, dropped };
297
+ }
298
+
299
+ // ── dispatcher.json ────────────────────────────────────────────────────
300
+
301
+ /** Validates and writes 0600 atomically at spaces/dispatcher.json. */
302
+ saveDispatcher(d) {
303
+ validateDispatcher(d);
304
+ this._ensureSpaces();
305
+ atomicWrite0600(this.dispatcherPath(), JSON.stringify(d));
306
+ }
307
+
308
+ /** A missing file means empty cursors; a bad shape throws (never defaults). */
309
+ loadDispatcher() {
310
+ const p = this.dispatcherPath();
311
+ // Only ENOENT means "empty cursors". Any other read failure (EACCES on a
312
+ // spaces/ at mode 000, ENOTDIR if the path is a file) throws the named error —
313
+ // existsSync would report those as absent and silently lose every cursor.
314
+ let raw;
315
+ try { raw = readFileSync(p, "utf-8"); }
316
+ catch (e) {
317
+ if (e.code === "ENOENT") return { v: DISPATCHER_V, cursors: {} };
318
+ throw badDispatcher(`unreadable: ${e.code}: ${e.message}`);
319
+ }
320
+ let parsed;
321
+ try { parsed = JSON.parse(raw); } catch (e) { throw badDispatcher(`not valid JSON: ${e.message}`); }
322
+ validateDispatcher(parsed);
323
+ return parsed;
324
+ }
325
+ }
package/src/store.mjs ADDED
@@ -0,0 +1,73 @@
1
+ import { DatabaseSync } from "node:sqlite";
2
+ import { mkdirSync, chmodSync } from "node:fs";
3
+ import { dirname } from "node:path";
4
+ import { digest } from "./profiles.mjs";
5
+
6
+ export class CommonsStore {
7
+ constructor(path=":memory:") {
8
+ if(path!==":memory:")mkdirSync(dirname(path),{recursive:true,mode:0o700});
9
+ this.db=new DatabaseSync(path);
10
+ this.db.exec(`PRAGMA journal_mode=WAL; CREATE TABLE IF NOT EXISTS kv(k TEXT PRIMARY KEY,v TEXT NOT NULL);
11
+ CREATE TABLE IF NOT EXISTS envelopes(seq INTEGER PRIMARY KEY AUTOINCREMENT,id TEXT NOT NULL UNIQUE,sender TEXT NOT NULL,recipient TEXT NOT NULL,sha TEXT NOT NULL,body TEXT NOT NULL,expires INTEGER NOT NULL);
12
+ CREATE INDEX IF NOT EXISTS inbox_recipient ON envelopes(recipient,seq);
13
+ CREATE TABLE IF NOT EXISTS events(seq INTEGER PRIMARY KEY AUTOINCREMENT,id TEXT NOT NULL UNIQUE,previous TEXT NOT NULL,hash TEXT NOT NULL,body TEXT NOT NULL);
14
+ CREATE TABLE IF NOT EXISTS outbox(id TEXT PRIMARY KEY,body TEXT NOT NULL,attempts INTEGER NOT NULL DEFAULT 0,next_at INTEGER NOT NULL DEFAULT 0,state TEXT NOT NULL DEFAULT 'pending',error TEXT);
15
+ CREATE TABLE IF NOT EXISTS processed(id TEXT PRIMARY KEY,hash TEXT NOT NULL);
16
+ CREATE TABLE IF NOT EXISTS held(id TEXT PRIMARY KEY,msg_key TEXT NOT NULL UNIQUE,channel TEXT NOT NULL,sender TEXT NOT NULL,envelope TEXT NOT NULL,sha TEXT NOT NULL,expires_at INTEGER NOT NULL,created_at INTEGER NOT NULL);
17
+ CREATE INDEX IF NOT EXISTS held_channel ON held(channel);
18
+ CREATE INDEX IF NOT EXISTS held_sender ON held(sender);
19
+ CREATE INDEX IF NOT EXISTS held_channel_sender ON held(channel,sender);
20
+ `);
21
+ if(!this.db.prepare("PRAGMA table_info(outbox)").all().some(c=>c.name==="profile_id"))this.db.exec("ALTER TABLE outbox ADD COLUMN profile_id TEXT");
22
+ if(path!==":memory:")chmodSync(path,0o600);
23
+ }
24
+ get(key,fallback=null){const row=this.db.prepare("SELECT v FROM kv WHERE k=?").get(key);return row?JSON.parse(row.v):fallback}
25
+ set(key,value){this.db.prepare("INSERT INTO kv(k,v) VALUES(?,?) ON CONFLICT(k) DO UPDATE SET v=excluded.v").run(key,JSON.stringify(value))}
26
+ transaction(fn){this.db.exec("BEGIN IMMEDIATE");try{const result=fn();this.db.exec("COMMIT");return result}catch(e){this.db.exec("ROLLBACK");throw e}}
27
+ append(id,event){
28
+ const existing=this.db.prepare("SELECT * FROM events WHERE id=?").get(id);if(existing){if(digest(JSON.parse(existing.body))!==digest(event))throw new Error("Audit event ID conflicts with content");return {...existing,duplicate:true}};
29
+ const previous=this.db.prepare("SELECT hash FROM events ORDER BY seq DESC LIMIT 1").get()?.hash??"genesis";
30
+ const hash=digest({previous,event});
31
+ this.db.prepare("INSERT INTO events(id,previous,hash,body) VALUES(?,?,?,?)").run(id,previous,hash,JSON.stringify(event));
32
+ return {id,previous,hash};
33
+ }
34
+ verify(){let previous="genesis";return this.db.prepare("SELECT * FROM events ORDER BY seq").all().every(r=>{const ok=r.previous===previous&&r.hash===digest({previous,event:JSON.parse(r.body)});previous=r.hash;return ok})}
35
+ audit(){return this.db.prepare("SELECT * FROM events ORDER BY seq DESC LIMIT 100").all()}
36
+ seen(id,hash=null){const row=this.db.prepare("SELECT hash FROM processed WHERE id=?").get(id);if(row&&hash&&row.hash!==hash)throw new Error("Replay ID conflicts with signed content");return !!row}
37
+ mark(id,hash){this.db.prepare("INSERT INTO processed(id,hash) VALUES(?,?)").run(id,hash)}
38
+ retain(envelope,sha,ttlMs=14*86400000){
39
+ const existing=this.db.prepare("SELECT seq,sha FROM envelopes WHERE id=?").get(envelope.id);
40
+ if(existing){if(existing.sha!==sha)throw new Error("Envelope ID conflicts with existing content");return {...existing,duplicate:true}}
41
+ this.db.prepare("DELETE FROM envelopes WHERE expires<?").run(Date.now());
42
+ if(this.db.prepare("SELECT COUNT(*) AS n FROM envelopes").get().n>=10000||this.db.prepare("PRAGMA page_count").get().page_count*this.db.prepare("PRAGMA page_size").get().page_size>=256*1024*1024)throw new Error("Carrier storage budget exhausted");
43
+ const r=this.db.prepare("INSERT INTO envelopes(id,sender,recipient,sha,body,expires) VALUES(?,?,?,?,?,?)").run(envelope.id,envelope.sender,envelope.recipient,sha,JSON.stringify(envelope),Date.now()+ttlMs);
44
+ return {seq:Number(r.lastInsertRowid),sha,duplicate:false};
45
+ }
46
+ inbox(recipient,since=0){return this.db.prepare("SELECT seq,body FROM envelopes WHERE recipient=? AND seq>? AND expires>? ORDER BY seq LIMIT 100").all(recipient,since,Date.now()).map(r=>({seq:r.seq,envelope:JSON.parse(r.body)}))}
47
+ queue(envelope,profileId=null){this.db.prepare("INSERT OR IGNORE INTO outbox(id,body,profile_id) VALUES(?,?,?)").run(envelope.id,JSON.stringify(envelope),profileId)}
48
+ pending(){return this.db.prepare("SELECT * FROM outbox WHERE state='pending' AND next_at<=? ORDER BY rowid LIMIT 50").all(Date.now())}
49
+ delivered(id){this.db.prepare("UPDATE outbox SET state='accepted',error=NULL WHERE id=?").run(id)}
50
+ failure(id,error){const row=this.db.prepare("SELECT attempts FROM outbox WHERE id=?").get(id);const attempts=row.attempts+1;const state=attempts>=5?"quarantined":"pending";this.db.prepare("UPDATE outbox SET attempts=?,next_at=?,state=?,error=? WHERE id=?").run(attempts,Date.now()+Math.min(300000,1000*2**attempts),state,error.slice(0,300),id)}
51
+ hold(envelope,msgKey,channel,sender,sha,expiresAt){this.db.prepare("INSERT INTO held(id,msg_key,channel,sender,envelope,sha,expires_at,created_at) VALUES(?,?,?,?,?,?,?,?)").run(envelope.id,msgKey,channel,sender,JSON.stringify(envelope),sha,expiresAt,Date.now())}
52
+ isHeld(id,msgKey){return !!this.db.prepare("SELECT 1 FROM held WHERE id=? OR msg_key=?").get(id,msgKey)}
53
+ removeHeld(id){this.db.prepare("DELETE FROM held WHERE id=?").run(id)}
54
+ allHeld(){return this.db.prepare("SELECT * FROM held ORDER BY created_at ASC, rowid ASC").all()}
55
+ holdCounts(channel,sender){
56
+ const total=this.db.prepare("SELECT COUNT(*) AS n FROM held").get().n;
57
+ const channelCount=this.db.prepare("SELECT COUNT(*) AS n FROM held WHERE channel=?").get(channel).n;
58
+ const senderCount=this.db.prepare("SELECT COUNT(*) AS n FROM held WHERE sender=?").get(sender).n;
59
+ const channelSender=this.db.prepare("SELECT COUNT(*) AS n FROM held WHERE channel=? AND sender=?").get(channel,sender).n;
60
+ return {total,channel:channelCount,sender:senderCount,channelSender};
61
+ }
62
+ pruneExpiredHeld(now=Date.now()){
63
+ return this.transaction(()=>{
64
+ const expired=this.db.prepare("SELECT * FROM held WHERE expires_at<=?").all(now);
65
+ if(expired.length){
66
+ const del=this.db.prepare("DELETE FROM held WHERE id=?");
67
+ for(const row of expired)del.run(row.id);
68
+ }
69
+ return expired;
70
+ });
71
+ }
72
+ close(){this.db.close()}
73
+ }
@@ -0,0 +1,76 @@
1
+ export interface TransportCapabilities {
2
+ readonly name: string;
3
+ readonly canPush: boolean; // Can receive inbound pushes
4
+ readonly canPoll: boolean; // Supports inbox polling
5
+ readonly supportsStreaming: boolean; // Supports SSE / live streams
6
+ readonly maxMessageBytes: number; // Transport-specific message size ceiling
7
+ readonly loopbackSafe: boolean; // Works on 127.0.0.1 without external network
8
+ }
9
+
10
+ export interface DeliveryResult {
11
+ readonly carrier: string; // Identifying URI of the accepting carrier
12
+ readonly seq?: number; // Monotonic sequence assigned by carrier
13
+ readonly sha: string; // Envelope SHA-256
14
+ readonly duplicate: boolean; // Whether the carrier already held this envelope
15
+ readonly all?: any[]; // Multi-carrier replication or refusal details
16
+ readonly accepted: boolean; // Must be true for outbox consumption
17
+ readonly retryable?: boolean;
18
+ readonly quarantine?: boolean;
19
+ readonly reason?: string;
20
+ }
21
+
22
+ export interface InboxEnvelopeItem {
23
+ readonly sourceId: string;
24
+ readonly seq: number;
25
+ readonly envelope: any;
26
+ }
27
+
28
+ export interface InboxBatch {
29
+ readonly sourceId: string;
30
+ readonly envelopes: InboxEnvelopeItem[];
31
+ readonly now: number;
32
+ }
33
+
34
+ export interface MessageTransport {
35
+ readonly capabilities: TransportCapabilities;
36
+ sources(): readonly string[];
37
+ deliver(envelope: any, options?: { timeoutMs?: number }): Promise<DeliveryResult>;
38
+ fetchInbox(sourceId: string, options?: { since?: number; waitS?: number; timeoutMs?: number }): Promise<InboxBatch>;
39
+ subscribe?(sourceId: string, onEnvelope: (item: InboxEnvelopeItem) => Promise<void>): () => void;
40
+ close(): Promise<void>;
41
+ }
42
+
43
+ export interface MemoryLoopbackOptions {
44
+ instanceId?: string;
45
+ capabilities?: Partial<TransportCapabilities>;
46
+ }
47
+
48
+ export class MemoryLoopbackTransport implements MessageTransport {
49
+ constructor(options?: MemoryLoopbackOptions);
50
+ readonly instanceId: string;
51
+ readonly sourceId: string;
52
+ readonly capabilities: TransportCapabilities;
53
+ sources(): readonly string[];
54
+ deliver(envelope: any, options?: { timeoutMs?: number }): Promise<DeliveryResult>;
55
+ fetchInbox(sourceId: string, options?: { since?: number; waitS?: number; timeoutMs?: number }): Promise<InboxBatch>;
56
+ subscribe(sourceId: string, onEnvelope: (item: InboxEnvelopeItem) => Promise<void>): () => void;
57
+ close(): Promise<void>;
58
+ }
59
+
60
+ export interface PillarCarrierOptions {
61
+ keychain?: any;
62
+ client?: any;
63
+ carriers?: string[];
64
+ env?: Record<string, string | undefined>;
65
+ capabilities?: Partial<TransportCapabilities>;
66
+ }
67
+
68
+ export class PillarCarrierTransport implements MessageTransport {
69
+ constructor(options?: PillarCarrierOptions);
70
+ readonly client: any;
71
+ readonly capabilities: TransportCapabilities;
72
+ sources(): readonly string[];
73
+ deliver(envelope: any, options?: { timeoutMs?: number }): Promise<DeliveryResult>;
74
+ fetchInbox(sourceId: string, options?: { since?: number; waitS?: number; timeoutMs?: number }): Promise<InboxBatch>;
75
+ close(): Promise<void>;
76
+ }