@uuaid/pillar 2.0.2 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uuaid/pillar",
3
- "version": "2.0.2",
3
+ "version": "2.2.0",
4
4
  "description": "Pillar (Protocol for Inter-agent Linked Ledger And Relay) — the secure delivery layer of UACP, the Universal Agent Communication Protocol. UACP = UUAID (identity) + Envelope (signed, encrypted container) + Pillar (relay, mailbox, routing, store-and-forward). Reference implementation of the UACP delivery layer; includes VDA-1 (IAASO-3901, in ballot) delivery attestation and a blind escrow for the Zilligon ZGN / EVM rails.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -13,6 +13,7 @@
13
13
  "pillar-seed": "bin/pillar-seed.mjs",
14
14
  "pillar-directory": "bin/pillar-directory.mjs",
15
15
  "pillar-escrow": "bin/pillar-escrow.mjs",
16
+ "pillar-group-v2": "bin/pillar-group-v2.mjs",
16
17
  "pillar-blob": "bin/pillar-blob.mjs"
17
18
  },
18
19
  "exports": {
@@ -1,27 +1,106 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
2
  import { join, dirname } from "node:path";
3
+ import { keyBindsUuaid } from "./groups.mjs";
4
+
5
+ const UUAID_RE = /^uuaid:[a-z0-9-]+:[a-z0-9-]+:[a-z0-9][a-z0-9-]*$/i;
6
+ const PUBKEY_RE = /^[0-9a-f]{64}$/i;
7
+ // Field names people reach for instead of `publicKey`; named in the diagnostic.
8
+ const KEY_LOOKALIKES = ["pubkey", "pubKey", "public_key", "publicKeyHex", "key", "pk", "ed25519"];
9
+
10
+ /**
11
+ * Read and check a fleet.json address book without touching a Pillar.
12
+ *
13
+ * Shape: { "fleet": { "<name>": { uuaid, publicKey, addrs?, peerId? } } }.
14
+ *
15
+ * Every way a file can fail to yield a usable contact is reported as a
16
+ * problem naming the file and the entry, because the failure used to be
17
+ * silent: a malformed or mis-shaped file loaded as zero contacts, and the
18
+ * first sign of it was `send: no public key known for …` about a peer the
19
+ * operator could see in the file.
20
+ *
21
+ * Returns { path, exists, error?, problems: string[], entries: [{ name, uuaid,
22
+ * publicKey?, addrs?, peerId? }], skipped: [{ name, uuaid?, reason }] }.
23
+ * `error` is set only when nothing could be read at all.
24
+ */
25
+ export function inspectFleetFile(fleetPath) {
26
+ const out = { path: fleetPath, exists: false, problems: [], entries: [], skipped: [] };
27
+ if (!fleetPath || !existsSync(fleetPath)) return out;
28
+ out.exists = true;
29
+ let raw;
30
+ try { raw = JSON.parse(readFileSync(fleetPath, "utf-8")); }
31
+ catch (e) {
32
+ out.error = `${fleetPath} is not valid JSON (${e.message}); no contacts were loaded from it`;
33
+ out.problems.push(out.error);
34
+ return out;
35
+ }
36
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
37
+ out.error = `${fleetPath} must be a JSON object { "fleet": { "<name>": { "uuaid", "publicKey" } } }, found ${Array.isArray(raw) ? "an array" : typeof raw}; no contacts were loaded from it`;
38
+ out.problems.push(out.error);
39
+ return out;
40
+ }
41
+ if (!("fleet" in raw)) {
42
+ const keys = Object.keys(raw);
43
+ out.error = `${fleetPath} has no "fleet" key (top-level keys: ${keys.length ? keys.join(", ") : "none"}); expected { "fleet": { "<name>": { "uuaid", "publicKey" } } }; no contacts were loaded from it`;
44
+ out.problems.push(out.error);
45
+ return out;
46
+ }
47
+ const fleet = raw.fleet;
48
+ if (!fleet || typeof fleet !== "object" || Array.isArray(fleet)) {
49
+ out.error = `${fleetPath}: "fleet" must be an object keyed by name, found ${Array.isArray(fleet) ? "an array" : typeof fleet}; no contacts were loaded from it`;
50
+ out.problems.push(out.error);
51
+ return out;
52
+ }
53
+ for (const [name, e] of Object.entries(fleet)) {
54
+ const skip = (reason, uuaid) => {
55
+ out.skipped.push({ name, ...(uuaid ? { uuaid } : {}), reason });
56
+ out.problems.push(`${fleetPath}: entry "${name}" ${reason}`);
57
+ };
58
+ if (!e || typeof e !== "object" || Array.isArray(e)) { skip("is not an object"); continue; }
59
+ if (typeof e.uuaid !== "string" || !UUAID_RE.test(e.uuaid)) {
60
+ skip(e.uuaid === undefined ? "has no uuaid" : `has a malformed uuaid ${JSON.stringify(e.uuaid)}`);
61
+ continue;
62
+ }
63
+ const entry = { name, uuaid: e.uuaid };
64
+ if (Array.isArray(e.addrs)) entry.addrs = e.addrs;
65
+ if (typeof e.peerId === "string") entry.peerId = e.peerId;
66
+ if (e.publicKey === undefined) {
67
+ const alt = KEY_LOOKALIKES.find((k) => e[k] !== undefined);
68
+ out.problems.push(`${fleetPath}: entry "${name}" has no publicKey${alt ? ` (found "${alt}" — the field must be named "publicKey")` : ""}; it cannot be sent to`);
69
+ } else if (typeof e.publicKey !== "string" || !PUBKEY_RE.test(e.publicKey)) {
70
+ out.problems.push(`${fleetPath}: entry "${name}" publicKey must be 64 hex characters (the raw Ed25519 key), found ${JSON.stringify(String(e.publicKey).slice(0, 80))}; it cannot be sent to`);
71
+ } else if (!keyBindsUuaid(e.uuaid, e.publicKey)) {
72
+ out.problems.push(`${fleetPath}: entry "${name}" publicKey does not derive its uuaid ${e.uuaid} (wrong key pasted, or the peer rotated its identity); it cannot be sent to`);
73
+ } else {
74
+ entry.publicKey = e.publicKey.toLowerCase();
75
+ }
76
+ out.entries.push(entry);
77
+ }
78
+ return out;
79
+ }
3
80
 
