@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,175 @@
1
+ /**
2
+ * Group threads — the `pillar-group` v1 app convention (INTERACTION-CONVENTION.md
3
+ * "group chats"), as the mobile app and chat-web already speak it.
4
+ *
5
+ * There is still no group key and no wire change: a group message is one
6
+ * ordinary 1:1 envelope per member, and the group rides INSIDE each sealed
7
+ * payload as `_group`. What this adds over the old `{ name, members }` tag is
8
+ * what makes it a thread rather than a fan-out:
9
+ *
10
+ * id stable `grp-<16 hex>`, so every member files it under one group
11
+ * msgId `gm-…`, identical across the fan-out copies of one message
12
+ * op message | create | rename | add | leave (roster changes travel
13
+ * as ordinary group messages)
14
+ * keys optional sealing hints, binding-checked on receipt, so members
15
+ * can reply to the whole group without a prior contact exchange
16
+ *
17
+ * Every member keeps its own roster (mailbox `groups` table) and applies the
18
+ * same rules: only a current member may rename or add, nobody can remove
19
+ * anyone but themselves, and a group a non-contact drops you into is a
20
+ * pending invitation until you accept it.
21
+ */
22
+ import { randomBytes } from "node:crypto";
23
+ import { localIdFromKey } from "./identity/keychain.mjs";
24
+
25
+ export const GROUP_TAG_KEY = "_group";
26
+ export const GROUP_TAG_V = 1;
27
+ export const GROUP_OPS = Object.freeze(["message", "create", "rename", "add", "leave"]);
28
+ export const MAX_GROUP_MEMBERS = 256;
29
+ export const MAX_GROUP_NAME = 100;
30
+
31
+ const GROUP_ID_RE = /^grp-[a-z0-9][a-z0-9-]{3,63}$/i;
32
+ const UUAID_RE = /^uuaid:[a-z0-9-]+:[a-z0-9-]+:[a-z0-9][a-z0-9-]*$/i;
33
+ const PUBKEY_RE = /^[0-9a-f]{64}$/i;
34
+
35
+ export function isGroupId(id) {
36
+ return typeof id === "string" && GROUP_ID_RE.test(id);
37
+ }
38
+
39
+ export function newGroupId() {
40
+ return `grp-${randomBytes(8).toString("hex")}`;
41
+ }
42
+
43
+ export function newGroupMsgId() {
44
+ return `gm-${Date.now().toString(36)}-${randomBytes(6).toString("hex")}`;
45
+ }
46
+
47
+ /** The key really owns the uuaid (the same check seal() makes). */
48
+ export function keyBindsUuaid(uuaid, publicKeyHex) {
49
+ if (typeof uuaid !== "string" || typeof publicKeyHex !== "string" || !PUBKEY_RE.test(publicKeyHex)) return false;
50
+ try { return localIdFromKey(Buffer.from(publicKeyHex, "hex")) === uuaid.split(":")[3]; }
51
+ catch { return false; }
52
+ }
53
+
54
+ function djb2(s) {
55
+ let h = 5381;
56
+ for (let i = 0; i < s.length; i++) h = ((h << 5) + h + s.charCodeAt(i)) >>> 0;
57
+ return h;
58
+ }
59
+
60
+ /**
61
+ * A local id for an id-less tag (an older pillar that sent only
62
+ * `{ name, members }`). Same derivation as the mobile app, so the same
63
+ * conversation groups the same way there and here. Never an authority.
64
+ */
65
+ export function deriveGroupId(name, members) {
66
+ const canon = `${String(name).trim().toLowerCase()}|${[...members].sort().join(",")}`;
67
+ const a = djb2(canon).toString(16).padStart(8, "0");
68
+ const b = djb2(canon.split("").reverse().join("")).toString(16).padStart(8, "0");
69
+ return `grp-ref-${a}${b}`;
70
+ }
71
+
72
+ /** Deduplicated, validated member list, order kept. */
73
+ export function normalizeMembers(members) {
74
+ const out = [];
75
+ const seen = new Set();
76
+ for (const m of Array.isArray(members) ? members : []) {
77
+ if (typeof m !== "string" || !UUAID_RE.test(m) || seen.has(m)) continue;
78
+ seen.add(m);
79
+ out.push(m);
80
+ if (out.length >= MAX_GROUP_MEMBERS) break; // fan-out amplification guard
81
+ }
82
+ return out;
83
+ }
84
+
85
+ /** Parse a `_group` tag out of a decrypted payload; null when absent or unusable. */
86
+ export function extractGroupTag(payload) {
87
+ if (!payload || typeof payload !== "object" || Array.isArray(payload)) return null;
88
+ const g = payload[GROUP_TAG_KEY];
89
+ if (!g || typeof g !== "object" || Array.isArray(g)) return null;
90
+ const name = typeof g.name === "string" ? g.name.trim().slice(0, MAX_GROUP_NAME) : "";
91
+ const members = normalizeMembers(g.members);
92
+ if (!members.length) return null;
93
+ const derivedId = !isGroupId(g.id);
94
+ const id = derivedId ? deriveGroupId(name || "group", members) : g.id;
95
+ let keys;
96
+ if (g.keys && typeof g.keys === "object" && !Array.isArray(g.keys)) {
97
+ for (const [u, pk] of Object.entries(g.keys)) {
98
+ if (UUAID_RE.test(u) && typeof pk === "string" && PUBKEY_RE.test(pk)) (keys ??= {})[u] = pk.toLowerCase();
99
+ }
100
+ }
101
+ const msgId = typeof g.msgId === "string" && g.msgId.length > 0 && g.msgId.length <= 64 ? g.msgId : null;
102
+ const op = typeof g.op === "string" && GROUP_OPS.includes(g.op) ? g.op : "message";
103
+ return { v: typeof g.v === "number" ? g.v : GROUP_TAG_V, id, name: name || "Group", members, keys, msgId, op, derivedId };
104
+ }
105
+
106
+ /** The `_group` tag for an outbound payload. */
107
+ export function buildGroupTag(group, { msgId, keys, op = "message" }) {
108
+ const tag = { v: GROUP_TAG_V, id: group.id, name: group.name, members: [...group.members], msgId };
109
+ if (keys && Object.keys(keys).length) tag.keys = keys;
110
+ if (op && op !== "message") tag.op = op;
111
+ return tag;
112
+ }
113
+
114
+ /**
115
+ * Fold an inbound tag into the local roster (mailbox `groups`). Returns the
116
+ * group as stored, or null when the tag changed nothing we keep.
117
+ *
118
+ * unknown group -> join it; pending when the sender is not a contact. A
119
+ * wire id (not derived here) needs the SENDER on its own
120
+ * roster: the first tag seen for an id sets the roster, so
121
+ * a stranger must not be able to seed it, and after that
122
+ * the real members would be the strangers
123
+ * known group -> only a current MEMBER may change it: `leave` drops the
124
+ * sender, `create`/`rename` set the name, every op unions
125
+ * new members in; a re-`add` naming us clears our `left`
126
+ * key hints -> kept per group, only when the key derives its uuaid;
127
+ * they let us SEAL to a member, and never make anyone a
128
+ * trusted sender (the address book and trust.json do that)
129
+ */
130
+ export function absorbGroupTag(mailbox, tag, { sender, self, text = "", fromContact = false }) {
131
+ const hints = {};
132
+ for (const [u, pk] of Object.entries(tag.keys ?? {})) {
133
+ if (u !== self && keyBindsUuaid(u, pk)) hints[u] = pk;
134
+ }
135
+ const existing = mailbox.getGroup(tag.id);
136
+ if (!existing) {
137
+ if (!tag.derivedId && !tag.members.includes(sender)) return null;
138
+ const members = tag.members.includes(self) ? tag.members : [...tag.members, self];
139
+ return mailbox.putGroup({
140
+ id: tag.id, name: tag.name, members, keys: hints, createdBy: sender, pending: !fromContact,
141
+ });
142
+ }
143
+ if (!existing.members.includes(sender)) return existing; // strangers cannot touch our roster
144
+ const keys = { ...hints, ...existing.keys }; // first key wins
145
+ if (tag.op === "leave") {
146
+ return mailbox.putGroup({ ...existing, keys, members: existing.members.filter((m) => m !== sender) });
147
+ }
148
+ const members = [...existing.members];
149
+ for (const m of tag.members) if (!members.includes(m) && members.length < MAX_GROUP_MEMBERS) members.push(m);
150
+ const addedUs = tag.op === "add" && String(text).split(",").map((x) => x.trim()).includes(self);
151
+ return mailbox.putGroup({
152
+ ...existing,
153
+ keys,
154
+ members,
155
+ name: tag.op === "rename" || tag.op === "create" ? tag.name : existing.name,
156
+ left: existing.left && !addedUs,
157
+ });
158
+ }
159
+
160
+ /**
161
+ * Resolve a group by exact id, else by unique case-insensitive name.
162
+ * Throws with the candidates listed when the name is ambiguous or unknown.
163
+ */
164
+ export function findGroup(mailbox, ref) {
165
+ const byId = mailbox.getGroup(ref);
166
+ if (byId) return byId;
167
+ const all = mailbox.listGroups();
168
+ const hits = all.filter((g) => g.name.toLowerCase() === String(ref).toLowerCase());
169
+ if (hits.length === 1) return hits[0];
170
+ const e = new Error(hits.length
171
+ ? `group name "${ref}" is ambiguous: ${hits.map((g) => g.id).join(", ")} — use the id`
172
+ : `no group "${ref}" in this home${all.length ? ` (known: ${all.map((g) => `${g.name} ${g.id}`).join("; ")})` : ""} — list them with: pillar groups`);
173
+ e.code = hits.length ? "group-ambiguous" : "group-unknown";
174
+ throw e;
175
+ }
@@ -24,11 +24,10 @@ function _ser(v) {
24
24
  if (typeof v === "bigint") return v.toString();
25
25
  if (Array.isArray(v)) return "[" + v.map(_ser).join(",") + "]";
26
26
  if (typeof v === "object") {
27
- // UTF-16 code unit sort — the RFC 8785 requirement.
28
- const keys = Object.keys(v).sort((a, b) => {
29
- const ab = Buffer.from(a, "utf16le"), bb = Buffer.from(b, "utf16le");
30
- return Buffer.compare(ab, bb);
31
- });
27
+ // RFC 8785 §3.2.3: keys sort by their UTF-16 code units, which is exactly
28
+ // JavaScript's default string order. (Comparing UTF-16LE *bytes* is not: it
29
+ // puts "Ā" (U+0100) before "a", and disagreed with @uuaid/pillar-client.)
30
+ const keys = Object.keys(v).sort(); // @rule jcs.code-unit-order
32
31
  return "{" + keys.map(k => JSON.stringify(k) + ":" + _ser(v[k])).join(",") + "}";
33
32
  }
34
33
  throw new Error("JCS: unsupported type " + typeof v);
@@ -65,6 +65,54 @@ export function localIdFromKey(rawPub) {
65
65
  return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`;
66
66
  }
67
67
 
68
+ /** Registry namespaces cannot be asserted by possession of a key alone. */
69
+ export const SELF_CERTIFYING_NAMESPACES = Object.freeze(["foundation", "pillar", "agentnet"]);
70
+
71
+ /**
72
+ * The namespace `pillar init` mints a NEW agent identity in when no --namespace is given.
73
+ * Expand before contract (owner order 2026-10-24; Uuaid-Codex review K1): 2.2.0 makes every
74
+ * verifier accept foundation, pillar and agentnet but still mints `foundation`, because a
75
+ * `pillar:` identity cannot read its inbox on a pre-2.2.0 carrier. 2.2.1 flips this to
76
+ * `pillar` once the carrier fleet, the main relay and the blob brokers run 2.2.x/1.2.0.
77
+ * Existing keychains keep the UUAID they were saved with. `Keychain.generate`'s own default
78
+ * stays `foundation` for library callers; CLI mint sites pass `mintNamespace(...)` instead.
79
+ */
80
+ export const NEW_IDENTITY_NAMESPACE = "foundation";
81
+
82
+ /**
83
+ * Carrier-infrastructure identities (pillar-seed, pillar-directory, pillar-escrow) stay in
84
+ * `foundation`: they are spoken to by third-party seeds and DHT peers on any version, and
85
+ * the owner's rule covers the identities people create, not the fleet's service keys
86
+ * (Uuaid-Codex review K2).
87
+ */
88
+ export const SERVICE_IDENTITY_NAMESPACE = "foundation";
89
+
90
+ /** The namespace to mint in: `requested` if it is self-certifying, else NEW_IDENTITY_NAMESPACE. Throws on any other namespace. */
91
+ export function mintNamespace(requested) {
92
+ if (requested === undefined || requested === null) return NEW_IDENTITY_NAMESPACE;
93
+ if (typeof requested !== "string" || !SELF_CERTIFYING_NAMESPACES.includes(requested)) {
94
+ throw new Error(`namespace must be one of ${SELF_CERTIFYING_NAMESPACES.join(", ")} (a key alone cannot claim ${String(requested)})`);
95
+ }
96
+ return requested;
97
+ }
98
+
99
+ /** Rebuild the complete key-derived identity; minting still defaults to foundation. */
100
+ export function keyDerivedUuaidFor(publicKeyHex, { namespace = "foundation", objectType = "agent" } = {}) {
101
+ if (!SELF_CERTIFYING_NAMESPACES.includes(namespace)
102
+ || typeof objectType !== "string" || !/^[a-z0-9-]+$/.test(objectType)
103
+ || typeof publicKeyHex !== "string" || !/^[0-9a-f]{64}$/i.test(publicKeyHex)) return null;
104
+ return `uuaid:${namespace}:${objectType}:${localIdFromKey(Buffer.from(publicKeyHex, "hex"))}`;
105
+ }
106
+
107
+ /** Compare the full identity, optionally constraining a path's existing object type. */
108
+ export function keyDerivesUuaid(publicKeyHex, uuaid, objectType = null) {
109
+ if (typeof uuaid !== "string") return false;
110
+ const parts = uuaid.split(":");
111
+ if (parts.length !== 4 || parts[0] !== "uuaid" || (objectType !== null && parts[2] !== objectType)) return false;
112
+ const expected = keyDerivedUuaidFor(publicKeyHex, { namespace: parts[1], objectType: parts[2] });
113
+ return expected !== null && uuaid === expected;
114
+ }
115
+
68
116
  export class Keychain {
69
117
  constructor(path) {
70
118
  this.path = path;
@@ -78,8 +126,9 @@ export class Keychain {
78
126
  /**
79
127
  * Generate a fresh identity. Never overwrites.
80
128
  *
81
- * uuaidNamespace: default "foundation" per IAASO-1001 today; will support
82
- * federated forms once federation profile ratifies.
129
+ * uuaidNamespace: default "foundation" (the genesis namespace) for library
130
+ * callers. The Pillar CLIs pass mintNamespace(...) explicitly, so a new CLI
131
+ * identity is `pillar` unless the operator asks for agentnet or foundation.
83
132
  * objectType: default "agent"; overridable to "service" etc.
84
133
  * localId: default random UUIDv4-like; caller may supply.
85
134
  */
@@ -20,6 +20,21 @@ function byTier(tiers) {
20
20
  return (a, b) => seedTier(tiers[a] ?? "probation").listRank - seedTier(tiers[b] ?? "probation").listRank;
21
21
  }
22
22
 
23
+ /**
24
+ * The carrier's signed acceptance receipt (uuaid.carrier-receipt.v1), kept as
25
+ * the carrier sent it, plus this client's own clock when the answer arrived
26
+ * (design §12 rule 3: record received-at beside the carrier's acceptedAt,
27
+ * which is only the carrier's claim). NOT verified here: verify against the
28
+ * pinned key for this carrier and the exact envelope posted. {} when the
29
+ * carrier sent no receipt (an older or keyless carrier): that never fails a
30
+ * delivery, the outcome is simply unreceipted.
31
+ */
32
+ function keptReceipt(body) {
33
+ const r = body?.receipt;
34
+ if (!r || typeof r !== "object" || Array.isArray(r) || typeof body.receiptSig !== "string") return {};
35
+ return { receipt: r, receiptSig: body.receiptSig, receivedAt: new Date().toISOString() }; // @rule client.keeps-receipt
36
+ }
37
+
23
38
  export class CarrierClient {
24
39
  /**
25
40
  * @param {{keychain: import('../identity/keychain.mjs').Keychain, carriers: string[]}} opts
@@ -66,8 +81,13 @@ export class CarrierClient {
66
81
  * Resolves once every carrier has answered. Succeeds if at least one
67
82
  * accepted; throws if every carrier rejects. Returns the receipt from the
68
83
  * most PROVEN accepting carrier as the primary ({ carrier, seq, sha,
69
- * duplicate }) plus `all` with every carrier's outcome ({ carrier, ok,
70
- * seq?, sha?, duplicate?, error?, status?, reason?, fix? }).
84
+ * duplicate, receipt?, receiptSig?, receivedAt? }) plus `all` with every
85
+ * carrier's outcome ({ carrier, ok, seq?, sha?, duplicate?, receipt?,
86
+ * receiptSig?, receivedAt?, error?, status?, reason?, fix? }).
87
+ *
88
+ * `receipt` / `receiptSig` are present only when that carrier signed one
89
+ * (uuaid.carrier-receipt.v1); they are unverified carrier output, and
90
+ * `receivedAt` is this client's clock, not the carrier's.
71
91
  *
72
92
  * When every carrier refuses, the thrown error carries `code` (the most
73
93
  * proven carrier's `reason`, else carrier-unreachable / carrier-bad-response),
@@ -105,7 +125,7 @@ export class CarrierClient {
105
125
  let body = {};
106
126
  try { body = raw ? JSON.parse(raw) : {}; } catch (_error) {}
107
127
  if (res.status === 202 && body.accepted) {
108
- return { carrier: base, ok: true, seq: body.seq, sha: body.sha, duplicate: !!body.duplicate };
128
+ return { carrier: base, ok: true, seq: body.seq, sha: body.sha, duplicate: !!body.duplicate, ...keptReceipt(body) };
109
129
  }
110
130
  const reason = typeof body.reason === "string" && body.reason ? body.reason
111
131
  : typeof body.error === "string" && body.error ? body.error : null;
@@ -132,15 +152,15 @@ export class CarrierClient {
132
152
  throw e;
133
153
  }
134
154
  const ranked = accepted.slice().sort((a, b) => byTier(this.carrierTiers)(a.carrier, b.carrier));
135
- const { carrier, seq, sha, duplicate } = ranked[0];
136
- return { carrier, seq, sha, duplicate, all };
155
+ const { carrier, seq, sha, duplicate, receipt, receiptSig, receivedAt } = ranked[0];
156
+ return { carrier, seq, sha, duplicate, ...(receipt ? { receipt, receiptSig, receivedAt } : {}), all };
137
157
  }
138
158
 
139
159
  /**
140
160
  * Fetch envelopes addressed to us with seq > since from a specific carrier.
141
161
  * Signed request; carrier verifies our key before serving.
142
162
  */
143
- async fetchInbox(base, { since = 0, waitS = 0, timeoutMs = 40000 } = {}) {
163
+ async fetchInbox(base, { since = 0, waitS = 0, timeoutMs = 40000, signal = null } = {}) {
144
164
  const path = `GET /v1/inbox/${this.uuaid}?since=${since}`;
145
165
  const ts = String(Date.now());
146
166
  const sig = this.keychain.sign(Buffer.from(`${path}\n${ts}`, "utf-8")).toString("hex");
@@ -150,7 +170,9 @@ export class CarrierClient {
150
170
  "x-pillar-ts": ts,
151
171
  "x-pillar-sig": sig,
152
172
  },
153
- signal: AbortSignal.timeout(timeoutMs),
173
+ // `signal` lets a caller cancel a long-poll early (peekCarrierInbox stops
174
+ // the other carriers once one has mail).
175
+ signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(timeoutMs)]) : AbortSignal.timeout(timeoutMs),
154
176
  });
155
177
  if (res.status !== 200) {
156
178
  const body = await res.json().catch(() => ({}));
@@ -34,6 +34,7 @@ export const REFUSAL_FIXES = Object.freeze({
34
34
  "bad-signature": "sign again with this identity's own key over the exact bytes; the signature does not verify",
35
35
  "weak-publicKey": "use a real Ed25519 identity key; this key is a small-order or non-canonical point that can sign anything, so no signature under it counts",
36
36
  "delegation-invalid": "mint a fresh delegation from the principal, then resend",
37
+ "envelope-id-conflict": "seal a new envelope with a fresh id; this carrier already holds a different envelope under this id",
37
38
  // Carrier, envelope shape and crypto (envelope.mjs open()): one fix for all
38
39
  "not-object": RESEAL,
39
40
  "bad-version": RESEAL,
@@ -69,8 +70,9 @@ export const REFUSAL_FIXES = Object.freeze({
69
70
  "carrier-unreachable": "check the carrier URLs and your network, then retry",
70
71
  "carrier-bad-response": "retry, or use another carrier; the carrier or its proxy answered without a reason",
71
72
  "over-tier-limit": "send the payload as a blob (pillar-blob send, or Pillar.sendBlob)",
72
- "blob-key-unproven": "use a uuaid:foundation:agent UUAID derived from this key (pillar init), or get a registry badge that names this key",
73
- "sender-key-not-active": "ask the sender to send from a uuaid:foundation:agent UUAID derived from its own key, or to get a registry badge naming its key",
73
+ "blob-key-unproven": "use a self-certifying agent UUAID derived from this key (pillar init), or get a registry badge that names this key",
74
+ "sender-key-not-active": "ask the sender to send from a self-certifying agent UUAID derived from its own key, or to get a registry badge naming its key",
75
+ "group-v2-use-v2-send": "send to this pillar-group/2 group with pillar group send <group>; v1 group sends (send-group, sendGroup) do not reach it",
74
76
  });
75
77
 
76
78
  /**
@@ -1,268 +0,0 @@
1
- import test from "node:test";
2
- import assert from "node:assert/strict";
3
- import {
4
- classifyThread,
5
- assertChannelSendAdmission,
6
- assertChannelReceiveAdmission,
7
- assertForwardingPermission,
8
- validateTenantBinding,
9
- CANONICAL_CHANNEL_REGEX,
10
- RESERVED_CHANNEL_PREFIX
11
- } from "../src/channels.mjs";
12
-
13
- test("Channel classifier: C2 Input / Mode Decision Table (two-pole)", () => {
14
- const canonicalUris = [
15
- "channel://commons/dm/agent-session-123",
16
- "channel://zilligon/community/welcome",
17
- "channel://local/topic/node-events",
18
- "channel://commons/session/delib-456"
19
- ];
20
-
21
- // 1. Canonical structured channels succeed across all policy modes
22
- for (const uri of canonicalUris) {
23
- for (const channelsEnabled of [false, true, undefined]) {
24
- const result = classifyThread(uri, { channelsEnabled });
25
- assert.equal(result.ok, true, `Expected ${uri} to succeed with channelsEnabled=${channelsEnabled}`);
26
- assert.equal(result.classification, "structured");
27
- assert.equal(result.channelUri, uri);
28
- assert.ok(result.parsed.provider);
29
- assert.ok(result.parsed.channelType);
30
- assert.ok(result.parsed.channelId);
31
- }
32
- }
33
-
34
- // 2. Non-canonical channel-like spellings fail closed with INVALID_CHANNEL_URI
35
- const nonCanonicalUris = [
36
- "CHANNEL://commons/dm/agent-123", // Uppercase scheme (must NOT fall back to legacy bypass)
37
- "channel:/commons/dm/123", // Missing slash
38
- "channel://commons", // Missing segments
39
- "channel://commons/dm", // Only two segments
40
- "channel://comm@ns/dm/123", // Disallowed characters
41
- "Channel://local/topic/test", // Mixed case scheme
42
- "CHANNEL://LOCAL/TOPIC/TEST" // All uppercase
43
- ];
44
-
45
- for (const badUri of nonCanonicalUris) {
46
- for (const channelsEnabled of [false, true, undefined]) {
47
- const result = classifyThread(badUri, { channelsEnabled });
48
- // Control pole: Must fail closed with INVALID_CHANNEL_URI
49
- assert.equal(
50
- result.ok,
51
- false,
52
- `Expected non-canonical ${badUri} to fail with channelsEnabled=${channelsEnabled}`
53
- );
54
- assert.equal(result.classification, "reserved-malformed");
55
- assert.equal(result.error, "INVALID_CHANNEL_URI");
56
-
57
- // Mutant pole check: verify that a naive startsWith("channel://") would have incorrectly bypassed
58
- const naivePrefixMatches = badUri.startsWith("channel://");
59
- const reservedPrefixMatches = RESERVED_CHANNEL_PREFIX.test(badUri);
60
- assert.equal(
61
- reservedPrefixMatches,
62
- true,
63
- `Reserved regex must catch channel-like spelling ${badUri}`
64
- );
65
- }
66
- }
67
-
68
- // 3. Ordinary legacy strings under default / disabled mode: ALLOWED
69
- const legacyStrings = ["commons", "app-topic", "default-thread", "session-789"];
70
- for (const legacy of legacyStrings) {
71
- for (const channelsEnabled of [false, undefined]) {
72
- const result = classifyThread(legacy, { channelsEnabled });
73
- assert.equal(result.ok, true);
74
- assert.equal(result.classification, "legacy-allowed");
75
- assert.equal(result.thread, legacy);
76
- }
77
- }
78
-
79
- // 4. Ordinary legacy strings under channelsEnabled === true: DENIED
80
- for (const legacy of legacyStrings) {
81
- const result = classifyThread(legacy, { channelsEnabled: true });
82
- // Control pole: Denied with STRUCTURED_CHANNELS_REQUIRED
83
- assert.equal(result.ok, false);
84
- assert.equal(result.classification, "legacy-denied");
85
- assert.equal(result.error, "STRUCTURED_CHANNELS_REQUIRED");
86
- }
87
-
88
- // 5. Invalid thread inputs
89
- assert.equal(classifyThread("").error, "INVALID_THREAD_LENGTH");
90
- assert.equal(classifyThread("a".repeat(121)).error, "INVALID_THREAD_LENGTH");
91
- assert.equal(classifyThread(null).error, "INVALID_THREAD_LENGTH");
92
- assert.equal(classifyThread(undefined).error, "INVALID_THREAD_LENGTH");
93
- });
94
-
95
- test("Bidirectional channel admission: outbound send assertions (two-pole)", () => {
96
- const channelUri = "channel://commons/dm/thread-1";
97
- const policy = {
98
- channels: {
99
- [channelUri]: {
100
- visibility: "private",
101
- members: ["agent-alice", "agent-bob"]
102
- }
103
- }
104
- };
105
-
106
- // Control pole: sender and all recipients admitted
107
- assert.doesNotThrow(() => {
108
- assertChannelSendAdmission(channelUri, "agent-alice", ["agent-bob"], policy);
109
- });
110
-
111
- // Mutant pole 1: unauthorized sender fails closed
112
- assert.throws(
113
- () => assertChannelSendAdmission(channelUri, "agent-eve", ["agent-bob"], policy),
114
- (err) => err.code === "UNAUTHORIZED_CHANNEL_SENDER"
115
- );
116
-
117
- // Mutant pole 2: unauthorized recipient fails closed
118
- assert.throws(
119
- () => assertChannelSendAdmission(channelUri, "agent-alice", ["agent-eve"], policy),
120
- (err) => err.code === "UNAUTHORIZED_CHANNEL_RECIPIENT"
121
- );
122
-
123
- // Mutant pole 3: unconfigured channel fails closed
124
- assert.throws(
125
- () => assertChannelSendAdmission("channel://commons/dm/unconfigured", "agent-alice", ["agent-bob"], policy),
126
- (err) => err.code === "UNCONFIGURED_CHANNEL"
127
- );
128
- });
129
-
130
- test("Bidirectional channel admission: inbound receive assertions (two-pole)", () => {
131
- const channelUri = "channel://commons/dm/thread-1";
132
- const policy = {
133
- channels: {
134
- [channelUri]: {
135
- visibility: "private",
136
- members: ["agent-alice", "agent-bob"]
137
- }
138
- }
139
- };
140
-
141
- // Control pole: decrypted sender and local receiver admitted
142
- assert.doesNotThrow(() => {
143
- assertChannelReceiveAdmission(channelUri, "agent-alice", "agent-bob", policy);
144
- });
145
-
146
- // Mutant pole 1: unauthorized sender fails closed
147
- assert.throws(
148
- () => assertChannelReceiveAdmission(channelUri, "agent-eve", "agent-bob", policy),
149
- (err) => err.code === "UNAUTHORIZED_CHANNEL_SENDER"
150
- );
151
-
152
- // Mutant pole 2: unauthorized receiver fails closed
153
- assert.throws(
154
- () => assertChannelReceiveAdmission(channelUri, "agent-alice", "agent-eve", policy),
155
- (err) => err.code === "UNAUTHORIZED_CHANNEL_RECEIVER"
156
- );
157
-
158
- // Mutant pole 3: unconfigured inbound channel fails closed with UNCONFIGURED_CHANNEL
159
- assert.throws(
160
- () => assertChannelReceiveAdmission("channel://commons/dm/unconfigured", "agent-alice", "agent-bob", policy),
161
- (err) => err.code === "UNCONFIGURED_CHANNEL"
162
- );
163
- });
164
-
165
- test("Cross-channel forwarding assertions (two-pole)", () => {
166
- const sourceChannel = "channel://commons/dm/source-1";
167
- const targetChannel = "channel://zilligon/community/announcements";
168
-
169
- const policy = {
170
- agents: {
171
- "agent-forwarder": { capabilities: ["commons:message", "commons:forward"] },
172
- "agent-regular": { capabilities: ["commons:message"] }
173
- },
174
- channels: {
175
- [targetChannel]: {
176
- visibility: "public",
177
- allowForwarding: true
178
- },
179
- "channel://commons/dm/strict-target": {
180
- visibility: "private",
181
- allowForwarding: false
182
- }
183
- }
184
- };
185
-
186
- // Control pole: authorized forwarder and target permits forwarding
187
- assert.doesNotThrow(() => {
188
- assertForwardingPermission(sourceChannel, targetChannel, "agent-forwarder", policy);
189
- });
190
-
191
- // Mutant pole 1: sender lacks commons:forward capability
192
- assert.throws(
193
- () => assertForwardingPermission(sourceChannel, targetChannel, "agent-regular", policy),
194
- (err) => err.code === "CROSS_CHANNEL_FORWARDING_DENIED"
195
- );
196
-
197
- // Mutant pole 2: target channel disallows forwarding
198
- assert.throws(
199
- () => assertForwardingPermission(sourceChannel, "channel://commons/dm/strict-target", "agent-forwarder", policy),
200
- (err) => err.code === "CROSS_CHANNEL_FORWARDING_DENIED"
201
- );
202
- });
203
-
204
- test("Tenant namespace binding assertions (two-pole)", () => {
205
- const boundTenantProfile = {
206
- id: "sha256-bound-profile-digest",
207
- scope: "tenant"
208
- };
209
- const unboundTenantProfile = {
210
- id: "sha256-unbound-tenant-profile",
211
- scope: "tenant"
212
- };
213
- const unboundLocalProfile = {
214
- id: "sha256-unbound-local-profile",
215
- scope: "local"
216
- };
217
-
218
- const policy = {
219
- tenantProfiles: {
220
- [boundTenantProfile.id]: "tenant-acme"
221
- },
222
- agents: {
223
- "agent-acme-1": { tenantId: "tenant-acme" },
224
- "agent-acme-2": { tenantId: "tenant-acme" },
225
- "agent-corp-x": { tenantId: "tenant-corp" }
226
- }
227
- };
228
-
229
- // Control pole 1: matching tenant passes
230
- assert.doesNotThrow(() => {
231
- validateTenantBinding("agent-acme-1", "agent-acme-2", boundTenantProfile, policy);
232
- });
233
-
234
- // Mutant pole 1: sender tenant mismatch fails closed
235
- assert.throws(
236
- () => validateTenantBinding("agent-corp-x", "agent-acme-2", boundTenantProfile, policy),
237
- (err) => err.code === "TENANT_NAMESPACE_MISMATCH"
238
- );
239
-
240
- // Mutant pole 2: recipient tenant mismatch fails closed
241
- assert.throws(
242
- () => validateTenantBinding("agent-acme-1", "agent-corp-x", boundTenantProfile, policy),
243
- (err) => err.code === "TENANT_NAMESPACE_MISMATCH"
244
- );
245
-
246
- // Control pole 2: unbound tenant profile MUST fail closed with UNBOUND_PROFILE_TENANT (R4)
247
- assert.throws(
248
- () => validateTenantBinding("agent-acme-1", "agent-acme-2", unboundTenantProfile, policy),
249
- (err) => err.code === "UNBOUND_PROFILE_TENANT"
250
- );
251
-
252
- // Control pole 3: unbound local profile passes unconstrained without tenant binding requirement
253
- assert.doesNotThrow(() => {
254
- validateTenantBinding("agent-acme-1", "agent-corp-x", unboundLocalProfile, policy);
255
- });
256
-
257
- // Mutant pole: unbound bare digest string fails closed with UNBOUND_PROFILE_TENANT (R4)
258
- assert.throws(
259
- () => validateTenantBinding("agent-acme-1", "agent-corp-x", "sha256-unbound-digest", policy),
260
- (err) => err.code === "UNBOUND_PROFILE_TENANT"
261
- );
262
-
263
- // Mutant pole: unbound scope-less ProfileRef fails closed with UNBOUND_PROFILE_TENANT (R4)
264
- assert.throws(
265
- () => validateTenantBinding("agent-acme-1", "agent-corp-x", { id: "profile-no-scope" }, policy),
266
- (err) => err.code === "UNBOUND_PROFILE_TENANT"
267
- );
268
- });
@@ -1,36 +0,0 @@
1
- import test from "node:test";
2
- import assert from "node:assert/strict";
3
- import { mkdtempSync, rmSync, readFileSync, writeFileSync } from "node:fs";
4
- import { tmpdir } from "node:os";
5
- import { join } from "node:path";
6
- import { initializeCohort, cohortStatus, FOUNDING_ROSTER } from "../src/cohort.mjs";
7
-
8
- test("Seven nodes persist distinct identity and scoped policy without remote enrollment",()=>{
9
- const root=mkdtempSync(join(tmpdir(),"commons-cohort-")),home=join(root,"nodes");
10
- try{
11
- const manifest=initializeCohort({home,target:"zilligon.com"});
12
- assert.equal(manifest.members.length,7);
13
- assert.equal(new Set(manifest.members.map(m=>m.uuaid)).size,7);
14
- assert.equal(manifest.registration,"not-performed");
15
- assert.equal(manifest.certified,false);
16
- const next=cohortStatus(home);
17
- assert.deepEqual(next.members.map(m=>m.uuaid),manifest.members.map(m=>m.uuaid));
18
- assert(next.members.every(m=>m.status.auditVerified));
19
- const config=JSON.parse(readFileSync(join(home,"security","config.json"),"utf8"));
20
- assert.equal(Object.keys(config.policy.agents).length,7);
21
- const security=manifest.members.find(m=>m.id==="security");
22
- assert(!config.policy.agents[security.uuaid].capabilities.includes("commons:evolve"));
23
- assert(Object.values(config.policy.agents).every(a=>!a.capabilities.includes("commons:recover")));
24
- assert.throws(()=>initializeCohort({home}),/not overwritten/);
25
- }finally{rmSync(root,{recursive:true,force:true})}
26
- });
27
- test("Cohort manifest cannot redirect identity loading to arbitrary paths",()=>{
28
- const root=mkdtempSync(join(tmpdir(),"commons-cohort-")),home=join(root,"nodes");
29
- try{
30
- const m=initializeCohort({home});
31
- m.members[0].home=root;
32
- writeFileSync(join(home,"cohort.json"),JSON.stringify(m));
33
- assert.throws(()=>cohortStatus(home),/Unsafe/);
34
- assert.equal(FOUNDING_ROSTER.length,7);
35
- }finally{rmSync(root,{recursive:true,force:true})}
36
- });