@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
|
@@ -0,0 +1,1108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `pillar-group` v2 — signed group rosters (docs/designs/pillar-group-v2.md
|
|
3
|
+
* rev 4). An independent reference: written from the spec and the shared
|
|
4
|
+
* vectors' README, never from another implementation.
|
|
5
|
+
*
|
|
6
|
+
* manifests canonical bytes, hashes, strict Ed25519, the closed schema,
|
|
7
|
+
* the genesis and version n+1 checks in the vectors' check
|
|
8
|
+
* order (it decides the reason code)
|
|
9
|
+
* receiver GroupReceiver: §5.0 unknown groups (invitation, recovery),
|
|
10
|
+
* §5.1 carried manifests, §5.2 catch-up holds, §5.3 accept,
|
|
11
|
+
* §5.4 equivocation, §5.5 removal
|
|
12
|
+
* store pillar-group-store/2: <home>/groups-v2/grp-<32 hex>.json
|
|
13
|
+
* mailbox after every verified write, the mailbox `groups` row of a v2
|
|
14
|
+
* group lists the active members of the verified head (minus
|
|
15
|
+
* anyone leaving); a frozen group or one that removed us is
|
|
16
|
+
* cleared. An egress filter reads that column (§8).
|
|
17
|
+
* sender recipients and the ⌊120/(N−1)⌋ budget (§6). No send path
|
|
18
|
+
* reads this store: sends still go through the mailbox roster.
|
|
19
|
+
*
|
|
20
|
+
* Every receiver and manifest rule carries an `@rule <id>` marker on the one
|
|
21
|
+
* line that enforces it; test/mutants/run.mjs deletes that line in a copy
|
|
22
|
+
* and checks that the rule's vectors go red.
|
|
23
|
+
*/
|
|
24
|
+
import { createHash, randomBytes, sign as edSign, verify as edVerify, createPublicKey } from "node:crypto";
|
|
25
|
+
import {
|
|
26
|
+
mkdirSync, writeFileSync, readFileSync, readdirSync, existsSync, renameSync, chmodSync, openSync, fsyncSync, closeSync,
|
|
27
|
+
} from "node:fs";
|
|
28
|
+
import { join } from "node:path";
|
|
29
|
+
import { ed25519 } from "@noble/curves/ed25519.js";
|
|
30
|
+
import { keyDerivedUuaidFor, keyDerivesUuaid, isStrictEd25519PublicKey } from "./identity/keychain.mjs";
|
|
31
|
+
import { findGroup } from "./groups.mjs";
|
|
32
|
+
|
|
33
|
+
export const MANIFEST_V = "pillar-group-manifest/2";
|
|
34
|
+
export const STORE_V = "pillar-group-store/2";
|
|
35
|
+
export const MAX_MEMBERS = 256;
|
|
36
|
+
export const MAX_NAME = 100; // code points
|
|
37
|
+
export const MAX_PURPOSE = 2000; // code points
|
|
38
|
+
export const MAX_MANIFEST_BYTES = 512 * 1024;
|
|
39
|
+
export const MAX_ANSWER = 64; // manifests per manifest.request answer
|
|
40
|
+
export const SEEN_CAP = 10_000; // accepted (sender, msgId) pairs kept per group; the oldest go first
|
|
41
|
+
export const COMMUNITY_SEND_BUDGET = 120; // envelopes per minute per sender
|
|
42
|
+
export const DEFAULT_LIMITS = Object.freeze({ holdPerSender: 20, holdPerGroup: 100, holdMs: 600_000 });
|
|
43
|
+
export const ACTIONS = Object.freeze(["create", "invite", "accept", "leave", "remove", "rename", "ext_update", "admin_transfer"]);
|
|
44
|
+
export const V2_OPS = Object.freeze([
|
|
45
|
+
"message", "manifest", "accept.request", "leave.request", "rename.request", "manifest.request", "equivocation",
|
|
46
|
+
]);
|
|
47
|
+
|
|
48
|
+
const ID_RE = /^grp-[0-9a-f]{32}$/;
|
|
49
|
+
const UUAID_RE = /^uuaid:[a-z0-9-]+:[a-z0-9-]+:[a-z0-9][a-z0-9-]*$/;
|
|
50
|
+
const HEX64 = /^[0-9a-f]{64}$/;
|
|
51
|
+
const HEX128 = /^[0-9a-f]{128}$/;
|
|
52
|
+
const ISSUED_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{3})?Z$/;
|
|
53
|
+
const MSGID_RE = /^gm-[0-9a-f]{16,128}$/;
|
|
54
|
+
const ZERO_HASH = "0".repeat(64);
|
|
55
|
+
const SPKI_PREFIX = Buffer.from("302a300506032b6570032100", "hex");
|
|
56
|
+
|
|
57
|
+
const TOP_KEYS = ["v", "id", "version", "prev", "epoch", "name", "purpose", "members", "transition", "ext", "issuedAt", "sig"];
|
|
58
|
+
const TOP_REQUIRED = TOP_KEYS.filter((k) => k !== "purpose");
|
|
59
|
+
const MEMBER_KEYS = ["uuaid", "key", "role", "status"];
|
|
60
|
+
const TRANSITION_KEYS = ["action", "actor", "target", "request"];
|
|
61
|
+
const SIG_KEYS = ["alg", "keyId", "publicKey", "signature"];
|
|
62
|
+
|
|
63
|
+
const plain = (o) => o !== null && typeof o === "object" && !Array.isArray(o);
|
|
64
|
+
const closed = (o, allowed, required) =>
|
|
65
|
+
plain(o) && Object.keys(o).every((k) => allowed.includes(k)) && required.every((k) => Object.hasOwn(o, k));
|
|
66
|
+
const codePoints = (s) => Array.from(s).length;
|
|
67
|
+
const no = (reason) => ({ ok: false, reason });
|
|
68
|
+
|
|
69
|
+
export function isGroupIdV2(id) { return typeof id === "string" && ID_RE.test(id); }
|
|
70
|
+
|
|
71
|
+
// ───────────────────────────────────────────── canonical JSON (RFC 8785) ──
|
|
72
|
+
// Keys sort by UTF-16 code units, which is what JS `<` compares (RFC 8785
|
|
73
|
+
// §3.2.3). The shared src/identity/jcs.mjs sorted by UTF-16LE *bytes* (wrong
|
|
74
|
+
// for keys at or above U+0100) until 10048e1 fixed it; this module keeps its
|
|
75
|
+
// own canonicalizer either way and does not use that one.
|
|
76
|
+
export function canonicalJson(v) {
|
|
77
|
+
if (v === null) return "null";
|
|
78
|
+
if (v === true) return "true";
|
|
79
|
+
if (v === false) return "false";
|
|
80
|
+
if (typeof v === "string") return JSON.stringify(v);
|
|
81
|
+
if (typeof v === "number") {
|
|
82
|
+
if (!Number.isFinite(v)) throw new Error("canonicalJson: non-finite number");
|
|
83
|
+
return JSON.stringify(v);
|
|
84
|
+
}
|
|
85
|
+
if (Array.isArray(v)) return `[${v.map(canonicalJson).join(",")}]`;
|
|
86
|
+
if (plain(v)) {
|
|
87
|
+
const keys = Object.keys(v).sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
|
|
88
|
+
return `{${keys.map((k) => `${JSON.stringify(k)}:${canonicalJson(v[k])}`).join(",")}}`;
|
|
89
|
+
}
|
|
90
|
+
throw new Error(`canonicalJson: unsupported ${typeof v}`);
|
|
91
|
+
}
|
|
92
|
+
const sha256Hex = (s) => createHash("sha256").update(Buffer.from(s, "utf-8")).digest("hex");
|
|
93
|
+
const without = (o, ...keys) => { const c = { ...o }; for (const k of keys) delete c[k]; return c; };
|
|
94
|
+
|
|
95
|
+
/** Genesis preimage: version 1 without `sig` and without `id` (rev 4 §2). */
|
|
96
|
+
export function genesisPreimage(m) { return canonicalJson(without(m, "sig", "id")); }
|
|
97
|
+
export function genesisHash(m) { return sha256Hex(genesisPreimage(m)); }
|
|
98
|
+
export function idFromGenesisHash(h) { return `grp-${h.slice(0, 32)}`; }
|
|
99
|
+
/** The bytes the signature covers: the manifest without `sig`. */
|
|
100
|
+
export function signingPreimage(m) { return canonicalJson(without(m, "sig")); }
|
|
101
|
+
/** Manifest hash: the full signed manifest. `prev` and `mv.hash` use it. */
|
|
102
|
+
export function manifestHash(m) { return sha256Hex(canonicalJson(m)); }
|
|
103
|
+
|
|
104
|
+
// ───────────────────────────────────────────── strict Ed25519 (RFC 8032) ──
|
|
105
|
+
const L = 2n ** 252n + 27742317777372353535851937790883648493n;
|
|
106
|
+
const leInt = (buf) => { let n = 0n; for (let i = buf.length - 1; i >= 0; i--) n = (n << 8n) | BigInt(buf[i]); return n; };
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* A public key a manifest may carry: the shared keychain rule (canonical, not
|
|
110
|
+
* of small order, in the prime-order subgroup). A mixed-order key (an honest
|
|
111
|
+
* key plus a torsion point) is refused here, not only a small-order one.
|
|
112
|
+
*/
|
|
113
|
+
export function isStrictPublicKey(hex) {
|
|
114
|
+
if (typeof hex !== "string" || !HEX64.test(hex)) return false;
|
|
115
|
+
if (!isStrictEd25519PublicKey(hex)) return false; // @rule M1-strict-key
|
|
116
|
+
return true;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** R must be a canonical encoding of a curve point (it may be of any order). */
|
|
120
|
+
function isCanonicalPoint(hex) {
|
|
121
|
+
try { return ed25519.Point.fromHex(hex, false).toHex() === hex; } catch { return false; }
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** RFC 8032 verification with S < L, R canonical and on the curve, A strict. */
|
|
125
|
+
export function verifyEd25519Strict(publicKeyHex, message, signatureHex) {
|
|
126
|
+
if (!isStrictPublicKey(publicKeyHex) || typeof signatureHex !== "string" || !HEX128.test(signatureHex)) return false;
|
|
127
|
+
const sig = Buffer.from(signatureHex, "hex");
|
|
128
|
+
if (leInt(sig.subarray(32)) >= L) return false;
|
|
129
|
+
if (!isCanonicalPoint(signatureHex.slice(0, 64))) return false;
|
|
130
|
+
try {
|
|
131
|
+
const key = createPublicKey({ key: Buffer.concat([SPKI_PREFIX, Buffer.from(publicKeyHex, "hex")]), format: "der", type: "spki" });
|
|
132
|
+
return edVerify(null, Buffer.from(message, "utf-8"), key, sig);
|
|
133
|
+
} catch { return false; }
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Key-derived agent identity in a self-certifying namespace; foundation remains the default. */
|
|
137
|
+
export function uuaidForKey(publicKeyHex, namespace = "foundation") { return keyDerivedUuaidFor(publicKeyHex, { namespace }); }
|
|
138
|
+
export function keyBindsUuaid(uuaid, publicKeyHex) {
|
|
139
|
+
return isStrictPublicKey(publicKeyHex) && keyDerivesUuaid(publicKeyHex, uuaid, "agent");
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ───────────────────────────────────────────── manifest checks ──
|
|
143
|
+
|
|
144
|
+
/** Step 1 of both check orders. Returns a reason, or null. */
|
|
145
|
+
export function checkShape(m) {
|
|
146
|
+
if (!plain(m)) return "bad-shape";
|
|
147
|
+
if (typeof m.v !== "string") return "bad-shape";
|
|
148
|
+
if (m.v !== MANIFEST_V) return "unknown-manifest-version";
|
|
149
|
+
if (!closed(m, TOP_KEYS, TOP_REQUIRED)) return "bad-shape"; // @rule M1-closed-top
|
|
150
|
+
if (!ID_RE.test(m.id) || !Number.isSafeInteger(m.version) || m.version < 1) return "bad-shape";
|
|
151
|
+
if (!(m.prev === null || (typeof m.prev === "string" && HEX64.test(m.prev)))) return "bad-shape";
|
|
152
|
+
if (!Number.isSafeInteger(m.epoch) || m.epoch < 0) return "bad-shape";
|
|
153
|
+
if (typeof m.name !== "string") return "bad-shape";
|
|
154
|
+
if (Object.hasOwn(m, "purpose") && typeof m.purpose !== "string") return "bad-shape";
|
|
155
|
+
if (!Array.isArray(m.members) || m.members.length === 0) return "bad-shape";
|
|
156
|
+
for (const r of m.members) {
|
|
157
|
+
if (!closed(r, MEMBER_KEYS, MEMBER_KEYS)) return "bad-shape"; // @rule M1-closed-member
|
|
158
|
+
if (typeof r.uuaid !== "string" || !UUAID_RE.test(r.uuaid) || typeof r.key !== "string" || !HEX64.test(r.key)) return "bad-shape";
|
|
159
|
+
if (!["admin", "member"].includes(r.role) || !["active", "invited", "removed"].includes(r.status)) return "bad-shape";
|
|
160
|
+
}
|
|
161
|
+
const t = m.transition;
|
|
162
|
+
if (!closed(t, TRANSITION_KEYS, TRANSITION_KEYS) || typeof t.action !== "string" || typeof t.actor !== "string") return "bad-shape";
|
|
163
|
+
if (!(t.target === null || typeof t.target === "string") || !(t.request === null || typeof t.request === "string")) return "bad-shape";
|
|
164
|
+
if (!plain(m.ext)) return "bad-shape";
|
|
165
|
+
if (typeof m.issuedAt !== "string" || !ISSUED_RE.test(m.issuedAt)) return "bad-shape";
|
|
166
|
+
const s = m.sig;
|
|
167
|
+
if (!closed(s, SIG_KEYS, SIG_KEYS) || s.alg !== "ed25519" || typeof s.keyId !== "string") return "bad-shape";
|
|
168
|
+
if (typeof s.publicKey !== "string" || !HEX64.test(s.publicKey) || typeof s.signature !== "string" || !HEX128.test(s.signature)) return "bad-shape";
|
|
169
|
+
let bytes;
|
|
170
|
+
try { bytes = Buffer.byteLength(canonicalJson(m), "utf-8"); } catch { return "bad-shape"; }
|
|
171
|
+
if (bytes > MAX_MANIFEST_BYTES) return "too-large";
|
|
172
|
+
if (m.members.length > MAX_MEMBERS) return "too-many-members";
|
|
173
|
+
if (codePoints(m.name) > MAX_NAME) return "name-too-long"; // @rule M1-name-code-points
|
|
174
|
+
if (Object.hasOwn(m, "purpose") && codePoints(m.purpose) > MAX_PURPOSE) return "purpose-too-long"; // @rule M1-purpose-code-points
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function rowChecks(m) {
|
|
179
|
+
for (const r of m.members) if (!keyBindsUuaid(r.uuaid, r.key)) return "key-uuaid-mismatch"; // @rule M1-key-derives
|
|
180
|
+
const seen = new Set();
|
|
181
|
+
for (const r of m.members) { if (seen.has(r.uuaid)) return "duplicate-uuaid"; seen.add(r.uuaid); }
|
|
182
|
+
const admins = activeAdmins(m);
|
|
183
|
+
if (admins.length === 0) return "no-active-admin"; // @rule M1-no-active-admin
|
|
184
|
+
if (admins.length > 1) return "multiple-active-admins"; // @rule M1-multiple-admins
|
|
185
|
+
return null;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export function activeAdmins(m) { return m.members.filter((r) => r.status === "active" && r.role === "admin"); }
|
|
189
|
+
/** The single active admin of a verified manifest. */
|
|
190
|
+
export function adminOf(m) { return activeAdmins(m)[0]; }
|
|
191
|
+
|
|
192
|
+
/** Version 1, in the vectors' check order. */
|
|
193
|
+
export function verifyGenesis(m) {
|
|
194
|
+
const shape = checkShape(m);
|
|
195
|
+
if (shape) return no(shape);
|
|
196
|
+
if (m.version !== 1 || m.prev !== null) return no("not-genesis");
|
|
197
|
+
if (m.id !== idFromGenesisHash(genesisHash(m))) return no("genesis-id-mismatch"); // @rule M1-genesis-id
|
|
198
|
+
const rows = rowChecks(m); // steps 4-6: keys derive, unique uuaids, one active admin
|
|
199
|
+
if (rows) return no(rows);
|
|
200
|
+
const admin = adminOf(m);
|
|
201
|
+
if (m.sig.publicKey !== admin.key) return no("signer-not-admin");
|
|
202
|
+
if (m.sig.keyId !== admin.uuaid || !keyBindsUuaid(m.sig.keyId, m.sig.publicKey)) return no("keyid-mismatch");
|
|
203
|
+
if (!verifyEd25519Strict(admin.key, signingPreimage(m), m.sig.signature)) return no("bad-signature"); // @rule M1-signature-genesis
|
|
204
|
+
if (m.members.some((r) => r !== admin && !(r.role === "member" && r.status === "invited"))) return no("no-matching-transition");
|
|
205
|
+
if (m.epoch !== 0) return no("epoch-mismatch");
|
|
206
|
+
const t = m.transition;
|
|
207
|
+
if (t.action !== "create" || t.actor !== admin.uuaid || t.target !== null || t.request !== null) return no("transition-mismatch");
|
|
208
|
+
return { ok: true, action: "create" };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Which §3 row turns `prev` into `next`? Null when none does. `actions` is a
|
|
213
|
+
* list because an `active → removed` change is `leave` or `remove` by label.
|
|
214
|
+
*/
|
|
215
|
+
export function classifyChange(prev, next) {
|
|
216
|
+
const A = prev.members, B = next.members;
|
|
217
|
+
if (B.length !== A.length && B.length !== A.length + 1) return null;
|
|
218
|
+
for (let i = 0; i < A.length; i++) {
|
|
219
|
+
if (B[i].uuaid !== A[i].uuaid || B[i].key !== A[i].key) return null; // @rule M1-rows-keep-positions
|
|
220
|
+
}
|
|
221
|
+
const changed = [];
|
|
222
|
+
for (let i = 0; i < A.length; i++) if (B[i].role !== A[i].role || B[i].status !== A[i].status) changed.push(i);
|
|
223
|
+
const renamed = prev.name !== next.name || prev.purpose !== next.purpose;
|
|
224
|
+
const extChanged = canonicalJson(prev.ext) !== canonicalJson(next.ext);
|
|
225
|
+
if (B.length === A.length + 1) {
|
|
226
|
+
const r = B[A.length];
|
|
227
|
+
if (changed.length || renamed || extChanged || r.role !== "member" || r.status !== "invited") return null;
|
|
228
|
+
return { actions: ["invite"], target: r.uuaid, epochDelta: 0 };
|
|
229
|
+
}
|
|
230
|
+
if (changed.length === 0) {
|
|
231
|
+
if (renamed && !extChanged) return { actions: ["rename"], target: null, epochDelta: 0 };
|
|
232
|
+
if (extChanged && !renamed) return { actions: ["ext_update"], target: null, epochDelta: 0 };
|
|
233
|
+
return null;
|
|
234
|
+
}
|
|
235
|
+
if (renamed || extChanged) return null; // @rule M1-one-change
|
|
236
|
+
if (changed.length === 1) {
|
|
237
|
+
const a = A[changed[0]], b = B[changed[0]];
|
|
238
|
+
if (a.role !== b.role) return null;
|
|
239
|
+
if (a.status === "removed" && b.status === "invited") return { actions: ["invite"], target: b.uuaid, epochDelta: 0 };
|
|
240
|
+
if (a.status === "invited" && b.status === "active") return { actions: ["accept"], target: b.uuaid, epochDelta: 0 };
|
|
241
|
+
if (a.status === "active" && b.status === "removed") return { actions: ["leave", "remove"], target: b.uuaid, epochDelta: 1 };
|
|
242
|
+
if (a.status === "invited" && b.status === "removed") return { actions: ["remove"], target: b.uuaid, epochDelta: 1 };
|
|
243
|
+
return null;
|
|
244
|
+
}
|
|
245
|
+
if (changed.length === 2) {
|
|
246
|
+
const [i, j] = changed;
|
|
247
|
+
for (const [o, n] of [[i, j], [j, i]]) {
|
|
248
|
+
const oldIsAdmin = A[o].role === "admin" && A[o].status === "active" && B[o].role === "member" && B[o].status === "active";
|
|
249
|
+
const newIsAdmin = A[n].role === "member" && A[n].status === "active" && B[n].role === "admin" && B[n].status === "active";
|
|
250
|
+
if (oldIsAdmin && newIsAdmin) return { actions: ["admin_transfer"], target: B[n].uuaid, epochDelta: 0 };
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
return null;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** Version n+1 against a verified version n, in the vectors' check order. */
|
|
257
|
+
export function verifyNext(prev, m) {
|
|
258
|
+
const shape = checkShape(m);
|
|
259
|
+
if (shape) return no(shape);
|
|
260
|
+
if (m.id !== prev.id) return no("id-mismatch");
|
|
261
|
+
if (m.version !== prev.version + 1) return no("not-next-version");
|
|
262
|
+
if (m.prev !== manifestHash(prev)) return no("prev-mismatch"); // @rule M1-prev
|
|
263
|
+
const pAdmin = adminOf(prev);
|
|
264
|
+
if (m.sig.publicKey !== pAdmin.key) return no("signer-not-admin"); // @rule M1-signer-from-chain
|
|
265
|
+
if (m.sig.keyId !== pAdmin.uuaid || !keyBindsUuaid(m.sig.keyId, m.sig.publicKey)) return no("keyid-mismatch"); // @rule M1-keyid
|
|
266
|
+
if (!verifyEd25519Strict(pAdmin.key, signingPreimage(m), m.sig.signature)) return no("bad-signature"); // @rule M1-signature
|
|
267
|
+
const rows = rowChecks(m);
|
|
268
|
+
if (rows) return no(rows);
|
|
269
|
+
const row = classifyChange(prev, m);
|
|
270
|
+
if (!row) return no("no-matching-transition");
|
|
271
|
+
if (m.epoch !== prev.epoch + row.epochDelta) return no("epoch-mismatch"); // @rule M1-epoch
|
|
272
|
+
const t = m.transition;
|
|
273
|
+
const needsRequest = t.action === "accept" || t.action === "leave";
|
|
274
|
+
if (!row.actions.includes(t.action)) return no("transition-mismatch"); // @rule M1-transition-label
|
|
275
|
+
if (t.actor !== pAdmin.uuaid || t.target !== row.target) return no("transition-mismatch");
|
|
276
|
+
if (needsRequest ? !(typeof t.request === "string" && t.request.length > 0) : t.request !== null) return no("transition-mismatch");
|
|
277
|
+
return { ok: true, action: t.action };
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** Version 1 and then every version against the one before. */
|
|
281
|
+
export function verifyChain(chain) {
|
|
282
|
+
if (!Array.isArray(chain) || chain.length === 0) return { ok: false, reason: "bad-shape", at: 0 };
|
|
283
|
+
let r = verifyGenesis(chain[0]);
|
|
284
|
+
if (!r.ok) return { ...r, at: 1 };
|
|
285
|
+
for (let i = 1; i < chain.length; i++) {
|
|
286
|
+
r = verifyNext(chain[i - 1], chain[i]);
|
|
287
|
+
if (!r.ok) return { ...r, at: i + 1 };
|
|
288
|
+
}
|
|
289
|
+
return { ok: true, at: chain.length };
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// ───────────────────────────────────────────── building (the admin's side) ──
|
|
293
|
+
|
|
294
|
+
function nowIso() { return new Date().toISOString(); }
|
|
295
|
+
|
|
296
|
+
/** Unsigned version 1. `members`: [{ uuaid, key }] — each becomes an invited member. */
|
|
297
|
+
export function buildGenesis({ name, purpose, creator, members = [], ext = {}, issuedAt = nowIso() }) {
|
|
298
|
+
if (!keyBindsUuaid(creator?.uuaid, creator?.publicKeyHex)) throw new Error("buildGenesis: the creator's key does not derive its uuaid");
|
|
299
|
+
const rows = [{ uuaid: creator.uuaid, key: creator.publicKeyHex, role: "admin", status: "active" }];
|
|
300
|
+
for (const m of members) {
|
|
301
|
+
if (!keyBindsUuaid(m.uuaid, m.key)) throw new Error(`buildGenesis: ${m.uuaid}: key does not derive its uuaid`);
|
|
302
|
+
if (rows.some((r) => r.uuaid === m.uuaid)) throw new Error(`buildGenesis: ${m.uuaid} named twice`);
|
|
303
|
+
rows.push({ uuaid: m.uuaid, key: m.key, role: "member", status: "invited" });
|
|
304
|
+
}
|
|
305
|
+
const m = {
|
|
306
|
+
v: MANIFEST_V, id: "", version: 1, prev: null, epoch: 0, name,
|
|
307
|
+
...(typeof purpose === "string" ? { purpose } : {}),
|
|
308
|
+
members: rows,
|
|
309
|
+
transition: { action: "create", actor: creator.uuaid, target: null, request: null },
|
|
310
|
+
ext, issuedAt,
|
|
311
|
+
};
|
|
312
|
+
m.id = idFromGenesisHash(genesisHash(m));
|
|
313
|
+
return m;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Unsigned version n+1. The caller signs with the admin key of `prev` and
|
|
318
|
+
* should run verifyNext() before storing (the CLI does).
|
|
319
|
+
*/
|
|
320
|
+
export function buildNext(prev, action, { target = null, request = null, member = null, name, purpose, ext, issuedAt = nowIso() } = {}) {
|
|
321
|
+
const admin = adminOf(prev);
|
|
322
|
+
const rows = prev.members.map((r) => ({ ...r }));
|
|
323
|
+
const at = (u) => { const r = rows.find((x) => x.uuaid === u); if (!r) throw new Error(`buildNext: ${u} is not in the roster`); return r; };
|
|
324
|
+
let epoch = prev.epoch;
|
|
325
|
+
let next = { name: prev.name, purpose: prev.purpose, ext: prev.ext };
|
|
326
|
+
switch (action) {
|
|
327
|
+
case "invite": {
|
|
328
|
+
if (!keyBindsUuaid(member?.uuaid, member?.key)) throw new Error("buildNext invite: key does not derive its uuaid");
|
|
329
|
+
const r = rows.find((x) => x.uuaid === member.uuaid);
|
|
330
|
+
if (r && r.status !== "removed") throw new Error(`buildNext invite: ${member.uuaid} is already ${r.status}`);
|
|
331
|
+
if (r && r.key !== member.key) throw new Error("buildNext invite: a removed row keeps its key");
|
|
332
|
+
if (r) r.status = "invited"; else rows.push({ uuaid: member.uuaid, key: member.key, role: "member", status: "invited" });
|
|
333
|
+
target = member.uuaid;
|
|
334
|
+
break;
|
|
335
|
+
}
|
|
336
|
+
case "accept": { const r = at(target); if (r.status !== "invited") throw new Error("buildNext accept: not invited"); r.status = "active"; break; }
|
|
337
|
+
case "leave": { const r = at(target); if (r.status !== "active") throw new Error("buildNext leave: only an active member leaves"); r.status = "removed"; epoch++; break; }
|
|
338
|
+
case "remove": { const r = at(target); if (r.status === "removed") throw new Error("buildNext remove: already removed"); r.status = "removed"; epoch++; break; }
|
|
339
|
+
case "rename": next = { ...next, name: name ?? prev.name, purpose: purpose === undefined ? prev.purpose : purpose }; target = null; break;
|
|
340
|
+
case "ext_update": next = { ...next, ext }; target = null; break;
|
|
341
|
+
case "admin_transfer": {
|
|
342
|
+
const r = at(target);
|
|
343
|
+
if (r.status !== "active" || r.uuaid === admin.uuaid) throw new Error("buildNext admin_transfer: target must be another active member");
|
|
344
|
+
rows.find((x) => x.uuaid === admin.uuaid).role = "member";
|
|
345
|
+
r.role = "admin";
|
|
346
|
+
break;
|
|
347
|
+
}
|
|
348
|
+
default: throw new Error(`buildNext: unknown action ${action}`);
|
|
349
|
+
}
|
|
350
|
+
if ((action === "leave" || action === "remove") && target === admin.uuaid) throw new Error("buildNext: the admin transfers first");
|
|
351
|
+
const m = {
|
|
352
|
+
v: MANIFEST_V, id: prev.id, version: prev.version + 1, prev: manifestHash(prev), epoch, name: next.name,
|
|
353
|
+
...(typeof next.purpose === "string" ? { purpose: next.purpose } : {}),
|
|
354
|
+
members: rows,
|
|
355
|
+
transition: { action, actor: admin.uuaid, target, request: action === "accept" || action === "leave" ? request : null },
|
|
356
|
+
ext: next.ext, issuedAt,
|
|
357
|
+
};
|
|
358
|
+
return m;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/** Sign with a loaded Keychain, or an identity { uuaid, publicKeyHex, privateKey }. */
|
|
362
|
+
export function signManifest(m, signer) {
|
|
363
|
+
const id = signer?._identity ?? signer;
|
|
364
|
+
if (!id?.privateKey || !id?.publicKeyHex) throw new Error("signManifest: need a loaded keychain or identity");
|
|
365
|
+
const body = without(m, "sig");
|
|
366
|
+
const signature = edSign(null, Buffer.from(canonicalJson(body), "utf-8"), id.privateKey).toString("hex");
|
|
367
|
+
return { ...body, sig: { alg: "ed25519", keyId: id.uuaid ?? uuaidForKey(id.publicKeyHex), publicKey: id.publicKeyHex, signature } };
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// ───────────────────────────────────────────── store (pillar-group-store/2) ──
|
|
371
|
+
|
|
372
|
+
/** A store record is fine only if its chain verifies and its head matches. */
|
|
373
|
+
export function checkStoreRecord(rec, id = rec?.id) {
|
|
374
|
+
if (!plain(rec) || rec.v !== STORE_V || rec.id !== id || !isGroupIdV2(id)) return "bad-record";
|
|
375
|
+
const v = verifyChain(rec.chain);
|
|
376
|
+
if (!v.ok) return `chain:${v.reason}@${v.at}`;
|
|
377
|
+
const last = rec.chain[rec.chain.length - 1];
|
|
378
|
+
if (last.id !== id) return "bad-record";
|
|
379
|
+
if (!plain(rec.head) || rec.head.version !== last.version || rec.head.hash !== manifestHash(last)) return "head-mismatch";
|
|
380
|
+
if (!(rec.frozen === null || (plain(rec.frozen) && Array.isArray(rec.frozen.manifests) && rec.frozen.manifests.length === 2))) return "bad-record";
|
|
381
|
+
if (typeof rec.outOfDate !== "boolean" || !(rec.recoveredFrom === null || typeof rec.recoveredFrom === "string")) return "bad-record";
|
|
382
|
+
if (!Array.isArray(rec.leaving) || !rec.leaving.every((u) => typeof u === "string")) return "bad-record";
|
|
383
|
+
if (!Array.isArray(rec.seen) || !rec.seen.every((s) => typeof s === "string")) return "bad-record";
|
|
384
|
+
return null;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
export function newRecord(chain, { outOfDate = false, recoveredFrom = null } = {}) {
|
|
388
|
+
const last = chain[chain.length - 1];
|
|
389
|
+
return {
|
|
390
|
+
v: STORE_V, id: last.id, chain: [...chain], head: { version: last.version, hash: manifestHash(last) },
|
|
391
|
+
frozen: null, outOfDate, recoveredFrom, leaving: [], seen: [],
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/** <home>/groups-v2/grp-<32 hex>.json, dir 0700, file 0600, written atomically. */
|
|
396
|
+
export class RosterStore {
|
|
397
|
+
constructor(home) { this.dir = join(home, "groups-v2"); }
|
|
398
|
+
path(id) {
|
|
399
|
+
if (!isGroupIdV2(id)) throw new Error(`groups-v2 store: not a v2 group id: ${id}`);
|
|
400
|
+
return join(this.dir, `${id}.json`);
|
|
401
|
+
}
|
|
402
|
+
has(id) { return isGroupIdV2(id) && existsSync(this.path(id)); }
|
|
403
|
+
list() {
|
|
404
|
+
if (!existsSync(this.dir)) return [];
|
|
405
|
+
return readdirSync(this.dir).filter((f) => /^grp-[0-9a-f]{32}\.json$/.test(f)).map((f) => f.slice(0, -5)).sort();
|
|
406
|
+
}
|
|
407
|
+
/** Throws on a file it cannot read, parse or verify: a reader denies those. */
|
|
408
|
+
load(id) {
|
|
409
|
+
const rec = JSON.parse(readFileSync(this.path(id), "utf-8"));
|
|
410
|
+
const bad = checkStoreRecord(rec, id);
|
|
411
|
+
if (bad) throw new Error(`groups-v2 store ${id}: ${bad}`);
|
|
412
|
+
return rec;
|
|
413
|
+
}
|
|
414
|
+
save(rec) {
|
|
415
|
+
const bad = checkStoreRecord(rec);
|
|
416
|
+
if (bad) throw new Error(`groups-v2 store ${rec?.id}: refusing to write: ${bad}`);
|
|
417
|
+
mkdirSync(this.dir, { recursive: true, mode: 0o700 });
|
|
418
|
+
chmodSync(this.dir, 0o700);
|
|
419
|
+
const p = this.path(rec.id);
|
|
420
|
+
const tmp = `${p}.tmp-${process.pid}-${randomBytes(4).toString("hex")}`;
|
|
421
|
+
writeFileSync(tmp, JSON.stringify(rec), { mode: 0o600, flag: "wx" });
|
|
422
|
+
const fd = openSync(tmp, "r");
|
|
423
|
+
try { fsyncSync(fd); } finally { closeSync(fd); }
|
|
424
|
+
renameSync(tmp, p);
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/** Same contract in memory (the vector driver, unit tests). */
|
|
429
|
+
export class MemoryRosterStore {
|
|
430
|
+
constructor() { this.records = new Map(); }
|
|
431
|
+
has(id) { return this.records.has(id); }
|
|
432
|
+
list() { return [...this.records.keys()].sort(); }
|
|
433
|
+
load(id) { const r = this.records.get(id); if (!r) throw new Error(`no group ${id}`); return structuredClone(r); }
|
|
434
|
+
save(rec) {
|
|
435
|
+
const bad = checkStoreRecord(rec);
|
|
436
|
+
if (bad) throw new Error(`groups-v2 store ${rec?.id}: refusing to write: ${bad}`);
|
|
437
|
+
this.records.set(rec.id, structuredClone(rec));
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// ───────────────────────────────────────────── mailbox coupling (egress filter) ──
|
|
442
|
+
|
|
443
|
+
/** Who a send seals to: the active members of the head except self and anyone leaving. */
|
|
444
|
+
export function recipientsOf(rec, selfUuaid) {
|
|
445
|
+
if (rec.frozen) return [];
|
|
446
|
+
const head = rec.chain[rec.chain.length - 1];
|
|
447
|
+
return head.members.filter((r) => r.status === "active" && r.uuaid !== selfUuaid && !rec.leaving.includes(r.uuaid)).map((r) => r.uuaid);
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* Keep the mailbox `groups` row of a v2 group true to the verified head: the
|
|
452
|
+
* egress filter and v1 sendGroup read `members` from it. Called only after
|
|
453
|
+
* a record was verified and written. A frozen group, or one whose head no
|
|
454
|
+
* longer has us active or invited, is cleared (no members, `left`).
|
|
455
|
+
* Either way the row is marked as v2's (mailbox.markGroupV2), for good.
|
|
456
|
+
*/
|
|
457
|
+
export function syncMailboxRoster(mailbox, rec, selfUuaid) {
|
|
458
|
+
if (!mailbox) return;
|
|
459
|
+
const head = rec.chain[rec.chain.length - 1];
|
|
460
|
+
const me = head.members.find((r) => r.uuaid === selfUuaid);
|
|
461
|
+
const createdBy = adminOf(rec.chain[0]).uuaid;
|
|
462
|
+
if (rec.frozen || !me || me.status === "removed") {
|
|
463
|
+
mailbox.putGroup({ id: rec.id, name: head.name, members: [], keys: {}, createdBy, pending: false, left: true }); // @rule MB-clear
|
|
464
|
+
} else {
|
|
465
|
+
const rows = head.members.filter((r) => r.status === "active" && !rec.leaving.includes(r.uuaid));
|
|
466
|
+
mailbox.putGroup({
|
|
467
|
+
id: rec.id, name: head.name, members: rows.map((r) => r.uuaid),
|
|
468
|
+
keys: Object.fromEntries(rows.map((r) => [r.uuaid, r.key])), createdBy, pending: me.status === "invited", left: false,
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
mailbox.markGroupV2(rec.id); // @rule MB-marker-set
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/**
|
|
475
|
+
* Re-sync the mailbox row of every group the store holds, from the verified
|
|
476
|
+
* store. A crash between a store write and its mailbox write leaves the row a
|
|
477
|
+
* superset of the head (a removed member still listed); this repairs it. A
|
|
478
|
+
* group file that no longer loads (unparsable, chain does not verify) gets an
|
|
479
|
+
* empty, `left` row: a filter that reads the row then allows no one.
|
|
480
|
+
* A row the v2 sync marked whose store file is gone is cleared the same way.
|
|
481
|
+
* Returns the group ids it wrote. Unmarked rows the store does not hold are
|
|
482
|
+
* v1 groups (whatever their id's shape) and are left alone.
|
|
483
|
+
*/
|
|
484
|
+
export function reconcileMailboxRosters(store, mailbox, selfUuaid) {
|
|
485
|
+
if (!mailbox) return [];
|
|
486
|
+
const done = [];
|
|
487
|
+
for (const id of store.list()) {
|
|
488
|
+
let rec;
|
|
489
|
+
try { rec = store.load(id); }
|
|
490
|
+
catch {
|
|
491
|
+
const row = mailbox.getGroup(id);
|
|
492
|
+
mailbox.putGroup({ id, name: row?.name ?? id, members: [], keys: {}, createdBy: row?.createdBy ?? null, pending: false, left: true });
|
|
493
|
+
done.push(id);
|
|
494
|
+
continue;
|
|
495
|
+
}
|
|
496
|
+
syncMailboxRoster(mailbox, rec, selfUuaid);
|
|
497
|
+
done.push(id);
|
|
498
|
+
}
|
|
499
|
+
// A row the v2 sync marked whose store file is gone: no verified chain is left
|
|
500
|
+
// to say who is in it, so it is cleared like an unloadable record.
|
|
501
|
+
for (const id of mailbox.groupV2MarkedIds()) {
|
|
502
|
+
if (store.has(id)) continue;
|
|
503
|
+
const row = mailbox.getGroup(id);
|
|
504
|
+
mailbox.putGroup({ id, name: row.name, members: [], keys: {}, createdBy: row.createdBy, pending: false, left: true }); // @rule MB-reconcile-missing-store
|
|
505
|
+
done.push(id);
|
|
506
|
+
}
|
|
507
|
+
return done;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
// ───────────────────────────────────────────── the admin's writes ──
|
|
511
|
+
|
|
512
|
+
/**
|
|
513
|
+
* Create a group: sign version 1 with `keychain`, verify it, store it, then
|
|
514
|
+
* write the mailbox row. `members`: [{ uuaid, key }], each invited.
|
|
515
|
+
*/
|
|
516
|
+
export function adminCreate({ store, mailbox, keychain, name, purpose, members = [] }) {
|
|
517
|
+
const me = keychain._identity;
|
|
518
|
+
const g = signManifest(buildGenesis({ name, purpose, creator: { uuaid: me.uuaid, publicKeyHex: me.publicKeyHex }, members }), keychain);
|
|
519
|
+
const v = verifyGenesis(g);
|
|
520
|
+
if (!v.ok) throw new Error(`refusing to store a genesis that does not verify: ${v.reason}`);
|
|
521
|
+
const rec = newRecord([g]);
|
|
522
|
+
store.save(rec);
|
|
523
|
+
syncMailboxRoster(mailbox, rec, me.uuaid);
|
|
524
|
+
return { rec, manifest: g };
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/**
|
|
528
|
+
* One §3 change by this home's key: build version n+1, sign it, verify it as
|
|
529
|
+
* the next version of the stored head (a non-admin is refused here with
|
|
530
|
+
* signer-not-admin), store it, then write the mailbox row. Nothing is sent.
|
|
531
|
+
*/
|
|
532
|
+
export function adminChange({ store, mailbox, keychain, groupId, action, opts = {} }) {
|
|
533
|
+
const rec = loadGroupForCommand(store, groupId);
|
|
534
|
+
if (rec.frozen) throw new Error(`${groupId} is frozen (equivocation); a frozen group can only be replaced by a new group`);
|
|
535
|
+
const head = rec.chain[rec.chain.length - 1];
|
|
536
|
+
const next = signManifest(buildNext(head, action, opts), keychain);
|
|
537
|
+
const v = verifyNext(head, next);
|
|
538
|
+
if (!v.ok) throw new Error(`refusing to store version ${next.version}: ${v.reason}`);
|
|
539
|
+
rec.chain.push(next);
|
|
540
|
+
rec.head = { version: next.version, hash: manifestHash(next) };
|
|
541
|
+
if (action === "leave" || action === "remove") rec.leaving = rec.leaving.filter((u) => u !== next.transition.target);
|
|
542
|
+
store.save(rec);
|
|
543
|
+
syncMailboxRoster(mailbox, rec, keychain._identity.uuaid);
|
|
544
|
+
return { rec, manifest: next };
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/**
|
|
548
|
+
* §4/§6: who gets the admin's newest manifest (version n), as queue rows for
|
|
549
|
+
* Pillar.syncGroupV2 (`{ to, key, op: "manifest", manifestHash, mvVersion }`).
|
|
550
|
+
* Nothing here sends.
|
|
551
|
+
* - every member of `row`, the mailbox row (the verified head minus leavers), but self;
|
|
552
|
+
* - an invitee (an `invite`, or the invited rows of version 1): every version 1..n;
|
|
553
|
+
* - the member a `remove` or `leave` took out: version n.
|
|
554
|
+
* Each key is that member's key in the chain.
|
|
555
|
+
*/
|
|
556
|
+
export function adminManifestSends(rec, row, selfUuaid) {
|
|
557
|
+
const m = rec.chain[rec.chain.length - 1];
|
|
558
|
+
const keyOf = (u) => {
|
|
559
|
+
for (let i = rec.chain.length - 1; i >= 0; i--) { const r = rec.chain[i].members.find((x) => x.uuaid === u); if (r) return r.key; }
|
|
560
|
+
return null;
|
|
561
|
+
};
|
|
562
|
+
const sends = [];
|
|
563
|
+
const add = (to, man) => sends.push({ to, key: keyOf(to), op: "manifest", manifestHash: manifestHash(man), mvVersion: m.version });
|
|
564
|
+
for (const u of row?.members ?? []) if (u !== selfUuaid) add(u, m); // @rule A-send-row
|
|
565
|
+
const t = m.transition;
|
|
566
|
+
const invitees = m.version === 1 ? m.members.filter((r) => r.status === "invited").map((r) => r.uuaid) : t.action === "invite" ? [t.target] : [];
|
|
567
|
+
for (const u of invitees) for (const x of rec.chain) add(u, x); // @rule A-send-invitee-chain
|
|
568
|
+
if (t.action === "remove" || t.action === "leave") add(t.target, m); // @rule A-send-removed
|
|
569
|
+
return sends;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
/** `<uuaid>:<64-hex pubkey>` -> { uuaid, key }; v2 binds every member to its key. */
|
|
573
|
+
export function parseMemberRef(s) {
|
|
574
|
+
const t = String(s);
|
|
575
|
+
const i = t.lastIndexOf(":");
|
|
576
|
+
const uuaid = t.slice(0, i), key = t.slice(i + 1).toLowerCase();
|
|
577
|
+
if (i < 0 || !UUAID_RE.test(uuaid) || !HEX64.test(key)) throw new Error(`member "${t}" must be <uuaid>:<64-hex pubkey>`);
|
|
578
|
+
return { uuaid, key };
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
// ──────────────────────────────────────── `pillar group` dispatch ──
|
|
582
|
+
|
|
583
|
+
/** The verbs v1's `pillar group` accepts; anything else it answers with its usage (exit 2). */
|
|
584
|
+
export const GROUP_V1_VERBS = Object.freeze(["create", "send", "add", "rename", "leave", "accept", "show"]);
|
|
585
|
+
/** Verbs only v2 has. v1 printed its usage for each of them. */
|
|
586
|
+
export const GROUP_V2_ONLY_VERBS = Object.freeze(["list", "invite", "remove", "ext", "transfer", "sync", "queue"]);
|
|
587
|
+
|
|
588
|
+
/**
|
|
589
|
+
* Which implementation runs `pillar group <verb> ...`: "v2" or "v1".
|
|
590
|
+
* `args` is the bin's parsed argv (`args._ = ["group", verb, ref, ...]`, flags as keys).
|
|
591
|
+
* `held(id)`: the v2 store holds this id. `mailboxId(ref)`: the id v1's findGroup
|
|
592
|
+
* resolves `ref` to (an id or a unique name), else null.
|
|
593
|
+
* Everything not routed to v2 runs v1 unchanged. A ref naming a v2 group goes
|
|
594
|
+
* to v2 whatever the verb: v1's add/rename would rewrite that group's mailbox row,
|
|
595
|
+
* which must stay the verified head (minus leavers). A v2 group is one the store
|
|
596
|
+
* holds, or one whose mailbox row the v2 sync marked (`marked(id)`) even if its
|
|
597
|
+
* store file is gone; v2 then refuses it (loadGroupForCommand). The id's shape
|
|
598
|
+
* alone never makes a group v2: a v1 peer's tag can carry a grp-<32 hex> id.
|
|
599
|
+
*/
|
|
600
|
+
export function groupCommandRoute(args, { held, marked = () => false, mailboxId = () => null }) {
|
|
601
|
+
const verb = args?._?.[1];
|
|
602
|
+
if (GROUP_V2_ONLY_VERBS.includes(verb)) return "v2"; // @rule P-cli-v2-verbs
|
|
603
|
+
if (!GROUP_V1_VERBS.includes(verb)) return "v1";
|
|
604
|
+
if (verb === "create" && args.v2 !== undefined) return "v2"; // @rule P-cli-create-v2
|
|
605
|
+
if (verb === "create") return "v1"; // @rule P-cli-create-stays-v1
|
|
606
|
+
const ref = args._[2];
|
|
607
|
+
let id = ref;
|
|
608
|
+
if (!isGroupIdV2(ref)) id = mailboxId(ref); // @rule P-cli-held-name
|
|
609
|
+
if (isGroupIdV2(id) && marked(id)) return "v2"; // @rule P-cli-marked-row
|
|
610
|
+
if (!isGroupIdV2(id) || !held(id)) return "v1"; // @rule P-cli-v1-unless-held
|
|
611
|
+
return "v2";
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
/** The lookups groupCommandRoute needs, from a roster store and an open mailbox (or null). */
|
|
615
|
+
export function groupRouteLookups(store, mailbox) {
|
|
616
|
+
return {
|
|
617
|
+
held: (id) => store.has(id),
|
|
618
|
+
marked: (id) => !!mailbox?.isGroupV2Marked(id),
|
|
619
|
+
mailboxId: (ref) => {
|
|
620
|
+
if (!mailbox || typeof ref !== "string") return null;
|
|
621
|
+
try { return findGroup(mailbox, ref).id; } catch { return null; } // unknown or ambiguous: v1 reports it
|
|
622
|
+
},
|
|
623
|
+
};
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
/**
|
|
627
|
+
* The record a v2 group command acts on. A group with no store file (its mailbox
|
|
628
|
+
* row is marked, the file was deleted) is refused: there is no v1 fallback.
|
|
629
|
+
*/
|
|
630
|
+
export function loadGroupForCommand(store, id) {
|
|
631
|
+
if (!store.has(id)) throw Object.assign(new Error(`no verified chain for ${id}; recover it from a member`), { code: "group-v2-no-chain" }); // @rule P-cli-refuse-no-chain
|
|
632
|
+
return store.load(id);
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
/**
|
|
636
|
+
* `create --v2` takes no value. The bin's parser gives the next word to a flag
|
|
637
|
+
* (`create --v2 Team` sets v2 to "Team" and loses the name), so any value but
|
|
638
|
+
* true is refused with how to write it. Returns the refusal, or null.
|
|
639
|
+
*/
|
|
640
|
+
export function createV2FlagRefusal(args) {
|
|
641
|
+
const v = args?.v2;
|
|
642
|
+
if (v === undefined || v === true || v === "true") return null;
|
|
643
|
+
return `--v2 takes no value (it took "${v}", which was meant as an argument): put --v2 last, or write --v2=true`; // @rule P-cli-v2-flag-value
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
// ───────────────────────────────────────────── sender rules (§6) ──
|
|
647
|
+
|
|
648
|
+
/** Group messages a sender may post per minute on the community tier: ⌊120/(N−1)⌋. */
|
|
649
|
+
export function fanOutBudget(groupSize) {
|
|
650
|
+
const n = Number(groupSize);
|
|
651
|
+
return n <= 1 ? Infinity : Math.floor(COMMUNITY_SEND_BUDGET / (n - 1));
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
/** The `_group` field for an outbound v2 message (the payload is sealed per member). */
|
|
655
|
+
export function buildGroupTag(rec, { op = "message", msgId = `gm-${randomBytes(16).toString("hex")}`, manifest, want, conflictsWith } = {}) {
|
|
656
|
+
if (!V2_OPS.includes(op)) throw new Error(`buildGroupTag: unknown op ${op}`);
|
|
657
|
+
const head = rec.chain[rec.chain.length - 1];
|
|
658
|
+
const tag = {
|
|
659
|
+
v: 2, id: rec.id, name: head.name, members: head.members.filter((r) => r.status === "active").map((r) => r.uuaid),
|
|
660
|
+
msgId, op, mv: { version: rec.head.version, hash: rec.head.hash },
|
|
661
|
+
};
|
|
662
|
+
if (manifest) tag.manifest = manifest;
|
|
663
|
+
if (want) tag.want = want;
|
|
664
|
+
if (conflictsWith) tag.conflictsWith = conflictsWith;
|
|
665
|
+
return tag;
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
// ───────────────────────────────────────────── receiver (§5) ──
|
|
669
|
+
|
|
670
|
+
/**
|
|
671
|
+
* The first malformed field of a v2 `_group`, or null. Reported as `detail`
|
|
672
|
+
* with bad-group-message. Checked in this order: id, msgId, op, mv, then the
|
|
673
|
+
* op's own fields (manifest, conflictsWith, want).
|
|
674
|
+
*/
|
|
675
|
+
function badTag(g) {
|
|
676
|
+
if (!isGroupIdV2(g.id)) return "id";
|
|
677
|
+
if (typeof g.msgId !== "string" || !MSGID_RE.test(g.msgId)) return "msgId";
|
|
678
|
+
if (!V2_OPS.includes(g.op)) return "op";
|
|
679
|
+
const mv = g.mv;
|
|
680
|
+
if (!plain(mv) || !Number.isSafeInteger(mv.version) || mv.version < 0 || typeof mv.hash !== "string" || !HEX64.test(mv.hash)) return "mv";
|
|
681
|
+
if (mv.version === 0 && !(g.op === "manifest.request" && mv.hash === ZERO_HASH)) return "mv";
|
|
682
|
+
if ((g.op === "manifest" || g.op === "equivocation") && !plain(g.manifest)) return "manifest";
|
|
683
|
+
if (g.op === "equivocation" && !(typeof g.conflictsWith === "string" && HEX64.test(g.conflictsWith))) return "conflictsWith";
|
|
684
|
+
if (g.op === "manifest.request") {
|
|
685
|
+
const w = g.want;
|
|
686
|
+
if (!plain(w) || !Number.isSafeInteger(w.from) || w.from < 1) return "want";
|
|
687
|
+
if (!(w.to === null || (Number.isSafeInteger(w.to) && w.to >= w.from))) return "want";
|
|
688
|
+
}
|
|
689
|
+
return null;
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
const res = (verdict, reason = null, extra = {}) => ({ verdict, reason, ...extra });
|
|
693
|
+
const drop = (reason, extra) => res("dropped", reason, extra);
|
|
694
|
+
|
|
695
|
+
/**
|
|
696
|
+
* One receiver. Every entry point returns
|
|
697
|
+
* { verdict, reason, detail?, sends, released, dropped, lateLines }
|
|
698
|
+
* and the caller seals and sends `sends` (it never decides recipients itself).
|
|
699
|
+
*/
|
|
700
|
+
export class GroupReceiver {
|
|
701
|
+
constructor({ self, store, limits = {}, allowDelegation = true, mailbox = null }) {
|
|
702
|
+
this.self = self; // { uuaid, key }
|
|
703
|
+
this.store = store;
|
|
704
|
+
this.limits = { ...DEFAULT_LIMITS, ...limits };
|
|
705
|
+
this.allowDelegation = allowDelegation;
|
|
706
|
+
this.mailbox = mailbox;
|
|
707
|
+
this.records = new Map(); // id -> record (seen as a Set in memory)
|
|
708
|
+
this.held = new Map(); // id -> [{ env, g, from, msgId, version, receivedAt }]
|
|
709
|
+
this.catchup = new Map(); // id -> { to, asked: Set }
|
|
710
|
+
this.asked = new Map(); // "id|target" -> { versions: Set, openFrom }
|
|
711
|
+
this.invites = new Map(); // "id|sender" -> { manifests: Map, firstAt, requested }
|
|
712
|
+
this.recovery = new Map(); // id -> { from, manifests: Map, firstAt }
|
|
713
|
+
this.acceptedLog = new Map(); // id -> [{ from, msgId, at }]
|
|
714
|
+
reconcileMailboxRosters(this.store, this.mailbox, this.self.uuaid); // @rule MB-reconcile-on-start
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
// ── records ──
|
|
718
|
+
_rec(id) {
|
|
719
|
+
if (this.records.has(id)) return this.records.get(id);
|
|
720
|
+
if (!this.store.has(id)) return null;
|
|
721
|
+
const r = this.store.load(id);
|
|
722
|
+
const rec = { ...r, seen: new Set(r.seen) };
|
|
723
|
+
this.records.set(id, rec);
|
|
724
|
+
return rec;
|
|
725
|
+
}
|
|
726
|
+
_persist(rec) {
|
|
727
|
+
this.store.save({ ...rec, seen: [...rec.seen] });
|
|
728
|
+
syncMailboxRoster(this.mailbox, rec, this.self.uuaid); // @rule MB-sync-on-write
|
|
729
|
+
}
|
|
730
|
+
_head(rec) { return rec.chain[rec.chain.length - 1]; }
|
|
731
|
+
|
|
732
|
+
/** Store a chain this client verified itself (its own admin writes, the driver's starting state). */
|
|
733
|
+
adoptVerifiedChain(chain) {
|
|
734
|
+
const v = verifyChain(chain);
|
|
735
|
+
if (!v.ok) throw new Error(`adoptVerifiedChain: ${v.reason} at version ${v.at}`);
|
|
736
|
+
const rec = { ...newRecord(chain), seen: new Set() };
|
|
737
|
+
this.records.set(rec.id, rec);
|
|
738
|
+
this._persist(rec);
|
|
739
|
+
return rec;
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
knownGroups() { return this.store.list(); }
|
|
743
|
+
|
|
744
|
+
state(groupId) {
|
|
745
|
+
const rec = groupId ? this._rec(groupId) : null;
|
|
746
|
+
if (!rec) return { groupId, known: false, headVersion: 0, frozen: false, outOfDate: false, leaving: [], recipients: [] };
|
|
747
|
+
return {
|
|
748
|
+
groupId, known: true, headVersion: rec.head.version, frozen: !!rec.frozen, outOfDate: rec.outOfDate,
|
|
749
|
+
leaving: [...rec.leaving], recipients: recipientsOf(rec, this.self.uuaid),
|
|
750
|
+
};
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
_out() { return { sends: [], released: [], dropped: [], lateLines: [] }; }
|
|
754
|
+
|
|
755
|
+
// ── driver actions ──
|
|
756
|
+
receive(env, now) {
|
|
757
|
+
const out = this._out();
|
|
758
|
+
this._expire(now, out);
|
|
759
|
+
return { ...this._judge(env, now, out), ...out };
|
|
760
|
+
}
|
|
761
|
+
tick(now) {
|
|
762
|
+
const out = this._out();
|
|
763
|
+
this._expire(now, out);
|
|
764
|
+
return { ...res("ignored"), ...out };
|
|
765
|
+
}
|
|
766
|
+
askAnother(groupId, now) {
|
|
767
|
+
const out = this._out();
|
|
768
|
+
this._expire(now, out);
|
|
769
|
+
const rec = this._rec(groupId), c = this.catchup.get(groupId);
|
|
770
|
+
if (rec && c && !rec.frozen) {
|
|
771
|
+
const head = this._head(rec);
|
|
772
|
+
const admin = adminOf(head).uuaid;
|
|
773
|
+
const order = [admin, ...head.members.filter((r) => r.status === "active" && r.uuaid !== admin).map((r) => r.uuaid)];
|
|
774
|
+
const target = order.find((u) => u !== this.self.uuaid && !c.asked.has(u));
|
|
775
|
+
if (target) { c.asked.add(target); this._request(groupId, target, { from: rec.head.version + 1, to: c.to }, out); }
|
|
776
|
+
}
|
|
777
|
+
return { ...res("ignored"), ...out };
|
|
778
|
+
}
|
|
779
|
+
beginRecovery({ groupId, from }, now) {
|
|
780
|
+
const out = this._out();
|
|
781
|
+
this._expire(now, out);
|
|
782
|
+
if (isGroupIdV2(groupId) && !this._rec(groupId)) {
|
|
783
|
+
this.recovery.set(groupId, { from, manifests: new Map(), firstAt: now });
|
|
784
|
+
out.sends.push({ to: from, op: "manifest.request", want: { from: 1, to: null }, mvVersion: 0 });
|
|
785
|
+
}
|
|
786
|
+
return { ...res("ignored"), ...out };
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
// ── §5 ──
|
|
790
|
+
_judge(env, now, out) {
|
|
791
|
+
const p = env.payload;
|
|
792
|
+
if (!plain(p) || !Object.hasOwn(p, "_group")) return res("not-group");
|
|
793
|
+
const g = p._group;
|
|
794
|
+
if (!plain(g)) return drop("bad-group-message");
|
|
795
|
+
if (g.v !== 2) {
|
|
796
|
+
return isGroupIdV2(g.id) && this._rec(g.id) ? res("warning", "v1-in-v2-group") : res("v1"); // @rule R7-v1-in-v2
|
|
797
|
+
}
|
|
798
|
+
const bad = badTag(g);
|
|
799
|
+
if (bad) return drop("bad-group-message", { detail: bad }); // @rule R4-tag-shape
|
|
800
|
+
const rec = this._rec(g.id);
|
|
801
|
+
if (!rec) return this._unknown(env, g, now, out);
|
|
802
|
+
return this._judgeKnown(env, g, now, out);
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
_effKey(env) {
|
|
806
|
+
if (env.delegation) return this.allowDelegation ? env.delegation.rootKey : null;
|
|
807
|
+
return env.transportKey;
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
/** §5.2 gate: an active or invited member, under its own key. */
|
|
811
|
+
_gate(env, rec) {
|
|
812
|
+
const row = this._head(rec).members.find((r) => r.uuaid === env.from);
|
|
813
|
+
if (!row) return false;
|
|
814
|
+
if (!(row.status === "active" || row.status === "invited")) return false; // @rule R2-gate-status
|
|
815
|
+
if (this._effKey(env) !== row.key) return false; // @rule R2-gate-key
|
|
816
|
+
return true;
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
/** §5.3 sender checks. Null when the sender may speak. */
|
|
820
|
+
_rule3(env, g, rec) {
|
|
821
|
+
const head = this._head(rec);
|
|
822
|
+
const row = head.members.find((r) => r.uuaid === env.from);
|
|
823
|
+
if (!row) return "not-member"; // @rule R3-not-member
|
|
824
|
+
if (env.delegation && !this.allowDelegation) return "delegation-refused"; // @rule R3-delegation
|
|
825
|
+
if (this._effKey(env) !== row?.key) return "transport-key-mismatch"; // @rule R3-transport-key
|
|
826
|
+
const invitedException = row?.status === "invited" && adminOf(head).uuaid === this.self.uuaid
|
|
827
|
+
&& (g.op === "accept.request" || g.op === "manifest.request");
|
|
828
|
+
if (invitedException) return null;
|
|
829
|
+
if (row?.status === "removed") return "sender-removed"; // @rule R5-removed-sender
|
|
830
|
+
if (row?.status !== "active") return "sender-not-active"; // @rule R3-active-only
|
|
831
|
+
return null;
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
_seenKey(from, msgId) { return `${from} ${msgId}`; }
|
|
835
|
+
|
|
836
|
+
/** A Set keeps insertion order, so the first key is the oldest. */
|
|
837
|
+
_markSeen(rec, k) {
|
|
838
|
+
rec.seen.add(k);
|
|
839
|
+
if (rec.seen.size > SEEN_CAP) rec.seen.delete(rec.seen.values().next().value); // @rule R3-seen-cap
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
_judgeKnown(env, g, now, out) {
|
|
843
|
+
const rec = this._rec(g.id);
|
|
844
|
+
if (rec.frozen) return drop("frozen"); // @rule R4-frozen-drops
|
|
845
|
+
const n = rec.head.version;
|
|
846
|
+
const carried = g.op === "manifest" || g.op === "equivocation" ? g.manifest : null;
|
|
847
|
+
const carriedAhead = carried && Number.isSafeInteger(carried.version) && carried.version > n + 1;
|
|
848
|
+
// 4. A carried manifest of version <= n+1: rule 3 first, then the manifest.
|
|
849
|
+
if (carried && !carriedAhead) {
|
|
850
|
+
const r3 = this._rule3(env, g, rec);
|
|
851
|
+
if (r3) return drop(r3); // @rule R1-carried-after-rule3
|
|
852
|
+
const k = this._seenKey(env.from, g.msgId);
|
|
853
|
+
if (rec.seen.has(k)) return drop("duplicate-msgid");
|
|
854
|
+
this._markSeen(rec, k);
|
|
855
|
+
return this._applyCarried(env, g, carried, rec, now, out);
|
|
856
|
+
}
|
|
857
|
+
// 5. Ahead: hold, but only for an active or invited member under its key.
|
|
858
|
+
const ahead = carriedAhead ? carried.version : g.mv.version;
|
|
859
|
+
if (ahead > n && this._gate(env, rec)) return this._hold(env, g, rec, ahead, now, out); // @rule R2-hold
|
|
860
|
+
// 6. The signal: same version, other hash. Ask once; never freeze on it.
|
|
861
|
+
if (g.mv.version === n && g.mv.hash !== rec.head.hash && this._gate(env, rec)) {
|
|
862
|
+
this._request(rec.id, env.from, { from: n, to: n }, out); // @rule R4-signal-asks
|
|
863
|
+
}
|
|
864
|
+
// 7. Rule 3 under the receiver's own manifest: mv never widens authority.
|
|
865
|
+
const r3 = this._rule3(env, g, rec);
|
|
866
|
+
if (r3) return drop(r3);
|
|
867
|
+
const k = this._seenKey(env.from, g.msgId);
|
|
868
|
+
if (rec.seen.has(k)) return drop("duplicate-msgid"); // @rule R3-msgid-dedupe
|
|
869
|
+
this._markSeen(rec, k);
|
|
870
|
+
this._effects(env, g, rec, out);
|
|
871
|
+
this._maybeInDate(env, g, rec);
|
|
872
|
+
this._log(rec.id, env.from, g.msgId, now);
|
|
873
|
+
this._persist(rec);
|
|
874
|
+
return res("accepted");
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
_effects(env, g, rec, out) {
|
|
878
|
+
const head = this._head(rec);
|
|
879
|
+
if (g.op === "leave.request" && !rec.leaving.includes(env.from)) {
|
|
880
|
+
let isAdmin = false;
|
|
881
|
+
if (env.from === adminOf(head).uuaid) isAdmin = true; // @rule R5-admin-leave-ignored
|
|
882
|
+
if (!isAdmin) rec.leaving.push(env.from); // @rule R5-leave-at-once
|
|
883
|
+
}
|
|
884
|
+
if (g.op === "manifest.request") {
|
|
885
|
+
const n = rec.head.version;
|
|
886
|
+
const to = Math.min(g.want.to ?? n, n);
|
|
887
|
+
for (let v = g.want.from, c = 0; v <= to && c < MAX_ANSWER; v++, c++) {
|
|
888
|
+
out.sends.push({ to: env.from, op: "manifest", manifestHash: manifestHash(rec.chain[v - 1]), mvVersion: n });
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
/** "Out of date" clears on a message carrying our exact head, from the admin or an active non-source member. */
|
|
894
|
+
_maybeInDate(env, g, rec) {
|
|
895
|
+
if (!rec.outOfDate || g.mv.version !== rec.head.version || g.mv.hash !== rec.head.hash) return;
|
|
896
|
+
const head = this._head(rec);
|
|
897
|
+
const row = head.members.find((r) => r.uuaid === env.from);
|
|
898
|
+
if (env.from === adminOf(head).uuaid || (row?.status === "active" && env.from !== rec.recoveredFrom)) {
|
|
899
|
+
rec.outOfDate = false; // @rule R2-out-of-date-clears
|
|
900
|
+
rec.recoveredFrom = null;
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
_applyCarried(env, g, m, rec, now, out) {
|
|
905
|
+
const n = rec.head.version;
|
|
906
|
+
const reject = (detail) => { this._persist(rec); return drop("manifest-rejected", { detail }); };
|
|
907
|
+
const shape = checkShape(m);
|
|
908
|
+
if (shape) return reject(shape);
|
|
909
|
+
if (m.id !== rec.id) return reject("id-mismatch");
|
|
910
|
+
if (m.version === n + 1) {
|
|
911
|
+
const v = verifyNext(this._head(rec), m);
|
|
912
|
+
if (!v.ok) return reject(v.reason); // @rule R1-verify-before-apply
|
|
913
|
+
this._applyNext(rec, m, out);
|
|
914
|
+
if (g.mv.version > rec.head.version && this._gate(env, rec)) {
|
|
915
|
+
this._request(rec.id, env.from, { from: rec.head.version + 1, to: g.mv.version }, out);
|
|
916
|
+
}
|
|
917
|
+
this._maybeInDate(env, g, rec);
|
|
918
|
+
this._persist(rec);
|
|
919
|
+
this._release(rec, now, out);
|
|
920
|
+
return res("accepted");
|
|
921
|
+
}
|
|
922
|
+
const stored = rec.chain[m.version - 1];
|
|
923
|
+
if (manifestHash(stored) === manifestHash(m)) { this._persist(rec); return res("accepted"); }
|
|
924
|
+
const check = m.version === 1 ? verifyGenesis(m) : verifyNext(rec.chain[m.version - 2], m);
|
|
925
|
+
if (!check.ok) return reject(check.reason); // @rule R4-proof-must-verify
|
|
926
|
+
return this._freeze(rec, stored, m, out);
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
_applyNext(rec, m, out) {
|
|
930
|
+
rec.chain.push(m);
|
|
931
|
+
rec.head = { version: m.version, hash: manifestHash(m) };
|
|
932
|
+
const t = m.transition;
|
|
933
|
+
if (t.action === "leave" || t.action === "remove") {
|
|
934
|
+
rec.leaving = rec.leaving.filter((u) => u !== t.target);
|
|
935
|
+
const since = Date.parse(m.issuedAt);
|
|
936
|
+
out.lateLines.push(...(this.acceptedLog.get(rec.id) ?? []).filter((x) => x.from === t.target && x.at > since).map((x) => x.msgId));
|
|
937
|
+
}
|
|
938
|
+
const c = this.catchup.get(rec.id);
|
|
939
|
+
if (c && rec.head.version >= c.to) this.catchup.delete(rec.id);
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
/** §5.4 proof: freeze, tell every non-removed member of either manifest once, then nothing. */
|
|
943
|
+
_freeze(rec, a, b, out) {
|
|
944
|
+
const ha = manifestHash(a), hb = manifestHash(b);
|
|
945
|
+
const to = [];
|
|
946
|
+
for (const m of [a, b]) for (const r of m.members) {
|
|
947
|
+
if (r.status !== "removed" && r.uuaid !== this.self.uuaid && !to.includes(r.uuaid)) to.push(r.uuaid);
|
|
948
|
+
}
|
|
949
|
+
for (const u of to) {
|
|
950
|
+
out.sends.push({ to: u, op: "equivocation", manifestHash: ha, conflictsWith: hb, mvVersion: rec.head.version });
|
|
951
|
+
out.sends.push({ to: u, op: "equivocation", manifestHash: hb, conflictsWith: ha, mvVersion: rec.head.version });
|
|
952
|
+
}
|
|
953
|
+
rec.frozen = { manifests: [a, b] }; // @rule R4-freeze
|
|
954
|
+
for (const h of this.held.get(rec.id) ?? []) out.dropped.push({ msgId: h.msgId, reason: "frozen" });
|
|
955
|
+
this.held.delete(rec.id);
|
|
956
|
+
this.catchup.delete(rec.id);
|
|
957
|
+
this._persist(rec);
|
|
958
|
+
return res("frozen", "equivocation-proof");
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
_hold(env, g, rec, version, now, out) {
|
|
962
|
+
const held = this.held.get(rec.id) ?? [];
|
|
963
|
+
if (held.filter((h) => h.from === env.from).length >= this.limits.holdPerSender) return drop("hold-quota-sender"); // @rule R2-quota-sender
|
|
964
|
+
if (held.length >= this.limits.holdPerGroup) return drop("hold-quota-group"); // @rule R2-quota-group
|
|
965
|
+
held.push({ env, g, from: env.from, msgId: g.msgId, version, receivedAt: now });
|
|
966
|
+
this.held.set(rec.id, held);
|
|
967
|
+
const c = this.catchup.get(rec.id) ?? { to: version, asked: new Set() };
|
|
968
|
+
c.to = Math.max(c.to, version);
|
|
969
|
+
c.asked.add(env.from);
|
|
970
|
+
this.catchup.set(rec.id, c);
|
|
971
|
+
this._request(rec.id, env.from, { from: rec.head.version + 1, to: version }, out);
|
|
972
|
+
return res("held");
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
/** At most one manifest.request per (target, version); an open-ended one covers every later version. */
|
|
976
|
+
_request(groupId, target, want, out) {
|
|
977
|
+
const key = `${groupId}|${target}`;
|
|
978
|
+
const a = this.asked.get(key) ?? { versions: new Set(), openFrom: null };
|
|
979
|
+
if (a.openFrom !== null && want.from >= a.openFrom) return;
|
|
980
|
+
if (want.to === null) a.openFrom = a.openFrom === null ? want.from : Math.min(a.openFrom, want.from);
|
|
981
|
+
else if (a.versions.has(want.to)) return; // @rule R2-one-request-per-version
|
|
982
|
+
else a.versions.add(want.to);
|
|
983
|
+
this.asked.set(key, a);
|
|
984
|
+
const rec = this._rec(groupId);
|
|
985
|
+
out.sends.push({ to: target, op: "manifest.request", want: { ...want }, mvVersion: rec ? rec.head.version : 0 });
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
/** After a head change, held messages now at or below the head are judged again from step 4. */
|
|
989
|
+
_release(rec, now, out) {
|
|
990
|
+
for (;;) {
|
|
991
|
+
const held = this.held.get(rec.id) ?? [];
|
|
992
|
+
const due = held.filter((h) => h.version <= rec.head.version);
|
|
993
|
+
if (!due.length) return;
|
|
994
|
+
this.held.set(rec.id, held.filter((h) => !due.includes(h)));
|
|
995
|
+
for (const h of due) {
|
|
996
|
+
const r = this._judgeKnown(h.env, h.g, now, out);
|
|
997
|
+
const x = r.reason ? { msgId: h.msgId, verdict: r.verdict, reason: r.reason } : { msgId: h.msgId, verdict: r.verdict };
|
|
998
|
+
if (h.env.ref !== undefined) x.ref = h.env.ref; // the caller's handle on the held message (pillar: the envelope id)
|
|
999
|
+
out.released.push(x);
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
_expire(now, out) {
|
|
1005
|
+
for (const [id, held] of this.held) {
|
|
1006
|
+
const keep = [];
|
|
1007
|
+
for (const h of held) {
|
|
1008
|
+
if (now - h.receivedAt > this.limits.holdMs) { out.dropped.push({ msgId: h.msgId, reason: "hold-expired", ...(h.env.ref !== undefined ? { ref: h.env.ref } : {}) }); continue; } // @rule R2-expiry
|
|
1009
|
+
keep.push(h);
|
|
1010
|
+
}
|
|
1011
|
+
if (keep.length !== held.length) {
|
|
1012
|
+
const rec = this._rec(id);
|
|
1013
|
+
if (rec) { rec.outOfDate = true; this._persist(rec); }
|
|
1014
|
+
if (keep.length) this.held.set(id, keep); else { this.held.delete(id); this.catchup.delete(id); }
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
for (const map of [this.invites, this.recovery]) {
|
|
1018
|
+
for (const [k, b] of map) if (now - b.firstAt > this.limits.holdMs) map.delete(k);
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
_log(id, from, msgId, at) {
|
|
1023
|
+
const log = this.acceptedLog.get(id) ?? [];
|
|
1024
|
+
log.push({ from, msgId, at });
|
|
1025
|
+
if (log.length > 2000) log.splice(0, log.length - 2000);
|
|
1026
|
+
this.acceptedLog.set(id, log);
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
// ── §5.0 ──
|
|
1030
|
+
_unknown(env, g, now, out) {
|
|
1031
|
+
const rc = this.recovery.get(g.id);
|
|
1032
|
+
if (rc && rc.from === env.from && g.op === "manifest") return this._recoveryStep(env, g, rc, now, out);
|
|
1033
|
+
if (g.op === "manifest") return this._invitationStep(env, g, now, out);
|
|
1034
|
+
return drop("unknown-group"); // @rule R0-unknown-group
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
/**
|
|
1038
|
+
* Design cap: a buffered invitation or recovery keeps versions 1..4096 and at
|
|
1039
|
+
* most 1,024 manifests. A group past version 4096 cannot be adopted by
|
|
1040
|
+
* invitation or recovery; its members keep it by applying each version.
|
|
1041
|
+
*/
|
|
1042
|
+
_buffer(map, key, m, now, extra = {}) {
|
|
1043
|
+
const b = map.get(key) ?? { manifests: new Map(), firstAt: now, requested: false, ...extra };
|
|
1044
|
+
if (Number.isSafeInteger(m.version) && m.version >= 1 && m.version <= 4096 && !b.manifests.has(m.version) && b.manifests.size < 1024) {
|
|
1045
|
+
b.manifests.set(m.version, m);
|
|
1046
|
+
}
|
|
1047
|
+
map.set(key, b);
|
|
1048
|
+
return b;
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
_invitationStep(env, g, now, out) {
|
|
1052
|
+
const m = g.manifest;
|
|
1053
|
+
if (m.id !== g.id) return drop("unknown-group");
|
|
1054
|
+
const key = `${g.id}|${env.from}`;
|
|
1055
|
+
const b = this._buffer(this.invites, key, m, now);
|
|
1056
|
+
if (!b.manifests.size) return drop("unknown-group");
|
|
1057
|
+
const top = Math.max(...b.manifests.keys());
|
|
1058
|
+
const topM = b.manifests.get(top);
|
|
1059
|
+
const me = Array.isArray(topM.members) ? topM.members.find((r) => plain(r) && r.uuaid === this.self.uuaid) : null;
|
|
1060
|
+
if (!me || me.status !== "invited" || me.key !== this.self.key) return res("ignored", "invitation-pending"); // @rule R0-names-self-invited
|
|
1061
|
+
const missing = [];
|
|
1062
|
+
for (let v = 1; v < top; v++) if (!b.manifests.has(v)) missing.push(v);
|
|
1063
|
+
if (missing.length) {
|
|
1064
|
+
const admin = Array.isArray(topM.members) ? topM.members.find((r) => plain(r) && r.status === "active" && r.role === "admin") : null;
|
|
1065
|
+
if (!b.requested && admin?.uuaid === env.from) {
|
|
1066
|
+
out.sends.push({ to: env.from, op: "manifest.request", want: { from: missing[0], to: missing[missing.length - 1] }, mvVersion: 0 });
|
|
1067
|
+
b.requested = true;
|
|
1068
|
+
}
|
|
1069
|
+
return res("ignored", "invitation-pending");
|
|
1070
|
+
}
|
|
1071
|
+
this.invites.delete(key);
|
|
1072
|
+
const chain = Array.from({ length: top }, (_, i) => b.manifests.get(i + 1));
|
|
1073
|
+
const v = verifyChain(chain);
|
|
1074
|
+
if (!v.ok) return drop(v.reason); // @rule R0-invitation-verifies
|
|
1075
|
+
const admin = adminOf(chain[top - 1]);
|
|
1076
|
+
if (env.from !== admin.uuaid || this._effKey(env) !== admin.key) return drop("invite-not-from-admin"); // @rule R0-invite-from-admin
|
|
1077
|
+
const rec = { ...newRecord(chain), seen: new Set() };
|
|
1078
|
+
this.records.set(rec.id, rec);
|
|
1079
|
+
this._persist(rec);
|
|
1080
|
+
return res("invitation");
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
_recoveryStep(env, g, rc, now, out) {
|
|
1084
|
+
const m = g.manifest;
|
|
1085
|
+
if (m.id !== g.id) return drop("unknown-group");
|
|
1086
|
+
this._buffer(this.recovery, g.id, m, now, { from: rc.from });
|
|
1087
|
+
const need = g.mv.version;
|
|
1088
|
+
for (let v = 1; v <= need; v++) if (!rc.manifests.has(v)) return res("ignored", "recovery-pending");
|
|
1089
|
+
if (need < 1) return res("ignored", "recovery-pending");
|
|
1090
|
+
this.recovery.delete(g.id);
|
|
1091
|
+
const chain = Array.from({ length: need }, (_, i) => rc.manifests.get(i + 1));
|
|
1092
|
+
const v = verifyChain(chain);
|
|
1093
|
+
if (!v.ok) return drop(v.reason);
|
|
1094
|
+
const head = chain[need - 1];
|
|
1095
|
+
const me = head.members.find((r) => r.uuaid === this.self.uuaid);
|
|
1096
|
+
if (!me || me.key !== this.self.key || !(me.status === "invited" || me.status === "active")) return drop("recovery-not-member"); // @rule R0-recovery-names-self
|
|
1097
|
+
const src = head.members.find((r) => r.uuaid === env.from);
|
|
1098
|
+
if (!src || src.status === "removed" || this._effKey(env) !== src.key) return drop("recovery-not-member");
|
|
1099
|
+
const rec = { ...newRecord(chain, { outOfDate: true, recoveredFrom: env.from }), seen: new Set() };
|
|
1100
|
+
this.records.set(rec.id, rec);
|
|
1101
|
+
this._persist(rec);
|
|
1102
|
+
const admin = adminOf(head).uuaid;
|
|
1103
|
+
const order = [admin, ...head.members.filter((r) => r.status === "active" && r.uuaid !== admin).map((r) => r.uuaid)];
|
|
1104
|
+
const target = order.find((u) => u !== this.self.uuaid && u !== env.from);
|
|
1105
|
+
if (target) this._request(rec.id, target, { from: need + 1, to: null }, out);
|
|
1106
|
+
return res("recovered");
|
|
1107
|
+
}
|
|
1108
|
+
}
|