@uuaid/agent-commons 0.2.0-alpha.2 → 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 (72) hide show
  1. package/CONFORMANCE.md +1 -1
  2. package/NOTICE +4 -7
  3. package/PROVENANCE.json +12 -17
  4. package/README.md +103 -12
  5. package/bin/agent-commons.mjs +8 -3
  6. package/examples/Dockerfile +1 -1
  7. package/package.json +5 -2
  8. package/src/carrier.mjs +7 -2
  9. package/src/channels.mjs +1 -1
  10. package/src/config.mjs +2 -1
  11. package/src/continuity.mjs +14 -7
  12. package/src/governance-scope.mjs +1 -1
  13. package/src/governance.mjs +4 -2
  14. package/src/index.d.ts +3 -1
  15. package/src/pillar.mjs +2 -2
  16. package/src/profiles.mjs +2 -1
  17. package/src/relay/auth.mjs +294 -0
  18. package/src/relay/board.mjs +223 -0
  19. package/src/relay/claims.mjs +25 -0
  20. package/src/relay/cli.mjs +49 -0
  21. package/src/relay/client.mjs +437 -0
  22. package/src/relay/docs/reconnect-v1.md +27 -0
  23. package/src/relay/docs/working-rules-v1.md +42 -0
  24. package/src/relay/enroll.mjs +232 -0
  25. package/src/relay/export-cli.mjs +47 -0
  26. package/src/relay/governance.mjs +1328 -0
  27. package/src/relay/mailbox.mjs +103 -0
  28. package/src/relay/records.mjs +146 -0
  29. package/src/relay/render.mjs +38 -0
  30. package/src/relay/roster.mjs +134 -0
  31. package/src/relay/routes.mjs +87 -0
  32. package/src/relay/schema.mjs +1081 -0
  33. package/src/relay/seed.mjs +89 -0
  34. package/src/relay/serve.mjs +116 -0
  35. package/src/relay/server.mjs +633 -0
  36. package/src/relay/signing.mjs +693 -0
  37. package/src/relay/test-mode.mjs +73 -0
  38. package/src/runtime.mjs +25 -12
  39. package/src/spaces/manifest.mjs +245 -0
  40. package/src/spaces/pillar-group.mjs +12 -0
  41. package/src/spaces/policy.mjs +229 -0
  42. package/src/spaces/store.mjs +325 -0
  43. package/src/trust.mjs +44 -5
  44. package/src/uuaid-namespace.d.mts +6 -0
  45. package/src/uuaid-namespace.mjs +48 -0
  46. package/src/vendor/pillar/blob/blob.mjs +12 -14
  47. package/src/vendor/pillar/groups-v2.mjs +1108 -0
  48. package/src/vendor/pillar/groups.mjs +175 -0
  49. package/src/vendor/pillar/identity/jcs.mjs +4 -5
  50. package/src/vendor/pillar/identity/keychain.mjs +51 -2
  51. package/src/vendor/pillar/net/carrier-client.mjs +29 -7
  52. package/src/vendor/pillar/net/refusals.mjs +4 -2
  53. package/test/channels.test.mjs +0 -268
  54. package/test/cohort.test.mjs +0 -36
  55. package/test/collaboration.test.mjs +0 -459
  56. package/test/continuity.test.mjs +0 -229
  57. package/test/custody.negative.test.mjs +0 -165
  58. package/test/derivation-parity.test.mjs +0 -19
  59. package/test/fixtures/derivation-vectors.json +0 -26
  60. package/test/fixtures/ed25519-small-order.vectors.json +0 -15
  61. package/test/governance-expected-red.expected-red.mjs +0 -241
  62. package/test/governance-scope.test.mjs +0 -142
  63. package/test/governance-t1-t10.test.mjs +0 -477
  64. package/test/governance.test.mjs +0 -514
  65. package/test/hold-admission.test.mjs +0 -1185
  66. package/test/keychain-strict-ed25519.test.mjs +0 -240
  67. package/test/offline.test.mjs +0 -152
  68. package/test/runtime-channels.test.mjs +0 -1140
  69. package/test/runtime.test.mjs +0 -201
  70. package/test/security-runtime.test.mjs +0 -104
  71. package/test/strict-ed25519-consumers.test.mjs +0 -234
  72. package/test/transport.test.mjs +0 -353
