@uuaid/pillar 2.0.3 → 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 +33 -14
- package/INTERACTION-CONVENTION.md +10 -6
- package/README.md +118 -12
- package/bin/carrier.mjs +51 -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 +11 -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/keychain.mjs +51 -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 +175 -20
- package/src/net/refusals.mjs +4 -2
- package/src/pillar.mjs +416 -35
- package/src/readers.mjs +120 -0
- package/src/receipts.mjs +145 -0
package/bin/pillar.mjs
CHANGED
|
@@ -2,32 +2,54 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* pillar — one-command CLI.
|
|
4
4
|
*
|
|
5
|
-
* pillar init [--home ~/.pillar]
|
|
5
|
+
* pillar init [--home ~/.pillar] [--namespace pillar|agentnet|foundation]
|
|
6
|
+
* first-time setup: mint UUAID + keys (default namespace: foundation in 2.2.0)
|
|
6
7
|
* pillar init --refresh [--directory URL] re-sync carriers + address book (keychain untouched)
|
|
8
|
+
* pillar init --force --confirm <current uuaid> REPLACE the identity (new UUAID); the old one is archived
|
|
7
9
|
* pillar id [--home ~/.pillar] print our UUAID + PeerId + addrs
|
|
8
|
-
* pillar send <recipient> <kind> <json>
|
|
9
|
-
*
|
|
10
|
-
* pillar
|
|
10
|
+
* pillar send <recipient|name> <kind> <json> [--wait-ack S]
|
|
11
|
+
* send a signed envelope; optionally wait for the recipient's signed ack
|
|
12
|
+
* pillar recv [--home ~/.pillar] [--reader NAME] listen for envelopes and print them
|
|
13
|
+
* pillar inbox [--home ~/.pillar] [--reader NAME] [--unread] [--notify CMD] [--mark-read]
|
|
11
14
|
* one-shot: pull pending mail, print, exit
|
|
15
|
+
* pillar wait [--reader NAME] [--timeout S] sleep until there is mail, print the count, exit 0 (3 on timeout)
|
|
16
|
+
* pillar readers every reader of this home, its cursor and any live lease
|
|
17
|
+
* pillar status [envelope-id] [--wait S] outbox: carrier acceptance and the recipient's signed receipts
|
|
18
|
+
* pillar receipts [on|off|delivered on|off|read on|off] send signed acks / read receipts (off by default)
|
|
19
|
+
* pillar group create|send|add|rename|leave|accept|show group threads (pillar-group v1)
|
|
20
|
+
* pillar group create --v2 | invite|remove|ext|transfer|list|queue|sync, and any verb
|
|
21
|
+
* naming a pillar-group/2 group signed rosters (src/group-v2-cli.mjs)
|
|
22
|
+
* pillar groups list this home's groups
|
|
12
23
|
* pillar trust [set|allow|block|unlist|quarantine] recipient consent policy
|
|
13
24
|
* pillar tier [issue|install|verify] signed volume/size tier grants
|
|
14
25
|
* pillar peers list currently connected peers
|
|
15
26
|
*
|
|
16
27
|
* All commands respect --home to point at an alternate pillar directory,
|
|
17
28
|
* useful for running multiple pillars on one machine (Zestro dogfood etc.).
|
|
29
|
+
* More than one process reading ONE home: give each its own --reader NAME
|
|
30
|
+
* (or PILLAR_READER) so each has its own cursor; see src/readers.mjs.
|
|
18
31
|
*/
|
|
19
32
|
import "../src/node-check.mjs"; // must stay the first import: refuses Node < 22 before any dependency loads
|
|
20
|
-
import { mkdirSync, existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
33
|
+
import { mkdirSync, existsSync, readFileSync, writeFileSync, renameSync } from "node:fs";
|
|
21
34
|
import { join, dirname } from "node:path";
|
|
22
35
|
import { homedir } from "node:os";
|
|
23
36
|
import { argv, exit } from "node:process";
|
|
24
|
-
import { Keychain } from "../src/identity/keychain.mjs";
|
|
37
|
+
import { Keychain, mintNamespace, SELF_CERTIFYING_NAMESPACES } from "../src/identity/keychain.mjs";
|
|
25
38
|
import { Pillar } from "../src/pillar.mjs";
|
|
26
|
-
import { loadFleetAddressBook } from "../src/address-book.mjs";
|
|
39
|
+
import { loadFleetAddressBook, inspectFleetFile, resolveRecipient, explainMissingKey } from "../src/address-book.mjs";
|
|
27
40
|
import { loadTrustPolicy, POLICIES } from "../src/identity/trust.mjs";
|
|
28
41
|
import { parseLinkOffer, isLinkOfferLive, LINK_GRANT_KINDS } from "../src/net/link-offer.mjs";
|
|
29
42
|
import { mintDelegationDoc } from "../src/net/delegation.mjs";
|
|
30
43
|
import { refusalLine } from "../src/net/refusals.mjs";
|
|
44
|
+
import {
|
|
45
|
+
readerKey, processContext, otherLiveLeases, describeProcess, describeReader, initialCursor, handleInOrder,
|
|
46
|
+
LEASE_TTL_MS, LEASE_HEARTBEAT_MS,
|
|
47
|
+
} from "../src/readers.mjs";
|
|
48
|
+
import { extractGroupTag, findGroup, normalizeMembers } from "../src/groups.mjs";
|
|
49
|
+
import { describeOutboundStatus, receiptFor } from "../src/receipts.mjs";
|
|
50
|
+
import { groupCommandRoute, groupRouteLookups, RosterStore } from "../src/groups-v2.mjs";
|
|
51
|
+
import { runGroupV2 } from "../src/group-v2-cli.mjs";
|
|
52
|
+
import { Mailbox } from "../src/mailbox/mailbox.mjs";
|
|
31
53
|
|
|
32
54
|
function parseArgs(argv) {
|
|
33
55
|
const args = { _: [] };
|
|
@@ -52,16 +74,34 @@ function fleetPath(args) {
|
|
|
52
74
|
return args.fleet || join(homePath(args), "fleet.json");
|
|
53
75
|
}
|
|
54
76
|
|
|
55
|
-
function
|
|
77
|
+
function readConfig(home) {
|
|
78
|
+
const cfgPath = join(home, "config.json");
|
|
79
|
+
if (!existsSync(cfgPath)) return {};
|
|
80
|
+
try { return JSON.parse(readFileSync(cfgPath, "utf-8")); }
|
|
81
|
+
catch (e) {
|
|
82
|
+
console.error(`${cfgPath} is not valid JSON (${e.message}) — fix it, or re-create it with pillar init --refresh --directory <url>`);
|
|
83
|
+
exit(2);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Receipts are opt-in: config.json `receipts: { delivered, read }` (pillar
|
|
88
|
+
// receipts on|off), overridden per run by --ack (delivery acks on) and
|
|
89
|
+
// --no-receipts (both off).
|
|
90
|
+
function receiptsFor(args, cfg) {
|
|
91
|
+
if (args["no-receipts"]) return { delivered: false, read: false };
|
|
92
|
+
const r = cfg.receipts ?? {};
|
|
93
|
+
return { delivered: !!r.delivered || !!args.ack, read: !!r.read };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function loadPillar(args, { reader = null } = {}) {
|
|
56
97
|
const home = homePath(args);
|
|
57
98
|
const kcPath = join(home, "keychain.json");
|
|
58
99
|
const mbPath = join(home, "mailbox.db");
|
|
59
|
-
const cfgPath = join(home, "config.json");
|
|
60
100
|
if (!existsSync(kcPath)) {
|
|
61
101
|
console.error(`no pillar found at ${home}. run: pillar init`);
|
|
62
102
|
exit(2);
|
|
63
103
|
}
|
|
64
|
-
const cfg =
|
|
104
|
+
const cfg = readConfig(home);
|
|
65
105
|
const kc = new Keychain(kcPath);
|
|
66
106
|
kc.load({ passphrase: process.env.PILLAR_PASSPHRASE ?? "" });
|
|
67
107
|
const carriers = args.carrier
|
|
@@ -91,9 +131,69 @@ function loadPillar(args) {
|
|
|
91
131
|
enableLibp2p: !(args["carrier-only"] || cfg.carrierOnly === true),
|
|
92
132
|
trust,
|
|
93
133
|
tierGrant,
|
|
134
|
+
reader,
|
|
135
|
+
receipts: receiptsFor(args, cfg),
|
|
94
136
|
});
|
|
95
137
|
}
|
|
96
138
|
|
|
139
|
+
/**
|
|
140
|
+
* Load the address book into `p`, printing every problem with it on stderr
|
|
141
|
+
* (once), and return the inspection so a later error can say exactly why a
|
|
142
|
+
* contact is unusable instead of "no public key known".
|
|
143
|
+
*/
|
|
144
|
+
function loadContacts(args, p) {
|
|
145
|
+
const path = fleetPath(args);
|
|
146
|
+
const info = inspectFleetFile(path);
|
|
147
|
+
const n = loadFleetAddressBook(path, p, { onProblem: (msg) => console.error(`warning: ${msg}`) });
|
|
148
|
+
return { info, n, path };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// ── readers ───────────────────────────────────────────────────────────────────
|
|
152
|
+
|
|
153
|
+
function readerFor(args) {
|
|
154
|
+
const name = args.reader ?? process.env.PILLAR_READER;
|
|
155
|
+
try { return readerKey(name); }
|
|
156
|
+
catch (e) { console.error(e.message); exit(2); }
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Tell this reader, on stderr, about any live process that will pull its
|
|
161
|
+
* mail first. Unnamed readers share a view with every other process, so any
|
|
162
|
+
* live consumer matters; a named reader only collides with its own name.
|
|
163
|
+
* `showsUnread`: an unnamed reader printing the unread backlog (--unread)
|
|
164
|
+
* loses nothing to a recv (recv never marks mail read), so it is not warned.
|
|
165
|
+
*/
|
|
166
|
+
function warnAboutLiveReaders(p, rk, ctx, { showsUnread = false } = {}) {
|
|
167
|
+
const leases = otherLiveLeases(p.mailbox, ctx);
|
|
168
|
+
const clash = rk.named ? leases.filter((l) => l.reader === rk.key) : (showsUnread ? [] : leases);
|
|
169
|
+
for (const l of clash) {
|
|
170
|
+
if (rk.named) {
|
|
171
|
+
console.error(`warning: reader "${rk.key}" is also held by ${describeProcess(l)}. Two processes under one reader name share one cursor, so each sees only part of the mail. Give each its own --reader NAME.`);
|
|
172
|
+
} else {
|
|
173
|
+
console.error(`warning: ${describeProcess(l)} is reading this home${l.reader.startsWith("ctx:") ? "" : ` as reader "${l.reader}"`}. It pulls mail as it arrives, so that mail will not be listed as new here. Give each reader its own cursor with --reader NAME (or PILLAR_READER).`);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function inboxLine(env, payload, extra = {}) {
|
|
179
|
+
const line = { ...extra, id: env.id, from: env.sender, fromPublicKey: env.transportSignature?.publicKey ?? null, kind: env.kind, at: env.createdAt, payload };
|
|
180
|
+
const tag = extractGroupTag(payload);
|
|
181
|
+
if (tag) {
|
|
182
|
+
line.group = tag.name;
|
|
183
|
+
line.groupId = tag.id;
|
|
184
|
+
if (tag.msgId) line.msgId = tag.msgId;
|
|
185
|
+
if (tag.op !== "message") line.op = tag.op;
|
|
186
|
+
}
|
|
187
|
+
return line;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function viewOfRow(p, row, decrypt) {
|
|
191
|
+
const env = JSON.parse(row.envelope_json);
|
|
192
|
+
let payload;
|
|
193
|
+
try { payload = decrypt(p.keychain, env); } catch (_e) { payload = { error: "decrypt failed" }; }
|
|
194
|
+
return { env, payload, seq: row.seq, groupId: row.group_id ?? null, pulledBy: row.pulled_by ?? null };
|
|
195
|
+
}
|
|
196
|
+
|
|
97
197
|
function loadGroups(args) {
|
|
98
198
|
// groups.json lives next to fleet.json by default:
|
|
99
199
|
// { "groups": { "<name>": { "members": ["uuaid:...", ...] } } }
|
|
@@ -186,14 +286,26 @@ async function cmdInit(args) {
|
|
|
186
286
|
}
|
|
187
287
|
|
|
188
288
|
const kcPath = join(home, "keychain.json");
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
289
|
+
let rotated = null;
|
|
290
|
+
if (existsSync(kcPath)) {
|
|
291
|
+
const old = new Keychain(kcPath).publicView();
|
|
292
|
+
if (!args.force) {
|
|
293
|
+
console.error(`this home already has an identity: ${old?.uuaid ?? kcPath}`);
|
|
294
|
+
console.error(` re-sync carriers and contacts, keeping it: pillar init --refresh --directory <url> --home ${home}`);
|
|
295
|
+
console.error(` replace it with a NEW identity (new UUAID): pillar init --force --confirm <current uuaid> --home ${home}`);
|
|
296
|
+
exit(1);
|
|
297
|
+
}
|
|
298
|
+
rotated = await confirmAndArchiveIdentity(home, old, args);
|
|
192
299
|
}
|
|
300
|
+
let uuaidNamespace;
|
|
301
|
+
try { uuaidNamespace = mintNamespace(args.namespace === undefined ? undefined : args.namespace); }
|
|
302
|
+
catch { console.error(`init: --namespace must be one of ${SELF_CERTIFYING_NAMESPACES.join(", ")}`); exit(1); }
|
|
193
303
|
const kc = new Keychain(kcPath);
|
|
194
|
-
const id = Keychain.generate();
|
|
304
|
+
const id = Keychain.generate({ uuaidNamespace });
|
|
195
305
|
kc.save(id, { passphrase: process.env.PILLAR_PASSPHRASE ?? "" });
|
|
196
|
-
|
|
306
|
+
// A rotation keeps the home's settings (carriers, receipts, …); only the
|
|
307
|
+
// identity and what is bound to it were archived.
|
|
308
|
+
const cfg = rotated ? readConfig(home) : {
|
|
197
309
|
listenAddrs: ["/ip4/0.0.0.0/tcp/0", "/ip4/0.0.0.0/tcp/0/ws"],
|
|
198
310
|
bootstrapPeers: [],
|
|
199
311
|
enableRelay: false,
|
|
@@ -223,10 +335,70 @@ async function cmdInit(args) {
|
|
|
223
335
|
console.log(`initialized pillar at ${home}`);
|
|
224
336
|
console.log(`uuaid: ${id.uuaid}`);
|
|
225
337
|
console.log(`publicKey: ${id.publicKeyHex}`);
|
|
338
|
+
if (rotated) {
|
|
339
|
+
console.log(`\nreplaced identity ${rotated.uuaid}`);
|
|
340
|
+
console.log(` archived to ${rotated.dir} (keychain, mailbox, tier grant): the old key still opens the old mail there`);
|
|
341
|
+
console.log(` every contact and directory listing still names the OLD uuaid: send them the new uuaid + publicKey, and re-list it`);
|
|
342
|
+
if (cfg.carriers?.length) console.log(` carriers kept: ${cfg.carriers.join(", ")}`);
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
226
345
|
console.log(`\nconfig: ${join(home, "config.json")}`);
|
|
227
346
|
console.log(`edit bootstrapPeers to join a network, or run again with --directory <url> / --carriers a,b to wire seeds.`);
|
|
228
347
|
}
|
|
229
348
|
|
|
349
|
+
/**
|
|
350
|
+
* `init --force` replaces an identity, which silently breaks every contact
|
|
351
|
+
* that knows the old UUAID. So it must be confirmed, and it is never lossy:
|
|
352
|
+
*
|
|
353
|
+
* - interactive: type the current UUAID's last 8 characters;
|
|
354
|
+
* non-interactive: --confirm <current uuaid, or its last 8 characters>
|
|
355
|
+
* - refused while a live `pillar recv` holds this home (it would keep
|
|
356
|
+
* writing into the archived mailbox)
|
|
357
|
+
* - everything bound to the old identity (keychain, mailbox, tier grant,
|
|
358
|
+
* runtime file) is MOVED to <home>/rotated/<time>-<old id>/, not deleted
|
|
359
|
+
*
|
|
360
|
+
* Returns { uuaid, dir }.
|
|
361
|
+
*/
|
|
362
|
+
async function confirmAndArchiveIdentity(home, old, args) {
|
|
363
|
+
const current = old?.uuaid ?? "";
|
|
364
|
+
const tail = current.replace(/-/g, "").slice(-8);
|
|
365
|
+
const accepted = (v) => typeof v === "string" && (v === current || v.replace(/-/g, "").toLowerCase() === tail.toLowerCase());
|
|
366
|
+
let answer = typeof args.confirm === "string" ? args.confirm : null;
|
|
367
|
+
if (answer === null && process.stdin.isTTY && process.stderr.isTTY) {
|
|
368
|
+
const { createInterface } = await import("node:readline/promises");
|
|
369
|
+
const rl = createInterface({ input: process.stdin, output: process.stderr });
|
|
370
|
+
console.error(`This REPLACES the identity of ${home}:`);
|
|
371
|
+
console.error(` ${current}`);
|
|
372
|
+
console.error(`with a new key and a new UUAID. Contacts that know the old one will no longer reach you.`);
|
|
373
|
+
answer = (await rl.question(`Type the last 8 characters of the current UUAID (${tail}) to continue: `)).trim();
|
|
374
|
+
rl.close();
|
|
375
|
+
}
|
|
376
|
+
if (!accepted(answer)) {
|
|
377
|
+
console.error(answer === null
|
|
378
|
+
? `init --force replaces ${current} with a NEW identity. Confirm it: pillar init --force --confirm ${current}`
|
|
379
|
+
: `confirmation "${answer}" does not match ${current}; nothing was changed`);
|
|
380
|
+
exit(1);
|
|
381
|
+
}
|
|
382
|
+
const { Mailbox } = await import("../src/mailbox/mailbox.mjs");
|
|
383
|
+
const mbPath = join(home, "mailbox.db");
|
|
384
|
+
if (existsSync(mbPath)) {
|
|
385
|
+
const mb = new Mailbox(mbPath);
|
|
386
|
+
const live = otherLiveLeases(mb);
|
|
387
|
+
mb.close();
|
|
388
|
+
if (live.length) {
|
|
389
|
+
console.error(`refusing to replace the identity while ${live.map(describeProcess).join("; ")} is reading this home — stop it first`);
|
|
390
|
+
exit(1);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
394
|
+
const dir = join(home, "rotated", `${stamp}-${(current.split(":")[3] ?? "unknown").slice(0, 8)}`);
|
|
395
|
+
mkdirSync(dir, { recursive: true });
|
|
396
|
+
for (const f of ["keychain.json", "mailbox.db", "mailbox.db-wal", "mailbox.db-shm", "tier-grant.json", "runtime.json"]) {
|
|
397
|
+
if (existsSync(join(home, f))) renameSync(join(home, f), join(dir, f));
|
|
398
|
+
}
|
|
399
|
+
return { uuaid: current, dir };
|
|
400
|
+
}
|
|
401
|
+
|
|
230
402
|
async function cmdId(args) {
|
|
231
403
|
const p = loadPillar(args);
|
|
232
404
|
await p.start();
|
|
@@ -244,31 +416,85 @@ async function cmdId(args) {
|
|
|
244
416
|
}
|
|
245
417
|
|
|
246
418
|
async function cmdSend(args) {
|
|
247
|
-
const [
|
|
248
|
-
if (!
|
|
249
|
-
console.error("usage: pillar send <recipient-uuaid> <kind> [json-payload] [--fleet FLEET.json]");
|
|
419
|
+
const [recipientArg, kind, jsonStr] = args._.slice(1);
|
|
420
|
+
if (!recipientArg || !kind) {
|
|
421
|
+
console.error("usage: pillar send <recipient-uuaid|contact-name> <kind> [json-payload] [--to-pubkey HEX] [--wait-ack SECONDS] [--fleet FLEET.json]");
|
|
250
422
|
exit(2);
|
|
251
423
|
}
|
|
252
|
-
|
|
424
|
+
let payload = {};
|
|
425
|
+
if (jsonStr) {
|
|
426
|
+
try { payload = JSON.parse(jsonStr); }
|
|
427
|
+
catch (e) { console.error(`the payload is not valid JSON (${e.message}): ${jsonStr.slice(0, 120)}`); exit(2); }
|
|
428
|
+
}
|
|
253
429
|
const p = loadPillar(args);
|
|
254
430
|
await p.start();
|
|
255
|
-
const n =
|
|
431
|
+
const { info, n } = loadContacts(args, p);
|
|
256
432
|
if (n > 0) console.log(`address book: ${n} peers`);
|
|
433
|
+
let recipient;
|
|
434
|
+
try { recipient = resolveRecipient(recipientArg, info).uuaid; }
|
|
435
|
+
catch (e) { await p.stop(); console.error(e.message); exit(2); }
|
|
436
|
+
const toKey = typeof args["to-pubkey"] === "string" ? args["to-pubkey"] : undefined;
|
|
437
|
+
// Say WHY there is no key before the library says only that there is none
|
|
438
|
+
// (a malformed contacts file used to surface as "no public key known").
|
|
439
|
+
if (!toKey && !p.addressBook?.get(recipient)?.publicKey && !p.node) {
|
|
440
|
+
await p.stop();
|
|
441
|
+
console.error(explainMissingKey(recipient, info));
|
|
442
|
+
exit(1);
|
|
443
|
+
}
|
|
257
444
|
try {
|
|
258
445
|
const { envelope, delivery } = await p.send({
|
|
259
446
|
recipient, kind, payload,
|
|
260
|
-
recipientPublicKey:
|
|
447
|
+
recipientPublicKey: toKey, // explicit key for peers outside fleet.json
|
|
261
448
|
resolveTimeoutMs: 5000,
|
|
262
449
|
});
|
|
263
450
|
console.log(`sent envelope ${envelope.id}`);
|
|
264
|
-
console.log(` to: ${recipient}`);
|
|
451
|
+
console.log(` to: ${recipient}${recipient !== recipientArg ? ` (${recipientArg})` : ""}`);
|
|
265
452
|
console.log(` kind: ${kind}`);
|
|
266
453
|
if (delivery?.peerId) console.log(` via peerId: ${delivery.peerId}`);
|
|
454
|
+
if (delivery?.carrier) console.log(` accepted by carrier ${delivery.carrier} (seq ${delivery.seq}) — that is the carrier's receipt, not the recipient's`);
|
|
455
|
+
if (args["wait-ack"] !== undefined) {
|
|
456
|
+
const secs = args["wait-ack"] === true ? 60 : Number(args["wait-ack"]);
|
|
457
|
+
if (!Number.isFinite(secs) || secs <= 0) { console.error("--wait-ack takes a number of seconds"); exit(2); }
|
|
458
|
+
const r = await waitForAck(p, envelope.id, recipient, Date.now() + secs * 1000);
|
|
459
|
+
if (r.acked) {
|
|
460
|
+
console.log(` acknowledged: signed by ${recipient} (${r.how}, ${r.at})`);
|
|
461
|
+
} else {
|
|
462
|
+
console.error(` no signed ack from ${recipient} within ${secs}s. The carrier holds the envelope; the recipient has not acknowledged it (acks are opt-in on their side: pillar receipts on).`);
|
|
463
|
+
exit(4);
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
} catch (e) {
|
|
467
|
+
if (/no public key known/.test(e.message)) { console.error(explainMissingKey(recipient, info)); exit(1); }
|
|
468
|
+
throw e;
|
|
267
469
|
} finally {
|
|
268
470
|
await p.stop();
|
|
269
471
|
}
|
|
270
472
|
}
|
|
271
473
|
|
|
474
|
+
/**
|
|
475
|
+
* Wait for the recipient's signed ack of `envelopeId` without consuming any
|
|
476
|
+
* other mail: the carriers are PEEKED (nothing stored, no cursor moved), so
|
|
477
|
+
* whoever reads this home next still sees everything as new. A row another
|
|
478
|
+
* process of this home already acked (it pulled the ack) counts too.
|
|
479
|
+
*/
|
|
480
|
+
async function waitForAck(p, envelopeId, recipient, deadlineMs) {
|
|
481
|
+
const acked = () => p.mailbox.getOutbound(envelopeId)?.recipient_ack_at ?? null;
|
|
482
|
+
if (acked()) return { acked: true, how: "already recorded", at: acked() };
|
|
483
|
+
if (!p.carrierClient) return { acked: false };
|
|
484
|
+
const hits = await p.peekCarrierInbox({
|
|
485
|
+
deadlineMs,
|
|
486
|
+
stopWhen: () => !!acked(),
|
|
487
|
+
match: (v) => receiptFor(v, envelopeId, recipient, { read: p.receipts.read }) !== null,
|
|
488
|
+
});
|
|
489
|
+
if (hits.length) {
|
|
490
|
+
const h = hits[0];
|
|
491
|
+
const said = receiptFor(h, envelopeId, recipient, { read: p.receipts.read });
|
|
492
|
+
if (said === "delivered") p.mailbox.markOutboundAcked(envelopeId, { from: recipient, ackId: h.id, at: h.createdAt });
|
|
493
|
+
else p.mailbox.markOutboundRead([envelopeId], { at: h.createdAt });
|
|
494
|
+
return { acked: true, how: said === "read" ? "read receipt" : "delivery ack", at: h.createdAt };
|
|
495
|
+
}
|
|
496
|
+
return acked() ? { acked: true, how: "recorded by another reader of this home", at: acked() } : { acked: false };
|
|
497
|
+
}
|
|
272
498
|
|
|
273
499
|
// ── delegate ──────────────────────────────────────────────────────────────────
|
|
274
500
|
// The desktop-link ceiling enforced by chat-web (browser-chat-session.ts,
|
|
@@ -388,42 +614,214 @@ async function cmdSendGroup(args) {
|
|
|
388
614
|
const [groupName, kind, jsonStr] = args._.slice(1);
|
|
389
615
|
if (!groupName || !kind) {
|
|
390
616
|
console.error("usage: pillar send-group <group> <kind> [json-payload] --fleet FLEET.json [--groups GROUPS.json]");
|
|
617
|
+
console.error(" (or keep the thread in this home: pillar group create / pillar group send)");
|
|
391
618
|
exit(2);
|
|
392
619
|
}
|
|
393
620
|
const { path: gPath, groups } = loadGroups(args);
|
|
394
621
|
const group = groups[groupName];
|
|
395
|
-
if (!group?.members?.length) {
|
|
396
|
-
console.error(`group "${groupName}" not found or empty (looked in ${gPath ?? "no groups file"})`);
|
|
397
|
-
exit(2);
|
|
398
|
-
}
|
|
399
622
|
const payload = jsonStr ? JSON.parse(jsonStr) : {};
|
|
400
623
|
const p = loadPillar(args);
|
|
401
624
|
await p.start();
|
|
402
|
-
|
|
625
|
+
loadContacts(args, p);
|
|
403
626
|
try {
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
627
|
+
let spec;
|
|
628
|
+
if (group?.members?.length) {
|
|
629
|
+
spec = { group: groupName, members: group.members };
|
|
630
|
+
} else {
|
|
631
|
+
let g;
|
|
632
|
+
try { g = findGroup(p.mailbox, groupName); }
|
|
633
|
+
catch (e) {
|
|
634
|
+
console.error(`group "${groupName}" not found or empty (looked in ${gPath ?? "no groups file"} and in this home's groups: ${e.message})`);
|
|
635
|
+
exit(2);
|
|
636
|
+
}
|
|
637
|
+
spec = { groupId: g.id };
|
|
638
|
+
}
|
|
639
|
+
const r = await p.sendGroup({ ...spec, kind, payload });
|
|
640
|
+
printGroupResults(r);
|
|
412
641
|
} finally {
|
|
413
642
|
await p.stop();
|
|
414
643
|
}
|
|
415
644
|
}
|
|
416
645
|
|
|
646
|
+
function printGroupResults({ group, groupId, msgId, results }) {
|
|
647
|
+
let failed = 0;
|
|
648
|
+
for (const r of results) {
|
|
649
|
+
if (r.ok) console.log(` ✓ ${r.member} via ${r.via} ${r.envelopeId}`);
|
|
650
|
+
else {
|
|
651
|
+
failed++;
|
|
652
|
+
const why = refusalLine(r) ?? (/no public key known/.test(r.error ?? "")
|
|
653
|
+
? `no public key known — add it to fleet.json, or ask a member to post in the group (its key hint is then learned)`
|
|
654
|
+
: r.error);
|
|
655
|
+
console.error(` ✗ ${r.member} ${why}`);
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
console.log(`group "${group}" ${groupId} msg ${msgId}: ${results.length - failed}/${results.length} accepted by carrier`);
|
|
659
|
+
if (failed) exit(1);
|
|
660
|
+
}
|
|
661
|
+
|
|
417
662
|
async function cmdGroups(args) {
|
|
663
|
+
const home = homePath(args);
|
|
664
|
+
const { Mailbox } = await import("../src/mailbox/mailbox.mjs");
|
|
665
|
+
const mine = existsSync(join(home, "mailbox.db")) ? (() => {
|
|
666
|
+
const mb = new Mailbox(join(home, "mailbox.db"));
|
|
667
|
+
try { return mb.listGroups(); } finally { mb.close(); }
|
|
668
|
+
})() : [];
|
|
418
669
|
const { path: gPath, groups } = loadGroups(args);
|
|
419
670
|
const names = Object.keys(groups);
|
|
420
|
-
if (!names.length) { console.log(`no groups
|
|
671
|
+
if (!mine.length && !names.length) { console.log(`no groups (create one: pillar group create <name> <member>...)`); return; }
|
|
672
|
+
for (const g of mine) {
|
|
673
|
+
const flags = [g.pending ? "PENDING invitation — pillar group accept " + g.id : null, g.left ? "left" : null].filter(Boolean);
|
|
674
|
+
console.log(`${g.id} ${g.name} (${g.members.length} members)${flags.length ? ` [${flags.join(", ")}]` : ""}`);
|
|
675
|
+
for (const m of g.members) console.log(` ${m}`);
|
|
676
|
+
}
|
|
421
677
|
for (const n of names) {
|
|
422
|
-
console.log(`${n} (${groups[n].members.length} members):`);
|
|
678
|
+
console.log(`${n} (${groups[n].members.length} members, from ${gPath}):`);
|
|
423
679
|
for (const m of groups[n].members) console.log(` ${m}`);
|
|
424
680
|
}
|
|
425
681
|
}
|
|
426
682
|
|
|
683
|
+
/**
|
|
684
|
+
* pillar group <create|send|add|rename|leave|accept|show> — group threads.
|
|
685
|
+
*
|
|
686
|
+
* The roster lives in this home (mailbox `groups`); every member keeps its
|
|
687
|
+
* own copy and applies the pillar-group v1 rules (src/groups.mjs). Members
|
|
688
|
+
* can be given as UUAIDs or as contact names from fleet.json.
|
|
689
|
+
*/
|
|
690
|
+
async function cmdGroup(args) {
|
|
691
|
+
const sub = args._[1];
|
|
692
|
+
const usage = () => {
|
|
693
|
+
console.error(`usage:
|
|
694
|
+
pillar group create <name> <member>... [--text TEXT]
|
|
695
|
+
pillar group send <group> <text> | --json '{...}' [--kind KIND]
|
|
696
|
+
pillar group add <group> <member>...
|
|
697
|
+
pillar group rename <group> <new name>
|
|
698
|
+
pillar group leave <group>
|
|
699
|
+
pillar group accept <group>
|
|
700
|
+
pillar group show <group> [--limit N]
|
|
701
|
+
<group> is a grp-… id or a unique group name; <member> is a uuaid or a fleet.json contact name.`);
|
|
702
|
+
exit(2);
|
|
703
|
+
};
|
|
704
|
+
if (!["create", "send", "add", "rename", "leave", "accept", "show"].includes(sub)) usage();
|
|
705
|
+
args["carrier-only"] = args["carrier-only"] ?? true;
|
|
706
|
+
const p = loadPillar(args);
|
|
707
|
+
await p.start();
|
|
708
|
+
const { info } = loadContacts(args, p);
|
|
709
|
+
const members = (list) => list.map((m) => {
|
|
710
|
+
try { return resolveRecipient(m, info).uuaid; }
|
|
711
|
+
catch (e) { console.error(e.message); exit(2); }
|
|
712
|
+
});
|
|
713
|
+
const group = (ref) => {
|
|
714
|
+
if (!ref) usage();
|
|
715
|
+
try { return findGroup(p.mailbox, ref); }
|
|
716
|
+
catch (e) { console.error(e.message); exit(2); }
|
|
717
|
+
};
|
|
718
|
+
try {
|
|
719
|
+
if (sub === "create") {
|
|
720
|
+
const [name, ...rest] = args._.slice(2);
|
|
721
|
+
if (!name || !rest.length) usage();
|
|
722
|
+
const roster = normalizeMembers([p.uuaid, ...members(rest)]);
|
|
723
|
+
const r = await p.sendGroup({ group: name, members: roster, payload: { text: typeof args.text === "string" ? args.text : "" }, op: "create" });
|
|
724
|
+
printGroupResults(r);
|
|
725
|
+
return;
|
|
726
|
+
}
|
|
727
|
+
if (sub === "send") {
|
|
728
|
+
const g = group(args._[2]);
|
|
729
|
+
let payload;
|
|
730
|
+
if (typeof args.json === "string") {
|
|
731
|
+
try { payload = JSON.parse(args.json); } catch (e) { console.error(`--json is not valid JSON: ${e.message}`); exit(2); }
|
|
732
|
+
} else {
|
|
733
|
+
const text = args._.slice(3).join(" ");
|
|
734
|
+
if (!text) usage();
|
|
735
|
+
payload = { text };
|
|
736
|
+
}
|
|
737
|
+
printGroupResults(await p.sendGroup({ groupId: g.id, kind: typeof args.kind === "string" ? args.kind : "message", payload }));
|
|
738
|
+
return;
|
|
739
|
+
}
|
|
740
|
+
if (sub === "add") {
|
|
741
|
+
const g = group(args._[2]);
|
|
742
|
+
const added = members(args._.slice(3)).filter((m) => !g.members.includes(m));
|
|
743
|
+
if (!added.length) { console.log(`nothing to add: every one of them is already in ${g.id}`); return; }
|
|
744
|
+
p.mailbox.putGroup({ ...g, members: [...g.members, ...added] });
|
|
745
|
+
printGroupResults(await p.sendGroup({ groupId: g.id, payload: { text: added.join(",") }, op: "add" }));
|
|
746
|
+
return;
|
|
747
|
+
}
|
|
748
|
+
if (sub === "rename") {
|
|
749
|
+
const g = group(args._[2]);
|
|
750
|
+
const name = args._.slice(3).join(" ").trim();
|
|
751
|
+
if (!name) usage();
|
|
752
|
+
p.mailbox.putGroup({ ...g, name });
|
|
753
|
+
printGroupResults(await p.sendGroup({ groupId: g.id, payload: { text: "" }, op: "rename" }));
|
|
754
|
+
return;
|
|
755
|
+
}
|
|
756
|
+
if (sub === "leave") {
|
|
757
|
+
const g = group(args._[2]);
|
|
758
|
+
if (g.left) { console.log(`already left ${g.id}`); return; }
|
|
759
|
+
if (!g.pending) {
|
|
760
|
+
const r = await p.sendGroup({ groupId: g.id, payload: { text: "" }, op: "leave" });
|
|
761
|
+
for (const x of r.results) if (!x.ok) console.error(` ✗ ${x.member} ${x.error}`);
|
|
762
|
+
}
|
|
763
|
+
p.mailbox.putGroup({ ...g, left: true, pending: false });
|
|
764
|
+
console.log(`left ${g.id} (${g.name}); its history stays in this home`);
|
|
765
|
+
return;
|
|
766
|
+
}
|
|
767
|
+
if (sub === "accept") {
|
|
768
|
+
const g = group(args._[2]);
|
|
769
|
+
if (!g.pending) { console.log(`${g.id} is not a pending invitation`); return; }
|
|
770
|
+
p.mailbox.putGroup({ ...g, pending: false });
|
|
771
|
+
console.log(`accepted ${g.id} (${g.name}), created by ${g.createdBy}`);
|
|
772
|
+
console.log(`note: messages from members who are not your contacts still follow trust.json (pillar trust allow <uuaid>)`);
|
|
773
|
+
return;
|
|
774
|
+
}
|
|
775
|
+
if (sub === "show") {
|
|
776
|
+
const g = group(args._[2]);
|
|
777
|
+
const limit = Number(args.limit ?? 30);
|
|
778
|
+
const { decrypt } = await import("../src/net/envelope.mjs");
|
|
779
|
+
// A local view: it never pulls from carriers (that would take "new" away
|
|
780
|
+
// from the next inbox run). Pull with pillar inbox first.
|
|
781
|
+
console.log(JSON.stringify({ group: g.id, name: g.name, members: g.members, pending: g.pending, left: g.left, createdBy: g.createdBy, view: "local — pull new mail with pillar inbox" }));
|
|
782
|
+
const items = [];
|
|
783
|
+
for (const row of p.mailbox.groupInbound(g.id, limit)) {
|
|
784
|
+
const { env, payload } = viewOfRow(p, row, decrypt);
|
|
785
|
+
const tag = extractGroupTag(payload);
|
|
786
|
+
items.push({ at: env.createdAt, line: { dir: "in", id: env.id, from: env.sender, kind: env.kind, msgId: tag?.msgId ?? null, op: tag?.op ?? "message", text: typeof payload?.text === "string" ? payload.text : undefined, payload: typeof payload?.text === "string" ? undefined : payload } });
|
|
787
|
+
}
|
|
788
|
+
// Our own messages: one line per group message (its fan-out copies
|
|
789
|
+
// collapsed by msgId). The outbox is sealed to each member, so we hold
|
|
790
|
+
// no plaintext for it — only who it went to and what they signed back.
|
|
791
|
+
const byMsg = new Map();
|
|
792
|
+
for (const row of p.mailbox.groupOutbound(g.id)) {
|
|
793
|
+
const k = row.group_msg_id ?? row.envelope_id;
|
|
794
|
+
const m = byMsg.get(k) ?? { at: row.created_at, to: 0, carrier: 0, acked: 0, read: 0 };
|
|
795
|
+
m.to++; if (row.status === "delivered") m.carrier++; if (row.recipient_ack_at) m.acked++; if (row.recipient_read_at) m.read++;
|
|
796
|
+
byMsg.set(k, m);
|
|
797
|
+
}
|
|
798
|
+
for (const [msgId, m] of byMsg) items.push({ at: m.at, line: { dir: "out", msgId, at: m.at, members: m.to, acceptedByCarrier: m.carrier, acknowledged: m.acked, read: m.read } });
|
|
799
|
+
items.sort((a, b) => (a.at < b.at ? -1 : a.at > b.at ? 1 : 0));
|
|
800
|
+
for (const it of items.slice(-limit)) console.log(JSON.stringify(it.line));
|
|
801
|
+
return;
|
|
802
|
+
}
|
|
803
|
+
} finally {
|
|
804
|
+
await p.stop();
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
// `pillar group` runs pillar-group/2 (src/group-v2-cli.mjs) for its own verbs, for
|
|
809
|
+
// create --v2, and for any group the v2 store holds or whose row the v2 sync marked
|
|
810
|
+
// (groupCommandRoute). Everything else runs cmdGroup above, unchanged.
|
|
811
|
+
function wantsGroupV2(args) {
|
|
812
|
+
const home = homePath(args);
|
|
813
|
+
const mbPath = join(home, "mailbox.db");
|
|
814
|
+
const mb = existsSync(mbPath) ? new Mailbox(mbPath) : null;
|
|
815
|
+
try { return groupCommandRoute(args, groupRouteLookups(new RosterStore(home), mb)) === "v2"; }
|
|
816
|
+
finally { mb?.close(); }
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
async function cmdGroupV2(args) {
|
|
820
|
+
args["carrier-only"] = args["carrier-only"] ?? true;
|
|
821
|
+
const code = await runGroupV2(args, { home: homePath(args), openPillar: async () => loadPillar(args) });
|
|
822
|
+
if (code) exit(code);
|
|
823
|
+
}
|
|
824
|
+
|
|
427
825
|
// Extract human-readable text from a zestro command_result. The server-side
|
|
428
826
|
// payload fields are unverified — defensively try the common shapes, cap at
|
|
429
827
|
// 8192 bytes (pillar-responder's MAX_REPLY_BYTES convention).
|
|
@@ -438,10 +836,29 @@ function replyTextFromResult(result) {
|
|
|
438
836
|
}
|
|
439
837
|
|
|
440
838
|
async function cmdRecv(args) {
|
|
441
|
-
const
|
|
839
|
+
const rk = readerFor(args);
|
|
840
|
+
const ctx = processContext({ command: rk.named ? `pillar recv --reader ${rk.key}` : "pillar recv" });
|
|
841
|
+
// A daemon with no contacts quarantines every fleet message under an
|
|
842
|
+
// allowlist or directory policy and moves the carrier cursor past it. A
|
|
843
|
+
// contacts file that exists but yields nothing at all stops recv instead.
|
|
844
|
+
const fleetInfo = inspectFleetFile(fleetPath(args));
|
|
845
|
+
if (fleetInfo.error) { console.error(`recv: ${fleetInfo.error}`); exit(1); }
|
|
846
|
+
const p = loadPillar(args, { reader: rk.key });
|
|
442
847
|
await p.start();
|
|
443
848
|
writeRuntimeFile(p, homePath(args)); // warm handoff: publish live addr for senders
|
|
444
|
-
const n =
|
|
849
|
+
const { n } = loadContacts(args, p);
|
|
850
|
+
// Lease: other readers of this home are told a live recv is pulling mail.
|
|
851
|
+
warnAboutLiveReaders(p, rk, ctx);
|
|
852
|
+
const prevReader = p.mailbox.getReader(rk.key);
|
|
853
|
+
// A NEW named reader of a bridged recv starts at the tip: its unread
|
|
854
|
+
// backlog would otherwise reach the bridge as fresh agent turns.
|
|
855
|
+
const bridged = !!(args["zestro-bridge"] || process.env.PILLAR_ZESTRO_URL);
|
|
856
|
+
const start = bridged ? p.mailbox.inboxTipSeq() : initialCursor(p.mailbox);
|
|
857
|
+
p.mailbox.touchReader(rk.key, { ...ctx, cursor: prevReader ? 0 : start });
|
|
858
|
+
const lease = () => p.mailbox.holdLease(rk.key, { ...ctx, ttlMs: LEASE_TTL_MS });
|
|
859
|
+
lease();
|
|
860
|
+
const heartbeat = setInterval(() => { try { lease(); } catch (_e) {} }, LEASE_HEARTBEAT_MS);
|
|
861
|
+
heartbeat.unref?.();
|
|
445
862
|
console.log(`listening as ${p.uuaid}`);
|
|
446
863
|
if (p.node) {
|
|
447
864
|
console.log(`peerId ${p.node.peerId.toString()}`);
|
|
@@ -472,9 +889,14 @@ async function cmdRecv(args) {
|
|
|
472
889
|
console.log(`zestro bridge connected to ${bridge.url.replace(/\/[0-9a-f-]{36}\/ws$/, "/<token>/ws")}`);
|
|
473
890
|
}
|
|
474
891
|
|
|
475
|
-
|
|
892
|
+
// With delivery acks on, the library already acked every envelope; the
|
|
893
|
+
// bridge must not send a second ack for a request.
|
|
894
|
+
const autoAcks = p.receipts.delivered;
|
|
895
|
+
const handle = async (env) => {
|
|
476
896
|
const line = { at: new Date().toISOString(), from: env.sender, kind: env.kind, payload: env.payload };
|
|
477
|
-
|
|
897
|
+
const tag = extractGroupTag(env.payload);
|
|
898
|
+
if (tag) { line.group = tag.name; line.groupId = tag.id; if (tag.msgId) line.msgId = tag.msgId; if (tag.op !== "message") line.op = tag.op; }
|
|
899
|
+
if (rk.named) line.id = env.id;
|
|
478
900
|
console.log(JSON.stringify(line));
|
|
479
901
|
if (bridge && env.kind === "request") {
|
|
480
902
|
// Interaction-convention return leg: ack FIRST — pillar-ask allows 120s
|
|
@@ -489,7 +911,7 @@ async function cmdRecv(args) {
|
|
|
489
911
|
const thread = env.payload?.thread ?? env.id;
|
|
490
912
|
const senderKey = env.transportSignature.publicKey; // open() F1: sender derives from this key
|
|
491
913
|
try {
|
|
492
|
-
await p.send({ recipient: env.sender, recipientPublicKey: senderKey, kind: "ack", payload: { inReplyTo: env.id } });
|
|
914
|
+
if (!autoAcks) await p.send({ recipient: env.sender, recipientPublicKey: senderKey, kind: "ack", payload: { inReplyTo: env.id } });
|
|
493
915
|
const result = await bridge.sendCommand({ command: JSON.stringify({ kind: env.kind, payload: env.payload, sender: env.sender }) });
|
|
494
916
|
const hasText = result && typeof result === "object" &&
|
|
495
917
|
["text", "result", "output", "response", "message"].some(k => k in result);
|
|
@@ -532,9 +954,46 @@ async function cmdRecv(args) {
|
|
|
532
954
|
// inject a Zestro agent turn per receipt. Print-only (line above).
|
|
533
955
|
console.log(` → zestro: not bridged (kind=${env.kind} is a receipt)`);
|
|
534
956
|
}
|
|
535
|
-
}
|
|
957
|
+
};
|
|
536
958
|
|
|
537
|
-
|
|
959
|
+
let tail = null;
|
|
960
|
+
if (rk.named) {
|
|
961
|
+
// A named reader follows its own cursor over the LOCAL inbox, so it also
|
|
962
|
+
// sees rows another process of this home pulled first. Rows are handled
|
|
963
|
+
// strictly in order, at most once: the cursor moves past a row whose
|
|
964
|
+
// handler threw (handleInOrder), as an unnamed recv's handlers do.
|
|
965
|
+
const { decrypt } = await import("../src/net/envelope.mjs");
|
|
966
|
+
let busy = false;
|
|
967
|
+
const drainLocal = async () => {
|
|
968
|
+
if (busy) return;
|
|
969
|
+
busy = true;
|
|
970
|
+
try {
|
|
971
|
+
const cursor = p.mailbox.getReader(rk.key)?.cursor ?? 0;
|
|
972
|
+
await handleInOrder(p.mailbox.inboxAfter(cursor, 200), async (row) => {
|
|
973
|
+
const { env, payload } = viewOfRow(p, row, decrypt);
|
|
974
|
+
await handle({ ...env, payload });
|
|
975
|
+
}, {
|
|
976
|
+
advance: (row) => p.mailbox.setReaderCursor(rk.key, row.seq),
|
|
977
|
+
onError: (row, e) => console.error(`reader ${rk.key}: row ${row.seq} not handled, not retried: ${e.message}`),
|
|
978
|
+
});
|
|
979
|
+
} finally { busy = false; }
|
|
980
|
+
};
|
|
981
|
+
p.onEnvelope(() => drainLocal());
|
|
982
|
+
tail = setInterval(() => { drainLocal().catch((e) => console.error(`reader ${rk.key}: ${e.message}`)); }, 1000);
|
|
983
|
+
await drainLocal();
|
|
984
|
+
console.log(`reader "${rk.key}": cursor ${p.mailbox.getReader(rk.key)?.cursor ?? 0}`);
|
|
985
|
+
} else {
|
|
986
|
+
p.onEnvelope(handle);
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
const shutdown = async () => {
|
|
990
|
+
clearInterval(heartbeat);
|
|
991
|
+
if (tail) clearInterval(tail);
|
|
992
|
+
try { p.mailbox.releaseLease(rk.key, ctx); } catch (_e) {}
|
|
993
|
+
if (bridge) bridge.close();
|
|
994
|
+
await p.stop();
|
|
995
|
+
exit(0);
|
|
996
|
+
};
|
|
538
997
|
process.on("SIGINT", shutdown);
|
|
539
998
|
process.on("SIGTERM", shutdown);
|
|
540
999
|
await new Promise(() => {});
|
|
@@ -545,47 +1004,99 @@ async function cmdInbox(args) {
|
|
|
545
1004
|
// minted the uuaid. Pulls pending envelopes from the carrier (no long-poll),
|
|
546
1005
|
// prints them decrypted, reports unread backlog, exits. --notify CMD runs a
|
|
547
1006
|
// shell hook when there is mail (cron/launchd-friendly); --mark-read stamps
|
|
548
|
-
// read_at on everything printed.
|
|
1007
|
+
// read_at on everything printed (and sends read receipts when they are on).
|
|
1008
|
+
//
|
|
1009
|
+
// Readers. Without --reader the output is exactly what it always was: "new"
|
|
1010
|
+
// is what THIS run pulled. With --reader NAME (or PILLAR_READER) this reader
|
|
1011
|
+
// has its own cursor over the local inbox and "new" is every row after it,
|
|
1012
|
+
// whoever pulled it, so several processes can share one home safely.
|
|
549
1013
|
args["carrier-only"] = true; // inbox check needs the carrier only — no libp2p listener
|
|
550
|
-
const
|
|
1014
|
+
const rk = readerFor(args);
|
|
1015
|
+
const ctx = processContext({ command: `pillar inbox${rk.named ? ` --reader ${rk.key}` : ""}` });
|
|
1016
|
+
const p = loadPillar(args, { reader: rk.key });
|
|
551
1017
|
const fresh = [];
|
|
552
1018
|
p.onEnvelope((env) => { fresh.push(env); });
|
|
553
1019
|
await p.start();
|
|
554
1020
|
// Address book BEFORE the drain: the trust "directory" policy needs known
|
|
555
1021
|
// senders loaded, or every fresh envelope counts as a stranger.
|
|
556
|
-
|
|
1022
|
+
loadContacts(args, p);
|
|
1023
|
+
warnAboutLiveReaders(p, rk, ctx, { showsUnread: !!args.unread });
|
|
1024
|
+
const prev = p.mailbox.getReader(rk.key);
|
|
1025
|
+
// A new named reader starts before the oldest unread row, computed BEFORE
|
|
1026
|
+
// this run's pull so that what it pulls is new to it too.
|
|
1027
|
+
const startCursor = prev ? prev.cursor : (rk.named ? initialCursor(p.mailbox) : p.mailbox.inboxTipSeq());
|
|
1028
|
+
p.mailbox.touchReader(rk.key, { ...ctx, cursor: startCursor });
|
|
557
1029
|
const pulled = await p.drainCarrierInbox();
|
|
558
|
-
// fromPublicKey: the sender's verified Ed25519 key (transportSignature —
|
|
559
|
-
// open() binds it to `from`). Lets a drain-and-reply consumer (the fleet
|
|
560
|
-
// courier's auto-ack, a bot) seal back to a sender who is NOT in fleet.json
|
|
561
|
-
// via `pillar send … --to-pubkey <fromPublicKey>`. Additive field.
|
|
562
|
-
for (const env of fresh) {
|
|
563
|
-
console.log(JSON.stringify({ new: true, id: env.id, from: env.sender, fromPublicKey: env.transportSignature?.publicKey ?? null, kind: env.kind, at: env.createdAt, payload: env.payload }));
|
|
564
|
-
}
|
|
565
|
-
// Backlog: rows stored earlier (e.g. by a recv daemon) but never surfaced.
|
|
566
1030
|
const { decrypt } = await import("../src/net/envelope.mjs");
|
|
567
|
-
const
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
console.log(JSON.stringify(
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
1031
|
+
const printed = []; // { id, sender, kind, payload, groupId } — for read receipts
|
|
1032
|
+
|
|
1033
|
+
let backlog = [];
|
|
1034
|
+
if (rk.named) {
|
|
1035
|
+
const rows = p.mailbox.inboxAfter(startCursor, 1000);
|
|
1036
|
+
for (const row of rows) {
|
|
1037
|
+
const { env, payload, groupId } = viewOfRow(p, row, decrypt);
|
|
1038
|
+
console.log(JSON.stringify(inboxLine(env, payload, { new: true })));
|
|
1039
|
+
printed.push({ id: env.id, sender: env.sender, kind: env.kind, payload, groupId });
|
|
1040
|
+
}
|
|
1041
|
+
if (rows.length && !args.peek) p.mailbox.setReaderCursor(rk.key, rows.at(-1).seq);
|
|
1042
|
+
const qCount = (p.mailbox.inboxQuarantined?.(500) ?? []).length;
|
|
1043
|
+
console.log(`inbox: ${rows.length} new for reader "${rk.key}" (${pulled} pulled from carriers by this run)${args.peek ? ", cursor not moved (--peek)" : ""}${qCount ? `, ${qCount} quarantined (pillar trust quarantine)` : ""}`);
|
|
1044
|
+
} else {
|
|
1045
|
+
// fromPublicKey: the sender's verified Ed25519 key (transportSignature —
|
|
1046
|
+
// open() binds it to `from`). Lets a drain-and-reply consumer (the fleet
|
|
1047
|
+
// courier's auto-ack, a bot) seal back to a sender who is NOT in fleet.json
|
|
1048
|
+
// via `pillar send … --to-pubkey <fromPublicKey>`. Additive field.
|
|
1049
|
+
for (const env of fresh) {
|
|
1050
|
+
console.log(JSON.stringify(inboxLine(env, env.payload, { new: true })));
|
|
1051
|
+
printed.push({ id: env.id, sender: env.sender, kind: env.kind, payload: env.payload, groupId: extractGroupTag(env.payload)?.id ?? null });
|
|
1052
|
+
}
|
|
1053
|
+
// Backlog: rows stored earlier (e.g. by a recv daemon) but never surfaced.
|
|
1054
|
+
const freshIds = new Set(fresh.map(e => e.id));
|
|
1055
|
+
backlog = (p.mailbox.inboxUnread?.(500) ?? []).filter(r => !freshIds.has(r.envelope_id));
|
|
1056
|
+
if (args.unread) {
|
|
1057
|
+
for (const row of backlog) {
|
|
1058
|
+
const { env, payload, groupId } = viewOfRow(p, row, decrypt);
|
|
1059
|
+
console.log(JSON.stringify(inboxLine(env, payload, { new: false })));
|
|
1060
|
+
printed.push({ id: env.id, sender: env.sender, kind: env.kind, payload, groupId });
|
|
1061
|
+
}
|
|
1062
|
+
}
|
|
1063
|
+
// Mail another process of this home pulled since this reader last looked:
|
|
1064
|
+
// it is not "new" here, and with --mark-read over there it is not even
|
|
1065
|
+
// unread. Say so, and who took it, instead of letting it vanish.
|
|
1066
|
+
// With --unread the unread ones were just printed, so only the ones some
|
|
1067
|
+
// other reader already marked read are missing.
|
|
1068
|
+
if (prev) {
|
|
1069
|
+
const taken = p.mailbox.inboxAfter(prev.cursor, 1000)
|
|
1070
|
+
.filter((r) => !freshIds.has(r.envelope_id) && r.pulled_by !== rk.key && (!args.unread || r.read_at));
|
|
1071
|
+
if (taken.length) {
|
|
1072
|
+
const by = new Map();
|
|
1073
|
+
for (const r of taken) by.set(r.pulled_by ?? null, (by.get(r.pulled_by ?? null) ?? 0) + 1);
|
|
1074
|
+
const readAlready = taken.filter((r) => r.read_at).length;
|
|
1075
|
+
for (const [who, count] of by) {
|
|
1076
|
+
console.error(`warning: ${count} envelope(s) reached this home since this reader last checked, pulled by ${describeReader(p.mailbox, who)}; they are not listed as new here.`);
|
|
1077
|
+
}
|
|
1078
|
+
const how = args.unread
|
|
1079
|
+
? "Give each reader its own cursor with --reader NAME (or PILLAR_READER)."
|
|
1080
|
+
: "Print them with --unread, or give each reader its own cursor with --reader NAME (or PILLAR_READER).";
|
|
1081
|
+
console.error(` ${readAlready ? `${readAlready} of them are already marked read (only --reader shows those). ` : ""}${how}`);
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
p.mailbox.setReaderCursor(rk.key, p.mailbox.inboxTipSeq());
|
|
1085
|
+
const qCount = (p.mailbox.inboxQuarantined?.(500) ?? []).length;
|
|
1086
|
+
console.log(`inbox: ${fresh.length} new, ${backlog.length} older unread${args.unread ? "" : " (--unread to print)"}${qCount ? `, ${qCount} quarantined (pillar trust quarantine)` : ""}`);
|
|
1087
|
+
}
|
|
579
1088
|
if (args["mark-read"]) {
|
|
580
|
-
for (const
|
|
581
|
-
|
|
1089
|
+
for (const v of printed) p.mailbox.markInboxRead?.(v.id);
|
|
1090
|
+
const r = await p.sendReadReceipts(printed);
|
|
1091
|
+
if (r.sent || r.dropped) console.error(`read receipts: ${r.sent} sent${r.dropped ? `, ${r.dropped} not sent (per-run cap)` : ""}`);
|
|
582
1092
|
}
|
|
583
|
-
|
|
1093
|
+
const newCount = rk.named ? printed.length : pulled;
|
|
1094
|
+
if (args.notify && typeof args.notify === "string" && (newCount > 0 || backlog.length > 0)) {
|
|
584
1095
|
const { spawnSync } = await import("node:child_process");
|
|
585
1096
|
const r = spawnSync(args.notify, {
|
|
586
1097
|
shell: true,
|
|
587
|
-
input: JSON.stringify({ uuaid: p.uuaid, new:
|
|
588
|
-
env: { ...process.env, PILLAR_NEW: String(
|
|
1098
|
+
input: JSON.stringify({ uuaid: p.uuaid, new: newCount, unread: backlog.length }) + "\n",
|
|
1099
|
+
env: { ...process.env, PILLAR_NEW: String(newCount), PILLAR_UNREAD: String(backlog.length), PILLAR_UUAID: p.uuaid },
|
|
589
1100
|
stdio: ["pipe", "inherit", "inherit"],
|
|
590
1101
|
});
|
|
591
1102
|
if (r.status !== 0) console.error(`notify hook exited ${r.status}`);
|
|
@@ -594,6 +1105,142 @@ async function cmdInbox(args) {
|
|
|
594
1105
|
exit(0);
|
|
595
1106
|
}
|
|
596
1107
|
|
|
1108
|
+
/**
|
|
1109
|
+
* pillar wait — sleep until there is mail, then exit 0 with the count.
|
|
1110
|
+
*
|
|
1111
|
+
* The wake signal for homes that cannot take a push (no open ports): one
|
|
1112
|
+
* outbound long-poll per carrier at zero CPU, woken by the carrier the moment
|
|
1113
|
+
* mail lands. It only PEEKS — nothing is stored, no cursor moves — so the
|
|
1114
|
+
* reader run it wakes still sees every message as new:
|
|
1115
|
+
*
|
|
1116
|
+
* while pillar wait --reader bot; do pillar inbox --reader bot --mark-read | my-agent; done
|
|
1117
|
+
*
|
|
1118
|
+
* Counts only mail that would reach the reader: quarantine-bound envelopes
|
|
1119
|
+
* (trust.json) never wake anyone. With --reader, rows another process already
|
|
1120
|
+
* pulled into this home after the reader's cursor count too.
|
|
1121
|
+
*
|
|
1122
|
+
* Prints {"uuaid","pending","carrier","local","kinds"}; exit 0 mail, 3 timeout.
|
|
1123
|
+
*/
|
|
1124
|
+
async function cmdWait(args) {
|
|
1125
|
+
args["carrier-only"] = true;
|
|
1126
|
+
const rk = readerFor(args);
|
|
1127
|
+
const ctx = processContext({ command: `pillar wait${rk.named ? ` --reader ${rk.key}` : ""}` });
|
|
1128
|
+
const p = loadPillar(args, { reader: rk.key });
|
|
1129
|
+
await p.start();
|
|
1130
|
+
loadContacts(args, p);
|
|
1131
|
+
warnAboutLiveReaders(p, rk, ctx);
|
|
1132
|
+
const timeoutS = args.timeout !== undefined ? Number(args.timeout) : null;
|
|
1133
|
+
if (timeoutS !== null && (!Number.isFinite(timeoutS) || timeoutS < 0)) { console.error("--timeout takes a number of seconds"); exit(2); }
|
|
1134
|
+
const deadlineMs = timeoutS === null ? null : Date.now() + timeoutS * 1000;
|
|
1135
|
+
const cursor = () => {
|
|
1136
|
+
const r = p.mailbox.getReader(rk.key);
|
|
1137
|
+
return r ? r.cursor : initialCursor(p.mailbox);
|
|
1138
|
+
};
|
|
1139
|
+
const localRows = () => (rk.named ? p.mailbox.inboxAfter(cursor(), 1000) : []);
|
|
1140
|
+
const report = (carrierViews, local) => {
|
|
1141
|
+
const kinds = {};
|
|
1142
|
+
for (const k of [...carrierViews.map((v) => v.kind), ...local.map((r) => JSON.parse(r.envelope_json).kind)]) kinds[k] = (kinds[k] ?? 0) + 1;
|
|
1143
|
+
console.log(JSON.stringify({ uuaid: p.uuaid, ...(rk.named ? { reader: rk.key } : {}), pending: carrierViews.length + local.length, carrier: carrierViews.length, local: local.length, kinds }));
|
|
1144
|
+
};
|
|
1145
|
+
let local = localRows();
|
|
1146
|
+
if (local.length) { report([], local); await p.stop(); exit(0); }
|
|
1147
|
+
if (!p.carrierClient) { console.error("wait: this home has no carriers configured (pillar init --refresh --directory <url>)"); exit(2); }
|
|
1148
|
+
const views = await p.peekCarrierInbox({ deadlineMs, stopWhen: () => localRows().length > 0 });
|
|
1149
|
+
local = localRows();
|
|
1150
|
+
await p.stop();
|
|
1151
|
+
if (views.length || local.length) { report(views, local); exit(0); }
|
|
1152
|
+
console.log(JSON.stringify({ uuaid: p.uuaid, pending: 0, timedOut: true }));
|
|
1153
|
+
exit(3);
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
/** pillar readers — every reader of this home, its cursor, and live leases. */
|
|
1157
|
+
async function cmdReaders(args) {
|
|
1158
|
+
const home = homePath(args);
|
|
1159
|
+
const { Mailbox } = await import("../src/mailbox/mailbox.mjs");
|
|
1160
|
+
if (!existsSync(join(home, "mailbox.db"))) { console.log(`no mailbox at ${home} yet`); return; }
|
|
1161
|
+
const mb = new Mailbox(join(home, "mailbox.db"));
|
|
1162
|
+
try {
|
|
1163
|
+
const tip = mb.inboxTipSeq();
|
|
1164
|
+
const live = otherLiveLeases(mb);
|
|
1165
|
+
const readers = mb.listReaders();
|
|
1166
|
+
if (!readers.length) console.log("no readers recorded yet");
|
|
1167
|
+
for (const r of readers) {
|
|
1168
|
+
const behind = mb.inboxAfter(r.cursor, 100000).length;
|
|
1169
|
+
const leases = live.filter((l) => l.reader === r.name);
|
|
1170
|
+
console.log(JSON.stringify({
|
|
1171
|
+
reader: r.name, named: !r.name.startsWith("ctx:"), cursor: r.cursor, unseen: behind,
|
|
1172
|
+
last: describeProcess(r), live: leases.map(describeProcess),
|
|
1173
|
+
}));
|
|
1174
|
+
}
|
|
1175
|
+
console.log(`inbox tip seq ${tip}; ${live.length} live lease(s)`);
|
|
1176
|
+
} finally { mb.close(); }
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
/**
|
|
1180
|
+
* pillar status [envelope-id] [--limit N] [--wait S]
|
|
1181
|
+
*
|
|
1182
|
+
* The outbox as the sender can prove it: whether a carrier accepted each
|
|
1183
|
+
* envelope, and whether the RECIPIENT'S KEY signed an ack or a read receipt
|
|
1184
|
+
* for it. A carrier receipt is not delivery; a signed ack is the recipient's
|
|
1185
|
+
* mailbox saying it stored the envelope; neither says anyone acted on it.
|
|
1186
|
+
*/
|
|
1187
|
+
async function cmdStatus(args) {
|
|
1188
|
+
const id = args._[1];
|
|
1189
|
+
args["carrier-only"] = true;
|
|
1190
|
+
const p = loadPillar(args);
|
|
1191
|
+
await p.start();
|
|
1192
|
+
try {
|
|
1193
|
+
if (id && args.wait !== undefined) {
|
|
1194
|
+
const row = p.mailbox.getOutbound(id);
|
|
1195
|
+
if (!row) { console.error(`no envelope ${id} in this home's outbox`); exit(2); }
|
|
1196
|
+
loadContacts(args, p);
|
|
1197
|
+
const secs = args.wait === true ? 60 : Number(args.wait);
|
|
1198
|
+
if (!Number.isFinite(secs) || secs <= 0) { console.error("--wait takes a number of seconds"); exit(2); }
|
|
1199
|
+
await waitForAck(p, id, row.recipient, Date.now() + secs * 1000);
|
|
1200
|
+
}
|
|
1201
|
+
const rows = id ? [p.mailbox.getOutbound(id)].filter(Boolean) : p.mailbox.outboxRecent(Number(args.limit ?? 20));
|
|
1202
|
+
if (id && !rows.length) { console.error(`no envelope ${id} in this home's outbox`); exit(2); }
|
|
1203
|
+
for (const r of rows) {
|
|
1204
|
+
console.log(JSON.stringify({
|
|
1205
|
+
id: r.envelope_id, to: r.recipient, kind: r.kind ?? null, at: r.created_at,
|
|
1206
|
+
...(r.group_id ? { groupId: r.group_id, msgId: r.group_msg_id ?? null } : {}),
|
|
1207
|
+
acceptedByCarrier: r.status === "delivered" ? (r.delivered_at ?? true) : null,
|
|
1208
|
+
acknowledged: r.recipient_ack_at ?? null,
|
|
1209
|
+
read: r.recipient_read_at ?? null,
|
|
1210
|
+
status: describeOutboundStatus(r),
|
|
1211
|
+
}));
|
|
1212
|
+
}
|
|
1213
|
+
if (id && args.wait !== undefined && !rows[0].recipient_ack_at) exit(4);
|
|
1214
|
+
} finally {
|
|
1215
|
+
await p.stop();
|
|
1216
|
+
}
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
/** pillar receipts [on|off|delivered on|off|read on|off] — config.json `receipts`. */
|
|
1220
|
+
async function cmdReceipts(args) {
|
|
1221
|
+
const home = homePath(args);
|
|
1222
|
+
const cfgPath = join(home, "config.json");
|
|
1223
|
+
if (!existsSync(join(home, "keychain.json"))) { console.error(`no pillar found at ${home}. run: pillar init`); exit(2); }
|
|
1224
|
+
const cfg = readConfig(home);
|
|
1225
|
+
const r = { delivered: !!cfg.receipts?.delivered, read: !!cfg.receipts?.read };
|
|
1226
|
+
const [a, b] = args._.slice(1);
|
|
1227
|
+
const onoff = (v) => (v === "on" ? true : v === "off" ? false : null);
|
|
1228
|
+
if (a === undefined) {
|
|
1229
|
+
// show only
|
|
1230
|
+
} else if (onoff(a) !== null && b === undefined) {
|
|
1231
|
+
r.delivered = r.read = onoff(a);
|
|
1232
|
+
} else if ((a === "delivered" || a === "read") && onoff(b) !== null) {
|
|
1233
|
+
r[a] = onoff(b);
|
|
1234
|
+
} else {
|
|
1235
|
+
console.error("usage: pillar receipts [on|off] | [delivered on|off] | [read on|off]");
|
|
1236
|
+
exit(2);
|
|
1237
|
+
}
|
|
1238
|
+
if (a !== undefined) { cfg.receipts = r; writeFileSync(cfgPath, JSON.stringify(cfg, null, 2)); }
|
|
1239
|
+
console.log(`delivery acks: ${r.delivered ? "on" : "off"} read receipts: ${r.read ? "on" : "off"} (${cfgPath})`);
|
|
1240
|
+
console.log(`sent only to contacts (fleet.json, trust allow, members of groups you accepted); read receipts go out on --mark-read.`);
|
|
1241
|
+
console.log(`read receipts are symmetric: with them off, incoming read receipts are ignored too. Delivery acks from others are always recorded.`);
|
|
1242
|
+
}
|
|
1243
|
+
|
|
597
1244
|
async function cmdTrust(args) {
|
|
598
1245
|
// pillar trust show policy + lists + quarantine count
|
|
599
1246
|
// pillar trust set <open|directory|allowlist>
|
|
@@ -756,8 +1403,15 @@ async function main() {
|
|
|
756
1403
|
case "tier": return cmdTier(args);
|
|
757
1404
|
case "peers": return cmdPeers(args);
|
|
758
1405
|
case "delegate": return cmdDelegate(args);
|
|
1406
|
+
case "wait": return cmdWait(args);
|
|
1407
|
+
case "readers": return cmdReaders(args);
|
|
1408
|
+
case "status": return cmdStatus(args);
|
|
1409
|
+
case "receipts": return cmdReceipts(args);
|
|
1410
|
+
case "group":
|
|
1411
|
+
if (wantsGroupV2(args)) return cmdGroupV2(args); // @rule P-cli-dispatch-v2
|
|
1412
|
+
return cmdGroup(args);
|
|
759
1413
|
default:
|
|
760
|
-
console.error(`usage: pillar <init|id|send|send-group|groups|recv|inbox|trust|tier|peers|delegate> [--home DIR]`);
|
|
1414
|
+
console.error(`usage: pillar <init|id|send|send-group|group|groups|recv|inbox|wait|readers|status|receipts|trust|tier|peers|delegate> [--home DIR]`);
|
|
761
1415
|
exit(2);
|
|
762
1416
|
}
|
|
763
1417
|
}
|