@join-pantheon/cli 0.28.0 → 0.29.1

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/dist/relay.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { createServer } from "node:http";
2
2
  import { createHash, randomBytes, timingSafeEqual as timingSafeEqualBuf } from "node:crypto";
3
- import { rmSync, readdirSync, readFileSync, statSync, existsSync } from "node:fs";
3
+ import { rmSync, readdirSync, readFileSync, statSync, existsSync, writeFileSync } from "node:fs";
4
4
  import { spawn } from "node:child_process";
5
5
  import { join } from "node:path";
6
6
  import { WebSocketServer } from "ws";
@@ -47,8 +47,14 @@ function cookieVal(req, name) {
47
47
  return "";
48
48
  for (const part of raw.split(";")) {
49
49
  const eq = part.indexOf("=");
50
- if (eq > -1 && part.slice(0, eq).trim() === name)
51
- return decodeURIComponent(part.slice(eq + 1).trim());
50
+ if (eq > -1 && part.slice(0, eq).trim() === name) {
51
+ try {
52
+ return decodeURIComponent(part.slice(eq + 1).trim());
53
+ }
54
+ catch {
55
+ return "";
56
+ }
57
+ }
52
58
  }
53
59
  return "";
54
60
  }
@@ -72,8 +78,25 @@ export function isTrustedSource(ip) {
72
78
  return true;
73
79
  return false;
74
80
  }
