@join-pantheon/cli 0.27.0 → 0.29.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/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";
@@ -72,8 +72,25 @@ export function isTrustedSource(ip) {
72
72
  return true;
73
73
  return false;
74
74
  }
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);
75
+ function fingerprint_(from, act, body, goal, ref, extra = null) {
76
+ return createHash("sha256").update([from, act, goal ?? "", ref ?? "", body, stableJson(extra)].join("\u0000")).digest("hex").slice(0, 32);
77
+ }
78
+ function stableJson(v) {
79
+ if (v === null || typeof v !== "object")
80
+ return JSON.stringify(v ?? null);
81
+ if (Array.isArray(v))
82
+ return `[${v.map(stableJson).join(",")}]`;
83
+ return `{${Object.keys(v).sort().map((k) => `${JSON.stringify(k)}:${stableJson(v[k])}`).join(",")}}`;
84
+ }
85
+ function clientIp(req) {
86
+ const raw = req.headers["x-forwarded-for"];
87
+ const hops = (Array.isArray(raw) ? raw.join(",") : raw ?? "").split(",").map((x) => x.trim()).filter(Boolean);
88
+ const trusted = Math.max(1, Number(process.env.PANTHEON_TRUSTED_HOPS ?? 1) || 1);
89
+ return hops.length ? hops[Math.max(0, hops.length - trusted)] : req.socket.remoteAddress || "unknown";
90
+ }
91
+ function secretEq(a, b) {
92
+ const x = createHash("sha256").update(a).digest(), y = createHash("sha256").update(b).digest();
93
+ return timingSafeEqualBuf(x, y);
77
94
  }
78
95
  const ADMIN_CSS = `<style>
79
96
  :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,6 +154,9 @@ const fmtAgo = (t) => {
137
154
  const m = Math.round((Date.now() - t) / 60_000);
138
155
  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
156
  };
157
+ const safeName = (name) => name.replace(/[^a-z0-9._-]/gi, "_");
158
+ const LIMITS_GOAL_READ = 1000;
159
+ const LIMITS_SPACE = { metaBytes: 8192, openGoals: 500, repos: 100, openInvites: 50 };
140
160
  export class Relay {
141
161
  opts;
142
162
  spaces = new Map();
@@ -197,8 +217,7 @@ export class Relay {
197
217
  this.busyTtlMs = opts.activityBusyTtlMs ?? LIMITS.activityBusyTtlMs;
198
218
  this.pruneMs = opts.activityPruneMs ?? LIMITS.activityPruneMs;
199
219
  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";
220
+ const ip = clientIp(req);
202
221
  this.wss.handleUpgrade(req, socket, head, (ws) => this.onSocket(ws, ip));
203
222
  });
204
223
  }
@@ -234,8 +253,7 @@ export class Relay {
234
253
  }
235
254
  onHttp(req, res) {
236
255
  const url = new URL(req.url ?? "/", "http://x");
237
- const ip = (req.headers["x-forwarded-for"]?.split(",")[0]?.trim())
238
- || req.socket.remoteAddress || "unknown";
256
+ const ip = clientIp(req);
239
257
  if (url.pathname === "/" || url.pathname.startsWith("/auth/") || url.pathname.startsWith("/account/")
240
258
  || url.pathname === "/audit" || url.pathname.startsWith("/admin")) {
241
259
  return this.onAuthHttp(req, res, url, ip);
@@ -264,6 +282,8 @@ export class Relay {
264
282
  });
265
283
  }
266
284
  if (url.pathname === "/join") {
285
+ if (!this.authRateOk(ip))
286
+ return this.json(res, 429, { error: "too many lookups from your network — wait a minute" });
267
287
  const token = (url.searchParams.get("token") ?? "").trim();
268
288
  if (!/^[A-Za-z0-9_-]{16,64}$/.test(token))
269
289
  return this.json(res, 400, { error: "an invite code is required: /join?token=<code>" });
@@ -273,9 +293,9 @@ export class Relay {
273
293
  : this.json(res, 404, { error: "no open invite matches that code — it may have been used already or expired" });
274
294
  }
275
295
  if (url.pathname === "/act") {
276
- const space = this.spaces.get(url.searchParams.get("space") ?? "");
296
+ const space = this.spaceForRead(url.searchParams.get("space") ?? "", url.searchParams.get("token") ?? "");
277
297
  const token = url.searchParams.get("token") ?? "";
278
- if (!space || space.token !== token)
298
+ if (!space || !secretEq(space.token, token))
279
299
  return this.json(res, 404, { error: "no such space" });
280
300
  const act = space.ledger.find(url.searchParams.get("id") ?? "");
281
301
  if (!act)
@@ -284,19 +304,20 @@ export class Relay {
284
304
  return this.json(res, 200, { act, ...(goal ? { goal: { id: goal.id, title: goal.title, state: goal.state } } : {}) });
285
305
  }
286
306
  if (url.pathname === "/history") {
287
- const space = this.spaces.get(url.searchParams.get("space") ?? "");
307
+ const space = this.spaceForRead(url.searchParams.get("space") ?? "", url.searchParams.get("token") ?? "");
288
308
  const token = url.searchParams.get("token") ?? "";
289
- if (!space || space.token !== token)
309
+ if (!space || !secretEq(space.token, token))
290
310
  return this.json(res, 404, { error: "no such space" });
291
311
  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();
312
+ const sinceRaw = url.searchParams.get("since");
313
+ const acts = goal ? space.ledger.byGoal(goal, LIMITS_GOAL_READ)
314
+ : sinceRaw !== null ? space.ledger.since(Number(sinceRaw) || 0) : space.ledger.latest();
294
315
  return this.json(res, 200, { acts, head: space.ledger.head });
295
316
  }
296
317
  if (url.pathname === "/obligations") {
297
- const space = this.spaces.get(url.searchParams.get("space") ?? "");
318
+ const space = this.spaceForRead(url.searchParams.get("space") ?? "", url.searchParams.get("token") ?? "");
298
319
  const token = url.searchParams.get("token") ?? "";
299
- if (!space || space.token !== token)
320
+ if (!space || !secretEq(space.token, token))
300
321
  return this.json(res, 404, { error: "no such space" });
301
322
  const member = url.searchParams.get("member");
302
323
  const now = Date.now();
@@ -312,9 +333,9 @@ export class Relay {
312
333
  if (url.pathname === "/activity") {
313
334
  if (req.method !== "POST")
314
335
  return this.json(res, 405, { error: "POST only" });
315
- const space = this.spaces.get(url.searchParams.get("space") ?? "");
336
+ const space = this.spaceForRead(url.searchParams.get("space") ?? "", url.searchParams.get("token") ?? "");
316
337
  const token = url.searchParams.get("token") ?? "";
317
- if (!space || space.token !== token)
338
+ if (!space || !secretEq(space.token, token))
318
339
  return this.json(res, 404, { error: "no such space" });
319
340
  const memberId = url.searchParams.get("member") ?? "";
320
341
  const member = space.members.get(memberId);
@@ -461,9 +482,10 @@ export class Relay {
461
482
  if (started.reason === "blocked")
462
483
  return this.json(res, 403, { error: "this email cannot sign up on this relay", reason: "blocked" });
463
484
  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";
485
+ : 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"
486
+ : started.reason === "username_taken" ? "that username is taken"
487
+ : started.reason === "email_taken" ? "that email already has an account — use `pantheon login` from this machine instead"
488
+ : "check the email and username";
467
489
  return this.json(res, 409, { error: msg, reason: started.reason });
468
490
  }
469
491
  A.audit({ event: "signup", target: normalizeUsername(b.username), ip });
@@ -485,8 +507,8 @@ export class Relay {
485
507
  if (!started.ok && started.reason === "blocked")
486
508
  return this.json(res, 403, { error: "this email cannot sign in on this relay", reason: "blocked" });
487
509
  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";
510
+ const code = started.reason === "too_soon" || started.reason === "in_progress_elsewhere" ? 429 : 404;
511
+ 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
512
  return this.json(res, code, { error: msg, reason: started.reason });
491
513
  }
492
514
  if (!this.emailBudgetOk())
@@ -503,12 +525,14 @@ export class Relay {
503
525
  return this.readJson(req, (b) => {
504
526
  if (!b || typeof b.email !== "string" || typeof b.code !== "string")
505
527
  return this.json(res, 400, { error: "email and code are required" });
506
- const r = A.verify(b.email, b.code.trim(), ip);
528
+ const r = A.verify(b.email, b.code.trim(), ip, typeof b.pubkey === "string" ? b.pubkey : undefined);
507
529
  if (!r.ok) {
508
530
  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";
531
+ : r.reason === "other_device" ? "that code was requested from a different device — request a new one from this one"
532
+ : r.reason === "suspended" ? "this account is suspended — contact the relay operator"
533
+ : r.reason === "too_many_attempts" ? "too many wrong tries — request a new code"
534
+ : r.reason === "no_pending" ? "no code is pending for that email — run signup or login first"
535
+ : "that code is not right";
512
536
  return this.json(res, 400, { error: msg, reason: r.reason });
513
537
  }
514
538
  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 +924,11 @@ button:hover{background:#1d4ed8}.err{color:var(--bad);margin-top:12px}
900
924
  }
901
925
  setTimeout(() => this.deleteSpaceFiles(name), 600).unref?.();
902
926
  const safe = name.replace(/[^a-z0-9._-]/gi, "_");
927
+ try {
928
+ const hash = sp?.allow ? JSON.parse(readFileSync(join(this.dataDir, `${safe}.members.json`), "utf8")).tokenHash ?? null : null;
929
+ writeFileSync(join(this.dataDir, `${safe}.deleted.json`), JSON.stringify({ name, at: Date.now(), tokenHash: hash }));
930
+ }
931
+ catch { }
903
932
  const existed = !!sp || [".jsonl", ".state.json", ".members.json"].some((suf) => existsSync(join(this.dataDir, `${safe}${suf}`)));
904
933
  this.deleteSpaceFiles(name);
905
934
  this.deletedSpaces.set(name, Date.now());
@@ -1033,6 +1062,42 @@ button:hover{background:#1d4ed8}.err{color:var(--bad);margin-top:12px}
1033
1062
  default: return this.json(res, 400, { error: `unknown action "${act}"` });
1034
1063
  }
1035
1064
  }
1065
+ spaceForRead(name, token) {
1066
+ const live = this.spaces.get(name);
1067
+ if (live)
1068
+ return secretEq(live.token, token) ? live : null;
1069
+ if (!name || !token || !this.spaceOnDisk(name))
1070
+ return null;
1071
+ if (new Allowlist(this.dataDir, safeName(name)).tokenMatches(token) !== true)
1072
+ return null;
1073
+ return this.getOrCreateSpace(name, token);
1074
+ }
1075
+ tombstone(name, _token) {
1076
+ try {
1077
+ return JSON.parse(readFileSync(join(this.dataDir, `${safeName(name)}.deleted.json`), "utf8")).at;
1078
+ }
1079
+ catch {
1080
+ return null;
1081
+ }
1082
+ }
1083
+ tombstoneMatches(name, token) {
1084
+ try {
1085
+ const t = JSON.parse(readFileSync(join(this.dataDir, `${safeName(name)}.deleted.json`), "utf8"));
1086
+ return !!t.tokenHash && !!token && createHash("sha256").update(token).digest("hex") === t.tokenHash;
1087
+ }
1088
+ catch {
1089
+ return false;
1090
+ }
1091
+ }
1092
+ nameCollides(name) {
1093
+ const stem = safeName(name).toLowerCase();
1094
+ try {
1095
+ return readdirSync(this.dataDir).some((f) => f.endsWith(".members.json") && f.slice(0, -".members.json".length).toLowerCase() === stem);
1096
+ }
1097
+ catch {
1098
+ return false;
1099
+ }
1100
+ }
1036
1101
  spaceOnDisk(name) {
1037
1102
  return existsSync(join(this.dataDir, `${name.replace(/[^a-z0-9._-]/gi, "_")}.members.json`));
1038
1103
  }
@@ -1549,7 +1614,7 @@ pantheon signup --email you@work.com --username you</code></pre>
1549
1614
  const fail = (code, message, cid) => {
1550
1615
  if (cid && space && me && !space.deleted) {
1551
1616
  noteRefusal(space.store.refusals, { ts: Date.now(), member: me.id, act: lastMsg || "?", code });
1552
- space.store.save(space.goals, space.obligations, space.profiles);
1617
+ space.store.flush(space.goals, space.obligations, space.profiles);
1553
1618
  }
1554
1619
  this.send(ws, { t: "error", code, message, ...(cid ? { cid } : {}) });
1555
1620
  };
@@ -1575,453 +1640,546 @@ pantheon signup --email you@work.com --username you</code></pre>
1575
1640
  ws.on("close", clearHelloTimer);
1576
1641
  this.send(ws, { t: "challenge", nonce, v: PROTOCOL_VERSION });
1577
1642
  ws.on("message", (raw) => {
1578
- let msg;
1579
1643
  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}"`);