@@ -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/trust.mjs CHANGED
@@ -1,5 +1,6 @@
1
1
  import { UuaidClient } from "@uuaid/sdk";
2
2
  import { Keychain } from "./pillar.mjs";
3
+ import { keyIsBlocked, SELF_CERTIFYING_AGENT_UUAID } from "./uuaid-namespace.mjs";
3
4
  import { isLoopbackUrl, isOffline } from "./offline.mjs";
4
5
 
5
6
  export const REQUIRED_STANDARDS=["IAASO-1001","IAASO-2001","IAASO-3101","IAASO-3301"];
@@ -8,6 +9,22 @@ export class TrustError extends Error { constructor(code,message,{transient=fals
8
9
  export function isTransientTrustFailure(error){if(error?.transient===true)return true;const status=error?.status??error?.statusCode;return error!=null&&!(error instanceof TrustError)&&(!Number.isInteger(status)||status>=500||status===408||status===429)}
9
10
  async function backend(fn,code){try{return await fn()}catch(error){if(error instanceof TrustError)throw error;if(isTransientTrustFailure(error))throw new TrustError(code,`Trust backend unavailable: ${String(error?.message??error).slice(0,120)}`,{transient:true});throw error}}
10
11
  export class RegistryTrust {
12
+ #denied=false;
13
+ static isCanonicalTrust(value){
14
+ if(value===null||(typeof value!=="object"&&typeof value!=="function")||!(#denied in value))return false;
15
+ let current=value,found=false;
16
+ while(current!==null){
17
+ if(current!==value&&current!==TRUST_PROTOTYPE&&current!==OBJECT_PROTOTYPE)return false;
18
+ const descriptor=getOwnPropertyDescriptor(current,"authorize");
19
+ if(!found&&descriptor){
20
+ if(!("value" in descriptor)||descriptor.value!==CANONICAL_AUTHORIZE)return false;
21
+ found=true;
22
+ }
23
+ current=getPrototypeOf(current);
24
+ }
25
+ return found;
26
+ }
27
+ denyPermanently(){this.#denied=true}
11
28
  constructor({policy={},client,fetchImpl=fetch,env=process.env}={}) {
12
29
  if(policy.mode!==undefined&&!["local","global"].includes(policy.mode))throw new TrustError("INVALID_POLICY_MODE","Policy mode must be explicitly local or global");
13
30
  this.policy=policy;this.policy.mode??="local";this.env=env;
@@ -33,15 +50,26 @@ export class RegistryTrust {
33
50
  }
34
51
  return list;
35
52
  }
36
- async authorize(uuaid,publicKey,capability="commons:message"){
37
- if(!/^uuaid:[a-z0-9-]+:agent:[0-9a-f-]{36}$/.test(uuaid)||!/^[0-9a-f]{64}$/.test(publicKey))throw new TrustError("AGENT_ONLY","A cryptographically bound agent UUAID is required");
53
+ // The local half of admission: identity shape, key binding, block list, and the
54
+ // operator's binding and capability. Synchronous, so it reads the policy as it is now.
55
+ #localAdmission(uuaid,publicKey,capability){
56
+ if(!SELF_CERTIFYING_AGENT_UUAID.test(uuaid)||!/^[0-9a-f]{64}$/.test(publicKey))throw new TrustError("AGENT_ONLY","A cryptographically bound agent UUAID in a self-certifying namespace (foundation, pillar, agentnet) is required");
38
57
  if(Keychain.localIdFromKey(Buffer.from(publicKey,"hex"))!==uuaid.split(":")[3])throw new TrustError("KEY_BINDING","Agent UUAID is not owned by the presented key");
39
- if(this.policy.blocked?.includes(uuaid))throw new TrustError("SUBJECT_BLOCKED","Subject is blocked");
58
+ if(keyIsBlocked(this.policy.blocked,uuaid))throw new TrustError("SUBJECT_BLOCKED","Subject is blocked (under this or another spelling of its key)");
40
59
  const binding=this.policy.agents?.[uuaid];
41
60
  // One key must be one voice: reject any policy that admits the same key under several UUAID spellings.
42
61
  if(binding&&Object.values(this.policy.agents).filter(a=>a?.publicKey===publicKey).length!==1)throw new TrustError("DUPLICATE_KEY","One public key is admitted under more than one UUAID; Sybil-ambiguous policy is denied");
43
62
  if(!binding||binding.publicKey!==publicKey||binding.kind!=="agent"||!binding.capabilities?.includes(capability))throw new TrustError("NOT_ADMITTED","Subject/key/capability has not been admitted by this deployment");
44
- if(this.policy.mode!=="global")return {tier:"local-policy-pinned",uuaid,capability,certified:false};
63
+ return binding;
64
+ }
65
+ async authorize(uuaid,publicKey,capability="commons:message"){
66
+ if(this.#denied)throw new TrustError("NOT_ADMITTED","Trust receiver has been permanently denied");
67
+ const binding=this.#localAdmission(uuaid,publicKey,capability);
68
+ if(this.policy.mode!=="global"){
69
+ const result={tier:"local-policy-pinned",uuaid,capability,certified:false};
70
+ if(this.#denied)throw new TrustError("NOT_ADMITTED","Trust receiver has been permanently denied");
71
+ return result;
72
+ }
45
73
  if(!(this.policy.registryUrl??"https://api.uuaid.org").startsWith("https://"))throw new TrustError("INSECURE_REGISTRY","Global registry verification requires HTTPS");
46
74
  await this.standards();
47
75
  const resolution=await backend(()=>this.client.resolve(uuaid),"REGISTRY_UNAVAILABLE");
@@ -50,9 +78,15 @@ export class RegistryTrust {
50
78
  if(!binding.credentialId)throw new TrustError("CREDENTIAL_REQUIRED","Global agent admission requires a pinned credential");
51
79
  const verdict=await backend(()=>this.client.verify(binding.credentialId),"REGISTRY_UNAVAILABLE");
52
80
  if(verdict.credential_id!==binding.credentialId||verdict.agent_uuaid!==uuaid||![verdict.valid,verdict.signatureValid,verdict.active,verdict.notExpired].every(v=>v===true))throw new TrustError("CREDENTIAL_INVALID","Credential signature, subject, active status, or expiry failed");
81
+ // The registry calls above await, and the operator's policy may change meanwhile
82
+ // (a block, a removal, a narrowed capability). Decide on the policy as it is now,
83
+ // for the same credential the registry just verified.
84
+ if(this.#localAdmission(uuaid,publicKey,capability).credentialId!==binding.credentialId)throw new TrustError("NOT_ADMITTED","Admission changed while the registry was consulted");
53
85
  // Capabilities are operator policy grants. A valid credential alone does not
54
86
  // imply an IAASO accreditation role or permission to ratify a standard.
55
- return {tier:"registry-credential-and-policy",uuaid,capability,credentialId:binding.credentialId,certified:false};
87
+ const result={tier:"registry-credential-and-policy",uuaid,capability,credentialId:binding.credentialId,certified:false};
88
+ if(this.#denied)throw new TrustError("NOT_ADMITTED","Trust receiver has been permanently denied");
89
+ return result;
56
90
  }
57
91
  async publishedProfilePin(code,profileHash){
58
92
  await this.standards();
@@ -63,3 +97,8 @@ export class RegistryTrust {
63
97
  return record;
64
98
  }
65
99
  }
100
+ const TRUST_PROTOTYPE=RegistryTrust.prototype;
101
+ const OBJECT_PROTOTYPE=Object.prototype;
102
+ const CANONICAL_AUTHORIZE=RegistryTrust.prototype.authorize;
103
+ const getOwnPropertyDescriptor=Object.getOwnPropertyDescriptor;
104
+ const getPrototypeOf=Object.getPrototypeOf;
@@ -0,0 +1,6 @@
1
+ export declare const RUNTIME_UUAID_NAMESPACE: "agentnet";
2
+ export declare function mintNamespace(requested?: string | null): string;
3
+ export declare const SELF_CERTIFYING_NAMESPACES: readonly string[];
4
+ export declare const SELF_CERTIFYING_AGENT_UUAID: RegExp;
5
+ export declare function keyBindsAgentUuaid(uuaid: unknown, publicKeyHex: unknown): boolean;
6
+ export declare function keyIsBlocked(blocked: unknown, uuaid: unknown): boolean;
@@ -0,0 +1,48 @@
1
+ import { localIdFromKey, mintNamespace, SELF_CERTIFYING_NAMESPACES } from "./vendor/pillar/identity/keychain.mjs";
2
+
3
+ // The namespace Agent Commons mints NEW key-derived identities in:
4
+ // uuaid:agentnet:agent:<localIdFromKey(publicKey)>.
5
+ //
6
+ // Pass it explicitly at every mint site. The vendored Keychain.generate default
7
+ // ("foundation") stays unchanged on purpose: a stored key without a recorded
8
+ // namespace must never be re-derived under a new default and renamed (the
9
+ // 2026-09-20 peek() defect). Existing identities keep their ids; "foundation"
10
+ // remains a valid self-certifying namespace.
11
+ export const RUNTIME_UUAID_NAMESPACE = "agentnet";
12
+
13
+ // Every mint site passes mintNamespace(RUNTIME_UUAID_NAMESPACE): the vendored
14
+ // check throws on a namespace a key cannot claim, so a wrong constant fails at
15
+ // mint instead of producing an identity no verifier accepts.
16
+ export { mintNamespace };
17
+
18
+ // The namespaces a key alone can claim, taken from the vendored @uuaid/pillar
19
+ // (foundation, pillar, agentnet). A registry namespace (zilligon, iaaso, ...) is
20
+ // vouched for by its registry, not by a key, so it is refused wherever Agent
21
+ // Commons accepts an identity on the strength of its key.
22
+ export { SELF_CERTIFYING_NAMESPACES };
23
+
24
+ // The shape of a key-derived agent UUAID in a self-certifying namespace. The
25
+ // local id is the canonical lower-case 8-4-4-4-12 form localIdFromKey emits.
26
+ export const SELF_CERTIFYING_AGENT_UUAID = new RegExp(`^uuaid:(?:${SELF_CERTIFYING_NAMESPACES.join("|")}):agent:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`);
27
+
28
+ // Namespace alignment rule 1: the UUAID has the shape above and its local id is
29
+ // the key's. The id is parsed and its local id compared; it is never rebuilt
30
+ // from the key and compared whole (rule 2).
31
+ export function keyBindsAgentUuaid(uuaid, publicKeyHex) {
32
+ return typeof uuaid === "string" && SELF_CERTIFYING_AGENT_UUAID.test(uuaid) &&
33
+ typeof publicKeyHex === "string" && /^[0-9a-f]{64}$/.test(publicKeyHex) &&
34
+ localIdFromKey(Buffer.from(publicKeyHex, "hex")) === uuaid.split(":")[3];
35
+ }
36
+
37
+ // Block by key, not by spelling. One key has a spelling in each self-certifying
38
+ // namespace, all with the same local id, so a block on any of them blocks the
39
+ // key. An entry that is not a key-derived spelling blocks only itself.
40
+ // Block entries are operator-asserted, not key-verified: a list carries no
41
+ // public keys, so a shape-valid entry blocks whichever key has that local id.
42
+ export function keyIsBlocked(blocked, uuaid) {
43
+ if (!Array.isArray(blocked) || typeof uuaid !== "string") return false;
44
+ if (blocked.includes(uuaid)) return true;
45
+ if (!SELF_CERTIFYING_AGENT_UUAID.test(uuaid)) return false;
46
+ const local = uuaid.split(":")[3];
47
+ return blocked.some(entry => typeof entry === "string" && SELF_CERTIFYING_AGENT_UUAID.test(entry) && entry.split(":")[3] === local);
48
+ }
@@ -35,7 +35,8 @@
35
35
  */
36
36
  import { createCipheriv, createDecipheriv, createHash, randomBytes } from "node:crypto";
37
37
  import { jcs } from "../identity/jcs.mjs";
38
- import { Keychain, localIdFromKey } from "../identity/keychain.mjs";
38
+ import { Keychain, keyDerivedUuaidFor, keyDerivesUuaid } from "../identity/keychain.mjs";
39
+ export { SELF_CERTIFYING_NAMESPACES } from "../identity/keychain.mjs";
39
40
  import { ABSOLUTE_MAX_BODY, TIER_DEFAULTS } from "../identity/tier.mjs";
40
41
  import { REFUSAL_FIXES } from "../net/refusals.mjs";
41
42
 
@@ -51,8 +52,9 @@ export const NONCE_BYTES = 12;
51
52
  export const DEK_BYTES = 32;
52
53
  /** keyId a broker request presents when the identity proves itself by its own key. */
53
54
  export const SELF_CERTIFIED_KEY_ID = "self-certified:ed25519";
54
- /** The one UUAID namespace and object type the broker accepts on the self-certified path. */
55
+ /** @deprecated Default minting namespace only; verification uses SELF_CERTIFYING_NAMESPACES. */
55
56
  export const SELF_CERTIFIED_NAMESPACE = "foundation";
57
+ /** The object type the broker accepts across self-certifying namespaces. */
56
58
  export const SELF_CERTIFIED_OBJECT_TYPE = "agent";
57
59
 
58
60
  // ---------------------------------------------------------------- errors ---
@@ -170,26 +172,22 @@ export function mkObjectName() {
170
172
 
171
173
  // ------------------------------------------------------ self-certified ---
172
174
  /**
173
- * The one UUAID a key self-certifies, rebuilt in full:
174
- * `uuaid:foundation:agent:` + sha256(publicKey)[:16] as a UUID (localIdFromKey,
175
- * the twin of pillar-client's localIdFromPublicKey). Null for a malformed key.
175
+ * Rebuild a key-derived agent UUAID in a self-certifying namespace.
176
+ * Foundation remains the default. Null for a malformed key or registry namespace.
176
177
  */
177
- export function selfCertifiedUuaidFor(publicKeyHex) {
178
- if (typeof publicKeyHex !== "string" || !/^[0-9a-f]{64}$/i.test(publicKeyHex)) return null;
179
- const localId = localIdFromKey(Buffer.from(publicKeyHex, "hex"));
180
- return `uuaid:${SELF_CERTIFIED_NAMESPACE}:${SELF_CERTIFIED_OBJECT_TYPE}:${localId}`;
178
+ export function selfCertifiedUuaidFor(publicKeyHex, namespace = "foundation") {
179
+ return keyDerivedUuaidFor(publicKeyHex, { namespace, objectType: SELF_CERTIFIED_OBJECT_TYPE });
181
180
  }
182
181
 
183
182
  /**
184
183
  * True only when `uuaid` is EXACTLY the UUAID the key self-certifies (full
185
- * string, namespace and object type included, the broker's rule); such an
184
+ * string, self-certifying namespace and agent object type included); such an
186
185
  * identity uses the broker without a registry badge. A device, a service,
187
- * another namespace, or a matching local id under any other prefix is not
186
+ * a registry namespace, or a matching local id under any other prefix is not
188
187
  * self-certified: it needs a registry badge naming its key.
189
188
  */
190
189
  export function isSelfCertifiedUuaid(uuaid, publicKeyHex) {
191
- const expected = selfCertifiedUuaidFor(publicKeyHex);
192
- return expected !== null && uuaid === expected;
190
+ return keyDerivesUuaid(publicKeyHex, uuaid, SELF_CERTIFIED_OBJECT_TYPE);
193
191
  }
194
192
 
195
193
  /**
@@ -471,7 +469,7 @@ export async function prepareBrokerBlobSend(keychain, { data, envelopeId, recipi
471
469
 
472
470
  /**
473
471
  * Production receive path; the sender key gate precedes broker redemption.
474
- * A sender whose UUAID is exactly the uuaid:foundation:agent UUAID of the
472
+ * A sender whose UUAID is exactly a self-certifying agent UUAID of the
475
473
  * ref's verified signing key passes without a badge; any other sender needs
476
474
  * a fresh registry badge naming that key (client.assertRegistryActiveSender).
477
475
  *