75
- function fingerprint_(from, act, body, goal, ref) {
76
- return createHash("sha256").update([from, act, goal ?? "", ref ?? "", body].join("\u0000")).digest("hex").slice(0, 32);
81
+ function fingerprint_(from, act, body, goal, ref, extra = null) {
82
+ return createHash("sha256").update([from, act, goal ?? "", ref ?? "", body, stableJson(extra)].join("\u0000")).digest("hex").slice(0, 32);
83
+ }
84
+ function stableJson(v) {
85
+ if (v === null || typeof v !== "object")
86
+ return JSON.stringify(v ?? null);
87
+ if (Array.isArray(v))
88
+ return `[${v.map(stableJson).join(",")}]`;
89
+ return `{${Object.keys(v).sort().map((k) => `${JSON.stringify(k)}:${stableJson(v[k])}`).join(",")}}`;
90
+ }
91
+ function clientIp(req) {
92
+ const raw = req.headers["x-forwarded-for"];
93
+ const hops = (Array.isArray(raw) ? raw.join(",") : raw ?? "").split(",").map((x) => x.trim()).filter(Boolean);
94
+ const trusted = Math.max(1, Number(process.env.PANTHEON_TRUSTED_HOPS ?? 1) || 1);
95
+ return hops.length ? hops[Math.max(0, hops.length - trusted)] : req.socket.remoteAddress || "unknown";
96
+ }
97
+ function secretEq(a, b) {
98
+ const x = createHash("sha256").update(a).digest(), y = createHash("sha256").update(b).digest();
99
+ return timingSafeEqualBuf(x, y);
77
100
  }
78
101
  const ADMIN_CSS = `<style>
79
102
  :root{--bg:#0b0c0e;--card:#14161a;--card2:#1b1e24;--line:#262a30;--fg:#e7e9ec;--dim:#9aa1a9;--accent:#5b9dff;--warn:#e8b45c;--bad:#ef8b80;--ok:#7bd39a;color-scheme:dark}
@@ -137,12 +160,38 @@ const fmtAgo = (t) => {
137
160
  const m = Math.round((Date.now() - t) / 60_000);
138
161
  return m < 1 ? "just now" : m < 60 ? `${m}m ago` : m < 1440 ? `${Math.round(m / 60)}h ago` : `${Math.round(m / 1440)}d ago`;
139
162
  };
163
+ const safeName = (name) => name.replace(/[^a-z0-9._-]/gi, "_");
164
+ const LIMITS_GOAL_READ = 1000;
165
+ const LIMITS_SPACE = { metaBytes: 8192, openGoals: 500, repos: 100, openInvites: 50 };
140
166
  export class Relay {
141
167
  opts;
142
168
  spaces = new Map();
143
169
  dataDir;
144
170
  log;
145
- http = createServer((req, res) => this.onHttp(req, res));
171
+ http = createServer((req, res) => {
172
+ const fail = (e) => {
173
+ const bad = e instanceof TypeError || e instanceof URIError;
174
+ if (!bad)
175
+ this.log(`! http ${req.method} ${String(req.url ?? "").slice(0, 120)} failed: ${e?.message ?? e}`);
176
+ try {
177
+ if (!res.headersSent)
178
+ this.json(res, bad ? 400 : 500, { error: bad ? "bad request" : "the relay could not handle that request" });
179
+ else
180
+ res.end();
181
+ }
182
+ catch { }
183
+ };
184
+ try {
185
+ if (!URL.canParse(req.url ?? "/", "http://x"))
186
+ return fail(new TypeError("unparseable request target"));
187
+ const r = this.onHttp(req, res);
188
+ if (r && typeof r.catch === "function")
189
+ r.catch(fail);
190
+ }
191
+ catch (e) {
192
+ fail(e);
193
+ }
194
+ });
146
195
  wss = new WebSocketServer({ noServer: true, maxPayload: 256 * 1024 });
147
196
  heartbeat;
148
197
  compactedAt = new Map();
@@ -197,8 +246,7 @@ export class Relay {
197
246
  this.busyTtlMs = opts.activityBusyTtlMs ?? LIMITS.activityBusyTtlMs;
198
247
  this.pruneMs = opts.activityPruneMs ?? LIMITS.activityPruneMs;
199
248
  this.http.on("upgrade", (req, socket, head) => {
200
- const fwd = req.headers["x-forwarded-for"]?.split(",")[0]?.trim();
201
- const ip = fwd || req.socket.remoteAddress || "unknown";
249
+ const ip = clientIp(req);
202
250
  this.wss.handleUpgrade(req, socket, head, (ws) => this.onSocket(ws, ip));
203
251
  });
204
252
  }
@@ -234,8 +282,7 @@ export class Relay {
234
282
  }
235
283
  onHttp(req, res) {
236
284
  const url = new URL(req.url ?? "/", "http://x");
237
- const ip = (req.headers["x-forwarded-for"]?.split(",")[0]?.trim())
238
- || req.socket.remoteAddress || "unknown";
285
+ const ip = clientIp(req);
239
286
  if (url.pathname === "/" || url.pathname.startsWith("/auth/") || url.pathname.startsWith("/account/")
240
287
  || url.pathname === "/audit" || url.pathname.startsWith("/admin")) {
241
288
  return this.onAuthHttp(req, res, url, ip);
@@ -264,6 +311,8 @@ export class Relay {
264
311
  });
265
312
  }
266
313
  if (url.pathname === "/join") {
314
+ if (!this.authRateOk(ip))
315
+ return this.json(res, 429, { error: "too many lookups from your network — wait a minute" });
267
316
  const token = (url.searchParams.get("token") ?? "").trim();
268
317
  if (!/^[A-Za-z0-9_-]{16,64}$/.test(token))
269
318
  return this.json(res, 400, { error: "an invite code is required: /join?token=<code>" });
@@ -273,9 +322,9 @@ export class Relay {
273
322
  : this.json(res, 404, { error: "no open invite matches that code — it may have been used already or expired" });
274
323
  }
275
324
  if (url.pathname === "/act") {
276
- const space = this.spaces.get(url.searchParams.get("space") ?? "");
325
+ const space = this.spaceForRead(url.searchParams.get("space") ?? "", url.searchParams.get("token") ?? "");
277
326
  const token = url.searchParams.get("token") ?? "";
278
- if (!space || space.token !== token)
327
+ if (!space || !secretEq(space.token, token))
279
328
  return this.json(res, 404, { error: "no such space" });
280
329
  const act = space.ledger.find(url.searchParams.get("id") ?? "");
281
330
  if (!act)
@@ -284,19 +333,20 @@ export class Relay {
284
333
  return this.json(res, 200, { act, ...(goal ? { goal: { id: goal.id, title: goal.title, state: goal.state } } : {}) });
285
334
  }
286
335
  if (url.pathname === "/history") {
287
- const space = this.spaces.get(url.searchParams.get("space") ?? "");
336
+ const space = this.spaceForRead(url.searchParams.get("space") ?? "", url.searchParams.get("token") ?? "");
288
337
  const token = url.searchParams.get("token") ?? "";
289
- if (!space || space.token !== token)
338
+ if (!space || !secretEq(space.token, token))
290
339
  return this.json(res, 404, { error: "no such space" });
291
340
  const goal = url.searchParams.get("goal");
292
- const since = Number(url.searchParams.get("since") ?? 0);
293
- const acts = goal ? space.ledger.byGoal(goal) : since > 0 ? space.ledger.since(since) : space.ledger.latest();
341
+ const sinceRaw = url.searchParams.get("since");
342
+ const acts = goal ? space.ledger.byGoal(goal, LIMITS_GOAL_READ)
343
+ : sinceRaw !== null ? space.ledger.since(Number(sinceRaw) || 0) : space.ledger.latest();
294
344
  return this.json(res, 200, { acts, head: space.ledger.head });
295
345
  }
296
346
  if (url.pathname === "/obligations") {
297
- const space = this.spaces.get(url.searchParams.get("space") ?? "");
347
+ const space = this.spaceForRead(url.searchParams.get("space") ?? "", url.searchParams.get("token") ?? "");
298
348
  const token = url.searchParams.get("token") ?? "";
299
- if (!space || space.token !== token)
349
+ if (!space || !secretEq(space.token, token))
300
350
  return this.json(res, 404, { error: "no such space" });
301
351
  const member = url.searchParams.get("member");
302
352
  const now = Date.now();
@@ -312,9 +362,9 @@ export class Relay {
312
362
  if (url.pathname === "/activity") {
313
363
  if (req.method !== "POST")
314
364
  return this.json(res, 405, { error: "POST only" });
315
- const space = this.spaces.get(url.searchParams.get("space") ?? "");
365
+ const space = this.spaceForRead(url.searchParams.get("space") ?? "", url.searchParams.get("token") ?? "");
316
366
  const token = url.searchParams.get("token") ?? "";
317
- if (!space || space.token !== token)
367
+ if (!space || !secretEq(space.token, token))
318
368
  return this.json(res, 404, { error: "no such space" });
319
369
  const memberId = url.searchParams.get("member") ?? "";
320
370
  const member = space.members.get(memberId);
@@ -461,9 +511,10 @@ export class Relay {
461
511
  if (started.reason === "blocked")
462
512
  return this.json(res, 403, { error: "this email cannot sign up on this relay", reason: "blocked" });
463
513
  const msg = started.reason === "too_soon" ? "a code was just sent — check your email, or wait a minute to resend"
464
- : started.reason === "username_taken" ? "that username is taken"
465
- : started.reason === "email_taken" ? "that email already has an account — use `pantheon login` from this machine instead"
466
- : "check the email and username";
514
+ : started.reason === "in_progress_elsewhere" ? "a code for this email is already on its way to another device — use that one, or wait 10 minutes for it to expire"
515
+ : started.reason === "username_taken" ? "that username is taken"
516
+ : started.reason === "email_taken" ? "that email already has an account — use `pantheon login` from this machine instead"
517
+ : "check the email and username";
467
518
  return this.json(res, 409, { error: msg, reason: started.reason });
468
519
  }
469
520
  A.audit({ event: "signup", target: normalizeUsername(b.username), ip });
@@ -485,8 +536,8 @@ export class Relay {
485
536
  if (!started.ok && started.reason === "blocked")
486
537
  return this.json(res, 403, { error: "this email cannot sign in on this relay", reason: "blocked" });
487
538
  if (!started.ok) {
488
- const code = started.reason === "too_soon" ? 429 : 404;
489
- const msg = started.reason === "too_soon" ? "a code was just sent — check your email, or wait a minute" : "no active account for that email — `pantheon signup` first";
539
+ const code = started.reason === "too_soon" || started.reason === "in_progress_elsewhere" ? 429 : 404;
540
+ const msg = started.reason === "too_soon" ? "a code was just sent — check your email, or wait a minute" : started.reason === "in_progress_elsewhere" ? "a code for this email is already on its way to another device — use that one, or wait 10 minutes for it to expire" : "no active account for that email — `pantheon signup` first";
490
541
  return this.json(res, code, { error: msg, reason: started.reason });
491
542
  }
492
543
  if (!this.emailBudgetOk())
@@ -503,12 +554,14 @@ export class Relay {
503
554
  return this.readJson(req, (b) => {
504
555
  if (!b || typeof b.email !== "string" || typeof b.code !== "string")
505
556
  return this.json(res, 400, { error: "email and code are required" });
506
- const r = A.verify(b.email, b.code.trim(), ip);
557
+ const r = A.verify(b.email, b.code.trim(), ip, typeof b.pubkey === "string" ? b.pubkey : undefined);
507
558
  if (!r.ok) {
508
559
  const msg = r.reason === "expired" ? "that code has expired — request a new one"
509
- : r.reason === "too_many_attempts" ? "too many wrong tries — request a new code"
510
- : r.reason === "no_pending" ? "no code is pending for that email — run signup or login first"
511
- : "that code is not right";
560
+ : r.reason === "other_device" ? "that code was requested from a different device — request a new one from this one"
561
+ : r.reason === "suspended" ? "this account is suspended — contact the relay operator"
562
+ : r.reason === "too_many_attempts" ? "too many wrong tries — request a new code"
563
+ : r.reason === "no_pending" ? "no code is pending for that email — run signup or login first"
564
+ : "that code is not right";
512
565
  return this.json(res, 400, { error: msg, reason: r.reason });
513
566
  }
514
567
  return this.json(res, 200, { ok: true, account: { id: r.account.id, username: r.account.username, email: r.account.email }, session: r.session.id, device: r.device.id });
@@ -900,6 +953,11 @@ button:hover{background:#1d4ed8}.err{color:var(--bad);margin-top:12px}
900
953
  }
901
954
  setTimeout(() => this.deleteSpaceFiles(name), 600).unref?.();
902
955
  const safe = name.replace(/[^a-z0-9._-]/gi, "_");
956
+ try {
957
+ const hash = sp?.allow ? JSON.parse(readFileSync(join(this.dataDir, `${safe}.members.json`), "utf8")).tokenHash ?? null : null;
958
+ writeFileSync(join(this.dataDir, `${safe}.deleted.json`), JSON.stringify({ name, at: Date.now(), tokenHash: hash }));
959
+ }
960
+ catch { }
903
961
  const existed = !!sp || [".jsonl", ".state.json", ".members.json"].some((suf) => existsSync(join(this.dataDir, `${safe}${suf}`)));
904
962
  this.deleteSpaceFiles(name);
905
963
  this.deletedSpaces.set(name, Date.now());
@@ -1033,6 +1091,42 @@ button:hover{background:#1d4ed8}.err{color:var(--bad);margin-top:12px}
1033
1091
  default: return this.json(res, 400, { error: `unknown action "${act}"` });
1034
1092
  }
1035
1093
  }
1094
+ spaceForRead(name, token) {
1095
+ const live = this.spaces.get(name);
1096
+ if (live)
1097
+ return secretEq(live.token, token) ? live : null;
1098
+ if (!name || !token || !this.spaceOnDisk(name))
1099
+ return null;
1100
+ if (new Allowlist(this.dataDir, safeName(name)).tokenMatches(token) !== true)
1101
+ return null;
1102
+ return this.getOrCreateSpace(name, token);
1103
+ }
1104
+ tombstone(name, _token) {
1105
+ try {
1106
+ return JSON.parse(readFileSync(join(this.dataDir, `${safeName(name)}.deleted.json`), "utf8")).at;
1107
+ }
1108
+ catch {
1109
+ return null;
1110
+ }
1111
+ }
1112
+ tombstoneMatches(name, token) {
1113
+ try {
1114
+ const t = JSON.parse(readFileSync(join(this.dataDir, `${safeName(name)}.deleted.json`), "utf8"));
1115
+ return !!t.tokenHash && !!token && createHash("sha256").update(token).digest("hex") === t.tokenHash;
1116
+ }
1117
+ catch {
1118
+ return false;
1119
+ }
1120
+ }
1121
+ nameCollides(name) {
1122
+ const stem = safeName(name).toLowerCase();
1123
+ try {
1124
+ return readdirSync(this.dataDir).some((f) => f.endsWith(".members.json") && f.slice(0, -".members.json".length).toLowerCase() === stem);
1125
+ }
1126
+ catch {
1127
+ return false;
1128
+ }
1129
+ }
1036
1130
  spaceOnDisk(name) {
1037
1131
  return existsSync(join(this.dataDir, `${name.replace(/[^a-z0-9._-]/gi, "_")}.members.json`));
1038
1132
  }
@@ -1549,7 +1643,7 @@ pantheon signup --email you@work.com --username you</code></pre>
1549
1643
  const fail = (code, message, cid) => {
1550
1644
  if (cid && space && me && !space.deleted) {
1551
1645
  noteRefusal(space.store.refusals, { ts: Date.now(), member: me.id, act: lastMsg || "?", code });
1552
- space.store.save(space.goals, space.obligations, space.profiles);
1646
+ space.store.flush(space.goals, space.obligations, space.profiles);
1553
1647
  }
1554
1648
  this.send(ws, { t: "error", code, message, ...(cid ? { cid } : {}) });
1555
1649
  };
@@ -1575,453 +1669,546 @@ pantheon signup --email you@work.com --username you</code></pre>
1575
1669
  ws.on("close", clearHelloTimer);
1576
1670
  this.send(ws, { t: "challenge", nonce, v: PROTOCOL_VERSION });
1577
1671
  ws.on("message", (raw) => {
1578
- let msg;
1579
1672
  try {
1580
- msg = JSON.parse(String(raw));
1581
- }
1582
- catch {
1583
- return fail("BAD_JSON", "message was not JSON");
1584
- }
1585
- const parsed = Inbound.safeParse(msg);
1586
- if (!parsed.success) {
1587
- const cid = typeof msg?.cid === "string"
1588
- ? msg.cid : undefined;
1589
- return fail("BAD_MESSAGE", parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; "), cid);
1590
- }
1591
- const m = parsed.data;
1592
- lastMsg = m.t === "act" ? m.act : m.t;
1593
- touch();
1594
- if (m.t === "ping")
1595
- return this.send(ws, { t: "pong" });
1596
- if (m.t === "hello") {
1597
- if (space)
1598
- return reject("ALREADY_JOINED", "this socket already said hello");
1599
- if (m.nonce !== nonce)
1600
- return reject("BAD_NONCE", "challenge mismatch — reconnect");
1601
- if (!m.token && !m.enroll)
1602
- return reject("NO_CREDENTIAL", "present either a space token or an invite");
1603
- if (!verifyChallenge(m.pubkey, m.space, m.member, m.nonce, m.sig)) {
1604
- return reject("BAD_SIGNATURE", `signature does not match the presented key for "${m.member}"`);
1673
+ let msg;
1674
+ try {
1675
+ msg = JSON.parse(String(raw));
1605
1676
  }
1606
- const gated = this.requireAccounts && !!this.accounts && !this.localTrusted(ip);
1607
- const pre = gated ? this.accounts.authorize(m.pubkey, m.member) : null;
1608
- let sp;
1609
- if (m.enroll) {
1610
- sp = this.spaces.get(m.space) ?? null;
1611
- if (!sp)
1612
- return reject("NO_SUCH_SPACE", `no space "${m.space}" on this relay`);
1677
+ catch {
1678
+ return fail("BAD_JSON", "message was not JSON");
1613
1679
  }
1614
- else {
1615
- const tomb = this.deletedSpaces.get(m.space);
1616
- if (tomb && Date.now() - tomb < 3_600_000) {
1617
- return reject("SPACE_DELETED", `the space "${m.space}" was removed by the relay operator. Start a new one with a different name, or contact them.`);
1680
+ const parsed = Inbound.safeParse(msg);
1681
+ if (!parsed.success) {
1682
+ const cid = typeof msg?.cid === "string"
1683
+ ? msg.cid : undefined;
1684
+ return fail("BAD_MESSAGE", parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; "), cid);
1685
+ }
1686
+ const m = parsed.data;
1687
+ lastMsg = m.t === "act" ? m.act : m.t;
1688
+ touch();
1689
+ if (m.t === "ping")
1690
+ return this.send(ws, { t: "pong" });
1691
+ if (m.t === "hello") {
1692
+ if (space)
1693
+ return reject("ALREADY_JOINED", "this socket already said hello");
1694
+ if (m.nonce !== nonce)
1695
+ return reject("BAD_NONCE", "challenge mismatch — reconnect");
1696
+ if (!m.token && !m.enroll)
1697
+ return reject("NO_CREDENTIAL", "present either a space token or an invite");
1698
+ if (!verifyChallenge(m.pubkey, m.space, m.member, m.nonce, m.sig)) {
1699
+ return reject("BAD_SIGNATURE", `signature does not match the presented key for "${m.member}"`);
1618
1700
  }
1619
- const isNew = !this.spaces.has(m.space) && !this.spaceOnDisk(m.space);
1620
- if (!this.spaces.has(m.space)) {
1621
- const denied = this.mayCreateSpace(ip);
1622
- if (denied)
1623
- return reject(denied.code, denied.message);
1701
+ const gated = this.requireAccounts && !!this.accounts && !this.localTrusted(ip);
1702
+ const pre = gated ? this.accounts.authorize(m.pubkey, m.member) : null;
1703
+ let sp;
1704
+ if (m.enroll) {
1705
+ sp = this.spaces.get(m.space) ?? null;
1706
+ if (!sp && this.spaceOnDisk(m.space)) {
1707
+ const inv = new Allowlist(this.dataDir, safeName(m.space)).findInvite(m.enroll);
1708
+ if (inv?.token === m.enroll && inv.space_token)
1709
+ sp = this.getOrCreateSpace(m.space, inv.space_token);
1710
+ }
1711
+ if (!sp)
1712
+ return reject("NO_SUCH_SPACE", `no space "${m.space}" on this relay`);
1624
1713
  }
1625
- if (isNew && gated && !pre?.ok) {
1626
- this.log(`! REFUSED ${m.member}: cannot create "${m.space}" without a signed-in account (${pre?.ok === false ? pre.reason : "no account"})`);
1627
- return reject("NOT_SIGNED_IN", "sign in first: run `pantheon signup` (new) or `pantheon login` (existing account) on this machine, then try again.");
1714
+ else {
1715
+ const tomb = this.deletedSpaces.get(m.space) ?? this.tombstone(m.space, m.token ?? "");
1716
+ if (tomb && Date.now() - tomb < 3_600_000 || (tomb && this.tombstoneMatches(m.space, m.token ?? ""))) {
1717
+ return reject("SPACE_DELETED", `the space "${m.space}" was removed by the relay operator. Start a new one with a different name, or contact them.`);
1718
+ }
1719
+ const isNew = !this.spaces.has(m.space) && !this.spaceOnDisk(m.space);
1720
+ if (isNew && this.nameCollides(m.space)) {
1721
+ return reject("NAME_TAKEN", `a team with a name too close to "${m.space}" already exists here — pick another name`);
1722
+ }
1723
+ if (!this.spaces.has(m.space)) {
1724
+ const denied = this.mayCreateSpace(ip);
1725
+ if (denied)
1726
+ return reject(denied.code, denied.message);
1727
+ }
1728
+ if (isNew && gated && !pre?.ok) {
1729
+ this.log(`! REFUSED ${m.member}: cannot create "${m.space}" without a signed-in account (${pre?.ok === false ? pre.reason : "no account"})`);
1730
+ return reject("NOT_SIGNED_IN", "sign in first: run `pantheon signup` (new) or `pantheon login` (existing account) on this machine, then try again.");
1731
+ }
1732
+ if (isNew && pre?.ok) {
1733
+ const lim = this.accounts.limitsFor(pre.account);
1734
+ const owned = this.accounts.spacesOwned(pre.account.id);
1735
+ if (owned.length >= lim.spaces) {
1736
+ const msg = `your ${pre.account.plan} plan allows ${fmtLimit(lim.spaces)} space${lim.spaces === 1 ? "" : "s"} you create${owned.length ? ` (you own ${owned.map((n) => `"${n}"`).join(", ")})` : ""}. Upgrade at https://www.joinpantheon.network, or free a slot: pantheon space delete <team> --yes`;
1737
+ this.accounts.audit({ event: "plan_limit", actor_account: pre.account.id, space: m.space, target: "create_space", detail: `${pre.account.plan}: ${owned.length}/${lim.spaces} spaces`, ip });
1738
+ this.log(`! PLAN_LIMIT ${m.member}: cannot create "${m.space}" (${owned.length}/${fmtLimit(lim.spaces)} spaces on ${pre.account.plan})`);
1739
+ return reject("PLAN_LIMIT", msg);
1740
+ }
1741
+ }
1742
+ sp = this.getOrCreateSpace(m.space, m.token);
1743
+ if (!sp)
1744
+ return reject("BAD_TOKEN", "wrong token for this space");
1745
+ if (isNew) {
1746
+ this.noteSpaceCreate(ip);
1747
+ this.accounts?.recordSpace(sp.name, pre?.ok ? pre.account.id : null);
1748
+ }
1628
1749
  }