1644
+ let msg;
1645
+ try {
1646
+ msg = JSON.parse(String(raw));
1605
1647
  }
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`);
1648
+ catch {
1649
+ return fail("BAD_JSON", "message was not JSON");
1613
1650
  }
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.`);
1651
+ const parsed = Inbound.safeParse(msg);
1652
+ if (!parsed.success) {
1653
+ const cid = typeof msg?.cid === "string"
1654
+ ? msg.cid : undefined;
1655
+ return fail("BAD_MESSAGE", parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; "), cid);
1656
+ }
1657
+ const m = parsed.data;
1658
+ lastMsg = m.t === "act" ? m.act : m.t;
1659
+ touch();
1660
+ if (m.t === "ping")
1661
+ return this.send(ws, { t: "pong" });
1662
+ if (m.t === "hello") {
1663
+ if (space)
1664
+ return reject("ALREADY_JOINED", "this socket already said hello");
1665
+ if (m.nonce !== nonce)
1666
+ return reject("BAD_NONCE", "challenge mismatch — reconnect");
1667
+ if (!m.token && !m.enroll)
1668
+ return reject("NO_CREDENTIAL", "present either a space token or an invite");
1669
+ if (!verifyChallenge(m.pubkey, m.space, m.member, m.nonce, m.sig)) {
1670
+ return reject("BAD_SIGNATURE", `signature does not match the presented key for "${m.member}"`);
1618
1671
  }
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);
1672
+ const gated = this.requireAccounts && !!this.accounts && !this.localTrusted(ip);
1673
+ const pre = gated ? this.accounts.authorize(m.pubkey, m.member) : null;
1674
+ let sp;
1675
+ if (m.enroll) {
1676
+ sp = this.spaces.get(m.space) ?? null;
1677
+ if (!sp && this.spaceOnDisk(m.space)) {
1678
+ const inv = new Allowlist(this.dataDir, safeName(m.space)).findInvite(m.enroll);
1679
+ if (inv?.token === m.enroll && inv.space_token)
1680
+ sp = this.getOrCreateSpace(m.space, inv.space_token);
1681
+ }
1682
+ if (!sp)
1683
+ return reject("NO_SUCH_SPACE", `no space "${m.space}" on this relay`);
1624
1684
  }
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.");
1685
+ else {
1686
+ const tomb = this.deletedSpaces.get(m.space) ?? this.tombstone(m.space, m.token ?? "");
1687
+ if (tomb && Date.now() - tomb < 3_600_000 || (tomb && this.tombstoneMatches(m.space, m.token ?? ""))) {
1688
+ 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.`);
1689
+ }
1690
+ const isNew = !this.spaces.has(m.space) && !this.spaceOnDisk(m.space);
1691
+ if (isNew && this.nameCollides(m.space)) {
1692
+ return reject("NAME_TAKEN", `a team with a name too close to "${m.space}" already exists here — pick another name`);
1693
+ }
1694
+ if (!this.spaces.has(m.space)) {
1695
+ const denied = this.mayCreateSpace(ip);
1696
+ if (denied)
1697
+ return reject(denied.code, denied.message);
1698
+ }
1699
+ if (isNew && gated && !pre?.ok) {
1700
+ this.log(`! REFUSED ${m.member}: cannot create "${m.space}" without a signed-in account (${pre?.ok === false ? pre.reason : "no account"})`);
1701
+ return reject("NOT_SIGNED_IN", "sign in first: run `pantheon signup` (new) or `pantheon login` (existing account) on this machine, then try again.");
1702
+ }
1703
+ if (isNew && pre?.ok) {
1704
+ const lim = this.accounts.limitsFor(pre.account);
1705
+ const owned = this.accounts.spacesOwned(pre.account.id);
1706
+ if (owned.length >= lim.spaces) {
1707
+ 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`;
1708
+ 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 });
1709
+ this.log(`! PLAN_LIMIT ${m.member}: cannot create "${m.space}" (${owned.length}/${fmtLimit(lim.spaces)} spaces on ${pre.account.plan})`);
1710
+ return reject("PLAN_LIMIT", msg);
1711
+ }
1712
+ }
1713
+ sp = this.getOrCreateSpace(m.space, m.token);
1714
+ if (!sp)
1715
+ return reject("BAD_TOKEN", "wrong token for this space");
1716
+ if (isNew) {
1717
+ this.noteSpaceCreate(ip);
1718
+ this.accounts?.recordSpace(sp.name, pre?.ok ? pre.account.id : null);
1719
+ }
1628
1720
  }
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);
1721
+ let acctSession = null;
1722
+ if (this.requireAccounts && this.accounts && !this.localTrusted(ip)) {
1723
+ const auth = this.accounts.authorize(m.pubkey, m.member);
1724
+ if (!auth.ok) {
1725
+ const msg = auth.reason === "username_mismatch"
1726
+ ? `this device is signed in as a different username than "${m.member}". Use your own username, or \`pantheon login\` on this machine.`
1727
+ : auth.reason === "suspended"
1728
+ ? `this account has been suspended by the relay operator. Contact them to have it reinstated.`
1729
+ : `sign in first: run \`pantheon signup\` (new) or \`pantheon login\` (existing account) on this machine, then try again.`;
1730
+ this.log(`[${sp.name}] ! REFUSED ${m.member}: ${auth.reason}`);
1731
+ return reject("NOT_SIGNED_IN", msg);
1637
1732
  }
1733
+ m.member = auth.account.username;
1734
+ acct = auth.account;
1735
+ acctSession = this.accounts.openSession(auth.account.id, auth.device.id, sp.name, ip, m.role ?? "interactive").id;
1736
+ dbSession = acctSession;
1737
+ lastTouch = Date.now();
1638
1738
  }
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);
1739
+ let enrolled = null;
1740
+ if (m.enroll) {
1741
+ const plan = this.spacePlan(sp.name);
1742
+ if (plan && !sp.allow.get(m.member)?.admitted && sp.allow.admittedCount() >= plan.limits.members) {
1743
+ 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 });
1744
+ 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`);
1745
+ }
1746
+ const r = sp.allow.redeem(m.enroll, m.member, m.pubkey);
1747
+ if (!r.ok) {
1748
+ this.log(`[${sp.name}] ! invite refused for ${m.member}: ${r.why}`);
1749
+ return reject("BAD_INVITE", r.fix ? `${r.why}\n${r.fix}` : r.why);
1750
+ }
1751
+ enrolled = sp.token;
1752
+ this.log(`[${sp.name}] * ENROLLED ${m.member} via invite from ${r.entry.admitted_by}${r.entry.may_wake ? " (may wake)" : ""}`);
1645
1753
  }
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);
1754
+ const verdict = enrolled ? "ok" : sp.allow.present(m.member, m.pubkey);
1755
+ if (verdict === "key-mismatch") {
1756
+ this.log(`[${sp.name}] ! REFUSED ${m.member}: key mismatch (${fingerprint(m.pubkey)})`);
1757
+ 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
1758
  }
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`);
1759
+ if (verdict === "revoked") {
1760
+ const e = sp.allow.get(m.member);
1761
+ this.log(`[${sp.name}] ! REFUSED ${m.member}: removed${e?.revoked_by ? ` by ${e.revoked_by}` : ""}`);
1762
+ 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
1763
  }
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);
1764
+ if (verdict === "new" || verdict === "pending") {
1765
+ this.log(`[${sp.name}] ? PENDING ${m.member} ${fingerprint(m.pubkey)}`);
1766
+ this.send(ws, {
1767
+ t: "pending", member: m.member, fingerprint: fingerprint(m.pubkey),
1768
+ 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)}`,
1769
+ });
1770
+ setTimeout(() => { try {
1771
+ ws.close(1008, "PENDING");
1772
+ }
1773
+ catch { } }, 20);
1774
+ return;
1775
+ }
1776
+ let newRepo = null;
1777
+ if (m.repo) {
1778
+ if (sp.allow.hasRepo(m.repo)) { }
1779
+ else if (sp.allow.repoCount() === 0 || enrolled || verdict === "founder")
1780
+ newRepo = m.repo;
1781
+ else {
1782
+ const known = sp.allow.repoList().map((r) => r.origin ?? r.fingerprint).join(", ");
1783
+ this.log(`[${sp.name}] ! REFUSED ${m.member}: repo ${m.repo_origin ?? m.repo} not bound to this space`);
1784
+ return reject("REPO_MISMATCH", `this checkout (${m.repo_origin ?? "no origin"}) is not one of the repos "${sp.name}" works from (${known || "none yet"}). ` +
1785
+ `Open your agent in the team's repo, or if this repo really belongs to the team: pantheon repo add`);
1786
+ }
1787
+ }
1788
+ space = sp;
1789
+ clearHelloTimer();
1790
+ sessionId = acctSession ?? this.mintSession();
1791
+ role = m.role;
1792
+ me = this.attach(sp, m.member, m.display ?? m.member, m.agent ?? "unknown", sessionId, m.role, ws, m.pkg);
1793
+ const firstSession = me.sessions.size === 1;
1794
+ const prior = typeof m.session === "string" ? m.session : "";
1795
+ const stale = prior && prior !== sessionId ? me.sessions.get(prior) : undefined;
1796
+ if (stale && stale.role === m.role) {
1797
+ me.sessions.delete(prior);
1798
+ try {
1799
+ stale.ws.terminate();
1800
+ }
1801
+ catch { }
1802
+ }
1803
+ if (prior && prior !== sessionId && !me.sessions.has(prior)) {
1804
+ let moved = 0;
1805
+ for (const o of sp.obligations.values()) {
1806
+ if (o.claimed_by?.session === prior && o.owner === me.id && o.claimed_by.role === m.role) {
1807
+ o.claimed_by = { ...o.claimed_by, session: sessionId, at: Date.now() };
1808
+ moved++;
1809
+ }
1810
+ }
1811
+ for (const v of sp.contLocks.values())
1812
+ if (v.session === prior && v.member === me.id) {
1813
+ v.session = sessionId;
1814
+ v.at = Date.now();
1815
+ }
1816
+ if (moved)
1817
+ this.pushObligations(sp);
1818
+ }
1819
+ sp.lastTouch = Date.now();
1820
+ if (newRepo && sp.allow.addRepo(newRepo, m.repo_origin ?? null, me.id)) {
1821
+ this.log(`[${sp.name}] + repo ${m.repo_origin ?? newRepo} registered by ${me.id}`);
1822
+ 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 } });
1823
+ this.broadcast(sp, { t: "repos", repos: sp.allow.repoList() });
1676
1824
  }
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
1825
  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)}`,
1826
+ t: "welcome", member: me.id, session: sessionId, space: sp.name, seq: sp.ledger.head,
1827
+ roster: this.roster(sp), goals: [...sp.goals.values()],
1828
+ obligations: [...sp.obligations.values()], members: this.members(sp), profiles: [...sp.profiles.values()],
1829
+ space_id: sp.allow.spaceId(), repos: sp.allow.repoList(),
1830
+ ...(acct ? { plan: this.planView(acct, sp) } : {}),
1831
+ ...(enrolled ? { token: enrolled } : {}),
1690
1832
  });
1691
- setTimeout(() => { try {
1692
- ws.close(1008, "PENDING");
1833
+ let from = m.from_seq && m.from_seq > 0 ? m.from_seq : sp.ledger.head;
1834
+ for (let page = sp.ledger.since(from); page.length; page = sp.ledger.since(from)) {
1835
+ for (const r of page) {
1836
+ this.send(ws, { t: "act", act: r });
1837
+ if (r.seq > me.delivered_seq)
1838
+ me.delivered_seq = r.seq;
1839
+ }
1840
+ from = page[page.length - 1].seq;
1693
1841
  }
1694
- catch { } }, 20);
1842
+ if (firstSession && m.role !== "cli") {
1843
+ this.commit(sp, { from: me.id, act: "JOIN", body: `${me.display} online (${me.agent})`, to: null, goal: null, ref: null });
1844
+ }
1845
+ this.broadcast(sp, { t: "roster", roster: this.roster(sp) });
1846
+ this.log(`[${sp.name}] + ${me.id}/${sessionId} (${m.role}, ${me.agent})${verdict === "founder" ? " [founder, auto-admitted]" : ""}`);
1695
1847
  return;
1696
1848
  }
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`);
1849
+ if (!space || !me)
1850
+ return reject("NOT_JOINED", "say hello first");
1851
+ if (m.t === "publish_profile") {
1852
+ if (m.profile.member !== me.id) {
1853
+ return fail("PROFILE_NOT_YOURS", `you may publish only the profile for ${me.id}`, m.cid);
1707
1854
  }
1855
+ if (!this.writeOk(me))
1856
+ return fail("RATE_LIMIT", "too many changes too fast — wait a few seconds", m.cid);
1857
+ const wild = Object.keys(m.profile.authority ?? {}).filter((k) => /[*?]/.test(k) || k.trim() === "");
1858
+ if (wild.length)
1859
+ return fail("BAD_AUTHORITY", `authority is claimed per named domain, not with "${wild[0]}"`, m.cid);
1860
+ const current = space.profiles.get(me.id);
1861
+ if (!current || m.profile.updated_at > current.updated_at) {
1862
+ space.profiles.set(me.id, m.profile);
1863
+ space.store.flush(space.goals, space.obligations, space.profiles);
1864
+ this.broadcast(space, { t: "profiles", profiles: [...space.profiles.values()] });
1865
+ this.commit(space, {
1866
+ from: me.id, session: sessionId, role, act: "NOTE", to: null, goal: null, ref: null,
1867
+ 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"}`,
1868
+ meta: { organization_profile: m.profile },
1869
+ });
1870
+ }
1871
+ this.send(ws, { t: "ack", ...(m.cid ? { cid: m.cid } : {}), id: me.id, seq: space.ledger.head });
1872
+ return;
1708
1873
  }
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 });
1874
+ if (m.t === "admit") {
1875
+ const self = space.allow.get(me.id);
1876
+ if (!self?.admitted)
1877
+ return fail("NOT_ADMITTED", "only an admitted member can vouch for another", m.cid);
1878
+ if (m.member === me.id)
1879
+ return fail("NO_SELF_ADMIT", "you cannot admit yourself", m.cid);
1880
+ const target = space.allow.get(m.member);
1881
+ if (!target)
1882
+ return fail("NO_SUCH_MEMBER", `"${m.member}" has never connected, so there is no key to admit`, m.cid);
1883
+ if (!m.revoke && !m.forget) {
1884
+ const plan = this.spacePlan(space.name);
1885
+ if (plan && m.may_wake && !plan.limits.wake)
1886
+ 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);
1887
+ if (plan && !target.admitted && space.allow.admittedCount() >= plan.limits.members)
1888
+ 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);
1889
+ }
1890
+ if (m.revoke || m.forget) {
1891
+ const founder = space.allow.founder();
1892
+ if (founder && me.id !== founder)
1893
+ return fail("FOUNDER_ONLY", `only ${founder}, who founded "${space.name}", can remove a member`, m.cid);
1894
+ if (m.member === founder)
1895
+ return fail("FOUNDER_STAYS", "the founder cannot be removed from their own team", m.cid);
1896
+ if (m.forget)
1897
+ space.allow.forget(m.member);
1898
+ else
1899
+ space.allow.revoke(m.member, me.id);
1900
+ if (space.profiles.delete(m.member))
1901
+ this.broadcast(space, { t: "profiles", profiles: [...space.profiles.values()] });
1902
+ for (const s of space.members.get(m.member)?.sessions.values() ?? []) {
1903
+ try {
1904
+ s.ws.close(1008, "REVOKED");
1905
+ }
1906
+ catch { }
1907
+ }
1908
+ space.members.delete(m.member);
1909
+ this.log(`[${space.name}] - ${m.forget ? "FORGOT" : "REVOKED"} ${m.member} by ${me.id}`);
1910
+ }
1911
+ else {
1912
+ space.allow.admit(m.member, me.id, m.may_wake);
1913
+ this.log(`[${space.name}] * ADMITTED ${m.member} by ${me.id}${m.may_wake ? " (may wake workers)" : ""}`);
1914
+ }
1915
+ this.broadcast(space, { t: "members", members: this.members(space) });
1916
+ this.broadcast(space, { t: "roster", roster: this.roster(space) });
1917
+ this.send(ws, { t: "ack", ...(m.cid ? { cid: m.cid } : {}), id: m.member, seq: space.ledger.head });
1918
+ return;
1736
1919
  }
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);
1920
+ if (m.t === "worker_state") {
1921
+ if (role !== "worker")
1922
+ return;
1923
+ me.worker = { state: m.state, ...(m.detail ? { detail: m.detail } : {}), ...(m.until ? { until: m.until } : {}), at: Date.now() };
1924
+ this.broadcast(space, { t: "roster", roster: this.roster(space) });
1925
+ return;
1746
1926
  }
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
- });
1927
+ if (m.t === "delete_space") {
1928
+ if (m.confirm !== space.name)
1929
+ return fail("CONFIRM_MISMATCH", `type the space name ("${space.name}") to confirm`, m.cid);
1930
+ const owner = this.accounts?.spaceOwner(space.name) ?? null;
1931
+ const isOwner = owner ? owner.id === acct?.id : space.allow.founder() === me.id;
1932
+ if (!isOwner)
1933
+ return fail("NOT_OWNER", `only the founder of "${space.name}" can delete it${owner ? ` (that is ${owner.username})` : ""}`, m.cid);
1934
+ const name = space.name;
1935
+ this.accounts?.audit({ event: "space_deleted", actor_account: acct?.id ?? null, space: name, detail: `by its founder ${me.id}`, ip });
1936
+ this.log(`[${name}] * DELETED by its founder ${me.id}`);
1937
+ this.send(ws, { t: "ack", ...(m.cid ? { cid: m.cid } : {}), id: name, seq: space.ledger.head });
1938
+ setTimeout(() => { this.deleteSpace(name); this.accounts?.markSpaceDeleted(name); }, 50).unref?.();
1939
+ return;
1757
1940
  }
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) {
1941
+ if (m.t === "invite") {
1942
+ const self = space.allow.get(me.id);
1943
+ if (!self?.admitted)
1944
+ return fail("NOT_ADMITTED", "only an admitted member can invite", m.cid);
1771
1945
  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");
1946
+ if (plan) {
1947
+ if (m.may_wake && !plan.limits.wake)
1948
+ 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);
1949
+ const open = space.allow.openInvites().length;
1950
+ if (space.allow.admittedCount() + open >= plan.limits.members) {
1951
+ 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
1952
  }
1786
- catch { }
1787
1953
  }
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")
1954
+ if (!this.writeOk(me))
1955
+ return fail("RATE_LIMIT", "too many invites too fast — wait a few seconds", m.cid);
1956
+ if (space.allow.openInvites().length >= LIMITS_SPACE.openInvites)
1957
+ return fail("TOO_MANY_INVITES", `this team already has ${LIMITS_SPACE.openInvites} unused invites — revoke some: pantheon invite --revoke all`, m.cid);
1958
+ const token = randomBytes(18).toString("base64url");
1959
+ const inv = space.allow.mintInvite(me.id, m.may_wake, m.ttl_minutes, token, space.token);
1960
+ this.log(`[${space.name}] * invite minted by ${me.id}, valid ${m.ttl_minutes}m, single use`);
1961
+ this.send(ws, { t: "invite", url: token, expires_at: inv.expires_at });
1802
1962
  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
1963
  }
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() });
1964
+ if (m.t === "invites") {
1965
+ const self = space.allow.get(me.id);
1966
+ if (!self?.admitted)
1967
+ return fail("NOT_ADMITTED", "only an admitted member can see the open invites", m.cid);
1968
+ const founder = space.allow.founder();
1969
+ const viewer = me.id;
1970
+ const invites = space.allow.openInvites().map((i) => {
1971
+ const mine = i.created_by === viewer;
1972
+ 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 };
1973
+ });
1974
+ this.send(ws, { t: "invites", invites, ...(m.cid ? { cid: m.cid } : {}) });
1975
+ return;
1876
1976
  }
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() });
1977
+ if (m.t === "revoke_invite") {
1978
+ const self = space.allow.get(me.id);
1979
+ if (!self?.admitted)
1980
+ return fail("NOT_ADMITTED", "only an admitted member can revoke an invite", m.cid);
1981
+ const inv = space.allow.findInvite(m.token.replace(/^…/, ""));
1982
+ if (!inv)
1983
+ return fail("NO_SUCH_INVITE", "no open invite matches that token — it may already be used, expired or revoked (pantheon invite --list)", m.cid);
1984
+ if (inv.created_by !== me.id && me.id !== space.allow.founder())
1985
+ return fail("NOT_YOURS", `that invite was minted by ${inv.created_by}; only they or the founder can revoke it`, m.cid);
1986
+ space.allow.revokeInvite(inv.token);
1987
+ this.log(`[${space.name}] * invite …${inv.token.slice(-6)} (minted by ${inv.created_by}) revoked by ${me.id}`);
1988
+ this.accounts?.audit({ event: "invite_revoked", space: space.name, target: me.id, detail: `minted by ${inv.created_by}, …${inv.token.slice(-6)}`, ip });
1989
+ this.send(ws, { t: "ack", ...(m.cid ? { cid: m.cid } : {}), id: inv.token.slice(-6), seq: space.ledger.head });
1990
+ return;
1881
1991
  }
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);
1992
+ if (m.t === "bind_repo") {
1993
+ const self = space.allow.get(me.id);
1994
+ if (!self?.admitted)
1995
+ return fail("NOT_ADMITTED", "only an admitted member can register a repo", m.cid);
1996
+ if (!this.writeOk(me))
1997
+ return fail("RATE_LIMIT", "too many repo changes too fast — wait a few seconds", m.cid);
1998
+ if (!space.allow.repoList().some((r) => r.fingerprint === m.repo) && space.allow.repoCount() >= LIMITS_SPACE.repos) {
1999
+ return fail("TOO_MANY_REPOS", `this team already works from ${LIMITS_SPACE.repos} repos`, m.cid);
2000
+ }
2001
+ if (m.base !== undefined && !/^[A-Za-z0-9._][A-Za-z0-9._\/-]{0,99}$/.test(m.base)) {
2002
+ return fail("BAD_BASE", `"${m.base}" is not a branch name`, m.cid);
2003
+ }
2004
+ const existing = space.allow.repoList().find((r) => r.fingerprint === m.repo);
2005
+ if (existing && m.base && existing.added_by !== me.id && space.allow.founder() !== me.id) {
2006
+ 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);
2007
+ }
2008
+ const added = space.allow.addRepo(m.repo, m.origin ?? null, me.id, m.base ?? null);
2009
+ if (!added && m.base && space.allow.setRepoBase(m.repo, m.base)) {
2010
+ this.log(`[${space.name}] ~ repo ${m.origin ?? m.repo} base branch → ${m.base} (by ${me.id})`);
2011
+ this.broadcast(space, { t: "repos", repos: space.allow.repoList() });
2012
+ }
2013
+ if (added) {
2014
+ this.log(`[${space.name}] + repo ${m.origin ?? m.repo} registered by ${me.id}${m.base ? ` (base ${m.base})` : ""}`);
2015
+ 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 } });
2016
+ this.broadcast(space, { t: "repos", repos: space.allow.repoList() });
2017
+ }
2018
+ this.send(ws, { t: "ack", cid: m.cid, id: m.repo, seq: space.ledger.head });
2019
+ return;
1890
2020
  }
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);
2021
+ if (m.t === "claim") {
2022
+ if (role === "worker") {
2023
+ const plan = this.spacePlan(space.name);
2024
+ if (plan && !plan.limits.wake)
2025
+ 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);
2026
+ }
2027
+ if (m.obligation.startsWith("cont:")) {
2028
+ const act = space.ledger.find(m.obligation.slice(5));
2029
+ if (!act)
2030
+ return fail("NO_SUCH_ACT", `no act ${m.obligation.slice(5)} to continue from`, m.cid);
2031
+ const refFrom = act.ref ? space.ledger.find(act.ref)?.from : undefined;
2032
+ const goal = act.goal ? space.goals.get(act.goal) : undefined;
2033
+ if (!isContinuationFor(act, me.id, {
2034
+ ...(refFrom !== undefined ? { refFrom } : {}),
2035
+ ...(goal ? { goalMembers: goal.members, goalState: goal.state } : {}),
2036
+ })) {
2037
+ return fail("NOT_YOURS", `that ${act.act} is not ${me.id}'s to continue`, m.cid);
2038
+ }
2039
+ if (role === "worker") {
2040
+ const defer = shouldDeferToHuman([...me.sessions.values()], this.work(me));
2041
+ const age = Date.now() - act.ts;
2042
+ if (defer && age < LIMITS.workerGraceMs) {
2043
+ 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);
2044
+ }
2045
+ const sender = space.allow.get(act.from);
2046
+ if (act.from !== me.id && !sender?.may_wake) {
2047
+ return fail("NOT_ALLOWED_TO_WAKE", `"${act.from}" is not permitted to wake a worker on this machine`, m.cid);
2048
+ }
2049
+ }
2050
+ const held = space.contLocks.get(m.obligation);
2051
+ if (held && held.session !== sessionId && Date.now() - held.at < LIMITS.claimTtlMs) {
2052
+ return fail("CLAIMED", `another of your sessions (${held.session}) is handling this`, m.cid);
2053
+ }
2054
+ for (const [k, v] of space.contLocks) {
2055
+ if (Date.now() - v.at > LIMITS.claimTtlMs)
2056
+ space.contLocks.delete(k);
2057
+ }
2058
+ space.contLocks.set(m.obligation, { session: sessionId, member: me.id, at: Date.now() });
2059
+ this.send(ws, { t: "ack", ...(m.cid ? { cid: m.cid } : {}), id: m.obligation, seq: space.ledger.head });
2060
+ return;
2061
+ }
2062
+ const o = space.obligations.get(m.obligation);
2063
+ if (!o)
2064
+ return fail("NO_SUCH_OBLIGATION", `no obligation ${m.obligation} — it may already be discharged`, m.cid);
2065
+ if (o.owner !== me.id)
2066
+ return fail("NOT_YOURS", `${o.owner} owes that, not you`, m.cid);
2067
+ if (o.claimed_by && o.claimed_by.session !== sessionId) {
2068
+ if (Date.now() - o.claimed_by.at < LIMITS.claimTtlMs) {
2069
+ const holder = o.claimed_by;
2070
+ const live = [...me.sessions.keys()].includes(holder.session);
2071
+ const left = Math.ceil((LIMITS.claimTtlMs - (Date.now() - holder.at)) / 60_000);
2072
+ return fail("CLAIMED", live
2073
+ ? `another of your sessions (${holder.session}, ${holder.role}) is handling this`
2074
+ : `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);
2075
+ }
1902
2076
  }
1903
2077
  if (role === "worker") {
2078
+ const age = Date.now() - o.created_at;
1904
2079
  const defer = shouldDeferToHuman([...me.sessions.values()], this.work(me));
1905
- const age = Date.now() - act.ts;
1906
2080
  if (defer && age < LIMITS.workerGraceMs) {
1907
2081
  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
2082
  }
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);
2083
+ const origin = space.ledger.find(o.ref);
2084
+ const asker = origin ? space.allow.get(origin.from) : undefined;
2085
+ if (origin && origin.from !== me.id && !asker?.may_wake) {
2086
+ return fail("NOT_ALLOWED_TO_WAKE", `"${origin.from}" is not permitted to wake a worker on this machine`, m.cid);
1912
2087
  }
1913
2088
  }
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);
2089
+ o.claimed_by = { session: sessionId, role, at: Date.now() };
2090
+ this.broadcast(space, { t: "claimed", obligation: o.id, session: sessionId, member: me.id });
2091
+ this.pushObligations(space);
2092
+ this.send(ws, { t: "ack", ...(m.cid ? { cid: m.cid } : {}), id: o.id, seq: space.ledger.head });
2093
+ return;
2094
+ }
2095
+ if (m.t === "open_goal") {
2096
+ if (!this.writeOk(me))
2097
+ return fail("RATE_LIMIT", "too many goals opened too fast — wait a few seconds", m.cid);
2098
+ const openCount = [...space.goals.values()].filter((g) => g.state !== "CLOSED").length;
2099
+ if (openCount >= LIMITS_SPACE.openGoals)
2100
+ return fail("TOO_MANY_GOALS", `this team has ${openCount} goals still open — close some first`, m.cid);
2101
+ for (const id of m.invite ?? []) {
2102
+ if (!space.members.has(id) && !space.allow.get(id)?.admitted)
2103
+ return fail("NO_SUCH_MEMBER", `nobody here is called "${id}"`, m.cid);
1917
2104
  }
1918
- for (const [k, v] of space.contLocks) {
1919
- if (Date.now() - v.at > LIMITS.claimTtlMs)
1920
- space.contLocks.delete(k);
2105
+ const invited = (m.invite ?? [...space.members.keys()]).filter((x) => x !== me.id);
2106
+ if (m.deadline !== undefined && m.deadline <= Date.now()) {
2107
+ return fail("DEADLINE_PAST", `deadline ${new Date(m.deadline).toISOString()} is already in the past`, m.cid);
1921
2108
  }
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 });
2109
+ const goal = {
2110
+ id: newId("g"),
2111
+ title: m.title,
2112
+ acceptance: m.acceptance,
2113
+ members: [me.id, ...new Set(invited)],
2114
+ state: "OPEN",
2115
+ acts: 0,
2116
+ budget: m.budget ?? LIMITS.goalBudget,
2117
+ opened_by: me.id,
2118
+ opened_at: Date.now(),
2119
+ ...(m.deadline !== undefined ? { deadline: m.deadline } : {}),
2120
+ ...(m.kind !== undefined ? { kind: m.kind } : {}),
2121
+ };
2122
+ space.goals.set(goal.id, goal);
2123
+ space.store.flush(space.goals, space.obligations, space.profiles);
2124
+ this.broadcast(space, { t: "goal", goal });
2125
+ const rec = this.commit(space, {
2126
+ from: me.id, act: "NOTE", to: null, goal: goal.id, ref: null,
2127
+ body: `goal opened: ${goal.title}\nacceptance: ${goal.acceptance || "(none stated)"}` +
2128
+ (goal.deadline ? `\ndeadline: ${new Date(goal.deadline).toISOString()}` : "") +
2129
+ (goal.kind === "worklist" ? `\nkind: worklist — ${me.id}'s own list; peers may read it, nobody owes anything in it` : ""),
2130
+ });
2131
+ this.send(ws, { t: "ack", ...(m.cid ? { cid: m.cid } : {}), id: goal.id, seq: rec.seq });
1924
2132
  return;
1925
2133
  }
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
- }
2134
+ 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 });
2135
+ const prior = space.recent.get(fp);
2136
+ if (prior && Date.now() - prior.at < LIMITS.dedupeWindowMs) {
2137
+ this.send(ws, { t: "ack", ...(m.cid ? { cid: m.cid } : {}), id: prior.id, seq: prior.seq });
2138
+ return;
1935
2139
  }
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);
1941
- }
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);
2140
+ if (m.after_seq !== undefined) {
2141
+ const mine = me.id;
2142
+ const missed = space.ledger.since(m.after_seq)
2143
+ .filter((r) => r.from !== mine && r.act !== "JOIN" && r.act !== "LEAVE")
2144
+ .filter((r) => (m.goal ? r.goal === m.goal : !r.goal)
2145
+ || (r.to?.includes(mine) ?? false)
2146
+ || (m.to?.includes(r.from) ?? false)
2147
+ || (!!m.ref && r.ref === m.ref));
2148
+ if (missed.length) {
2149
+ return fail("STALE_VIEW", `${missed.length} act(s) arrived while you were composing — read them before you answer:\n` +
2150
+ missed.slice(0, 8).map((r) => ` [${r.seq}] ${r.act} from ${r.from}: ${first(r.body)}`).join("\n") +
2151
+ (missed.length > 8 ? `\n ...and ${missed.length - 8} more` : "") +
2152
+ `\nCall pantheon_wait, then send again.`, m.cid);
1946
2153
  }
1947
2154
  }
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
- }
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 });
2155
+ 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);
2156
+ if (err)
2157
+ return fail(err.code, err.message, m.cid);
1975
2158
  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` : ""),
2159
+ from: me.id, session: sessionId, role, act: m.act, body: m.body,
2160
+ ...(m.summary ? { summary: m.summary } : {}),
2161
+ to: m.to ?? null, goal: m.goal ?? null, ref: m.ref ?? null,
2162
+ ...(m.meta || m.defers_to || m.affects
2163
+ ? { meta: { ...(m.meta ?? {}),
2164
+ ...(m.defers_to ? { defers_to: m.defers_to } : {}),
2165
+ ...(m.affects ? { affects: m.affects } : {}) } }
2166
+ : {}),
1980
2167
  });
1981
- this.send(ws, { t: "ack", ...(m.cid ? { cid: m.cid } : {}), id: goal.id, seq: rec.seq });
1982
- return;
1983
- }
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;
2168
+ space.recent.set(fp, { id: rec.id, seq: rec.seq, at: Date.now() });
2169
+ for (const [k, v] of space.recent)
2170
+ if (Date.now() - v.at > LIMITS.dedupeWindowMs)
2171
+ space.recent.delete(k);
2172
+ this.send(ws, { t: "ack", ...(m.cid ? { cid: m.cid } : {}), id: rec.id, seq: rec.seq });
1989
2173
  }
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);
2174
+ catch (e) {
2175
+ this.log(`! message handling failed: ${e.message}`);
2176
+ try {
2177
+ ws.send(JSON.stringify({ t: "error", code: "STORAGE", message: "the relay could not save that — try again in a moment" }));
2003
2178
  }
2179
+ catch { }
2004
2180
  }
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
2181
  });
2024
- ws.on("close", () => {
2182
+ ws.on("close", (code) => {
2025
2183
  if (dbSession) {
2026
2184
  this.accounts?.endSession(dbSession);
2027
2185
  dbSession = null;
@@ -2029,13 +2187,22 @@ pantheon signup --email you@work.com --username you</code></pre>
2029
2187
  if (!space || !me)
2030
2188
  return;
2031
2189
  me.sessions.delete(sessionId);
2190
+ const deliberate = code === 1000 || code === 1001 || code === 1005;
2032
2191
  for (const o of space.obligations.values()) {
2033
- if (o.claimed_by?.session === sessionId)
2192
+ if (o.claimed_by?.session !== sessionId)
2193
+ continue;
2194
+ if (deliberate)
2034
2195
  delete o.claimed_by;
2196
+ else
2197
+ o.claimed_by = { ...o.claimed_by, at: Date.now() };
2035
2198
  }
2036
2199
  for (const [k, v] of space.contLocks) {
2037
- if (v.session === sessionId)
2200
+ if (v.session !== sessionId)
2201
+ continue;
2202
+ if (deliberate)
2038
2203
  space.contLocks.delete(k);
2204
+ else
2205
+ v.at = Date.now();
2039
2206
  }
2040
2207
  if (me.sessions.size === 0 && role !== "cli") {
2041
2208
  me.last_seen = Date.now();
@@ -2064,10 +2231,29 @@ pantheon signup --email you@work.com --username you</code></pre>
2064
2231
  for (const sp of this.spaces.values()) {
2065
2232
  if (now - (this.compactedAt.get(sp.name) ?? 0) > LIMITS.ledgerCompactEveryMs)
2066
2233
  this.compactSpace(sp);
2234
+ this.lapseClaims(sp, now);
2067
2235
  }
2068
2236
  this.evictSpaces();
2069
2237
  }
2070
- validateAct(sp, me, act, goalId, ref, to, meta, deferTo, bodyLen = 0, summary, repo) {
2238
+ lapseClaims(sp, now) {
2239
+ const live = new Set();
2240
+ for (const m of sp.members.values())
2241
+ for (const id of m.sessions.keys())
2242
+ live.add(id);
2243
+ let lapsed = 0;
2244
+ for (const o of sp.obligations.values()) {
2245
+ if (o.claimed_by && !live.has(o.claimed_by.session) && now - o.claimed_by.at >= LIMITS.claimTtlMs) {
2246
+ delete o.claimed_by;
2247
+ lapsed++;
2248
+ }
2249
+ }
2250
+ for (const [k, v] of sp.contLocks)
2251
+ if (!live.has(v.session) && now - v.at >= LIMITS.claimTtlMs)
2252
+ sp.contLocks.delete(k);
2253
+ if (lapsed)
2254
+ this.pushObligations(sp);
2255
+ }
2256
+ validateAct(sp, me, act, goalId, ref, to, meta, deferTo, bodyLen = 0, summary, repo, role) {
2071
2257
  if (!SENDABLE.includes(act))
2072
2258
  return { code: "RESERVED_ACT", message: `${act} is emitted by the relay, not by a delegate` };
2073
2259
  const now = Date.now();
@@ -2083,6 +2269,9 @@ pantheon signup --email you@work.com --username you</code></pre>
2083
2269
  }
2084
2270
  if (ref && !sp.ledger.find(ref))
2085
2271
  return { code: "BAD_REF", message: `no act with id ${ref}` };
2272
+ if (meta && JSON.stringify(meta).length > LIMITS_SPACE.metaBytes) {
2273
+ return { code: "META_TOO_LARGE", message: `act metadata is limited to ${LIMITS_SPACE.metaBytes} bytes` };
2274
+ }
2086
2275
  if ((act === "ANSWER" || act === "ACCEPT" || act === "OBJECT") && !ref) {
2087
2276
  return { code: "REF_REQUIRED", message: `${act} must reference the act it responds to` };
2088
2277
  }
@@ -2219,6 +2408,15 @@ pantheon signup --email you@work.com --username you</code></pre>
2219
2408
  return { code: "BAD_REMOTE", message: `${why}. Your peer fetches this from another machine.` };
2220
2409
  }
2221
2410
  }
2411
+ if (act === "ANSWER" && ref) {
2412
+ const asked = sp.ledger.find(ref);
2413
+ if (asked?.act === "ESCALATE" && asked.from !== me.id) {
2414
+ return { code: "NOT_YOUR_DECISION", message: `that ESCALATE is for ${asked.from}'s own human to settle — add to it with a NOTE instead` };
2415
+ }
2416
+ if (asked?.act === "HUMAN_REQUIRED" && asked.to?.[0] && asked.to[0] !== me.id) {
2417
+ return { code: "NOT_YOUR_DECISION", message: `that decision is ${asked.to[0]}'s to make — add to it with a NOTE instead` };
2418
+ }
2419
+ }
2222
2420
  if (!goalId) {
2223
2421
  const target = ref ? sp.ledger.find(ref) : undefined;
2224
2422
  const settlesHuman = act === "ANSWER" && (target?.act === "ESCALATE" || target?.act === "HUMAN_REQUIRED");
@@ -2230,12 +2428,24 @@ pantheon signup --email you@work.com --username you</code></pre>
2230
2428
  const goal = sp.goals.get(goalId);
2231
2429
  if (!goal)
2232
2430
  return { code: "NO_SUCH_GOAL", message: `no goal ${goalId}` };
2431
+ if (ref && act !== "RETRACT") {
2432
+ const target = sp.ledger.find(ref);
2433
+ if (target && target.goal !== goalId) {
2434
+ 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)"}` };
2435
+ }
2436
+ }
2437
+ for (const id of to ?? []) {
2438
+ if (!goal.members.includes(id)) {
2439
+ 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` };
2440
+ }
2441
+ }
2233
2442
  if (goal.state === "CLOSED")
2234
2443
  return { code: "GOAL_CLOSED", message: `goal "${goal.title}" is closed` };
2235
2444
  if (!goal.members.includes(me.id))
2236
2445
  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}`);
2446
+ const decision = act === "ANSWER" && ref ? sp.ledger.find(ref) : undefined;
2447
+ const settlesOwnHuman = !!decision && decision.goal === goalId && role === "cli"
2448
+ && [...sp.obligations.values()].some((o) => o.kind === "HUMAN" && o.ref === ref && o.goal === goalId && o.owner === `human:${me.id}`);
2239
2449
  if (goal.acts >= goal.budget && !settlesOwnHuman) {
2240
2450
  return { code: "BUDGET_SPENT", message: `goal "${goal.title}" spent its ${goal.budget}-act budget and needs a human` };
2241
2451
  }
@@ -2298,6 +2508,15 @@ pantheon signup --email you@work.com --username you</code></pre>
2298
2508
  break;
2299
2509
  case "ANSWER":
2300
2510
  if (rec.ref) {
2511
+ for (const o of sp.obligations.values()) {
2512
+ const g = o.goal ? sp.goals.get(o.goal) : undefined;
2513
+ if (o.kind === "HUMAN" && o.ref === rec.ref && o.owner === `human:${rec.from}` && g?.state === "STALLED"
2514
+ && !["ESCALATE", "HUMAN_REQUIRED"].includes(sp.ledger.find(o.ref)?.act ?? "")) {
2515
+ g.budget = Math.max(g.budget, g.acts) + Math.ceil(LIMITS.goalBudget / 2);
2516
+ g.state = "OPEN";
2517
+ this.broadcast(sp, { t: "goal", goal: g });
2518
+ }
2519
+ }
2301
2520
  closeFor(rec.from, rec.ref);
2302
2521
  closeFor(`human:${rec.from}`, rec.ref);
2303
2522
  }
@@ -2320,9 +2539,10 @@ pantheon signup --email you@work.com --username you</code></pre>
2320
2539
  if (rec.ref)
2321
2540
  closeFor(rec.from, rec.ref);
2322
2541
  const accepted = rec.ref ? sp.ledger.find(rec.ref) : undefined;
2323
- if (accepted?.act === "WITHDRAW" && accepted.ref) {
2542
+ if (accepted?.act === "WITHDRAW" && accepted.ref && accepted.from !== rec.from) {
2543
+ const owedTo = sp.ledger.find(accepted.ref)?.from;
2324
2544
  for (const [id, o] of sp.obligations) {
2325
- if (o.ref === accepted.ref && o.owner === accepted.from)
2545
+ if (o.ref === accepted.ref && o.owner === accepted.from && owedTo === rec.from)
2326
2546
  sp.obligations.delete(id);
2327
2547
  }
2328
2548
  }
@@ -2339,16 +2559,12 @@ pantheon signup --email you@work.com --username you</code></pre>
2339
2559
  }
2340
2560
  break;
2341
2561
  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
2562
  case "DELIVERED":
2348
2563
  if (rec.ref)
2349
- for (const [id, o] of sp.obligations)
2350
- if (o.ref === rec.ref)
2564
+ for (const [id, o] of sp.obligations) {
2565
+ if (o.ref === rec.ref && o.goal === rec.goal && o.kind !== "HUMAN")
2351
2566
  sp.obligations.delete(id);
2567
+ }
2352
2568
  break;
2353
2569
  case "ESCALATE":
2354
2570
  this.oblige(sp, { kind: "HUMAN", owner: `human:${rec.from}`, ref: rec.id, goal: rec.goal,
@@ -2373,6 +2589,27 @@ pantheon signup --email you@work.com --username you</code></pre>
2373
2589
  for (const [id, o] of sp.obligations)
2374
2590
  if (o.ref === rec.ref)
2375
2591
  sp.obligations.delete(id);
2592
+ if (rec.ref)
2593
+ for (const [k, v] of sp.recent)
2594
+ if (v.id === rec.ref)
2595
+ sp.recent.delete(k);
2596
+ if (rec.ref) {
2597
+ const answer = sp.ledger.find(rec.ref);
2598
+ const asked = answer?.act === "ANSWER" && answer.from === rec.from && answer.ref ? sp.ledger.find(answer.ref) : undefined;
2599
+ const decider = asked?.act === "ESCALATE" ? asked.from : asked?.act === "HUMAN_REQUIRED" ? asked.to?.[0] : undefined;
2600
+ const retracted = new Set(sp.ledger.all().filter((r) => r.act === "RETRACT" && r.ref).map((r) => r.ref));
2601
+ 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));
2602
+ const alreadyLive = asked && [...sp.obligations.values()].some((o) => o.kind === "HUMAN" && o.ref === asked.id);
2603
+ if (asked && decider === rec.from && !stillAnswered && !alreadyLive && !retracted.has(asked.id)) {
2604
+ this.oblige(sp, { kind: "HUMAN", owner: `human:${decider}`, ref: asked.id, goal: asked.goal,
2605
+ body: asked.act === "ESCALATE" ? `${asked.from} escalated: ${first(asked.body)}` : `${asked.from} needs a decision: ${first(asked.body)}` });
2606
+ const g = asked.goal ? sp.goals.get(asked.goal) : undefined;
2607
+ if (g && g.state === "OPEN") {
2608
+ g.state = "BLOCKED";
2609
+ this.broadcast(sp, { t: "goal", goal: g });
2610
+ }
2611
+ }
2612
+ }
2376
2613
  break;
2377
2614
  case "CLOSE":
2378
2615
  if (goal) {
@@ -2400,9 +2637,17 @@ pantheon signup --email you@work.com --username you</code></pre>
2400
2637
  sp.obligations.set(id, { ...o, id, created_at: Date.now() });
2401
2638
  }
2402
2639
  pushObligations(sp) {
2403
- sp.store.save(sp.goals, sp.obligations, sp.profiles);
2640
+ sp.store.flush(sp.goals, sp.obligations, sp.profiles);
2404
2641
  this.broadcast(sp, { t: "obligations", list: [...sp.obligations.values()] });
2405
2642
  }
2643
+ writeOk(me) {
2644
+ const now = Date.now();
2645
+ me.stamps = me.stamps.filter((t) => now - t < LIMITS.rateWindowMs);
2646
+ if (me.stamps.length >= LIMITS.rateActs)
2647
+ return false;
2648
+ me.stamps.push(now);
2649
+ return true;
2650
+ }
2406
2651
  mayCreateSpace(ip) {
2407
2652
  const now = Date.now();
2408
2653
  if (!this.localTrusted(ip)) {
@@ -2490,7 +2735,7 @@ pantheon signup --email you@work.com --username you</code></pre>
2490
2735
  getOrCreateSpace(name, token) {
2491
2736
  const existing = this.spaces.get(name);
2492
2737
  if (existing)
2493
- return existing.token === token ? existing : null;
2738
+ return secretEq(existing.token, token) ? existing : null;
2494
2739
  const safe = name.replace(/[^a-z0-9._-]/gi, "_");
2495
2740
  const allow = new Allowlist(this.dataDir, safe);
2496
2741
  const match = allow.tokenMatches(token);
@@ -2533,7 +2778,8 @@ pantheon signup --email you@work.com --username you</code></pre>
2533
2778
  const g = sp.goals.get(goal);
2534
2779
  return !!g && g.state !== "CLOSED";
2535
2780
  };
2536
- const { dropped, kept } = sp.ledger.compact({ keepRecent: LIMITS.ledgerKeepRecent, isLive });
2781
+ const referenced = new Set([...sp.obligations.values()].map((o) => o.ref));
2782
+ const { dropped, kept } = sp.ledger.compact({ keepRecent: LIMITS.ledgerKeepRecent, isLive, keep: (id) => referenced.has(id) });
2537
2783
  this.compactedAt.set(sp.name, Date.now());
2538
2784
  if (dropped)
2539
2785
  this.log(`[${sp.name}] compacted ledger — dropped ${dropped} old act(s) from closed goals, ${kept} kept`);