4
81
  /**
5
- * Load a fleet.json address book into a Pillar instance.
82
+ * Load a fleet.json address book into a Pillar instance. Returns the number
83
+ * of peers registered (unchanged contract). Entries that cannot be used are
84
+ * skipped, never thrown on, and each problem goes to `onProblem` (default: a
85
+ * `warning:` line on stderr; pass false to silence), so one bad row cannot
86
+ * stop an inbox check and a bad file is never silent.
6
87
  *
7
- * Shape: { "fleet": { "<role>": { uuaid, addrs?, peerId?, publicKey } } }.
8
88
  * `pillar init --directory` writes one from the directory's agents[] so a
9
89
  * fresh agent can `ask`/`send` with no manual key plumbing.
10
90
  */
11
- export function loadFleetAddressBook(fleetPath, pillar) {
12
- if (!fleetPath || !existsSync(fleetPath)) return 0;
13
- const fleet = JSON.parse(readFileSync(fleetPath, "utf-8")).fleet ?? {};
91
+ export function loadFleetAddressBook(fleetPath, pillar, { onProblem = (p) => console.warn(`warning: ${p}`) } = {}) {
92
+ const info = inspectFleetFile(fleetPath);
93
+ if (onProblem) for (const p of info.problems) onProblem(p);
14
94
  // Warm handoff: fleet.json addrs come from a short-lived `pillar id` probe
15
95
  // on /tcp/0, so a running daemon's REAL addr differs. Each daemon writes
16
96
  // <home>/runtime.json on boot; prefer that addr when fresh.
17
- const fleetRoot = dirname(fleetPath);
97
+ const fleetRoot = fleetPath ? dirname(fleetPath) : null;
18
98
  let n = 0;
19
- for (const [role, id] of Object.entries(fleet)) {
20
- if (!id.uuaid) continue;
99
+ for (const id of info.entries) {
21
100
  if (id.uuaid === pillar.uuaid) continue; // don't self-register
22
101
  let multiaddr = id.addrs?.[0];
23
102
  let peerId = id.peerId;
24
- const rtPath = join(fleetRoot, role, "runtime.json");
103
+ const rtPath = join(fleetRoot, id.name, "runtime.json");
25
104
  if (existsSync(rtPath)) {
26
105
  try {
27
106
  const rt = JSON.parse(readFileSync(rtPath, "utf-8"));
@@ -34,9 +113,55 @@ export function loadFleetAddressBook(fleetPath, pillar) {
34
113
  pillar.registerAddress(id.uuaid, {
35
114
  peerId,
36
115
  multiaddr,
37
- publicKey: id.publicKey, // REQUIRED for E2E sends
116
+ publicKey: id.publicKey, // REQUIRED for E2E sends; absent when the entry's key was unusable
38
117
  });
39
118
  n++;
40
119
  }
41
120
  return n;
42
121
  }
122
+
123
+ /**
124
+ * A recipient argument as a UUAID. A UUAID passes through; anything else is
125
+ * a contact name looked up in fleet.json (exact, then case-insensitive; a
126
+ * leading `...` handle prefix is accepted). Returns { uuaid, name? } or
127
+ * throws an error that says what the address book does contain.
128
+ */
129
+ export function resolveRecipient(arg, info) {
130
+ if (UUAID_RE.test(arg)) return { uuaid: arg };
131
+ const name = String(arg).replace(/^\.\.\./, "");
132
+ const exact = info.entries.find((e) => e.name === name);
133
+ const folded = exact ? [exact] : info.entries.filter((e) => e.name.toLowerCase() === name.toLowerCase());
134
+ if (folded.length === 1) return { uuaid: folded[0].uuaid, name: folded[0].name };
135
+ const e = new Error(explainUnknownName(arg, info, folded.length));
136
+ e.code = "unknown-recipient";
137
+ throw e;
138
+ }
139
+
140
+ function explainUnknownName(arg, info, matches) {
141
+ if (matches > 1) return `recipient "${arg}" matches ${matches} contacts in ${info.path} — use the full UUAID`;
142
+ const where = !info.path ? "no address book" : !info.exists ? `no address book at ${info.path}` : info.error ?? `${info.path}`;
143
+ const names = info.entries.map((e) => e.name);
144
+ return `recipient "${arg}" is not a UUAID and is not a contact name in ${where}` +
145
+ (names.length ? ` (contacts: ${names.slice(0, 20).join(", ")}${names.length > 20 ? ", …" : ""})` : "") +
146
+ ". Pass the full uuaid:… (with --to-pubkey for a peer outside the address book).";
147
+ }
148
+
149
+ /**
150
+ * Why no public key is known for `uuaid`, from the address book's point of
151
+ * view — the sentence `send` prints instead of a bare "no public key known".
152
+ */
153
+ export function explainMissingKey(uuaid, info) {
154
+ const hint = "pass --to-pubkey <64-hex Ed25519 key>, or add { \"uuaid\", \"publicKey\" } for it under \"fleet\"";
155
+ if (!info.path) return `no public key known for ${uuaid}: no address book was given; ${hint}`;
156
+ if (!info.exists) return `no public key known for ${uuaid}: there is no address book at ${info.path}; ${hint}`;
157
+ if (info.error) return `no public key known for ${uuaid} because the address book could not be read: ${info.error}. Fix the file, or ${hint}`;
158
+ const entry = info.entries.find((e) => e.uuaid === uuaid);
159
+ if (entry) {
160
+ const problem = info.problems.find((p) => p.includes(`entry "${entry.name}"`));
161
+ return `no usable public key for ${uuaid}: ${problem ?? `entry "${entry.name}" in ${info.path} has none`}`;
162
+ }
163
+ const skipped = info.skipped.find((s) => s.uuaid === uuaid);
164
+ if (skipped) return `no public key known for ${uuaid}: its entry "${skipped.name}" in ${info.path} ${skipped.reason}`;
165
+ const bad = info.skipped.length ? ` ${info.skipped.length} entr${info.skipped.length === 1 ? "y was" : "ies were"} skipped as malformed (${info.skipped.map((s) => s.name).join(", ")}).` : "";
166
+ return `no public key known for ${uuaid}: it is not in ${info.path} (${info.entries.length} contact${info.entries.length === 1 ? "" : "s"}).${bad} ${hint}`;
167
+ }
package/src/blob/blob.mjs CHANGED
@@ -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
  *
package/src/blob/http.mjs CHANGED
@@ -72,7 +72,7 @@ function safeBase(url) {
72
72
  * `keyId` is optional. Given, it is presented as is. Omitted, the client
73
73
  * resolves one per identity (resolveKeyId): the registry badge's key when a
74
74
  * badge names this exact key, else "self-certified:ed25519" when the UUAID is
75
- * exactly the key's uuaid:foundation:agent UUAID, else a `blob-key-unproven`
75
+ * exactly a self-certifying agent UUAID of the key, else a `blob-key-unproven`
76
76
  * refusal.
77
77
  */
78
78
  /**
@@ -133,7 +133,7 @@ export class PillarBlobHttpClient {
133
133
  * 2. the registry badge's presentation keyId, when a fresh registry-signed
134
134
  * badge for this UUAID names this exact public key (badge limits);
135
135
  * 3. "self-certified:ed25519", when this UUAID is exactly
136
- * `uuaid:foundation:agent:` + the key's local id (self-certified
136
+ * a self-certifying agent namespace + the key's local id (self-certified
137
137
  * limits; the full string, as the broker compares it);
138
138
  * else throws `blob-key-unproven`. A badge that names another key, a
139
139
  * missing badge and an unreachable registry all fall through to 3.
@@ -158,7 +158,7 @@ export class PillarBlobHttpClient {
158
158
  if (!value) {
159
159
  throw new BlobBrokerError(
160
160
  "blob-key-unproven",
161
- `${uuaid} cannot sign broker requests: ${badgeProblem}, and it is not the uuaid:foundation:agent UUAID derived from its key`,
161
+ `${uuaid} cannot sign broker requests: ${badgeProblem}, and it is not a self-certifying agent UUAID derived from its key`,
162
162
  null,
163
163
  { fix: REFUSAL_FIXES["blob-key-unproven"] },
164
164
  );
@@ -310,7 +310,7 @@ export class PillarBlobHttpClient {
310
310
  /**
311
311
  * Recipient sender gate for a received blob-ref. "self-certified" when the
312
312
  * ref's signature verifies and its sender is exactly that signing key's
313
- * uuaid:foundation:agent UUAID (proven key holder, unverified identity);
313
+ * self-certifying agent UUAID (proven key holder, unverified identity);
314
314
  * otherwise "active" when a registry badge names that key; otherwise
315
315
  * throws BlobSenderKeyError (code sender-key-not-active, with a fix).
316
316
  */
@@ -0,0 +1,231 @@
1
+ /**
2
+ * `pillar group` for pillar-group/2 groups. bin/pillar.mjs runs this when
3
+ * groupCommandRoute says "v2"; tests call runGroupV2 in-process.
4
+ *
5
+ * WHAT SENDS, AND TO WHOM (an egress filter reads mailbox groups.members and
6
+ * the group_v2_sendq queue):
7
+ * send <group> <text>|--json message the mailbox row minus self (Pillar.sendGroupV2)
8
+ * accept <group> accept.request the admin, taken from the mailbox row
9
+ * leave <group> leave.request the mailbox row minus self
10
+ * rename <group> <name> rename.request the admin, taken from the mailbox row (a member's suggestion)
11
+ * sync [<grp-id>] queued rows each row's own recipient and key (Pillar.syncGroupV2)
12
+ * EVERYTHING ELSE SENDS NOTHING:
13
+ * create --v2, invite, remove, ext, transfer, the admin's accept/leave
14
+ * <group> <uuaid> --request ENV, and the admin's rename: sign, verify as the
15
+ * next version, store, write the mailbox row, and QUEUE the manifest sends
16
+ * (adminManifestSends) for `sync`. They never start a transport.
17
+ * list, show, queue: read only. They open no Pillar and no keychain.
18
+ */
19
+ import { existsSync } from "node:fs";
20
+ import { join } from "node:path";
21
+ import { randomBytes } from "node:crypto";
22
+ import { Mailbox } from "./mailbox/mailbox.mjs";
23
+ import { refusalLine } from "./net/refusals.mjs";
24
+ import { findGroup } from "./groups.mjs";
25
+ import {
26
+ RosterStore, isGroupIdV2, adminOf, adminCreate, adminChange, adminManifestSends, parseMemberRef,
27
+ loadGroupForCommand, createV2FlagRefusal,
28
+ } from "./groups-v2.mjs";
29
+
30
+ export const GROUP_V2_USAGE = `usage (pillar-group/2):
31
+ pillar group create <name> [<uuaid>:<pubkey>]... [--purpose TEXT] --v2
32
+ pillar group send <group> <text> | --json '{...}' [--kind KIND]
33
+ pillar group invite <group> <uuaid>:<pubkey>
34
+ pillar group accept <group> an invitee asks the admin to let it in
35
+ pillar group accept <group> <uuaid> --request ENV_ID the admin folds in that member's request
36
+ pillar group leave <group> a member leaves
37
+ pillar group leave <group> <uuaid> --request ENV_ID the admin records that member's leave
38
+ pillar group remove <group> <uuaid>
39
+ pillar group rename <group> <name...> [--purpose TEXT] the admin renames; a member suggests
40
+ pillar group ext <group> <json>
41
+ pillar group transfer <group> <uuaid>
42
+ pillar group show <group>
43
+ pillar group list
44
+ pillar group queue [<grp-id>] the sends waiting for sync (read only)
45
+ pillar group sync [<grp-id>] send them
46
+ <group> is a grp-<32 hex> id or a unique group name. An admin change is stored at once and its
47
+ manifests are queued, not sent; pillar group sync <grp-id> delivers them.`;
48
+
49
+ const newQueueId = () => `gq-${randomBytes(12).toString("hex")}`;
50
+
51
+ function openMailbox(home) {
52
+ const p = join(home, "mailbox.db");
53
+ return existsSync(p) ? new Mailbox(p) : null;
54
+ }
55
+
56
+ /** A grp-<32 hex> id, or the id a unique name has in this mailbox; else null. */
57
+ function resolveId(ref, mailbox) {
58
+ if (isGroupIdV2(ref)) return ref;
59
+ if (!mailbox || typeof ref !== "string") return null;
60
+ try { return findGroup(mailbox, ref).id; } catch { return null; }
61
+ }
62
+
63
+ /**
64
+ * Run one `pillar group <verb> ...` (args parsed by the bin's parseArgs) for a
65
+ * v2 group. io: { home, openPillar() -> an UNSTARTED Pillar for this home, out, err }.
66
+ * Returns the exit code: 0, 1 (refused or a send failed), 2 (usage).
67
+ */
68
+ export async function runGroupV2(args, io) {
69
+ const out = io.out ?? ((s) => console.log(s));
70
+ const err = io.err ?? ((s) => console.error(s));
71
+ const usage = (why) => { err(why ? `pillar group: ${why}` : GROUP_V2_USAGE); return 2; };
72
+ const verb = args._[1];
73
+ try {
74
+ if (verb === "list") return listGroups(io, out);
75
+ if (verb === "queue") return queueList(args, io, out, usage); // @rule CLI-queue-read-only
76
+ if (verb === "show") return showGroup(args, io, out, usage);
77
+ if (verb === "sync") return queueSync(args, io, out, usage);
78
+ if (verb === "add") return usage("pillar-group/2 has no add: invite each member with pillar group invite <group> <uuaid>:<pubkey>");
79
+ if (!["create", "send", "invite", "accept", "leave", "remove", "rename", "ext", "transfer"].includes(verb)) return usage();
80
+ return await keyed(verb, args, io, out, err, usage);
81
+ } catch (e) {
82
+ err(refusalLine(e) ?? `pillar group: ${e.message}`);
83
+ return 1;
84
+ }
85
+ }
86
+
87
+ function listGroups(io, out) {
88
+ const store = new RosterStore(io.home);
89
+ const ids = store.list();
90
+ if (!ids.length) { out("no pillar-group/2 groups in this home"); return 0; }
91
+ for (const id of ids) {
92
+ let r;
93
+ try { r = store.load(id); } catch (e) { out(`${id} unreadable: ${e.message}`); continue; }
94
+ const head = r.chain[r.chain.length - 1];
95
+ out(`${id} v${head.version} "${head.name}" admin ${adminOf(head).uuaid} ${head.members.filter((m) => m.status === "active").length} active${r.frozen ? " FROZEN" : ""}${r.outOfDate ? " out of date" : ""}`);
96
+ }
97
+ return 0;
98
+ }
99
+
100
+ function showGroup(args, io, out, usage) {
101
+ const ref = args._[2];
102
+ if (!ref) return usage();
103
+ const mb = openMailbox(io.home);
104
+ try {
105
+ const id = resolveId(ref, mb);
106
+ if (!id) return usage(`no pillar-group/2 group "${ref}" in this home`);
107
+ const r = loadGroupForCommand(new RosterStore(io.home), id);
108
+ const head = r.chain[r.chain.length - 1];
109
+ out(JSON.stringify({
110
+ id: r.id, head: r.head, name: head.name, purpose: head.purpose, epoch: head.epoch, members: head.members, ext: head.ext,
111
+ frozen: !!r.frozen, outOfDate: r.outOfDate, leaving: r.leaving, queued: mb ? mb.queuedGroupV2Sends(id).length : 0,
112
+ }, null, 2));
113
+ return 0;
114
+ } finally { mb?.close(); }
115
+ }
116
+
117
+ /** Read only: no Pillar, no keychain, no transport. */
118
+ function queueList(args, io, out, usage) {
119
+ const ref = args._[2];
120
+ const mb = openMailbox(io.home);
121
+ try {
122
+ let id = null;
123
+ if (ref !== undefined) { id = resolveId(ref, mb); if (!id) return usage(`no pillar-group/2 group "${ref}" in this home`); }
124
+ const rows = mb ? mb.queuedGroupV2Sends(id) : [];
125
+ if (!rows.length) { out("nothing queued"); return 0; }
126
+ for (const q of rows) out(`${q.id} ${q.group_id} ${q.op} -> ${q.recipient}${q.reason ? ` (last attempt: ${q.reason})` : ""}`);
127
+ out(`${rows.length} queued; send them with: pillar group sync <grp-id>`);
128
+ return 0;
129
+ } finally { mb?.close(); }
130
+ }
131
+
132
+ async function queueSync(args, io, out, usage) {
133
+ const ref = args._[2];
134
+ if (ref !== undefined && !isGroupIdV2(ref)) return usage("sync takes a grp-<32 hex> id; it does not resolve names"); // @rule CLI-sync-literal-id
135
+ const p = await io.openPillar();
136
+ try {
137
+ await p.start();
138
+ const rows = await p.syncGroupV2({ groupId: ref ?? null });
139
+ for (const r of rows) out(`${r.state} ${r.groupId} ${r.op} -> ${r.to}${r.envelopeId ? ` ${r.envelopeId}` : ""}${r.reason ? ` (${r.reason})` : ""}`);
140
+ out(`${rows.filter((r) => r.state === "sent").length} sent, ${rows.filter((r) => r.state === "dropped").length} dropped, ${rows.filter((r) => r.state === "queued").length} still queued`);
141
+ return rows.some((r) => r.state === "queued") ? 1 : 0;
142
+ } finally { await p.stop(); }
143
+ }
144
+
145
+ async function keyed(verb, args, io, out, err, usage) {
146
+ const p = await io.openPillar();
147
+ try {
148
+ const store = p.groupsV2.store, mb = p.mailbox, me = p.uuaid;
149
+ /** Queue the manifest sends of the change just stored, and say so. */
150
+ const queued = (rec, what) => {
151
+ const sends = adminManifestSends(rec, mb.getGroup(rec.id), me);
152
+ const ids = mb.enqueueGroupV2Sends(rec.id, sends, { newId: newQueueId });
153
+ out(`${rec.id} v${rec.head.version} ${what} manifest ${rec.head.hash}`);
154
+ out(`queued ${ids.length} manifest send(s)${ids.length < sends.length ? ` (${sends.length - ids.length} did not fit the queue)` : ""}; deliver with: pillar group sync ${rec.id}`);
155
+ return 0;
156
+ };
157
+ const change = (groupId, action, opts, what) => queued(adminChange({ store, mailbox: mb, keychain: p.keychain, groupId, action, opts }).rec, what);
158
+ /** A member's request or message, to the recipients the mailbox row gives (Pillar.sendGroupV2). */
159
+ const sendAs = async (groupId, op, payload, kind = "message") => {
160
+ await p.start();
161
+ const r = await p.sendGroupV2({ groupId, op, payload, kind });
162
+ let failed = 0;
163
+ for (const x of r.results) {
164
+ if (x.ok) out(` ✓ ${x.member} via ${x.via} ${x.envelopeId}`);
165
+ else { failed++; err(` ✗ ${x.member} ${refusalLine(x) ?? x.error}`); }
166
+ }
167
+ out(`group "${r.group}" ${r.groupId} ${op} ${r.msgId}: ${r.results.length - failed}/${r.results.length} accepted by carrier`);
168
+ return failed ? 1 : 0;
169
+ };
170
+
171
+ if (verb === "create") {
172
+ if (createV2FlagRefusal(args)) return usage(createV2FlagRefusal(args)); // @rule CLI-create-flag-refused
173
+ const [name, ...members] = args._.slice(2);
174
+ if (!name) return usage();
175
+ const purpose = typeof args.purpose === "string" ? args.purpose : undefined;
176
+ return queued(adminCreate({ store, mailbox: mb, keychain: p.keychain, name, purpose, members: members.map(parseMemberRef) }).rec, "create");
177
+ }
178
+ const ref = args._[2];
179
+ if (!ref) return usage();
180
+ const id = resolveId(ref, mb);
181
+ if (!id) return usage(`no pillar-group/2 group "${ref}" in this home`);
182
+ const rec = loadGroupForCommand(store, id);
183
+ const head = rec.chain[rec.chain.length - 1];
184
+ const amAdmin = adminOf(head).uuaid === me;
185
+ const target = args._[3];
186
+ const request = typeof args.request === "string" ? args.request : null;
187
+
188
+ if (verb === "send") {
189
+ let payload;
190
+ if (typeof args.json === "string") {
191
+ try { payload = JSON.parse(args.json); } catch (e) { return usage(`--json is not valid JSON: ${e.message}`); }
192
+ } else {
193
+ const text = args._.slice(3).join(" ");
194
+ if (!text) return usage();
195
+ payload = { text };
196
+ }
197
+ return await sendAs(id, "message", payload, typeof args.kind === "string" ? args.kind : "message");
198
+ }
199
+ if (verb === "invite") {
200
+ if (!target) return usage();
201
+ return change(id, "invite", { member: parseMemberRef(target) }, `invite ${parseMemberRef(target).uuaid}`);
202
+ }
203
+ if (verb === "remove" || verb === "transfer") {
204
+ if (!target) return usage();
205
+ return change(id, verb === "remove" ? "remove" : "admin_transfer", { target }, `${verb} ${target}`);
206
+ }
207
+ if (verb === "ext") {
208
+ let ext;
209
+ try { ext = JSON.parse(args._.slice(3).join(" ")); } catch (e) { return usage(`ext is not JSON: ${e.message}`); }
210
+ return change(id, "ext_update", { ext }, "ext");
211
+ }
212
+ if (verb === "rename") {
213
+ const name = args._.slice(3).join(" ").trim();
214
+ if (!amAdmin) {
215
+ if (!name) return usage();
216
+ return await sendAs(id, "rename.request", { text: `suggests renaming the group to "${name}"`, name });
217
+ }
218
+ const opts = { name: name || head.name };
219
+ if (typeof args.purpose === "string") opts.purpose = args.purpose;
220
+ return change(id, "rename", opts, `rename "${opts.name}"`);
221
+ }
222
+ // accept / leave: with a member and --request, the admin's change; without, this member's request.
223
+ if (target !== undefined) {
224
+ if (!request) return usage(`${verb} ${target} folds in the member's signed ${verb}.request: pass --request <its envelope id>`);
225
+ return change(id, verb, { target, request }, `${verb} ${target}`);
226
+ }
227
+ if (verb === "accept") return await sendAs(id, "accept.request", { text: "asks to join the group" });
228
+ if (amAdmin) return usage("an admin may not leave: hand the group over first (pillar group transfer <group> <uuaid>)"); // @rule CLI-admin-no-leave
229
+ return await sendAs(id, "leave.request", { text: "leaves the group" });
230
+ } finally { await p.stop(); }
231
+ }