1629
- if (isNew && pre?.ok) {
1630
- const lim = this.accounts.limitsFor(pre.account);
1631
- const owned = this.accounts.spacesOwned(pre.account.id);
1632
- if (owned.length >= lim.spaces) {
1633
- const msg = `your ${pre.account.plan} plan allows ${fmtLimit(lim.spaces)} space${lim.spaces === 1 ? "" : "s"} you create${owned.length ? ` (you own ${owned.map((n) => `"${n}"`).join(", ")})` : ""}. Upgrade at https://www.joinpantheon.network, or free a slot: pantheon space delete <team> --yes`;
1634
- this.accounts.audit({ event: "plan_limit", actor_account: pre.account.id, space: m.space, target: "create_space", detail: `${pre.account.plan}: ${owned.length}/${lim.spaces} spaces`, ip });
1635
- this.log(`! PLAN_LIMIT ${m.member}: cannot create "${m.space}" (${owned.length}/${fmtLimit(lim.spaces)} spaces on ${pre.account.plan})`);
1636
- return reject("PLAN_LIMIT", msg);
1750
+ let acctSession = null;
1751
+ if (this.requireAccounts && this.accounts && !this.localTrusted(ip)) {
1752
+ const auth = this.accounts.authorize(m.pubkey, m.member);
1753
+ if (!auth.ok) {
1754
+ const msg = auth.reason === "username_mismatch"
1755
+ ? `this device is signed in as a different username than "${m.member}". Use your own username, or \`pantheon login\` on this machine.`
1756
+ : auth.reason === "suspended"
1757
+ ? `this account has been suspended by the relay operator. Contact them to have it reinstated.`
1758
+ : `sign in first: run \`pantheon signup\` (new) or \`pantheon login\` (existing account) on this machine, then try again.`;
1759
+ this.log(`[${sp.name}] ! REFUSED ${m.member}: ${auth.reason}`);
1760
+ return reject("NOT_SIGNED_IN", msg);
1637
1761
  }
1762
+ m.member = auth.account.username;
1763
+ acct = auth.account;
1764
+ acctSession = this.accounts.openSession(auth.account.id, auth.device.id, sp.name, ip, m.role ?? "interactive").id;
1765
+ dbSession = acctSession;
1766
+ lastTouch = Date.now();
1638
1767
  }
1639
- sp = this.getOrCreateSpace(m.space, m.token);
1640
- if (!sp)
1641
- return reject("BAD_TOKEN", "wrong token for this space");
1642
- if (isNew) {
1643
- this.noteSpaceCreate(ip);
1644
- this.accounts?.recordSpace(sp.name, pre?.ok ? pre.account.id : null);
1768
+ let enrolled = null;
1769
+ if (m.enroll) {
1770
+ const plan = this.spacePlan(sp.name);
1771
+ if (plan && !sp.allow.get(m.member)?.admitted && sp.allow.admittedCount() >= plan.limits.members) {
1772
+ this.accounts?.audit({ event: "plan_limit", actor_account: acct?.id ?? null, space: sp.name, target: "join", detail: `${plan.name}: ${sp.allow.admittedCount()}/${plan.limits.members} people`, ip });
1773
+ return reject("PLAN_LIMIT", `"${sp.name}" is full: its owner's ${plan.name} plan allows ${fmtLimit(plan.limits.members)} people. Ask ${plan.owner.username} to upgrade at https://www.joinpantheon.network`);
1774
+ }
1775
+ const r = sp.allow.redeem(m.enroll, m.member, m.pubkey);
1776
+ if (!r.ok) {
1777
+ this.log(`[${sp.name}] ! invite refused for ${m.member}: ${r.why}`);
1778
+ return reject("BAD_INVITE", r.fix ? `${r.why}\n${r.fix}` : r.why);
1779
+ }
1780
+ enrolled = sp.token;
1781
+ this.log(`[${sp.name}] * ENROLLED ${m.member} via invite from ${r.entry.admitted_by}${r.entry.may_wake ? " (may wake)" : ""}`);
1645
1782
  }
1646
- }
1647
- let acctSession = null;
1648
- if (this.requireAccounts && this.accounts && !this.localTrusted(ip)) {
1649
- const auth = this.accounts.authorize(m.pubkey, m.member);
1650
- if (!auth.ok) {
1651
- const msg = auth.reason === "username_mismatch"
1652
- ? `this device is signed in as a different username than "${m.member}". Use your own username, or \`pantheon login\` on this machine.`
1653
- : auth.reason === "suspended"
1654
- ? `this account has been suspended by the relay operator. Contact them to have it reinstated.`
1655
- : `sign in first: run \`pantheon signup\` (new) or \`pantheon login\` (existing account) on this machine, then try again.`;
1656
- this.log(`[${sp.name}] ! REFUSED ${m.member}: ${auth.reason}`);
1657
- return reject("NOT_SIGNED_IN", msg);
1783
+ const verdict = enrolled ? "ok" : sp.allow.present(m.member, m.pubkey);
1784
+ if (verdict === "key-mismatch") {
1785
+ this.log(`[${sp.name}] ! REFUSED ${m.member}: key mismatch (${fingerprint(m.pubkey)})`);
1786
+ return reject("KEY_MISMATCH", `"${m.member}" is already registered here with a different key — another machine, or this one before its key was replaced. If this is really you, an admitted member runs pantheon admit ${m.member} --forget and sends you a new invite.`);
1658
1787
  }
1659
- m.member = auth.account.username;
1660
- acct = auth.account;
1661
- acctSession = this.accounts.openSession(auth.account.id, auth.device.id, sp.name, ip, m.role ?? "interactive").id;
1662
- dbSession = acctSession;
1663
- lastTouch = Date.now();
1664
- }
1665
- let enrolled = null;
1666
- if (m.enroll) {
1667
- const plan = this.spacePlan(sp.name);
1668
- if (plan && !sp.allow.get(m.member)?.admitted && sp.allow.admittedCount() >= plan.limits.members) {
1669
- this.accounts?.audit({ event: "plan_limit", actor_account: acct?.id ?? null, space: sp.name, target: "join", detail: `${plan.name}: ${sp.allow.admittedCount()}/${plan.limits.members} people`, ip });
1670
- return reject("PLAN_LIMIT", `"${sp.name}" is full: its owner's ${plan.name} plan allows ${fmtLimit(plan.limits.members)} people. Ask ${plan.owner.username} to upgrade at https://www.joinpantheon.network`);
1788
+ if (verdict === "revoked") {
1789
+ const e = sp.allow.get(m.member);
1790
+ this.log(`[${sp.name}] ! REFUSED ${m.member}: removed${e?.revoked_by ? ` by ${e.revoked_by}` : ""}`);
1791
+ return reject("REVOKED", `"${m.member}" was removed from "${sp.name}"${e?.revoked_by ? ` by ${e.revoked_by}` : ""}. Only a member of the team can let you back in (pantheon admit ${m.member}); until then this machine cannot read or send in it.`);
1671
1792
  }
1672
- const r = sp.allow.redeem(m.enroll, m.member, m.pubkey);
1673
- if (!r.ok) {
1674
- this.log(`[${sp.name}] ! invite refused for ${m.member}: ${r.why}`);
1675
- return reject("BAD_INVITE", r.fix ? `${r.why}\n${r.fix}` : r.why);
1793
+ if (verdict === "new" || verdict === "pending") {
1794
+ this.log(`[${sp.name}] ? PENDING ${m.member} ${fingerprint(m.pubkey)}`);
1795
+ this.send(ws, {
1796
+ t: "pending", member: m.member, fingerprint: fingerprint(m.pubkey),
1797
+ message: `Not admitted yet. Ask an existing member to run: pantheon admit ${m.member}\nHave them check this fingerprint over a channel you both trust: ${fingerprint(m.pubkey)}`,
1798
+ });
1799
+ setTimeout(() => { try {
1800
+ ws.close(1008, "PENDING");
1801
+ }
1802
+ catch { } }, 20);
1803
+ return;
1804
+ }
1805
+ let newRepo = null;
1806
+ if (m.repo) {
1807
+ if (sp.allow.hasRepo(m.repo)) { }
1808
+ else if (sp.allow.repoCount() === 0 || enrolled || verdict === "founder")
1809
+ newRepo = m.repo;
1810
+ else {
1811
+ const known = sp.allow.repoList().map((r) => r.origin ?? r.fingerprint).join(", ");
1812
+ this.log(`[${sp.name}] ! REFUSED ${m.member}: repo ${m.repo_origin ?? m.repo} not bound to this space`);
1813
+ return reject("REPO_MISMATCH", `this checkout (${m.repo_origin ?? "no origin"}) is not one of the repos "${sp.name}" works from (${known || "none yet"}). ` +
1814
+ `Open your agent in the team's repo, or if this repo really belongs to the team: pantheon repo add`);
1815
+ }
1816
+ }
1817
+ space = sp;
1818
+ clearHelloTimer();
1819
+ sessionId = acctSession ?? this.mintSession();
1820
+ role = m.role;
1821
+ me = this.attach(sp, m.member, m.display ?? m.member, m.agent ?? "unknown", sessionId, m.role, ws, m.pkg);
1822
+ const firstSession = me.sessions.size === 1;
1823
+ const prior = typeof m.session === "string" ? m.session : "";
1824
+ const stale = prior && prior !== sessionId ? me.sessions.get(prior) : undefined;
1825
+ if (stale && stale.role === m.role) {
1826
+ me.sessions.delete(prior);
1827
+ try {
1828
+ stale.ws.terminate();
1829
+ }
1830
+ catch { }
1831
+ }
1832
+ if (prior && prior !== sessionId && !me.sessions.has(prior)) {
1833
+ let moved = 0;
1834
+ for (const o of sp.obligations.values()) {
1835
+ if (o.claimed_by?.session === prior && o.owner === me.id && o.claimed_by.role === m.role) {
1836
+ o.claimed_by = { ...o.claimed_by, session: sessionId, at: Date.now() };
1837
+ moved++;
1838
+ }
1839
+ }
1840
+ for (const v of sp.contLocks.values())
1841
+ if (v.session === prior && v.member === me.id) {
1842
+ v.session = sessionId;
1843
+ v.at = Date.now();
1844
+ }
1845
+ if (moved)
1846
+ this.pushObligations(sp);
1847
+ }
1848
+ sp.lastTouch = Date.now();
1849
+ if (newRepo && sp.allow.addRepo(newRepo, m.repo_origin ?? null, me.id)) {
1850
+ this.log(`[${sp.name}] + repo ${m.repo_origin ?? newRepo} registered by ${me.id}`);
1851
+ this.commit(sp, { from: me.id, act: "NOTE", to: null, goal: null, ref: null, body: `repo registered with this space: ${m.repo_origin ?? newRepo}`, meta: { repo: newRepo, repo_origin: m.repo_origin ?? null } });
1852
+ this.broadcast(sp, { t: "repos", repos: sp.allow.repoList() });
1676
1853
  }
1677
- enrolled = sp.token;
1678
- this.log(`[${sp.name}] * ENROLLED ${m.member} via invite from ${r.entry.admitted_by}${r.entry.may_wake ? " (may wake)" : ""}`);
1679
- }
1680
- const verdict = enrolled ? "ok" : sp.allow.present(m.member, m.pubkey);
1681
- if (verdict === "key-mismatch") {
1682
- this.log(`[${sp.name}] ! REFUSED ${m.member}: key mismatch (${fingerprint(m.pubkey)})`);
1683
- return reject("KEY_MISMATCH", `"${m.member}" is already registered with a different key. If this is really you, an admitted member must revoke and re-admit you.`);
1684
- }
1685
- if (verdict === "new" || verdict === "pending") {
1686
- this.log(`[${sp.name}] ? PENDING ${m.member} ${fingerprint(m.pubkey)}`);
1687
1854
  this.send(ws, {
1688
- t: "pending", member: m.member, fingerprint: fingerprint(m.pubkey),
1689
- message: `Not admitted yet. Ask an existing member to run: pantheon admit ${m.member}\nHave them check this fingerprint over a channel you both trust: ${fingerprint(m.pubkey)}`,
1855
+ t: "welcome", member: me.id, session: sessionId, space: sp.name, seq: sp.ledger.head,
1856
+ roster: this.roster(sp), goals: [...sp.goals.values()],
1857
+ obligations: [...sp.obligations.values()], members: this.members(sp), profiles: [...sp.profiles.values()],
1858
+ space_id: sp.allow.spaceId(), repos: sp.allow.repoList(),
1859
+ ...(acct ? { plan: this.planView(acct, sp) } : {}),
1860
+ ...(enrolled ? { token: enrolled } : {}),
1690
1861
  });
1691
- setTimeout(() => { try {
1692
- ws.close(1008, "PENDING");
1862
+ let from = m.from_seq && m.from_seq > 0 ? m.from_seq : sp.ledger.head;
1863
+ for (let page = sp.ledger.since(from); page.length; page = sp.ledger.since(from)) {
1864
+ for (const r of page) {
1865
+ this.send(ws, { t: "act", act: r });
1866
+ if (r.seq > me.delivered_seq)
1867
+ me.delivered_seq = r.seq;
1868
+ }
1869
+ from = page[page.length - 1].seq;
1870
+ }
1871
+ if (firstSession && m.role !== "cli") {
1872
+ this.commit(sp, { from: me.id, act: "JOIN", body: `${me.display} online (${me.agent})`, to: null, goal: null, ref: null });
1693
1873
  }
1694
- catch { } }, 20);
1874
+ this.broadcast(sp, { t: "roster", roster: this.roster(sp) });
1875
+ this.log(`[${sp.name}] + ${me.id}/${sessionId} (${m.role}, ${me.agent})${verdict === "founder" ? " [founder, auto-admitted]" : ""}`);
1695
1876
  return;
1696
1877
  }
1697
- let newRepo = null;
1698
- if (m.repo) {
1699
- if (sp.allow.hasRepo(m.repo)) { }
1700
- else if (sp.allow.repoCount() === 0 || enrolled || verdict === "founder")
1701
- newRepo = m.repo;
1702
- else {
1703
- const known = sp.allow.repoList().map((r) => r.origin ?? r.fingerprint).join(", ");
1704
- this.log(`[${sp.name}] ! REFUSED ${m.member}: repo ${m.repo_origin ?? m.repo} not bound to this space`);
1705
- return reject("REPO_MISMATCH", `this checkout (${m.repo_origin ?? "no origin"}) is not one of the repos "${sp.name}" works from (${known || "none yet"}). ` +
1706
- `Open your agent in the team's repo, or if this repo really belongs to the team: pantheon repo add`);
1878
+ if (!space || !me)
1879
+ return reject("NOT_JOINED", "say hello first");
1880
+ if (m.t === "publish_profile") {
1881
+ if (m.profile.member !== me.id) {
1882
+ return fail("PROFILE_NOT_YOURS", `you may publish only the profile for ${me.id}`, m.cid);
1707
1883
  }
1884
+ if (!this.writeOk(me))
1885
+ return fail("RATE_LIMIT", "too many changes too fast — wait a few seconds", m.cid);
1886
+ const wild = Object.keys(m.profile.authority ?? {}).filter((k) => /[*?]/.test(k) || k.trim() === "");
1887
+ if (wild.length)
1888
+ return fail("BAD_AUTHORITY", `authority is claimed per named domain, not with "${wild[0]}"`, m.cid);
1889
+ const current = space.profiles.get(me.id);
1890
+ if (!current || m.profile.updated_at > current.updated_at) {
1891
+ space.profiles.set(me.id, m.profile);
1892
+ space.store.flush(space.goals, space.obligations, space.profiles);
1893
+ this.broadcast(space, { t: "profiles", profiles: [...space.profiles.values()] });
1894
+ this.commit(space, {
1895
+ from: me.id, session: sessionId, role, act: "NOTE", to: null, goal: null, ref: null,
1896
+ body: `organization profile updated: ${m.profile.display_name} — ${m.profile.title || "no title"}; human authority: ${Object.entries(m.profile.authority).filter(([, v]) => v === "human_required").map(([k]) => k).join(", ") || "none"}`,
1897
+ meta: { organization_profile: m.profile },
1898
+ });
1899
+ }
1900
+ this.send(ws, { t: "ack", ...(m.cid ? { cid: m.cid } : {}), id: me.id, seq: space.ledger.head });
1901
+ return;
1708
1902
  }
1709
- space = sp;
1710
- clearHelloTimer();
1711
- sessionId = acctSession ?? this.mintSession();
1712
- role = m.role;
1713
- me = this.attach(sp, m.member, m.display ?? m.member, m.agent ?? "unknown", sessionId, m.role, ws, m.pkg);
1714
- const firstSession = me.sessions.size === 1;
1715
- sp.lastTouch = Date.now();
1716
- if (newRepo && sp.allow.addRepo(newRepo, m.repo_origin ?? null, me.id)) {
1717
- this.log(`[${sp.name}] + repo ${m.repo_origin ?? newRepo} registered by ${me.id}`);
1718
- this.commit(sp, { from: me.id, act: "NOTE", to: null, goal: null, ref: null, body: `repo registered with this space: ${m.repo_origin ?? newRepo}`, meta: { repo: newRepo, repo_origin: m.repo_origin ?? null } });
1719
- this.broadcast(sp, { t: "repos", repos: sp.allow.repoList() });
1720
- }
1721
- this.send(ws, {
1722
- t: "welcome", member: me.id, session: sessionId, space: sp.name, seq: sp.ledger.head,
1723
- roster: this.roster(sp), goals: [...sp.goals.values()],
1724
- obligations: [...sp.obligations.values()], members: this.members(sp), profiles: [...sp.profiles.values()],
1725
- space_id: sp.allow.spaceId(), repos: sp.allow.repoList(),
1726
- ...(acct ? { plan: this.planView(acct, sp) } : {}),
1727
- ...(enrolled ? { token: enrolled } : {}),
1728
- });
1729
- for (const r of sp.ledger.since(m.from_seq ?? sp.ledger.head)) {
1730
- this.send(ws, { t: "act", act: r });
1731
- if (r.seq > me.delivered_seq)
1732
- me.delivered_seq = r.seq;
1733
- }
1734
- if (firstSession && m.role !== "cli") {
1735
- this.commit(sp, { from: me.id, act: "JOIN", body: `${me.display} online (${me.agent})`, to: null, goal: null, ref: null });
1903
+ if (m.t === "admit") {
1904
+ const self = space.allow.get(me.id);
1905
+ if (!self?.admitted)
1906
+ return fail("NOT_ADMITTED", "only an admitted member can vouch for another", m.cid);
1907
+ if (m.member === me.id)
1908
+ return fail("NO_SELF_ADMIT", "you cannot admit yourself", m.cid);
1909
+ const target = space.allow.get(m.member);
1910
+ if (!target)
1911
+ return fail("NO_SUCH_MEMBER", `"${m.member}" has never connected, so there is no key to admit`, m.cid);
1912
+ if (!m.revoke && !m.forget) {
1913
+ const plan = this.spacePlan(space.name);
1914
+ if (plan && m.may_wake && !plan.limits.wake)
1915
+ return this.planRefusal(space, plan, "admit", `the ${plan.name} plan does not include unattended workers — admit without --may-wake`, m.cid, fail, acct?.id ?? null, ip);
1916
+ if (plan && !target.admitted && space.allow.admittedCount() >= plan.limits.members)
1917
+ return this.planRefusal(space, plan, "admit", `"${space.name}" is full for the ${plan.name} plan (${fmtLimit(plan.limits.members)} people). Upgrade at https://www.joinpantheon.network to admit more.`, m.cid, fail, acct?.id ?? null, ip);
1918
+ }
1919
+ if (m.revoke || m.forget) {
1920
+ const founder = space.allow.founder();
1921
+ if (founder && me.id !== founder)
1922
+ return fail("FOUNDER_ONLY", `only ${founder}, who founded "${space.name}", can remove a member`, m.cid);
1923
+ if (m.member === founder)
1924
+ return fail("FOUNDER_STAYS", "the founder cannot be removed from their own team", m.cid);
1925
+ if (m.forget)
1926
+ space.allow.forget(m.member);
1927
+ else
1928
+ space.allow.revoke(m.member, me.id);
1929
+ if (space.profiles.delete(m.member))
1930
+ this.broadcast(space, { t: "profiles", profiles: [...space.profiles.values()] });
1931
+ for (const s of space.members.get(m.member)?.sessions.values() ?? []) {
1932
+ try {
1933
+ s.ws.close(1008, "REVOKED");
1934
+ }
1935
+ catch { }
1936
+ }
1937
+ space.members.delete(m.member);
1938
+ this.log(`[${space.name}] - ${m.forget ? "FORGOT" : "REVOKED"} ${m.member} by ${me.id}`);
1939
+ }
1940
+ else {
1941
+ space.allow.admit(m.member, me.id, m.may_wake);
1942
+ this.log(`[${space.name}] * ADMITTED ${m.member} by ${me.id}${m.may_wake ? " (may wake workers)" : ""}`);
1943
+ }
1944
+ this.broadcast(space, { t: "members", members: this.members(space) });
1945
+ this.broadcast(space, { t: "roster", roster: this.roster(space) });
1946
+ this.send(ws, { t: "ack", ...(m.cid ? { cid: m.cid } : {}), id: m.member, seq: space.ledger.head });
1947
+ return;
1736
1948
  }
1737
- this.broadcast(sp, { t: "roster", roster: this.roster(sp) });
1738
- this.log(`[${sp.name}] + ${me.id}/${sessionId} (${m.role}, ${me.agent})${verdict === "founder" ? " [founder, auto-admitted]" : ""}`);
1739
- return;
1740
- }
1741
- if (!space || !me)
1742
- return reject("NOT_JOINED", "say hello first");
1743
- if (m.t === "publish_profile") {
1744
- if (m.profile.member !== me.id) {
1745
- return fail("PROFILE_NOT_YOURS", `you may publish only the profile for ${me.id}`, m.cid);
1949
+ if (m.t === "worker_state") {
1950
+ if (role !== "worker")
1951
+ return;
1952
+ me.worker = { state: m.state, ...(m.detail ? { detail: m.detail } : {}), ...(m.until ? { until: m.until } : {}), at: Date.now() };
1953
+ this.broadcast(space, { t: "roster", roster: this.roster(space) });
1954
+ return;
1746
1955
  }
1747
- const current = space.profiles.get(me.id);
1748
- if (!current || m.profile.updated_at > current.updated_at) {
1749
- space.profiles.set(me.id, m.profile);
1750
- space.store.save(space.goals, space.obligations, space.profiles);
1751
- this.broadcast(space, { t: "profiles", profiles: [...space.profiles.values()] });
1752
- this.commit(space, {
1753
- from: me.id, session: sessionId, role, act: "NOTE", to: null, goal: null, ref: null,
1754
- body: `organization profile updated: ${m.profile.display_name} — ${m.profile.title || "no title"}; human authority: ${Object.entries(m.profile.authority).filter(([, v]) => v === "human_required").map(([k]) => k).join(", ") || "none"}`,
1755
- meta: { organization_profile: m.profile },
1756
- });
1956
+ if (m.t === "delete_space") {
1957
+ if (m.confirm !== space.name)
1958
+ return fail("CONFIRM_MISMATCH", `type the space name ("${space.name}") to confirm`, m.cid);
1959
+ const owner = this.accounts?.spaceOwner(space.name) ?? null;
1960
+ const isOwner = owner ? owner.id === acct?.id : space.allow.founder() === me.id;
1961
+ if (!isOwner)
1962
+ return fail("NOT_OWNER", `only the founder of "${space.name}" can delete it${owner ? ` (that is ${owner.username})` : ""}`, m.cid);
1963
+ const name = space.name;
1964
+ this.accounts?.audit({ event: "space_deleted", actor_account: acct?.id ?? null, space: name, detail: `by its founder ${me.id}`, ip });
1965
+ this.log(`[${name}] * DELETED by its founder ${me.id}`);
1966
+ this.send(ws, { t: "ack", ...(m.cid ? { cid: m.cid } : {}), id: name, seq: space.ledger.head });
1967
+ setTimeout(() => { this.deleteSpace(name); this.accounts?.markSpaceDeleted(name); }, 50).unref?.();
1968
+ return;
1757
1969
  }
1758
- this.send(ws, { t: "ack", ...(m.cid ? { cid: m.cid } : {}), id: me.id, seq: space.ledger.head });
1759
- return;
1760
- }
1761
- if (m.t === "admit") {
1762
- const self = space.allow.get(me.id);
1763
- if (!self?.admitted)
1764
- return fail("NOT_ADMITTED", "only an admitted member can vouch for another", m.cid);
1765
- if (m.member === me.id)
1766
- return fail("NO_SELF_ADMIT", "you cannot admit yourself", m.cid);
1767
- const target = space.allow.get(m.member);
1768
- if (!target)
1769
- return fail("NO_SUCH_MEMBER", `"${m.member}" has never connected, so there is no key to admit`, m.cid);
1770
- if (!m.revoke && !m.forget) {
1970
+ if (m.t === "invite") {
1971
+ const self = space.allow.get(me.id);
1972
+ if (!self?.admitted)
1973
+ return fail("NOT_ADMITTED", "only an admitted member can invite", m.cid);
1771
1974
  const plan = this.spacePlan(space.name);
1772
- if (plan && m.may_wake && !plan.limits.wake)
1773
- return this.planRefusal(space, plan, "admit", `the ${plan.name} plan does not include unattended workers — admit without --may-wake`, m.cid, fail, acct?.id ?? null, ip);
1774
- if (plan && !target.admitted && space.allow.admittedCount() >= plan.limits.members)
1775
- return this.planRefusal(space, plan, "admit", `"${space.name}" is full for the ${plan.name} plan (${fmtLimit(plan.limits.members)} people). Upgrade at https://www.joinpantheon.network to admit more.`, m.cid, fail, acct?.id ?? null, ip);
1776
- }
1777
- if (m.revoke || m.forget) {
1778
- if (m.forget)
1779
- space.allow.forget(m.member);
1780
- else
1781
- space.allow.revoke(m.member);
1782
- for (const s of space.members.get(m.member)?.sessions.values() ?? []) {
1783
- try {
1784
- s.ws.close(1008, "REVOKED");
1975
+ if (plan) {
1976
+ if (m.may_wake && !plan.limits.wake)
1977
+ return this.planRefusal(space, plan, "invite", `the ${plan.name} plan does not include unattended workers — invite without wake: pantheon invite --no-wake`, m.cid, fail, acct?.id ?? null, ip);
1978
+ const open = space.allow.openInvites().length;
1979
+ if (space.allow.admittedCount() + open >= plan.limits.members) {
1980
+ return this.planRefusal(space, plan, "invite", `"${space.name}" is full for the ${plan.name} plan: ${fmtLimit(plan.limits.members)} people${open ? `, counting ${open} unused invite${open === 1 ? "" : "s"}` : ""}.${open ? ` An unused invite holds a seat until it expires — free them: pantheon invite --revoke all.` : ""} Upgrade at https://www.joinpantheon.network to invite more.`, m.cid, fail, acct?.id ?? null, ip);
1785
1981
  }
1786
- catch { }
1787
1982
  }
1788
- space.members.delete(m.member);
1789
- this.log(`[${space.name}] - ${m.forget ? "FORGOT" : "REVOKED"} ${m.member} by ${me.id}`);
1790
- }
1791
- else {
1792
- space.allow.admit(m.member, me.id, m.may_wake);
1793
- this.log(`[${space.name}] * ADMITTED ${m.member} by ${me.id}${m.may_wake ? " (may wake workers)" : ""}`);
1794
- }
1795
- this.broadcast(space, { t: "members", members: this.members(space) });
1796
- this.broadcast(space, { t: "roster", roster: this.roster(space) });
1797
- this.send(ws, { t: "ack", ...(m.cid ? { cid: m.cid } : {}), id: m.member, seq: space.ledger.head });
1798
- return;
1799
- }
1800
- if (m.t === "worker_state") {
1801
- if (role !== "worker")
1983
+ if (!this.writeOk(me))
1984
+ return fail("RATE_LIMIT", "too many invites too fast — wait a few seconds", m.cid);
1985
+ if (space.allow.openInvites().length >= LIMITS_SPACE.openInvites)
1986
+ return fail("TOO_MANY_INVITES", `this team already has ${LIMITS_SPACE.openInvites} unused invites — revoke some: pantheon invite --revoke all`, m.cid);
1987
+ const token = randomBytes(18).toString("base64url");
1988
+ const inv = space.allow.mintInvite(me.id, m.may_wake, m.ttl_minutes, token, space.token);
1989
+ this.log(`[${space.name}] * invite minted by ${me.id}, valid ${m.ttl_minutes}m, single use`);
1990
+ this.send(ws, { t: "invite", url: token, expires_at: inv.expires_at });
1802
1991
  return;
1803
- me.worker = { state: m.state, ...(m.detail ? { detail: m.detail } : {}), ...(m.until ? { until: m.until } : {}), at: Date.now() };
1804
- this.broadcast(space, { t: "roster", roster: this.roster(space) });
1805
- return;
1806
- }
1807
- if (m.t === "delete_space") {
1808
- if (m.confirm !== space.name)
1809
- return fail("CONFIRM_MISMATCH", `type the space name ("${space.name}") to confirm`, m.cid);
1810
- const owner = this.accounts?.spaceOwner(space.name) ?? null;
1811
- const isOwner = owner ? owner.id === acct?.id : space.allow.founder() === me.id;
1812
- if (!isOwner)
1813
- return fail("NOT_OWNER", `only the founder of "${space.name}" can delete it${owner ? ` (that is ${owner.username})` : ""}`, m.cid);
1814
- const name = space.name;
1815
- this.accounts?.audit({ event: "space_deleted", actor_account: acct?.id ?? null, space: name, detail: `by its founder ${me.id}`, ip });
1816
- this.log(`[${name}] * DELETED by its founder ${me.id}`);
1817
- this.send(ws, { t: "ack", ...(m.cid ? { cid: m.cid } : {}), id: name, seq: space.ledger.head });
1818
- setTimeout(() => { this.deleteSpace(name); this.accounts?.markSpaceDeleted(name); }, 50).unref?.();
1819
- return;
1820
- }
1821
- if (m.t === "invite") {
1822
- const self = space.allow.get(me.id);
1823
- if (!self?.admitted)
1824
- return fail("NOT_ADMITTED", "only an admitted member can invite", m.cid);
1825
- const plan = this.spacePlan(space.name);
1826
- if (plan) {
1827
- if (m.may_wake && !plan.limits.wake)
1828
- return this.planRefusal(space, plan, "invite", `the ${plan.name} plan does not include unattended workers — invite without wake: pantheon invite --no-wake`, m.cid, fail, acct?.id ?? null, ip);
1829
- const open = space.allow.openInvites().length;
1830
- if (space.allow.admittedCount() + open >= plan.limits.members) {
1831
- return this.planRefusal(space, plan, "invite", `"${space.name}" is full for the ${plan.name} plan: ${fmtLimit(plan.limits.members)} people${open ? `, counting ${open} unused invite${open === 1 ? "" : "s"}` : ""}. Upgrade at https://www.joinpantheon.network to invite more.`, m.cid, fail, acct?.id ?? null, ip);
1832
- }
1833
1992
  }
1834
- const token = randomBytes(18).toString("base64url");
1835
- const inv = space.allow.mintInvite(me.id, m.may_wake, m.ttl_minutes, token);
1836
- this.log(`[${space.name}] * invite minted by ${me.id}, valid ${m.ttl_minutes}m, single use`);
1837
- this.send(ws, { t: "invite", url: token, expires_at: inv.expires_at });
1838
- return;
1839
- }
1840
- if (m.t === "invites") {
1841
- const self = space.allow.get(me.id);
1842
- if (!self?.admitted)
1843
- return fail("NOT_ADMITTED", "only an admitted member can see the open invites", m.cid);
1844
- const founder = space.allow.founder();
1845
- const viewer = me.id;
1846
- const invites = space.allow.openInvites().map((i) => {
1847
- const mine = i.created_by === viewer;
1848
- return { token: mine || viewer === founder ? i.token : `…${i.token.slice(-6)}`, created_by: i.created_by, created_at: i.created_at, expires_at: i.expires_at, may_wake: i.may_wake, mine };
1849
- });
1850
- this.send(ws, { t: "invites", invites, ...(m.cid ? { cid: m.cid } : {}) });
1851
- return;
1852
- }
1853
- if (m.t === "revoke_invite") {
1854
- const self = space.allow.get(me.id);
1855
- if (!self?.admitted)
1856
- return fail("NOT_ADMITTED", "only an admitted member can revoke an invite", m.cid);
1857
- const inv = space.allow.findInvite(m.token.replace(/^…/, ""));
1858
- if (!inv)
1859
- return fail("NO_SUCH_INVITE", "no open invite matches that token — it may already be used, expired or revoked (pantheon invite --list)", m.cid);
1860
- if (inv.created_by !== me.id && me.id !== space.allow.founder())
1861
- return fail("NOT_YOURS", `that invite was minted by ${inv.created_by}; only they or the founder can revoke it`, m.cid);
1862
- space.allow.revokeInvite(inv.token);
1863
- this.log(`[${space.name}] * invite …${inv.token.slice(-6)} (minted by ${inv.created_by}) revoked by ${me.id}`);
1864
- this.accounts?.audit({ event: "invite_revoked", space: space.name, target: me.id, detail: `minted by ${inv.created_by}, …${inv.token.slice(-6)}`, ip });
1865
- this.send(ws, { t: "ack", ...(m.cid ? { cid: m.cid } : {}), id: inv.token.slice(-6), seq: space.ledger.head });
1866
- return;
1867
- }
1868
- if (m.t === "bind_repo") {
1869
- const self = space.allow.get(me.id);
1870
- if (!self?.admitted)
1871
- return fail("NOT_ADMITTED", "only an admitted member can register a repo", m.cid);
1872
- const added = space.allow.addRepo(m.repo, m.origin ?? null, me.id, m.base ?? null);
1873
- if (!added && m.base && space.allow.setRepoBase(m.repo, m.base)) {
1874
- this.log(`[${space.name}] ~ repo ${m.origin ?? m.repo} base branch → ${m.base} (by ${me.id})`);
1875
- this.broadcast(space, { t: "repos", repos: space.allow.repoList() });
1993
+ if (m.t === "invites") {
1994
+ const self = space.allow.get(me.id);
1995
+ if (!self?.admitted)
1996
+ return fail("NOT_ADMITTED", "only an admitted member can see the open invites", m.cid);
1997
+ const founder = space.allow.founder();
1998
+ const viewer = me.id;
1999
+ const invites = space.allow.openInvites().map((i) => {
2000
+ const mine = i.created_by === viewer;
2001
+ return { token: mine || viewer === founder ? i.token : `…${i.token.slice(-6)}`, created_by: i.created_by, created_at: i.created_at, expires_at: i.expires_at, may_wake: i.may_wake, mine };
2002
+ });
2003
+ this.send(ws, { t: "invites", invites, ...(m.cid ? { cid: m.cid } : {}) });
2004
+ return;
1876
2005
  }
1877
- if (added) {
1878
- this.log(`[${space.name}] + repo ${m.origin ?? m.repo} registered by ${me.id}${m.base ? ` (base ${m.base})` : ""}`);
1879
- this.commit(space, { from: me.id, session: sessionId, role, act: "NOTE", to: null, goal: null, ref: null, body: `repo registered with this space: ${m.origin ?? m.repo}`, meta: { repo: m.repo, repo_origin: m.origin ?? null } });
1880
- this.broadcast(space, { t: "repos", repos: space.allow.repoList() });
2006
+ if (m.t === "revoke_invite") {
2007
+ const self = space.allow.get(me.id);
2008
+ if (!self?.admitted)
2009
+ return fail("NOT_ADMITTED", "only an admitted member can revoke an invite", m.cid);
2010
+ const inv = space.allow.findInvite(m.token.replace(/^…/, ""));
2011
+ if (!inv)
2012
+ return fail("NO_SUCH_INVITE", "no open invite matches that token — it may already be used, expired or revoked (pantheon invite --list)", m.cid);
2013
+ if (inv.created_by !== me.id && me.id !== space.allow.founder())
2014
+ return fail("NOT_YOURS", `that invite was minted by ${inv.created_by}; only they or the founder can revoke it`, m.cid);
2015
+ space.allow.revokeInvite(inv.token);
2016
+ this.log(`[${space.name}] * invite …${inv.token.slice(-6)} (minted by ${inv.created_by}) revoked by ${me.id}`);
2017
+ this.accounts?.audit({ event: "invite_revoked", space: space.name, target: me.id, detail: `minted by ${inv.created_by}, …${inv.token.slice(-6)}`, ip });
2018
+ this.send(ws, { t: "ack", ...(m.cid ? { cid: m.cid } : {}), id: inv.token.slice(-6), seq: space.ledger.head });
2019
+ return;
1881
2020
  }
1882
- this.send(ws, { t: "ack", cid: m.cid, id: m.repo, seq: space.ledger.head });
1883
- return;
1884
- }
1885
- if (m.t === "claim") {
1886
- if (role === "worker") {
1887
- const plan = this.spacePlan(space.name);
1888
- if (plan && !plan.limits.wake)
1889
- return this.planRefusal(space, plan, "wake", `unattended workers are not included in this space's ${plan.name} plan`, m.cid, fail, acct?.id ?? null, ip);
2021
+ if (m.t === "bind_repo") {
2022
+ const self = space.allow.get(me.id);
2023
+ if (!self?.admitted)
2024
+ return fail("NOT_ADMITTED", "only an admitted member can register a repo", m.cid);
2025
+ if (!this.writeOk(me))
2026
+ return fail("RATE_LIMIT", "too many repo changes too fast — wait a few seconds", m.cid);
2027
+ if (!space.allow.repoList().some((r) => r.fingerprint === m.repo) && space.allow.repoCount() >= LIMITS_SPACE.repos) {
2028
+ return fail("TOO_MANY_REPOS", `this team already works from ${LIMITS_SPACE.repos} repos`, m.cid);
2029
+ }
2030
+ if (m.base !== undefined && !/^[A-Za-z0-9._][A-Za-z0-9._\/-]{0,99}$/.test(m.base)) {
2031
+ return fail("BAD_BASE", `"${m.base}" is not a branch name`, m.cid);
2032
+ }
2033
+ const existing = space.allow.repoList().find((r) => r.fingerprint === m.repo);
2034
+ if (existing && m.base && existing.added_by !== me.id && space.allow.founder() !== me.id) {
2035
+ return fail("NOT_YOURS", `only ${existing.added_by} (who registered this repo) or the team's founder can change which branch its runs start from`, m.cid);
2036
+ }
2037
+ const added = space.allow.addRepo(m.repo, m.origin ?? null, me.id, m.base ?? null);
2038
+ if (!added && m.base && space.allow.setRepoBase(m.repo, m.base)) {
2039
+ this.log(`[${space.name}] ~ repo ${m.origin ?? m.repo} base branch → ${m.base} (by ${me.id})`);
2040
+ this.broadcast(space, { t: "repos", repos: space.allow.repoList() });
2041
+ }
2042
+ if (added) {
2043
+ this.log(`[${space.name}] + repo ${m.origin ?? m.repo} registered by ${me.id}${m.base ? ` (base ${m.base})` : ""}`);
2044
+ this.commit(space, { from: me.id, session: sessionId, role, act: "NOTE", to: null, goal: null, ref: null, body: `repo registered with this space: ${m.origin ?? m.repo}`, meta: { repo: m.repo, repo_origin: m.origin ?? null } });
2045
+ this.broadcast(space, { t: "repos", repos: space.allow.repoList() });
2046
+ }
2047
+ this.send(ws, { t: "ack", cid: m.cid, id: m.repo, seq: space.ledger.head });
2048
+ return;
1890
2049
  }
1891
- if (m.obligation.startsWith("cont:")) {
1892
- const act = space.ledger.find(m.obligation.slice(5));
1893
- if (!act)
1894
- return fail("NO_SUCH_ACT", `no act ${m.obligation.slice(5)} to continue from`, m.cid);
1895
- const refFrom = act.ref ? space.ledger.find(act.ref)?.from : undefined;
1896
- const goal = act.goal ? space.goals.get(act.goal) : undefined;
1897
- if (!isContinuationFor(act, me.id, {
1898
- ...(refFrom !== undefined ? { refFrom } : {}),
1899
- ...(goal ? { goalMembers: goal.members, goalState: goal.state } : {}),
1900
- })) {
1901
- return fail("NOT_YOURS", `that ${act.act} is not ${me.id}'s to continue`, m.cid);
2050
+ if (m.t === "claim") {
2051
+ if (role === "worker") {
2052
+ const plan = this.spacePlan(space.name);
2053
+ if (plan && !plan.limits.wake)
2054
+ return this.planRefusal(space, plan, "wake", `unattended workers are not included in this space's ${plan.name} plan`, m.cid, fail, acct?.id ?? null, ip);
2055
+ }
2056
+ if (m.obligation.startsWith("cont:")) {
2057
+ const act = space.ledger.find(m.obligation.slice(5));
2058
+ if (!act)
2059
+ return fail("NO_SUCH_ACT", `no act ${m.obligation.slice(5)} to continue from`, m.cid);
2060
+ const refFrom = act.ref ? space.ledger.find(act.ref)?.from : undefined;
2061
+ const goal = act.goal ? space.goals.get(act.goal) : undefined;
2062
+ if (!isContinuationFor(act, me.id, {
2063
+ ...(refFrom !== undefined ? { refFrom } : {}),
2064
+ ...(goal ? { goalMembers: goal.members, goalState: goal.state } : {}),
2065
+ })) {
2066
+ return fail("NOT_YOURS", `that ${act.act} is not ${me.id}'s to continue`, m.cid);
2067
+ }
2068
+ if (role === "worker") {
2069
+ const defer = shouldDeferToHuman([...me.sessions.values()], this.work(me));
2070
+ const age = Date.now() - act.ts;
2071
+ if (defer && age < LIMITS.workerGraceMs) {
2072
+ return fail("DEFER", `you have a live interactive session; waiting ${Math.ceil((LIMITS.workerGraceMs - age) / 1000)}s before a worker may take this`, m.cid);
2073
+ }
2074
+ const sender = space.allow.get(act.from);
2075
+ if (act.from !== me.id && !sender?.may_wake) {
2076
+ return fail("NOT_ALLOWED_TO_WAKE", `"${act.from}" is not permitted to wake a worker on this machine`, m.cid);
2077
+ }
2078
+ }
2079
+ const held = space.contLocks.get(m.obligation);
2080
+ if (held && held.session !== sessionId && Date.now() - held.at < LIMITS.claimTtlMs) {
2081
+ return fail("CLAIMED", `another of your sessions (${held.session}) is handling this`, m.cid);
2082
+ }
2083
+ for (const [k, v] of space.contLocks) {
2084
+ if (Date.now() - v.at > LIMITS.claimTtlMs)
2085
+ space.contLocks.delete(k);
2086
+ }
2087
+ space.contLocks.set(m.obligation, { session: sessionId, member: me.id, at: Date.now() });
2088
+ this.send(ws, { t: "ack", ...(m.cid ? { cid: m.cid } : {}), id: m.obligation, seq: space.ledger.head });
2089
+ return;
2090
+ }
2091
+ const o = space.obligations.get(m.obligation);
2092
+ if (!o)
2093
+ return fail("NO_SUCH_OBLIGATION", `no obligation ${m.obligation} — it may already be discharged`, m.cid);
2094
+ if (o.owner !== me.id)
2095
+ return fail("NOT_YOURS", `${o.owner} owes that, not you`, m.cid);
2096
+ if (o.claimed_by && o.claimed_by.session !== sessionId) {
2097
+ if (Date.now() - o.claimed_by.at < LIMITS.claimTtlMs) {
2098
+ const holder = o.claimed_by;
2099
+ const live = [...me.sessions.keys()].includes(holder.session);
2100
+ const left = Math.ceil((LIMITS.claimTtlMs - (Date.now() - holder.at)) / 60_000);
2101
+ return fail("CLAIMED", live
2102
+ ? `another of your sessions (${holder.session}, ${holder.role}) is handling this`
2103
+ : `a session of yours (${holder.role}) was working on this when its connection dropped; it is held for it for up to ${left} more minute${left === 1 ? "" : "s"} in case it comes back`, m.cid);
2104
+ }
1902
2105
  }
1903
2106
  if (role === "worker") {
2107
+ const age = Date.now() - o.created_at;
1904
2108
  const defer = shouldDeferToHuman([...me.sessions.values()], this.work(me));
1905
- const age = Date.now() - act.ts;
1906
2109
  if (defer && age < LIMITS.workerGraceMs) {
1907
2110
  return fail("DEFER", `you have a live interactive session; waiting ${Math.ceil((LIMITS.workerGraceMs - age) / 1000)}s before a worker may take this`, m.cid);
1908
2111
  }
1909
- const sender = space.allow.get(act.from);
1910
- if (act.from !== me.id && !sender?.may_wake) {
1911
- return fail("NOT_ALLOWED_TO_WAKE", `"${act.from}" is not permitted to wake a worker on this machine`, m.cid);
2112
+ const origin = space.ledger.find(o.ref);
2113
+ const asker = origin ? space.allow.get(origin.from) : undefined;
2114
+ if (origin && origin.from !== me.id && !asker?.may_wake) {
2115
+ return fail("NOT_ALLOWED_TO_WAKE", `"${origin.from}" is not permitted to wake a worker on this machine`, m.cid);
1912
2116
  }
1913
2117
  }
1914
- const held = space.contLocks.get(m.obligation);
1915
- if (held && held.session !== sessionId && Date.now() - held.at < LIMITS.claimTtlMs) {
1916
- return fail("CLAIMED", `another of your sessions (${held.session}) is handling this`, m.cid);
2118
+ o.claimed_by = { session: sessionId, role, at: Date.now() };
2119
+ this.broadcast(space, { t: "claimed", obligation: o.id, session: sessionId, member: me.id });
2120
+ this.pushObligations(space);
2121
+ this.send(ws, { t: "ack", ...(m.cid ? { cid: m.cid } : {}), id: o.id, seq: space.ledger.head });
2122
+ return;
2123
+ }
2124
+ if (m.t === "open_goal") {
2125
+ if (!this.writeOk(me))
2126
+ return fail("RATE_LIMIT", "too many goals opened too fast — wait a few seconds", m.cid);
2127
+ const openCount = [...space.goals.values()].filter((g) => g.state !== "CLOSED").length;
2128
+ if (openCount >= LIMITS_SPACE.openGoals)
2129
+ return fail("TOO_MANY_GOALS", `this team has ${openCount} goals still open — close some first`, m.cid);
2130
+ for (const id of m.invite ?? []) {
2131
+ if (!space.members.has(id) && !space.allow.get(id)?.admitted)
2132
+ return fail("NO_SUCH_MEMBER", `nobody here is called "${id}"`, m.cid);
1917
2133
  }
1918
- for (const [k, v] of space.contLocks) {
1919
- if (Date.now() - v.at > LIMITS.claimTtlMs)
1920
- space.contLocks.delete(k);
2134
+ const invited = (m.invite ?? [...space.members.keys()]).filter((x) => x !== me.id);
2135
+ if (m.deadline !== undefined && m.deadline <= Date.now()) {
2136
+ return fail("DEADLINE_PAST", `deadline ${new Date(m.deadline).toISOString()} is already in the past`, m.cid);
1921
2137
  }
1922
- space.contLocks.set(m.obligation, { session: sessionId, member: me.id, at: Date.now() });
1923
- this.send(ws, { t: "ack", ...(m.cid ? { cid: m.cid } : {}), id: m.obligation, seq: space.ledger.head });
2138
+ const goal = {
2139
+ id: newId("g"),
2140
+ title: m.title,
2141
+ acceptance: m.acceptance,
2142
+ members: [me.id, ...new Set(invited)],
2143
+ state: "OPEN",
2144
+ acts: 0,
2145
+ budget: m.budget ?? LIMITS.goalBudget,
2146
+ opened_by: me.id,
2147
+ opened_at: Date.now(),
2148
+ ...(m.deadline !== undefined ? { deadline: m.deadline } : {}),
2149
+ ...(m.kind !== undefined ? { kind: m.kind } : {}),
2150
+ };
2151
+ space.goals.set(goal.id, goal);
2152
+ space.store.flush(space.goals, space.obligations, space.profiles);
2153
+ this.broadcast(space, { t: "goal", goal });
2154
+ const rec = this.commit(space, {
2155
+ from: me.id, act: "NOTE", to: null, goal: goal.id, ref: null,
2156
+ body: `goal opened: ${goal.title}\nacceptance: ${goal.acceptance || "(none stated)"}` +
2157
+ (goal.deadline ? `\ndeadline: ${new Date(goal.deadline).toISOString()}` : "") +
2158
+ (goal.kind === "worklist" ? `\nkind: worklist — ${me.id}'s own list; peers may read it, nobody owes anything in it` : ""),
2159
+ });
2160
+ this.send(ws, { t: "ack", ...(m.cid ? { cid: m.cid } : {}), id: goal.id, seq: rec.seq });
1924
2161
  return;
1925
2162
  }
1926
- const o = space.obligations.get(m.obligation);
1927
- if (!o)
1928
- return fail("NO_SUCH_OBLIGATION", `no obligation ${m.obligation} — it may already be discharged`, m.cid);
1929
- if (o.owner !== me.id)
1930
- return fail("NOT_YOURS", `${o.owner} owes that, not you`, m.cid);
1931
- if (o.claimed_by && o.claimed_by.session !== sessionId) {
1932
- if (Date.now() - o.claimed_by.at < LIMITS.claimTtlMs) {
1933
- return fail("CLAIMED", `another of your sessions (${o.claimed_by.session}, ${o.claimed_by.role}) is handling this`, m.cid);
1934
- }
2163
+ const fp = fingerprint_(me.id, m.act, m.body, m.goal ?? null, m.ref ?? null, { to: m.to ?? null, meta: m.meta ?? null, defers_to: m.defers_to ?? null });
2164
+ const prior = space.recent.get(fp);
2165
+ if (prior && Date.now() - prior.at < LIMITS.dedupeWindowMs) {
2166
+ this.send(ws, { t: "ack", ...(m.cid ? { cid: m.cid } : {}), id: prior.id, seq: prior.seq });
2167
+ return;
1935
2168
  }
1936
- if (role === "worker") {
1937
- const age = Date.now() - o.created_at;
1938
- const defer = shouldDeferToHuman([...me.sessions.values()], this.work(me));
1939
- if (defer && age < LIMITS.workerGraceMs) {
1940
- return fail("DEFER", `you have a live interactive session; waiting ${Math.ceil((LIMITS.workerGraceMs - age) / 1000)}s before a worker may take this`, m.cid);
2169
+ if (m.after_seq !== undefined) {
2170
+ const mine = me.id;
2171
+ const missed = space.ledger.since(m.after_seq)
2172
+ .filter((r) => r.from !== mine && r.act !== "JOIN" && r.act !== "LEAVE")
2173
+ .filter((r) => (m.goal ? r.goal === m.goal : !r.goal)
2174
+ || (r.to?.includes(mine) ?? false)
2175
+ || (m.to?.includes(r.from) ?? false)
2176
+ || (!!m.ref && r.ref === m.ref));
2177
+ if (missed.length) {
2178
+ return fail("STALE_VIEW", `${missed.length} act(s) arrived while you were composing — read them before you answer:\n` +
2179
+ missed.slice(0, 8).map((r) => ` [${r.seq}] ${r.act} from ${r.from}: ${first(r.body)}`).join("\n") +
2180
+ (missed.length > 8 ? `\n ...and ${missed.length - 8} more` : "") +
2181
+ `\nCall pantheon_wait, then send again.`, m.cid);
1941
2182
  }
1942
- const origin = space.ledger.find(o.ref);
1943
- const asker = origin ? space.allow.get(origin.from) : undefined;
1944
- if (origin && origin.from !== me.id && !asker?.may_wake) {
1945
- return fail("NOT_ALLOWED_TO_WAKE", `"${origin.from}" is not permitted to wake a worker on this machine`, m.cid);
1946
- }
1947
- }
1948
- o.claimed_by = { session: sessionId, role, at: Date.now() };
1949
- this.broadcast(space, { t: "claimed", obligation: o.id, session: sessionId, member: me.id });
1950
- this.pushObligations(space);
1951
- this.send(ws, { t: "ack", ...(m.cid ? { cid: m.cid } : {}), id: o.id, seq: space.ledger.head });
1952
- return;
1953
- }
1954
- if (m.t === "open_goal") {
1955
- const invited = (m.invite ?? [...space.members.keys()]).filter((x) => x !== me.id);
1956
- if (m.deadline !== undefined && m.deadline <= Date.now()) {
1957
- return fail("DEADLINE_PAST", `deadline ${new Date(m.deadline).toISOString()} is already in the past`, m.cid);
1958
2183
  }
1959
- const goal = {
1960
- id: newId("g"),
1961
- title: m.title,
1962
- acceptance: m.acceptance,
1963
- members: [me.id, ...new Set(invited)],
1964
- state: "OPEN",
1965
- acts: 0,
1966
- budget: m.budget ?? LIMITS.goalBudget,
1967
- opened_by: me.id,
1968
- opened_at: Date.now(),
1969
- ...(m.deadline !== undefined ? { deadline: m.deadline } : {}),
1970
- ...(m.kind !== undefined ? { kind: m.kind } : {}),
1971
- };
1972
- space.goals.set(goal.id, goal);
1973
- space.store.save(space.goals, space.obligations, space.profiles);
1974
- this.broadcast(space, { t: "goal", goal });
2184
+ const err = this.validateAct(space, me, m.act, m.goal ?? null, m.ref ?? null, m.to ?? null, m.meta, m.defers_to, m.body.length, m.summary, m.repo, role);
2185
+ if (err)
2186
+ return fail(err.code, err.message, m.cid);
1975
2187
  const rec = this.commit(space, {
1976
- from: me.id, act: "NOTE", to: null, goal: goal.id, ref: null,
1977
- body: `goal opened: ${goal.title}\nacceptance: ${goal.acceptance || "(none stated)"}` +
1978
- (goal.deadline ? `\ndeadline: ${new Date(goal.deadline).toISOString()}` : "") +
1979
- (goal.kind === "worklist" ? `\nkind: worklist — ${me.id}'s own list; peers may read it, nobody owes anything in it` : ""),
2188
+ from: me.id, session: sessionId, role, act: m.act, body: m.body,
2189
+ ...(m.summary ? { summary: m.summary } : {}),
2190
+ to: m.to ?? null, goal: m.goal ?? null, ref: m.ref ?? null,
2191
+ ...(m.meta || m.defers_to || m.affects
2192
+ ? { meta: { ...(m.meta ?? {}),
2193
+ ...(m.defers_to ? { defers_to: m.defers_to } : {}),
2194
+ ...(m.affects ? { affects: m.affects } : {}) } }
2195
+ : {}),
1980
2196
  });
1981
- this.send(ws, { t: "ack", ...(m.cid ? { cid: m.cid } : {}), id: goal.id, seq: rec.seq });
1982
- return;
2197
+ space.recent.set(fp, { id: rec.id, seq: rec.seq, at: Date.now() });
2198
+ for (const [k, v] of space.recent)
2199
+ if (Date.now() - v.at > LIMITS.dedupeWindowMs)
2200
+ space.recent.delete(k);
2201
+ this.send(ws, { t: "ack", ...(m.cid ? { cid: m.cid } : {}), id: rec.id, seq: rec.seq });
1983
2202
  }
1984
- const fp = fingerprint_(me.id, m.act, m.body, m.goal ?? null, m.ref ?? null);
1985
- const prior = space.recent.get(fp);
1986
- if (prior && Date.now() - prior.at < LIMITS.dedupeWindowMs) {
1987
- this.send(ws, { t: "ack", ...(m.cid ? { cid: m.cid } : {}), id: prior.id, seq: prior.seq });
1988
- return;
1989
- }
1990
- if (m.after_seq !== undefined) {
1991
- const mine = me.id;
1992
- const missed = space.ledger.since(m.after_seq)
1993
- .filter((r) => r.from !== mine && r.act !== "JOIN" && r.act !== "LEAVE")
1994
- .filter((r) => (m.goal ? r.goal === m.goal : !r.goal)
1995
- || (r.to?.includes(mine) ?? false)
1996
- || (m.to?.includes(r.from) ?? false)
1997
- || (!!m.ref && r.ref === m.ref));
1998
- if (missed.length) {
1999
- return fail("STALE_VIEW", `${missed.length} act(s) arrived while you were composing — read them before you answer:\n` +
2000
- missed.slice(0, 8).map((r) => ` [${r.seq}] ${r.act} from ${r.from}: ${first(r.body)}`).join("\n") +
2001
- (missed.length > 8 ? `\n ...and ${missed.length - 8} more` : "") +
2002
- `\nCall pantheon_wait, then send again.`, m.cid);
2203
+ catch (e) {
2204
+ this.log(`! message handling failed: ${e.message}`);
2205
+ try {
2206
+ ws.send(JSON.stringify({ t: "error", code: "STORAGE", message: "the relay could not save that — try again in a moment" }));
2003
2207
  }
2208
+ catch { }
2004
2209
  }
2005
- const err = this.validateAct(space, me, m.act, m.goal ?? null, m.ref ?? null, m.to ?? null, m.meta, m.defers_to, m.body.length, m.summary, m.repo);
2006
- if (err)
2007
- return fail(err.code, err.message, m.cid);
2008
- const rec = this.commit(space, {
2009
- from: me.id, session: sessionId, role, act: m.act, body: m.body,
2010
- ...(m.summary ? { summary: m.summary } : {}),
2011
- to: m.to ?? null, goal: m.goal ?? null, ref: m.ref ?? null,
2012
- ...(m.meta || m.defers_to || m.affects
2013
- ? { meta: { ...(m.meta ?? {}),
2014
- ...(m.defers_to ? { defers_to: m.defers_to } : {}),
2015
- ...(m.affects ? { affects: m.affects } : {}) } }
2016
- : {}),
2017
- });
2018
- space.recent.set(fp, { id: rec.id, seq: rec.seq, at: Date.now() });
2019
- for (const [k, v] of space.recent)
2020
- if (Date.now() - v.at > LIMITS.dedupeWindowMs)
2021
- space.recent.delete(k);
2022
- this.send(ws, { t: "ack", ...(m.cid ? { cid: m.cid } : {}), id: rec.id, seq: rec.seq });
2023
2210
  });
2024
- ws.on("close", () => {
2211
+ const closeWork = (code) => {
2025
2212
  if (dbSession) {
2026
2213
  this.accounts?.endSession(dbSession);
2027
2214
  dbSession = null;
@@ -2029,13 +2216,22 @@ pantheon signup --email you@work.com --username you</code></pre>
2029
2216
  if (!space || !me)
2030
2217
  return;
2031
2218
  me.sessions.delete(sessionId);
2219
+ const deliberate = code === 1000 || code === 1001 || code === 1005;
2032
2220
  for (const o of space.obligations.values()) {
2033
- if (o.claimed_by?.session === sessionId)
2221
+ if (o.claimed_by?.session !== sessionId)
2222
+ continue;
2223
+ if (deliberate)
2034
2224
  delete o.claimed_by;
2225
+ else
2226
+ o.claimed_by = { ...o.claimed_by, at: Date.now() };
2035
2227
  }
2036
2228
  for (const [k, v] of space.contLocks) {
2037
- if (v.session === sessionId)
2229
+ if (v.session !== sessionId)
2230
+ continue;
2231
+ if (deliberate)
2038
2232
  space.contLocks.delete(k);
2233
+ else
2234
+ v.at = Date.now();
2039
2235
  }
2040
2236
  if (me.sessions.size === 0 && role !== "cli") {
2041
2237
  me.last_seen = Date.now();
@@ -2044,7 +2240,13 @@ pantheon signup --email you@work.com --username you</code></pre>
2044
2240
  this.pushObligations(space);
2045
2241
  this.broadcast(space, { t: "roster", roster: this.roster(space) });
2046
2242
  this.log(`[${space.name}] - ${me.id}/${sessionId}`);
2047
- });
2243
+ };
2244
+ ws.on("close", (code) => { try {
2245
+ closeWork(code);
2246
+ }
2247
+ catch (e) {
2248
+ this.log(`! cleanup after a disconnect failed: ${e.message}`);
2249
+ } });
2048
2250
  ws.on("error", () => { });
2049
2251
  }
2050
2252
  sweep() {
@@ -2062,12 +2264,41 @@ pantheon signup --email you@work.com --username you</code></pre>
2062
2264
  }
2063
2265
  const now = Date.now();
2064
2266
  for (const sp of this.spaces.values()) {
2065
- if (now - (this.compactedAt.get(sp.name) ?? 0) > LIMITS.ledgerCompactEveryMs)
2066
- this.compactSpace(sp);
2267
+ try {
2268
+ if (now - (this.compactedAt.get(sp.name) ?? 0) > LIMITS.ledgerCompactEveryMs)
2269
+ this.compactSpace(sp);
2270
+ this.lapseClaims(sp, now);
2271
+ }
2272
+ catch (e) {
2273
+ this.log(`! [${sp.name}] housekeeping failed: ${e.message}`);
2274
+ }
2067
2275
  }
2068
- this.evictSpaces();
2276
+ try {
2277
+ this.evictSpaces();
2278
+ }
2279
+ catch (e) {
2280
+ this.log(`! evicting idle spaces failed: ${e.message}`);
2281
+ }
2282
+ }
2283
+ lapseClaims(sp, now) {
2284
+ const live = new Set();
2285
+ for (const m of sp.members.values())
2286
+ for (const id of m.sessions.keys())
2287
+ live.add(id);
2288
+ let lapsed = 0;
2289
+ for (const o of sp.obligations.values()) {
2290
+ if (o.claimed_by && !live.has(o.claimed_by.session) && now - o.claimed_by.at >= LIMITS.claimTtlMs) {
2291
+ delete o.claimed_by;
2292
+ lapsed++;
2293
+ }
2294
+ }
2295
+ for (const [k, v] of sp.contLocks)
2296
+ if (!live.has(v.session) && now - v.at >= LIMITS.claimTtlMs)
2297
+ sp.contLocks.delete(k);
2298
+ if (lapsed)
2299
+ this.pushObligations(sp);
2069
2300
  }
2070
- validateAct(sp, me, act, goalId, ref, to, meta, deferTo, bodyLen = 0, summary, repo) {
2301
+ validateAct(sp, me, act, goalId, ref, to, meta, deferTo, bodyLen = 0, summary, repo, role) {
2071
2302
  if (!SENDABLE.includes(act))
2072
2303
  return { code: "RESERVED_ACT", message: `${act} is emitted by the relay, not by a delegate` };
2073
2304
  const now = Date.now();
@@ -2083,6 +2314,9 @@ pantheon signup --email you@work.com --username you</code></pre>
2083
2314
  }
2084
2315
  if (ref && !sp.ledger.find(ref))
2085
2316
  return { code: "BAD_REF", message: `no act with id ${ref}` };
2317
+ if (meta && JSON.stringify(meta).length > LIMITS_SPACE.metaBytes) {
2318
+ return { code: "META_TOO_LARGE", message: `act metadata is limited to ${LIMITS_SPACE.metaBytes} bytes` };
2319
+ }
2086
2320
  if ((act === "ANSWER" || act === "ACCEPT" || act === "OBJECT") && !ref) {
2087
2321
  return { code: "REF_REQUIRED", message: `${act} must reference the act it responds to` };
2088
2322
  }
@@ -2219,6 +2453,15 @@ pantheon signup --email you@work.com --username you</code></pre>
2219
2453
  return { code: "BAD_REMOTE", message: `${why}. Your peer fetches this from another machine.` };
2220
2454
  }
2221
2455
  }
2456
+ if (act === "ANSWER" && ref) {
2457
+ const asked = sp.ledger.find(ref);
2458
+ if (asked?.act === "ESCALATE" && asked.from !== me.id) {
2459
+ return { code: "NOT_YOUR_DECISION", message: `that ESCALATE is for ${asked.from}'s own human to settle — add to it with a NOTE instead` };
2460
+ }
2461
+ if (asked?.act === "HUMAN_REQUIRED" && asked.to?.[0] && asked.to[0] !== me.id) {
2462
+ return { code: "NOT_YOUR_DECISION", message: `that decision is ${asked.to[0]}'s to make — add to it with a NOTE instead` };
2463
+ }
2464
+ }
2222
2465
  if (!goalId) {
2223
2466
  const target = ref ? sp.ledger.find(ref) : undefined;
2224
2467
  const settlesHuman = act === "ANSWER" && (target?.act === "ESCALATE" || target?.act === "HUMAN_REQUIRED");
@@ -2230,12 +2473,24 @@ pantheon signup --email you@work.com --username you</code></pre>
2230
2473
  const goal = sp.goals.get(goalId);
2231
2474
  if (!goal)
2232
2475
  return { code: "NO_SUCH_GOAL", message: `no goal ${goalId}` };
2476
+ if (ref && act !== "RETRACT") {
2477
+ const target = sp.ledger.find(ref);
2478
+ if (target && target.goal !== goalId) {
2479
+ return { code: "REF_OTHER_GOAL", message: `that act belongs to ${target.goal ? `goal ${target.goal}` : "no goal"}, not this one — reply in its own goal${target.goal ? "" : " (send it without a goal)"}` };
2480
+ }
2481
+ }
2482
+ for (const id of to ?? []) {
2483
+ if (!goal.members.includes(id)) {
2484
+ return { code: "NOT_IN_GOAL", message: `${id} is not in "${goal.title}", so they could never answer it here — open a goal with them, or send it without a goal` };
2485
+ }
2486
+ }
2233
2487
  if (goal.state === "CLOSED")
2234
2488
  return { code: "GOAL_CLOSED", message: `goal "${goal.title}" is closed` };
2235
2489
  if (!goal.members.includes(me.id))
2236
2490
  return { code: "NOT_IN_GOAL", message: `you are not a member of "${goal.title}"` };
2237
- const settlesOwnHuman = act === "ANSWER" && !!ref
2238
- && [...sp.obligations.values()].some((o) => o.kind === "HUMAN" && o.ref === ref && o.owner === `human:${me.id}`);
2491
+ const decision = act === "ANSWER" && ref ? sp.ledger.find(ref) : undefined;
2492
+ const settlesOwnHuman = !!decision && decision.goal === goalId && role === "cli"
2493
+ && [...sp.obligations.values()].some((o) => o.kind === "HUMAN" && o.ref === ref && o.goal === goalId && o.owner === `human:${me.id}`);
2239
2494
  if (goal.acts >= goal.budget && !settlesOwnHuman) {
2240
2495
  return { code: "BUDGET_SPENT", message: `goal "${goal.title}" spent its ${goal.budget}-act budget and needs a human` };
2241
2496
  }
@@ -2298,6 +2553,15 @@ pantheon signup --email you@work.com --username you</code></pre>
2298
2553
  break;
2299
2554
  case "ANSWER":
2300
2555
  if (rec.ref) {
2556
+ for (const o of sp.obligations.values()) {
2557
+ const g = o.goal ? sp.goals.get(o.goal) : undefined;
2558
+ if (o.kind === "HUMAN" && o.ref === rec.ref && o.owner === `human:${rec.from}` && g?.state === "STALLED"
2559
+ && !["ESCALATE", "HUMAN_REQUIRED"].includes(sp.ledger.find(o.ref)?.act ?? "")) {
2560
+ g.budget = Math.max(g.budget, g.acts) + Math.ceil(LIMITS.goalBudget / 2);
2561
+ g.state = "OPEN";
2562
+ this.broadcast(sp, { t: "goal", goal: g });
2563
+ }
2564
+ }
2301
2565
  closeFor(rec.from, rec.ref);
2302
2566
  closeFor(`human:${rec.from}`, rec.ref);
2303
2567
  }
@@ -2320,9 +2584,10 @@ pantheon signup --email you@work.com --username you</code></pre>
2320
2584
  if (rec.ref)
2321
2585
  closeFor(rec.from, rec.ref);
2322
2586
  const accepted = rec.ref ? sp.ledger.find(rec.ref) : undefined;
2323
- if (accepted?.act === "WITHDRAW" && accepted.ref) {
2587
+ if (accepted?.act === "WITHDRAW" && accepted.ref && accepted.from !== rec.from) {
2588
+ const owedTo = sp.ledger.find(accepted.ref)?.from;
2324
2589
  for (const [id, o] of sp.obligations) {
2325
- if (o.ref === accepted.ref && o.owner === accepted.from)
2590
+ if (o.ref === accepted.ref && o.owner === accepted.from && owedTo === rec.from)
2326
2591
  sp.obligations.delete(id);
2327
2592
  }
2328
2593
  }
@@ -2339,16 +2604,12 @@ pantheon signup --email you@work.com --username you</code></pre>
2339
2604
  }
2340
2605
  break;
2341
2606
  case "CONTRACT":
2342
- if (rec.ref)
2343
- for (const [id, o] of sp.obligations)
2344
- if (o.ref === rec.ref)
2345
- sp.obligations.delete(id);
2346
- break;
2347
2607
  case "DELIVERED":
2348
2608
  if (rec.ref)
2349
- for (const [id, o] of sp.obligations)
2350
- if (o.ref === rec.ref)
2609
+ for (const [id, o] of sp.obligations) {
2610
+ if (o.ref === rec.ref && o.goal === rec.goal && o.kind !== "HUMAN")
2351
2611
  sp.obligations.delete(id);
2612
+ }
2352
2613
  break;
2353
2614
  case "ESCALATE":
2354
2615
  this.oblige(sp, { kind: "HUMAN", owner: `human:${rec.from}`, ref: rec.id, goal: rec.goal,
@@ -2373,6 +2634,27 @@ pantheon signup --email you@work.com --username you</code></pre>
2373
2634
  for (const [id, o] of sp.obligations)
2374
2635
  if (o.ref === rec.ref)
2375
2636
  sp.obligations.delete(id);
2637
+ if (rec.ref)
2638
+ for (const [k, v] of sp.recent)
2639
+ if (v.id === rec.ref)
2640
+ sp.recent.delete(k);
2641
+ if (rec.ref) {
2642
+ const answer = sp.ledger.find(rec.ref);
2643
+ const asked = answer?.act === "ANSWER" && answer.from === rec.from && answer.ref ? sp.ledger.find(answer.ref) : undefined;
2644
+ const decider = asked?.act === "ESCALATE" ? asked.from : asked?.act === "HUMAN_REQUIRED" ? asked.to?.[0] : undefined;
2645
+ const retracted = new Set(sp.ledger.all().filter((r) => r.act === "RETRACT" && r.ref).map((r) => r.ref));
2646
+ const stillAnswered = asked && sp.ledger.all().some((r) => r.act === "ANSWER" && r.ref === asked.id && r.from === rec.from && r.id !== answer.id && !retracted.has(r.id));
2647
+ const alreadyLive = asked && [...sp.obligations.values()].some((o) => o.kind === "HUMAN" && o.ref === asked.id);
2648
+ if (asked && decider === rec.from && !stillAnswered && !alreadyLive && !retracted.has(asked.id)) {
2649
+ this.oblige(sp, { kind: "HUMAN", owner: `human:${decider}`, ref: asked.id, goal: asked.goal,
2650
+ body: asked.act === "ESCALATE" ? `${asked.from} escalated: ${first(asked.body)}` : `${asked.from} needs a decision: ${first(asked.body)}` });
2651
+ const g = asked.goal ? sp.goals.get(asked.goal) : undefined;
2652
+ if (g && g.state === "OPEN") {
2653
+ g.state = "BLOCKED";
2654
+ this.broadcast(sp, { t: "goal", goal: g });
2655
+ }
2656
+ }
2657
+ }
2376
2658
  break;
2377
2659
  case "CLOSE":
2378
2660
  if (goal) {
@@ -2400,9 +2682,17 @@ pantheon signup --email you@work.com --username you</code></pre>
2400
2682
  sp.obligations.set(id, { ...o, id, created_at: Date.now() });
2401
2683
  }
2402
2684
  pushObligations(sp) {
2403
- sp.store.save(sp.goals, sp.obligations, sp.profiles);
2685
+ sp.store.flush(sp.goals, sp.obligations, sp.profiles);
2404
2686
  this.broadcast(sp, { t: "obligations", list: [...sp.obligations.values()] });
2405
2687
  }
2688
+ writeOk(me) {
2689
+ const now = Date.now();
2690
+ me.stamps = me.stamps.filter((t) => now - t < LIMITS.rateWindowMs);
2691
+ if (me.stamps.length >= LIMITS.rateActs)
2692
+ return false;
2693
+ me.stamps.push(now);
2694
+ return true;
2695
+ }
2406
2696
  mayCreateSpace(ip) {
2407
2697
  const now = Date.now();
2408
2698
  if (!this.localTrusted(ip)) {
@@ -2490,7 +2780,7 @@ pantheon signup --email you@work.com --username you</code></pre>
2490
2780
  getOrCreateSpace(name, token) {
2491
2781
  const existing = this.spaces.get(name);
2492
2782
  if (existing)
2493
- return existing.token === token ? existing : null;
2783
+ return secretEq(existing.token, token) ? existing : null;
2494
2784
  const safe = name.replace(/[^a-z0-9._-]/gi, "_");
2495
2785
  const allow = new Allowlist(this.dataDir, safe);
2496
2786
  const match = allow.tokenMatches(token);
@@ -2533,7 +2823,8 @@ pantheon signup --email you@work.com --username you</code></pre>
2533
2823
  const g = sp.goals.get(goal);
2534
2824
  return !!g && g.state !== "CLOSED";
2535
2825
  };
2536
- const { dropped, kept } = sp.ledger.compact({ keepRecent: LIMITS.ledgerKeepRecent, isLive });
2826
+ const referenced = new Set([...sp.obligations.values()].map((o) => o.ref));
2827
+ const { dropped, kept } = sp.ledger.compact({ keepRecent: LIMITS.ledgerKeepRecent, isLive, keep: (id) => referenced.has(id) });
2537
2828
  this.compactedAt.set(sp.name, Date.now());
2538
2829
  if (dropped)
2539
2830
  this.log(`[${sp.name}] compacted ledger — dropped ${dropped} old act(s) from closed goals, ${kept} kept`);