@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/HOSTING-LIMITS.md +49 -14
- package/INTERACTION-CONVENTION.md +10 -6
- package/README.md +118 -12
- package/bin/carrier.mjs +64 -2
- package/bin/pillar-directory.mjs +2 -2
- package/bin/pillar-escrow.mjs +2 -2
- package/bin/pillar-group-v2.mjs +17 -0
- package/bin/pillar-seed.mjs +22 -4
- package/bin/pillar.mjs +727 -73
- package/package.json +2 -1
- package/src/address-book.mjs +135 -10
- package/src/blob/blob.mjs +12 -14
- package/src/blob/http.mjs +4 -4
- package/src/group-v2-cli.mjs +231 -0
- package/src/groups-v2.mjs +1108 -0
- package/src/groups.mjs +175 -0
- package/src/identity/jcs.mjs +4 -5
- package/src/identity/keychain.mjs +86 -2
- package/src/index.mjs +1 -1
- package/src/mailbox/mailbox.mjs +296 -12
- package/src/net/carrier-client.mjs +29 -7
- package/src/net/carrier-receipt.mjs +77 -0
- package/src/net/carrier-server.mjs +227 -19
- package/src/net/envelope.mjs +3 -1
- package/src/net/refusals.mjs +5 -2
- package/src/pillar.mjs +416 -35
- package/src/readers.mjs +120 -0
- package/src/receipts.mjs +145 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uuaid/pillar",
|
|
3
|
-
"version": "2.0
|
|
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": {
|
package/src/address-book.mjs
CHANGED
|
@@ -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
|
-
|
|
13
|
-
const
|
|
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
|
|
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,
|
|
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,
|
|
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
|
-
/**
|
|
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
|
-
*
|
|
174
|
-
*
|
|
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
|
-
|
|
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
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
*
|
|
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
|
|
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
|
-
*
|
|
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
|
+
}
|