@join-pantheon/cli 0.29.0 → 0.30.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.
Files changed (46) hide show
  1. package/README.md +45 -22
  2. package/bin/pantheon.js +12 -0
  3. package/dist/accounts.js +151 -26
  4. package/dist/agents.js +197 -14
  5. package/dist/claude-confine.js +283 -0
  6. package/dist/cli.js +725 -119
  7. package/dist/client.js +37 -2
  8. package/dist/command-state.js +79 -0
  9. package/dist/config.js +211 -10
  10. package/dist/doctor.js +103 -13
  11. package/dist/grants.js +1 -1
  12. package/dist/history-view.js +2 -1
  13. package/dist/home-guard.js +210 -0
  14. package/dist/hooks-install.js +2 -1
  15. package/dist/identity.js +24 -0
  16. package/dist/install.js +76 -26
  17. package/dist/kiro-files.js +2 -2
  18. package/dist/ledger.js +111 -28
  19. package/dist/machine.js +507 -38
  20. package/dist/mcp-supervisor.js +25 -6
  21. package/dist/mcp-windows.js +47 -25
  22. package/dist/mcp.js +25 -10
  23. package/dist/network.js +39 -8
  24. package/dist/notify.js +17 -6
  25. package/dist/pending.js +8 -14
  26. package/dist/persona.js +21 -0
  27. package/dist/profile.js +4 -1
  28. package/dist/quickstart.js +88 -15
  29. package/dist/reads.js +30 -0
  30. package/dist/refusal.js +14 -1
  31. package/dist/relay.js +1193 -185
  32. package/dist/roster-file.js +169 -23
  33. package/dist/run-failure.js +83 -3
  34. package/dist/secret-guard.js +141 -0
  35. package/dist/service.js +105 -36
  36. package/dist/setup-guide.js +28 -9
  37. package/dist/spend.js +6 -2
  38. package/dist/store.js +94 -10
  39. package/dist/text-file.js +74 -0
  40. package/dist/tray.js +11 -2
  41. package/dist/update-check.js +2 -0
  42. package/dist/which.js +69 -12
  43. package/dist/worker-policy.js +137 -3
  44. package/dist/worker.js +702 -81
  45. package/helper/macos/menubar.swift +15 -1
  46. package/package.json +2 -2
package/dist/relay.js CHANGED
@@ -1,19 +1,19 @@
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, writeFileSync } from "node:fs";
3
+ import { rmSync, readdirSync, readFileSync, statSync, existsSync, writeFileSync, openSync, readSync, closeSync, fstatSync } from "node:fs";
4
4
  import { spawn } from "node:child_process";
5
5
  import { join } from "node:path";
6
6
  import { WebSocketServer } from "ws";
7
7
  import { badRemote, Inbound, isContinuationFor, LIMITS, newId, PROTOCOL_VERSION, SENDABLE, shouldDeferToHuman, } from "./protocol.js";
8
8
  import { VERSION } from "./version.js";
9
- import { Ledger } from "./ledger.js";
10
- import { fingerprint, newNonce, verifyChallenge, verifyActivity, verifyAccount } from "./identity.js";
9
+ import { Ledger, SpaceFull } from "./ledger.js";
10
+ import { fingerprint, newNonce, verifyChallenge, verifyActivity, verifyAccount, verifyRead } from "./identity.js";
11
11
  import { Accounts, normalizeUsername, validEmail, validUsername } from "./accounts.js";
12
12
  import { emailSenderFromEnv } from "./email.js";
13
- import { Allowlist } from "./roster-file.js";
14
- import { StateStore } from "./store.js";
13
+ import { Allowlist, RosterUnreadable } from "./roster-file.js";
14
+ import { StateStore, ensurePrivateDir } from "./store.js";
15
15
  import { activity, mergeActivity, feed, noteRefusal, refusalsSince, countActs, spaceHealth, fleetReport, ESCALATIONS, MESSAGE_ACTS, ACT_TONE, DAY } from "./admin-stats.js";
16
- import { fmtLimit, parseLimits, PLAN_NAME } from "./plans.js";
16
+ import { fmtLimit, parseLimits, planLimits, PLAN_NAME } from "./plans.js";
17
17
  import { routeDecision } from "./profile.js";
18
18
  function workState(m, now = Date.now(), busyTtlMs = LIMITS.activityBusyTtlMs, pruneMs = LIMITS.activityPruneMs) {
19
19
  let known = false;
@@ -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
  }
@@ -75,6 +81,17 @@ export function isTrustedSource(ip) {
75
81
  function fingerprint_(from, act, body, goal, ref, extra = null) {
76
82
  return createHash("sha256").update([from, act, goal ?? "", ref ?? "", body, stableJson(extra)].join("\u0000")).digest("hex").slice(0, 32);
77
83
  }
84
+ function actFingerprint(from, act, body, goal, ref, to, meta) {
85
+ let m = meta ?? null;
86
+ if (m && ("authority" in m || "unverified_domains" in m)) {
87
+ m = { ...m };
88
+ delete m["authority"];
89
+ delete m["unverified_domains"];
90
+ }
91
+ if (m && Object.keys(m).length === 0)
92
+ m = null;
93
+ return fingerprint_(from, act, body, goal, ref, { to: to ?? null, meta: m });
94
+ }
78
95
  function stableJson(v) {
79
96
  if (v === null || typeof v !== "object")
80
97
  return JSON.stringify(v ?? null);
@@ -83,6 +100,8 @@ function stableJson(v) {
83
100
  return `{${Object.keys(v).sort().map((k) => `${JSON.stringify(k)}:${stableJson(v[k])}`).join(",")}}`;
84
101
  }
85
102
  function clientIp(req) {
103
+ if (process.env.PANTHEON_TRUSTED_HOPS?.trim() === "0")
104
+ return req.socket.remoteAddress || "unknown";
86
105
  const raw = req.headers["x-forwarded-for"];
87
106
  const hops = (Array.isArray(raw) ? raw.join(",") : raw ?? "").split(",").map((x) => x.trim()).filter(Boolean);
88
107
  const trusted = Math.max(1, Number(process.env.PANTHEON_TRUSTED_HOPS ?? 1) || 1);
@@ -155,14 +174,161 @@ const fmtAgo = (t) => {
155
174
  return m < 1 ? "just now" : m < 60 ? `${m}m ago` : m < 1440 ? `${Math.round(m / 60)}h ago` : `${Math.round(m / 1440)}d ago`;
156
175
  };
157
176
  const safeName = (name) => name.replace(/[^a-z0-9._-]/gi, "_");
177
+ const spaceKey = (name) => safeName(name).toLowerCase();
178
+ function firstLedgerName(file) {
179
+ let fd = null;
180
+ try {
181
+ fd = openSync(file, "r");
182
+ const chunks = [];
183
+ const buf = Buffer.allocUnsafe(64 * 1024);
184
+ for (let pos = 0, total = 0; total < 1 << 20;) {
185
+ const n = readSync(fd, buf, 0, buf.length, pos);
186
+ if (n <= 0)
187
+ break;
188
+ const nl = buf.subarray(0, n).indexOf(10);
189
+ chunks.push(Buffer.from(buf.subarray(0, nl === -1 ? n : nl)));
190
+ if (nl !== -1)
191
+ break;
192
+ pos += n;
193
+ total += n;
194
+ }
195
+ const r = JSON.parse(Buffer.concat(chunks).toString("utf8"));
196
+ return typeof r.space === "string" && r.space ? r.space : null;
197
+ }
198
+ catch {
199
+ return null;
200
+ }
201
+ finally {
202
+ if (fd !== null)
203
+ try {
204
+ closeSync(fd);
205
+ }
206
+ catch { }
207
+ }
208
+ }
209
+ const GOAL_OPENED = /^goal opened: ([^\n]*)\nacceptance: ([\s\S]*?)(?:\ndeadline: (\S+))?(?:\nkind: (\w+)[\s\S]*)?$/;
158
210
  const LIMITS_GOAL_READ = 1000;
159
211
  const LIMITS_SPACE = { metaBytes: 8192, openGoals: 500, repos: 100, openInvites: 50 };
212
+ const SPACE_MAX_BYTES_DEFAULT = 50 * 1024 * 1024;
213
+ const SPACE_HARD_SLACK = 1024 * 1024;
214
+ const REFUSAL_BUDGET = 100;
215
+ const REFUSAL_WINDOW_MS = 10_000;
216
+ const RECENT_MAX = 5_000;
217
+ const DEVICE_OTHER_ACCOUNT = [
218
+ "this machine's key is already signed in to a different account, and one key belongs to one account — nothing was changed and no code was sent.",
219
+ "To sign back in to that account: pantheon login --email <its email>.",
220
+ "To switch this machine to the other account, sign it out of the first one (pantheon sessions revoke <this device's id> --device, while signed in to it), then run this again.",
221
+ "To keep both, give the second account its own identity folder: PANTHEON_HOME=~/.pantheon-<name> pantheon signup … (and use the same PANTHEON_HOME for every command as that account).",
222
+ ].join("\n");
223
+ const UPGRADE_NOTE = "This team now needs Pantheon 0.30 or newer to read its history — run: pantheon upgrade";
224
+ const UPGRADE_NOTE_ID = "a_pantheon_upgrade_required";
225
+ const RELAY_MEMBER = "pantheon";
226
+ function ledgerTailSeq(file) {
227
+ let fd = null;
228
+ try {
229
+ fd = openSync(file, "r");
230
+ const size = fstatSync(fd).size;
231
+ const len = Math.min(size, 256 * 1024);
232
+ const buf = Buffer.allocUnsafe(len);
233
+ readSync(fd, buf, 0, len, size - len);
234
+ const lines = buf.toString("utf8").split("\n");
235
+ for (let i = lines.length - 1; i >= 0; i--) {
236
+ try {
237
+ const r = JSON.parse(lines[i]);
238
+ if (typeof r.seq === "number")
239
+ return r.seq;
240
+ }
241
+ catch { }
242
+ }
243
+ return 0;
244
+ }
245
+ catch {
246
+ return 0;
247
+ }
248
+ finally {
249
+ if (fd !== null)
250
+ try {
251
+ closeSync(fd);
252
+ }
253
+ catch { }
254
+ }
255
+ }
256
+ function versionBelow(a, b) {
257
+ if (!a)
258
+ return true;
259
+ const pa = a.split(/[.-]/).map((x) => Number.parseInt(x, 10) || 0), pb = b.split(".").map(Number);
260
+ for (let i = 0; i < 3; i++) {
261
+ if ((pa[i] ?? 0) !== (pb[i] ?? 0))
262
+ return (pa[i] ?? 0) < (pb[i] ?? 0);
263
+ }
264
+ return false;
265
+ }
266
+ const LOCKED_MSG = "too many wrong codes from this device or your network in the last hour — wait an hour, then request a new code";
267
+ const STORAGE_UNHEALTHY_MS = 5 * 60_000;
268
+ function isStorageError(e) {
269
+ const code = e?.code;
270
+ return typeof code === "string" && (/^E[A-Z0-9]+$/.test(code) || code.startsWith("ERR_SQLITE"));
271
+ }
272
+ function buildCommit() {
273
+ const c = process.env.PANTHEON_COMMIT ?? process.env.RAILWAY_GIT_COMMIT_SHA ?? process.env.SOURCE_COMMIT ?? process.env.GIT_COMMIT ?? "";
274
+ return /^[0-9a-f]{7,40}$/i.test(c.trim()) ? c.trim() : null;
275
+ }
276
+ export function netOf(ip) {
277
+ const h = ip.replace(/^::ffff:/i, "");
278
+ const v4 = /^(\d+)\.(\d+)\.(\d+)\.\d+$/.exec(h);
279
+ if (v4)
280
+ return `${v4[1]}.${v4[2]}.${v4[3]}.0/24`;
281
+ if (h.includes(":")) {
282
+ const [head, tail] = h.split("::");
283
+ const a = head ? head.split(":") : [];
284
+ const b = tail !== undefined ? (tail ? tail.split(":") : []) : [];
285
+ const groups = tail !== undefined ? [...a, ...Array(Math.max(0, 8 - a.length - b.length)).fill("0"), ...b] : a;
286
+ return `${groups.slice(0, 4).map((g) => (g || "0").toLowerCase().replace(/^0+(?=.)/, "")).join(":")}::/64`;
287
+ }
288
+ return h;
289
+ }
160
290
  export class Relay {
161
291
  opts;
162
292
  spaces = new Map();
163
293
  dataDir;
164
294
  log;
165
- http = createServer((req, res) => this.onHttp(req, res));
295
+ http = createServer((req, res) => {
296
+ const fail = (e) => {
297
+ if (e instanceof RosterUnreadable) {
298
+ this.log(`!! ${e.message} — reads of this space are refused until it is repaired (it was left in place)`);
299
+ try {
300
+ if (!res.headersSent)
301
+ this.json(res, 503, { code: "SPACE_UNREADABLE", error: "the relay cannot read this team's member list, so it cannot say who may read it. Nothing was changed; tell the relay operator." });
302
+ else
303
+ res.end();
304
+ }
305
+ catch { }
306
+ return;
307
+ }
308
+ const bad = e instanceof TypeError || e instanceof URIError;
309
+ if (!bad)
310
+ this.log(`! http ${req.method} ${String(req.url ?? "").slice(0, 120)} failed: ${e?.message ?? e}`);
311
+ if (isStorageError(e))
312
+ this.noteStorageFailure(`http ${String(req.url ?? "").split("?")[0].slice(0, 60)}`, e);
313
+ try {
314
+ if (!res.headersSent)
315
+ this.json(res, bad ? 400 : 500, { error: bad ? "bad request" : "the relay could not handle that request" });
316
+ else
317
+ res.end();
318
+ }
319
+ catch { }
320
+ };
321
+ try {
322
+ if (!URL.canParse(req.url ?? "/", "http://x"))
323
+ return fail(new TypeError("unparseable request target"));
324
+ const r = this.onHttp(req, res);
325
+ if (r && typeof r.catch === "function")
326
+ r.catch(fail);
327
+ }
328
+ catch (e) {
329
+ fail(e);
330
+ }
331
+ });
166
332
  wss = new WebSocketServer({ noServer: true, maxPayload: 256 * 1024 });
167
333
  heartbeat;
168
334
  compactedAt = new Map();
@@ -173,20 +339,32 @@ export class Relay {
173
339
  pruneMs;
174
340
  accounts;
175
341
  requireAccounts;
342
+ readsMode;
176
343
  trustLocal;
177
344
  email;
178
345
  authHits = new Map();
179
346
  adminHits = new Map();
180
347
  emailSends = [];
348
+ emailSendsTo = new Map();
349
+ emailSendsFrom = new Map();
350
+ storageFailure = null;
351
+ unbound = new Set();
352
+ maxLedgerBytes;
181
353
  adminToken;
182
354
  adminAllow;
183
355
  startedAt = Date.now();
184
356
  deletedSpaces = new Map();
357
+ authEpoch = 0;
358
+ rotateOnRemove;
185
359
  backupTimer;
186
360
  constructor(opts = {}) {
187
361
  this.opts = opts;
188
362
  this.dataDir = opts.dataDir ?? "./pantheon-data";
189
363
  this.log = opts.log ?? ((l) => console.error(l));
364
+ ensurePrivateDir(this.dataDir);
365
+ this.maxLedgerBytes = opts.spaceMaxBytes
366
+ ?? (Number(process.env.PANTHEON_SPACE_MAX_BYTES) > 0 ? Number(process.env.PANTHEON_SPACE_MAX_BYTES) : SPACE_MAX_BYTES_DEFAULT);
367
+ this.scanUnbound();
190
368
  this.requireAccounts = opts.requireAccounts ?? process.env.PANTHEON_REQUIRE_ACCOUNTS === "1";
191
369
  this.trustLocal = process.env.PANTHEON_TRUST_LOCAL !== undefined
192
370
  ? process.env.PANTHEON_TRUST_LOCAL === "1"
@@ -214,8 +392,14 @@ export class Relay {
214
392
  spaceJunkDeleteMs: opts.spaceLimits?.spaceJunkDeleteMs ?? LIMITS.spaceJunkDeleteMs,
215
393
  spaceMinAgeMs: opts.spaceLimits?.spaceMinAgeMs ?? LIMITS.spaceMinAgeMs,
216
394
  };
395
+ this.rotateOnRemove = opts.rotateOnRemove ?? process.env.PANTHEON_ROTATE_ON_REMOVE === "1";
217
396
  this.busyTtlMs = opts.activityBusyTtlMs ?? LIMITS.activityBusyTtlMs;
218
397
  this.pruneMs = opts.activityPruneMs ?? LIMITS.activityPruneMs;
398
+ const readsEnv = (process.env.PANTHEON_READS ?? "").trim().toLowerCase();
399
+ this.readsMode = opts.reads ?? (readsEnv === "" || readsEnv === "transition" ? "transition" : "signed");
400
+ if (!opts.reads && readsEnv && readsEnv !== "transition" && readsEnv !== "signed") {
401
+ this.log(`! PANTHEON_READS="${process.env.PANTHEON_READS}" is not "signed" or "transition" — using "signed"`);
402
+ }
219
403
  this.http.on("upgrade", (req, socket, head) => {
220
404
  const ip = clientIp(req);
221
405
  this.wss.handleUpgrade(req, socket, head, (ws) => this.onSocket(ws, ip));
@@ -238,8 +422,14 @@ export class Relay {
238
422
  });
239
423
  }
240
424
  async close() {
241
- for (const sp of this.spaces.values())
242
- sp.store.close(sp.goals, sp.obligations, sp.profiles);
425
+ for (const sp of this.spaces.values()) {
426
+ try {
427
+ sp.store.close(sp.goals, sp.obligations, sp.profiles);
428
+ }
429
+ catch (e) {
430
+ this.noteStorageFailure(`[${sp.name}] final flush`, e);
431
+ }
432
+ }
243
433
  if (this.backupTimer)
244
434
  clearInterval(this.backupTimer);
245
435
  if (this.accounts && !this.opts.accounts)
@@ -251,6 +441,48 @@ export class Relay {
251
441
  await new Promise((r) => this.wss.close(() => r()));
252
442
  await new Promise((r) => this.http.close(() => r()));
253
443
  }
444
+ noteStorageFailure(where, e) {
445
+ const message = e?.message ?? String(e);
446
+ this.log(`! storage: ${where} failed: ${message}`);
447
+ if (isStorageError(e))
448
+ this.storageFailure = { at: Date.now(), where, message };
449
+ }
450
+ scanUnbound() {
451
+ let files = [];
452
+ try {
453
+ files = readdirSync(this.dataDir);
454
+ }
455
+ catch {
456
+ return;
457
+ }
458
+ for (const f of files) {
459
+ const safe = f.endsWith(".members.json") ? f.slice(0, -".members.json".length) : f.endsWith(".jsonl") ? f.slice(0, -".jsonl".length) : null;
460
+ if (safe === null || this.unbound.has(safe))
461
+ continue;
462
+ try {
463
+ if (this.unboundOnDisk(safe))
464
+ this.unbound.add(safe);
465
+ }
466
+ catch { }
467
+ }
468
+ if (this.unbound.size) {
469
+ this.log(`! ${this.unbound.size} space(s) on disk have no bound token: ${[...this.unbound].sort().join(", ")}. ` +
470
+ `Each is re-bound only by a reconnect from a member already admitted in it, with that member's key; anyone else is refused (UNBOUND_SPACE).`);
471
+ }
472
+ }
473
+ unboundOnDisk(safe) {
474
+ const size = (suf) => { try {
475
+ return statSync(join(this.dataDir, `${safe}${suf}`)).size;
476
+ }
477
+ catch {
478
+ return -1;
479
+ } };
480
+ const ledger = size(".jsonl");
481
+ if (size(".members.json") < 0)
482
+ return ledger > 0;
483
+ const al = new Allowlist(this.dataDir, safe);
484
+ return !al.hasToken && (!al.empty || ledger > 0);
485
+ }
254
486
  onHttp(req, res) {
255
487
  const url = new URL(req.url ?? "/", "http://x");
256
488
  const ip = clientIp(req);
@@ -269,11 +501,16 @@ export class Relay {
269
501
  seq: s.ledger.head,
270
502
  });
271
503
  const asked = url.searchParams.get("space");
272
- const one = asked ? this.spaces.get(asked) : undefined;
504
+ const one = asked ? this.live(asked) : undefined;
505
+ const sf = this.storageFailure && Date.now() - this.storageFailure.at < STORAGE_UNHEALTHY_MS ? this.storageFailure : null;
506
+ const commit = buildCommit();
273
507
  return this.json(res, 200, {
274
- ok: true,
508
+ ok: !sf,
509
+ ...(sf ? { storage: { error: sf.message, where: sf.where, at: new Date(sf.at).toISOString() } } : {}),
275
510
  v: PROTOCOL_VERSION,
276
511
  pkg: VERSION,
512
+ ...(commit ? { commit } : {}),
513
+ ...(adminView && this.unbound.size ? { unbound_spaces: [...this.unbound].sort() } : {}),
277
514
  space_count: this.spaces.size,
278
515
  space_capacity: this.limits.maxSpaces,
279
516
  ...(adminView ? { spaces: [...this.spaces.values()].map(detail) }
@@ -293,10 +530,12 @@ export class Relay {
293
530
  : this.json(res, 404, { error: "no open invite matches that code — it may have been used already or expired" });
294
531
  }
295
532
  if (url.pathname === "/act") {
296
- const space = this.spaceForRead(url.searchParams.get("space") ?? "", url.searchParams.get("token") ?? "");
297
- const token = url.searchParams.get("token") ?? "";
298
- if (!space || !secretEq(space.token, token))
299
- return this.json(res, 404, { error: "no such space" });
533
+ const gate = this.readGate(req, url, ip);
534
+ if (!gate.ok && gate.notice)
535
+ return this.upgradeAnswer(res, url.pathname, url, gate.notice, gate.error);
536
+ if (!gate.ok)
537
+ return this.json(res, gate.status, { error: gate.error, ...(gate.code ? { code: gate.code } : {}) });
538
+ const space = gate.space;
300
539
  const act = space.ledger.find(url.searchParams.get("id") ?? "");
301
540
  if (!act)
302
541
  return this.json(res, 404, { error: "no such act" });
@@ -304,10 +543,12 @@ export class Relay {
304
543
  return this.json(res, 200, { act, ...(goal ? { goal: { id: goal.id, title: goal.title, state: goal.state } } : {}) });
305
544
  }
306
545
  if (url.pathname === "/history") {
307
- const space = this.spaceForRead(url.searchParams.get("space") ?? "", url.searchParams.get("token") ?? "");
308
- const token = url.searchParams.get("token") ?? "";
309
- if (!space || !secretEq(space.token, token))
310
- return this.json(res, 404, { error: "no such space" });
546
+ const gate = this.readGate(req, url, ip);
547
+ if (!gate.ok && gate.notice)
548
+ return this.upgradeAnswer(res, url.pathname, url, gate.notice, gate.error);
549
+ if (!gate.ok)
550
+ return this.json(res, gate.status, { error: gate.error, ...(gate.code ? { code: gate.code } : {}) });
551
+ const space = gate.space;
311
552
  const goal = url.searchParams.get("goal");
312
553
  const sinceRaw = url.searchParams.get("since");
313
554
  const acts = goal ? space.ledger.byGoal(goal, LIMITS_GOAL_READ)
@@ -315,10 +556,12 @@ export class Relay {
315
556
  return this.json(res, 200, { acts, head: space.ledger.head });
316
557
  }
317
558
  if (url.pathname === "/obligations") {
318
- const space = this.spaceForRead(url.searchParams.get("space") ?? "", url.searchParams.get("token") ?? "");
319
- const token = url.searchParams.get("token") ?? "";
320
- if (!space || !secretEq(space.token, token))
321
- return this.json(res, 404, { error: "no such space" });
559
+ const gate = this.readGate(req, url, ip);
560
+ if (!gate.ok && gate.notice)
561
+ return this.upgradeAnswer(res, url.pathname, url, gate.notice, gate.error);
562
+ if (!gate.ok)
563
+ return this.json(res, gate.status, { error: gate.error, ...(gate.code ? { code: gate.code } : {}) });
564
+ const space = gate.space;
322
565
  const member = url.searchParams.get("member");
323
566
  const now = Date.now();
324
567
  const owed = [...space.obligations.values()]
@@ -333,25 +576,38 @@ export class Relay {
333
576
  if (url.pathname === "/activity") {
334
577
  if (req.method !== "POST")
335
578
  return this.json(res, 405, { error: "POST only" });
336
- const space = this.spaceForRead(url.searchParams.get("space") ?? "", url.searchParams.get("token") ?? "");
337
- const token = url.searchParams.get("token") ?? "";
338
- if (!space || !secretEq(space.token, token))
579
+ const spaceName = url.searchParams.get("space") ?? "", spaceToken = url.searchParams.get("token") ?? "";
580
+ const target = this.readTarget(spaceName, spaceToken);
581
+ if (!target)
339
582
  return this.json(res, 404, { error: "no such space" });
340
583
  const memberId = url.searchParams.get("member") ?? "";
341
- const member = space.members.get(memberId);
342
584
  const session = (url.searchParams.get("session") ?? "").slice(0, 64);
343
585
  const state = url.searchParams.get("state");
344
- if (!member || !session || (state !== "busy" && state !== "idle")) {
586
+ if (!memberId || !session || (state !== "busy" && state !== "idle")) {
345
587
  return this.json(res, 400, { error: "member, session and state=busy|idle are required" });
346
588
  }
347
589
  const ts = Number(url.searchParams.get("ts"));
348
590
  const sig = url.searchParams.get("sig") ?? "";
349
591
  const seenParam = url.searchParams.get("seen") ?? "";
350
- const pub = space.allow.pubkeyOf(memberId);
592
+ const pub = target.allow.pubkeyOf(memberId);
351
593
  const fresh = Number.isFinite(ts) && Math.abs(Date.now() - ts) < LIMITS.activityFreshMs;
352
- if (!pub || !fresh || !verifyActivity(pub, space.name, memberId, session, state, seenParam, ts, sig)) {
594
+ if (!pub || !fresh || !verifyActivity(pub, spaceName, memberId, session, state, seenParam, ts, sig)) {
353
595
  return this.json(res, 403, { error: "activity report must be signed by the member's key and recent" });
354
596
  }
597
+ if (this.requireAccounts && this.accounts && !this.localTrusted(ip)) {
598
+ const auth = this.accounts.authorize(pub, memberId);
599
+ if (!auth.ok) {
600
+ return this.json(res, 403, { code: "NOT_SIGNED_IN", error: auth.reason === "suspended"
601
+ ? "this account has been suspended by the relay operator"
602
+ : "this device is not signed in to an account that may report here — run pantheon login" });
603
+ }
604
+ }
605
+ const space = target.live ?? this.getOrCreateSpace(spaceName, spaceToken, { retired: true });
606
+ if (!space)
607
+ return this.json(res, 404, { error: "no such space" });
608
+ const member = space.members.get(memberId);
609
+ if (!member)
610
+ return this.json(res, 400, { error: "member, session and state=busy|idle are required" });
355
611
  if (member.activity.size > 64 && !member.activity.has(session)) {
356
612
  const oldest = [...member.activity.entries()].sort((a, b) => a[1].at - b[1].at)[0];
357
613
  if (oldest)
@@ -408,7 +664,7 @@ export class Relay {
408
664
  res.writeHead(429, { "content-type": "text/html; charset=utf-8", "retry-after": "60" });
409
665
  return void res.end("<p>Too many attempts. Wait a minute and try again.</p>");
410
666
  }
411
- return this.readForm(req, (body) => {
667
+ return this.readForm(req, res, (body) => {
412
668
  if (!tokenOk(body.token ?? "")) {
413
669
  this.noteAdminFail(ip);
414
670
  A0.audit({ event: "admin_login_failed", ip });
@@ -450,7 +706,7 @@ export class Relay {
450
706
  if (url.pathname === "/admin/action" && req.method === "POST") {
451
707
  if (!authed)
452
708
  return this.json(res, 403, { error: "sign in at /admin" });
453
- return this.readForm(req, (body) => this.adminAction(res, A0, body, ip));
709
+ return this.readForm(req, res, (body) => this.adminAction(res, A0, body, ip));
454
710
  }
455
711
  if (url.pathname === "/admin/space") {
456
712
  if (!authed)
@@ -469,7 +725,7 @@ export class Relay {
469
725
  if (url.pathname === "/auth/signup" && req.method === "POST") {
470
726
  if (!this.authRateOk(ip))
471
727
  return this.json(res, 429, { error: "too many attempts from your network — wait a minute and try again" });
472
- return this.readJson(req, async (b) => {
728
+ return this.readJson(req, res, async (b) => {
473
729
  if (!b || typeof b.email !== "string" || typeof b.username !== "string" || typeof b.pubkey !== "string") {
474
730
  return this.json(res, 400, { error: "email, username and pubkey are required" });
475
731
  }
@@ -477,63 +733,74 @@ export class Relay {
477
733
  return this.json(res, 400, { error: "that does not look like an email address" });
478
734
  if (!validUsername(normalizeUsername(b.username)))
479
735
  return this.json(res, 400, { error: "username must be 2–32 chars: letters, digits, . _ - (not at the ends)" });
736
+ const over = this.emailRefusal("signup", b.email, ip);
737
+ if (over)
738
+ return this.json(res, 429, { error: over, reason: "email_budget" });
480
739
  const started = A.startVerification(b.email, "signup", { username: b.username, pubkey: b.pubkey, label: String(b.label ?? "a device").slice(0, 60) });
481
740
  if (!started.ok) {
482
741
  if (started.reason === "blocked")
483
742
  return this.json(res, 403, { error: "this email cannot sign up on this relay", reason: "blocked" });
743
+ if (started.reason === "locked")
744
+ return this.json(res, 429, { error: LOCKED_MSG, reason: started.reason });
484
745
  const msg = started.reason === "too_soon" ? "a code was just sent — check your email, or wait a minute to resend"
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"
746
+ : started.reason === "username_taken" ? "that username is taken"
747
+ : started.reason === "email_taken" ? "that email already has an account — use `pantheon login` from this machine instead"
748
+ : started.reason === "device_other_account" ? DEVICE_OTHER_ACCOUNT
488
749
  : "check the email and username";
489
750
  return this.json(res, 409, { error: msg, reason: started.reason });
490
751
  }
491
752
  A.audit({ event: "signup", target: normalizeUsername(b.username), ip });
492
- if (!this.emailBudgetOk())
493
- return this.json(res, 429, { error: "the relay is sending too many codes right now — try again shortly" });
494
753
  const sent = await this.email.sendCode(b.email, started.code, "signup");
495
754
  if (sent)
496
- this.emailSends.push(Date.now());
755
+ this.noteEmailSent("signup", b.email, ip);
497
756
  return this.json(res, 200, { ok: true, sent, via: this.email.kind });
498
757
  });
499
758
  }
500
759
  if (url.pathname === "/auth/login" && req.method === "POST") {
501
760
  if (!this.authRateOk(ip))
502
761
  return this.json(res, 429, { error: "too many attempts from your network — wait a minute and try again" });
503
- return this.readJson(req, async (b) => {
762
+ return this.readJson(req, res, async (b) => {
504
763
  if (!b || typeof b.email !== "string" || typeof b.pubkey !== "string")
505
764
  return this.json(res, 400, { error: "email and pubkey are required" });
765
+ const own = A.isDeviceOf(b.email, b.pubkey);
766
+ const over = this.emailRefusal("login", b.email, ip, b.pubkey, own);
767
+ if (over)
768
+ return this.json(res, 429, { error: over, reason: "email_budget" });
506
769
  const started = A.startVerification(b.email, "login", { pubkey: b.pubkey, label: String(b.label ?? "a device").slice(0, 60) });
507
770
  if (!started.ok && started.reason === "blocked")
508
771
  return this.json(res, 403, { error: "this email cannot sign in on this relay", reason: "blocked" });
772
+ if (!started.ok && started.reason === "device_other_account")
773
+ return this.json(res, 409, { error: DEVICE_OTHER_ACCOUNT, reason: started.reason });
509
774
  if (!started.ok) {
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";
775
+ const code = started.reason === "too_soon" || started.reason === "locked" ? 429 : 404;
776
+ const msg = started.reason === "too_soon" ? "a code was just sent — check your email, or wait a minute" : started.reason === "locked" ? LOCKED_MSG : "no active account for that email — `pantheon signup` first";
512
777
  return this.json(res, code, { error: msg, reason: started.reason });
513
778
  }
514
- if (!this.emailBudgetOk())
515
- return this.json(res, 429, { error: "the relay is sending too many codes right now — try again shortly" });
516
779
  const sent = await this.email.sendCode(b.email, started.code, "login");
517
780
  if (sent)
518
- this.emailSends.push(Date.now());
781
+ this.noteEmailSent("login", b.email, ip, b.pubkey, own);
519
782
  return this.json(res, 200, { ok: true, sent, via: this.email.kind });
520
783
  });
521
784
  }
522
785
  if (url.pathname === "/auth/verify" && req.method === "POST") {
523
786
  if (!this.authRateOk(ip))
524
787
  return this.json(res, 429, { error: "too many attempts from your network — wait a minute and try again" });
525
- return this.readJson(req, (b) => {
788
+ return this.readJson(req, res, (b) => {
526
789
  if (!b || typeof b.email !== "string" || typeof b.code !== "string")
527
790
  return this.json(res, 400, { error: "email and code are required" });
528
- const r = A.verify(b.email, b.code.trim(), ip, typeof b.pubkey === "string" ? b.pubkey : undefined);
791
+ const r = A.verify(b.email, b.code.trim(), ip, typeof b.pubkey === "string" ? b.pubkey : undefined, this.localTrusted(ip) ? null : netOf(ip));
529
792
  if (!r.ok) {
530
793
  const msg = r.reason === "expired" ? "that code has expired — request a new one"
531
794
  : r.reason === "other_device" ? "that code was requested from a different device — request a new one from this one"
532
795
  : r.reason === "suspended" ? "this account is suspended — contact the relay operator"
533
796
  : 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";
536
- return this.json(res, 400, { error: msg, reason: r.reason });
797
+ : r.reason === "locked" ? LOCKED_MSG
798
+ : r.reason === "several_pending" ? "codes for this email were asked for from more than one device, and this pantheon is too old to say which device it is — run pantheon upgrade , then verify again"
799
+ : r.reason === "device_other_account" ? DEVICE_OTHER_ACCOUNT
800
+ : r.reason === "no_pending" ? "no code is pending for that email — run signup or login first"
801
+ : r.tries_left === 0 ? "that code is not right, and it was the last try — request a new code"
802
+ : `that code is not right — ${r.tries_left ?? "a few"} ${r.tries_left === 1 ? "try" : "tries"} left on it`;
803
+ return this.json(res, 400, { error: msg, reason: r.reason, ...(r.tries_left !== undefined ? { tries_left: r.tries_left } : {}) });
537
804
  }
538
805
  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 });
539
806
  });
@@ -558,8 +825,10 @@ export class Relay {
558
825
  const hit = A.revoke(who.account.id, kind, id, ip);
559
826
  for (const sid of hit.sessionIds)
560
827
  this.dropSessionEverywhere(sid);
561
- if (hit.deviceId)
828
+ if (hit.deviceId) {
562
829
  this.dropDeviceSockets(who.account.id, hit.deviceId);
830
+ this.noteAccountRemoval(who.account.username);
831
+ }
563
832
  return this.json(res, 200, { ok: true, revoked: hit.sessionIds.length, device_removed: !!hit.deviceId });
564
833
  }
565
834
  if (url.pathname === "/account/delete" && req.method === "POST") {
@@ -570,6 +839,7 @@ export class Relay {
570
839
  A.deleteAccount(who.account.id, ip);
571
840
  for (const s of sessions)
572
841
  this.dropSessionEverywhere(s.id);
842
+ this.noteAccountRemoval(who.account.username);
573
843
  return this.json(res, 200, { ok: true });
574
844
  }
575
845
  if (url.pathname === "/audit" && req.method === "GET") {
@@ -578,7 +848,7 @@ export class Relay {
578
848
  return this.json(res, 403, { error: "this request must be signed by a device on the account" });
579
849
  const space = url.searchParams.get("space") ?? undefined;
580
850
  if (space) {
581
- const sp = this.spaces.get(space);
851
+ const sp = this.live(space);
582
852
  const member = sp?.allow.get(who.account.username);
583
853
  if (!sp || !member?.admitted)
584
854
  return this.json(res, 403, { error: "you are not a member of that space" });
@@ -646,11 +916,47 @@ export class Relay {
646
916
  }
647
917
  }
648
918
  }
649
- emailBudgetOk() {
650
- const now = Date.now();
651
- this.emailSends = this.emailSends.filter((t) => now - t < 3_600_000);
919
+ emailRefusal(kind, email, ip, pubkey = "", own = false) {
920
+ const now = Date.now(), hour = 3_600_000;
921
+ const live = (v) => v.filter((t) => now - t < hour);
922
+ this.emailSends = this.emailSends.filter((s) => now - s.at < hour);
652
923
  const cap = Number(process.env.PANTHEON_EMAIL_MAX_PER_HOUR) || 200;
653
- return this.emailSends.length < cap;
924
+ const reserved = Math.min(cap - 1, Math.max(0, Number(process.env.PANTHEON_EMAIL_RESERVED_PER_HOUR ?? Math.floor(cap / 4)) || 0));
925
+ const perAddress = Number(process.env.PANTHEON_EMAIL_MAX_PER_ADDRESS) || 5;
926
+ const perNet = Number(process.env.PANTHEON_EMAIL_MAX_PER_NETWORK) || 20;
927
+ if (live(this.emailSendsTo.get(this.emailBucket(email, pubkey, own)) ?? []).length >= perAddress) {
928
+ return own ? "this device has asked for too many codes in the last hour — use the latest one, or try again in an hour"
929
+ : "too many codes were sent to this address in the last hour — use the latest one, or try again in an hour";
930
+ }
931
+ if (!own && !this.localTrusted(ip) && live(this.emailSendsFrom.get(netOf(ip)) ?? []).length >= perNet)
932
+ return "too many codes were requested from your network in the last hour — try again later";
933
+ if (this.emailSends.length >= cap)
934
+ return "the relay is sending too many codes right now — try again shortly";
935
+ if (kind === "signup" && this.emailSends.filter((s) => s.signup).length >= cap - reserved)
936
+ return "the relay is taking too many new sign-ups right now — try again shortly";
937
+ return null;
938
+ }
939
+ emailBucket(email, pubkey, own) {
940
+ const to = email.trim().toLowerCase();
941
+ return own ? `${to}\u0000${pubkey}` : to;
942
+ }
943
+ noteEmailSent(kind, email, ip, pubkey = "", own = false) {
944
+ const now = Date.now(), hour = 3_600_000;
945
+ this.emailSends.push({ at: now, signup: kind === "signup" });
946
+ const bump = (m, k) => {
947
+ m.set(k, [...(m.get(k) ?? []).filter((t) => now - t < hour), now]);
948
+ if (m.size > 10_000)
949
+ for (const [key, v] of m) {
950
+ const keep = v.filter((t) => now - t < hour);
951
+ if (keep.length)
952
+ m.set(key, keep);
953
+ else
954
+ m.delete(key);
955
+ }
956
+ };
957
+ bump(this.emailSendsTo, this.emailBucket(email, pubkey, own));
958
+ if (!own && !this.localTrusted(ip))
959
+ bump(this.emailSendsFrom, netOf(ip));
654
960
  }
655
961
  authRateOk(ip) {
656
962
  if (this.localTrusted(ip))
@@ -670,41 +976,134 @@ export class Relay {
670
976
  }
671
977
  return recent.length <= 20;
672
978
  }
673
- readJson(req, cb) {
674
- let raw = "";
675
- req.on("data", (c) => { raw += c; if (raw.length > 64_000)
676
- req.destroy(); });
677
- req.on("end", () => { try {
678
- cb(raw ? JSON.parse(raw) : {});
979
+ readBody(req, res, limit, cb) {
980
+ const HARD = 4 * 1024 * 1024;
981
+ let done = false;
982
+ const tooBig = () => {
983
+ if (done)
984
+ return;
985
+ done = true;
986
+ if (!res.headersSent) {
987
+ res.setHeader("connection", "close");
988
+ this.json(res, 413, { error: `request body too large: the limit here is ${limit} bytes` });
989
+ }
990
+ };
991
+ const declared = Number(req.headers["content-length"]);
992
+ if (Number.isFinite(declared) && declared > HARD) {
993
+ tooBig();
994
+ req.resume();
995
+ res.on("finish", () => req.destroy());
996
+ return;
679
997
  }
680
- catch {
681
- cb(null);
682
- } });
683
- req.on("error", () => cb(null));
998
+ const chunks = [];
999
+ let size = 0, over = false;
1000
+ req.on("data", (c) => {
1001
+ size += c.length;
1002
+ if (size > HARD) {
1003
+ tooBig();
1004
+ res.on("finish", () => req.destroy());
1005
+ return;
1006
+ }
1007
+ if (size > limit) {
1008
+ over = true;
1009
+ chunks.length = 0;
1010
+ return;
1011
+ }
1012
+ if (!over)
1013
+ chunks.push(Buffer.isBuffer(c) ? c : Buffer.from(c));
1014
+ });
1015
+ req.on("end", () => {
1016
+ if (over)
1017
+ return tooBig();
1018
+ if (done)
1019
+ return;
1020
+ done = true;
1021
+ cb(Buffer.concat(chunks).toString("utf8"));
1022
+ });
1023
+ req.on("error", () => { if (done)
1024
+ return; done = true; cb(null); });
1025
+ }
1026
+ settle(res, r) {
1027
+ if (r && typeof r.catch === "function") {
1028
+ r.catch((e) => {
1029
+ this.log(`! http handler failed: ${e?.message ?? e}`);
1030
+ if (isStorageError(e))
1031
+ this.noteStorageFailure("http handler", e);
1032
+ try {
1033
+ if (!res.headersSent)
1034
+ this.json(res, 500, { error: "the relay could not handle that request" });
1035
+ else
1036
+ res.end();
1037
+ }
1038
+ catch { }
1039
+ });
1040
+ }
1041
+ }
1042
+ readJson(req, res, cb) {
1043
+ this.readBody(req, res, 64_000, (raw) => {
1044
+ let body = null;
1045
+ if (raw !== null) {
1046
+ try {
1047
+ body = raw ? JSON.parse(raw) : {};
1048
+ }
1049
+ catch {
1050
+ body = null;
1051
+ }
1052
+ }
1053
+ try {
1054
+ this.settle(res, cb(body));
1055
+ }
1056
+ catch (e) {
1057
+ this.settle(res, Promise.reject(e));
1058
+ }
1059
+ });
684
1060
  }
685
1061
  dropSessionEverywhere(sessionId) {
1062
+ this.authEpoch++;
686
1063
  for (const sp of this.spaces.values()) {
687
1064
  for (const m of sp.members.values()) {
688
1065
  const s = m.sessions.get(sessionId);
689
1066
  if (s) {
690
- try {
691
- s.ws.close(1008, "SESSION_REVOKED");
692
- }
693
- catch { }
1067
+ this.hangUp(s.ws, "SESSION_REVOKED");
694
1068
  m.sessions.delete(sessionId);
695
1069
  }
696
1070
  }
697
1071
  }
698
1072
  }
1073
+ hangUp(ws, reason) {
1074
+ try {
1075
+ ws.close(1008, reason);
1076
+ }
1077
+ catch { }
1078
+ const t = setTimeout(() => { try {
1079
+ ws.terminate();
1080
+ }
1081
+ catch { } }, 100);
1082
+ t.unref?.();
1083
+ }
1084
+ stillIn(sp, me, sessionId, ws) {
1085
+ if (ws.readyState !== 1)
1086
+ return false;
1087
+ if (sp.deleted || this.spaces.get(spaceKey(sp.name)) !== sp)
1088
+ return false;
1089
+ if (sp.members.get(me.id) !== me || me.sessions.get(sessionId)?.ws !== ws)
1090
+ return false;
1091
+ return !!sp.allow.get(me.id)?.admitted;
1092
+ }
699
1093
  dropDeviceSockets(_accountId, _deviceId) {
700
1094
  }
701
- readForm(req, cb) {
702
- let raw = "";
703
- req.on("data", (c) => { raw += c; if (raw.length > 8_000)
704
- req.destroy(); });
705
- req.on("end", () => { const o = {}; for (const [k, v] of new URLSearchParams(raw))
706
- o[k] = v; cb(o); });
707
- req.on("error", () => cb({}));
1095
+ readForm(req, res, cb) {
1096
+ this.readBody(req, res, 8_000, (raw) => {
1097
+ const o = {};
1098
+ for (const [k, v] of new URLSearchParams(raw ?? ""))
1099
+ o[k] = v;
1100
+ try {
1101
+ this.settle(res, cb(o));
1102
+ }
1103
+ catch (e) {
1104
+ this.settle(res, Promise.reject(e));
1105
+ }
1106
+ });
708
1107
  }
709
1108
  adminLoginPage(res, wrong) {
710
1109
  res.writeHead(wrong ? 401 : 200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store", "referrer-policy": "no-referrer" });
@@ -756,7 +1155,7 @@ button:hover{background:#1d4ed8}.err{color:var(--bad);margin-top:12px}
756
1155
  if (exact(sp.allow))
757
1156
  return sp.name;
758
1157
  const safeOf = (name) => name.replace(/[^a-z0-9._-]/gi, "_");
759
- const live = new Set([...this.spaces.keys()].map(safeOf));
1158
+ const live = new Set(this.spaces.keys());
760
1159
  let files = [];
761
1160
  try {
762
1161
  files = readdirSync(this.dataDir);
@@ -768,7 +1167,7 @@ button:hover{background:#1d4ed8}.err{color:var(--bad);margin-top:12px}
768
1167
  if (!f.endsWith(".members.json"))
769
1168
  continue;
770
1169
  const safe = f.slice(0, -".members.json".length);
771
- if (live.has(safe))
1170
+ if (live.has(safe.toLowerCase()))
772
1171
  continue;
773
1172
  try {
774
1173
  if (!exact(new Allowlist(this.dataDir, safe)))
@@ -910,28 +1309,27 @@ button:hover{background:#1d4ed8}.err{color:var(--bad);margin-top:12px}
910
1309
  return { goals, refusals: st.refusals };
911
1310
  }
912
1311
  deleteSpace(name) {
913
- const sp = this.spaces.get(name);
1312
+ const sp = this.spaces.get(spaceKey(name));
914
1313
  if (sp) {
915
1314
  sp.deleted = true;
916
1315
  for (const m of sp.members.values())
917
- for (const s of m.sessions.values()) {
918
- try {
919
- s.ws.close(1008, "SPACE_DELETED");
920
- }
921
- catch { }
922
- }
923
- this.spaces.delete(name);
1316
+ for (const s of m.sessions.values())
1317
+ this.hangUp(s.ws, "SPACE_DELETED");
1318
+ this.spaces.delete(spaceKey(name));
924
1319
  }
925
1320
  setTimeout(() => this.deleteSpaceFiles(name), 600).unref?.();
926
1321
  const safe = name.replace(/[^a-z0-9._-]/gi, "_");
927
1322
  try {
928
1323
  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 }));
1324
+ const earlier = sp?.allow ? sp.allow.tokenHashes().filter((h) => h !== hash) : [];
1325
+ writeFileSync(join(this.dataDir, `${safe}.deleted.json`), JSON.stringify({ name, at: Date.now(), tokenHash: hash, ...(earlier.length ? { retiredTokenHashes: earlier } : {}) }));
1326
+ this.tombstoneFile(name);
1327
+ this.tombstoneStems.set(spaceKey(name), safe);
930
1328
  }
931
1329
  catch { }
932
1330
  const existed = !!sp || [".jsonl", ".state.json", ".members.json"].some((suf) => existsSync(join(this.dataDir, `${safe}${suf}`)));
933
1331
  this.deleteSpaceFiles(name);
934
- this.deletedSpaces.set(name, Date.now());
1332
+ this.deletedSpaces.set(spaceKey(name), Date.now());
935
1333
  if (this.deletedSpaces.size > 1000)
936
1334
  for (const [k, t] of this.deletedSpaces)
937
1335
  if (Date.now() - t > 3_600_000)
@@ -951,14 +1349,17 @@ button:hover{background:#1d4ed8}.err{color:var(--bad);margin-top:12px}
951
1349
  return this.json(res, 404, { error: "no such account" });
952
1350
  const live = A.sessionsFor(acct.id).filter((s) => !s.revoked_at);
953
1351
  A.setStatus(acct.id, act === "suspend" ? "suspended" : "active", ip);
954
- if (act === "suspend")
1352
+ this.authEpoch++;
1353
+ if (act === "suspend") {
955
1354
  for (const s of live)
956
1355
  this.dropSessionEverywhere(s.id);
1356
+ this.noteAccountRemoval(acct.username);
1357
+ }
957
1358
  this.log(`[admin] ${act} ${acct.username} from ${ip}`);
958
1359
  return done(`${acct.username} ${act === "suspend" ? "suspended and signed out everywhere" : "reinstated"}`);
959
1360
  }
960
1361
  case "revoke_invite": {
961
- const sp = this.spaces.get(id);
1362
+ const sp = this.spaces.get(spaceKey(id));
962
1363
  const allow = sp ? sp.allow : new Allowlist(this.dataDir, id.replace(/[^a-z0-9._-]/gi, "_"));
963
1364
  const token = (body.token ?? "").trim();
964
1365
  const inv = allow.findInvite(token);
@@ -975,8 +1376,12 @@ button:hover{background:#1d4ed8}.err{color:var(--bad);margin-top:12px}
975
1376
  const hit = A.adminRevoke(act === "revoke_session" ? "session" : "device", id, ip);
976
1377
  for (const sid of hit.sessionIds)
977
1378
  this.dropSessionEverywhere(sid);
978
- if (hit.deviceId && hit.accountId)
1379
+ if (hit.deviceId && hit.accountId) {
979
1380
  this.dropDeviceSockets(hit.accountId, hit.deviceId);
1381
+ const owner = A.accountById(hit.accountId);
1382
+ if (owner)
1383
+ this.noteAccountRemoval(owner.username);
1384
+ }
980
1385
  return done(hit.sessionIds.length || hit.deviceId ? `revoked ${act === "revoke_session" ? "session" : "device"} ${id}` : `nothing to revoke for ${id}`);
981
1386
  }
982
1387
  case "block": {
@@ -1062,28 +1467,213 @@ button:hover{background:#1d4ed8}.err{color:var(--bad);margin-top:12px}
1062
1467
  default: return this.json(res, 400, { error: `unknown action "${act}"` });
1063
1468
  }
1064
1469
  }
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))
1470
+ readTarget(name, token) {
1471
+ if (!name || !token)
1070
1472
  return null;
1071
- if (new Allowlist(this.dataDir, safeName(name)).tokenMatches(token) !== true)
1473
+ const live = this.live(name);
1474
+ if (live) {
1475
+ const st = this.tokenState(live, token);
1476
+ return st ? { live, allow: live.allow, st } : null;
1477
+ }
1478
+ if (this.aliasOf(name) || !this.spaceOnDisk(name))
1072
1479
  return null;
1073
- return this.getOrCreateSpace(name, token);
1480
+ const allow = new Allowlist(this.dataDir, safeName(name));
1481
+ const st = allow.tokenMatches(token) === true ? "current" : allow.tokenRetired(token) ? "retired" : null;
1482
+ return st ? { live: undefined, allow, st } : null;
1483
+ }
1484
+ unsignedRefusal(name, allow, st) {
1485
+ if (this.readsMode === "signed")
1486
+ return "This relay serves only signed reads.";
1487
+ if (allow.removalRecorded())
1488
+ return `"${name}" has removed a member, so it serves only signed reads.`;
1489
+ if (st !== "current")
1490
+ return `"${name}" has a new token since this machine last connected, so it serves this read only signed.`;
1491
+ return null;
1492
+ }
1493
+ upgradeAct(name, seq, goal, why) {
1494
+ return {
1495
+ id: UPGRADE_NOTE_ID, seq, ts: Date.now(), space: name, from: RELAY_MEMBER, act: "NOTE",
1496
+ body: `${UPGRADE_NOTE}\n${why}`, to: null, goal, ref: null, meta: { notice: "upgrade_required" },
1497
+ };
1498
+ }
1499
+ upgradeAnswer(res, path, url, n, error) {
1500
+ const since = url.searchParams.get("since");
1501
+ const seq = since !== null ? Math.max(0, Math.min(n.head, Number(since) || 0)) : n.head;
1502
+ const act = this.upgradeAct(n.name, seq, url.searchParams.get("goal"), n.why);
1503
+ const common = { code: "UPGRADE_REQUIRED", error, note: UPGRADE_NOTE };
1504
+ if (path === "/act")
1505
+ return this.json(res, 200, { act, ...common });
1506
+ if (path === "/obligations")
1507
+ return this.json(res, 200, { obligations: [], closed_goals: [], seq: n.head, worker_online: false, ...common });
1508
+ if (since !== null && Number(since) >= n.head)
1509
+ return this.json(res, 200, { acts: [], head: n.head, ...common });
1510
+ return this.json(res, 200, { acts: [act], head: n.head, ...common });
1511
+ }
1512
+ tokenState(sp, token) {
1513
+ if (!token)
1514
+ return null;
1515
+ if (sp.token && secretEq(sp.token, token))
1516
+ return "current";
1517
+ if (sp.allow.tokenMatches(token) === true) {
1518
+ sp.token = token;
1519
+ this.handTokenToWaiting(sp);
1520
+ return "current";
1521
+ }
1522
+ return sp.allow.tokenRetired(token) ? "retired" : null;
1523
+ }
1524
+ knownToken(sp) {
1525
+ if (!sp.token) {
1526
+ const t = sp.allow.currentTokenFromInvites();
1527
+ if (t)
1528
+ sp.token = t;
1529
+ }
1530
+ return sp.token;
1531
+ }
1532
+ handTokenToWaiting(sp) {
1533
+ for (const m of sp.members.values()) {
1534
+ if (!sp.allow.get(m.id)?.admitted)
1535
+ continue;
1536
+ for (const s of m.sessions.values())
1537
+ if (s.wantsToken) {
1538
+ s.wantsToken = false;
1539
+ this.send(s.ws, { t: "token", token: sp.token });
1540
+ }
1541
+ }
1542
+ }
1543
+ ensureToken(sp) {
1544
+ if (!this.knownToken(sp))
1545
+ this.rotateSpaceToken(sp, "its current token was not in memory");
1546
+ return sp.token;
1547
+ }
1548
+ rotateSpaceToken(sp, why) {
1549
+ const next = randomBytes(16).toString("hex");
1550
+ sp.allow.rotateToken(next);
1551
+ sp.token = next;
1552
+ let told = 0;
1553
+ for (const m of sp.members.values()) {
1554
+ if (!sp.allow.get(m.id)?.admitted)
1555
+ continue;
1556
+ for (const s of m.sessions.values()) {
1557
+ this.send(s.ws, { t: "token", token: next });
1558
+ told++;
1559
+ }
1560
+ }
1561
+ this.log(`[${sp.name}] * space token rotated (${why}); sent to ${told} live session${told === 1 ? "" : "s"}`);
1562
+ }
1563
+ noteAccountRemoval(username) {
1564
+ this.authEpoch++;
1565
+ const u = normalizeUsername(username);
1566
+ const seen = new Set();
1567
+ for (const sp of this.spaces.values()) {
1568
+ seen.add(safeName(sp.name));
1569
+ if (sp.allow.get(u))
1570
+ sp.allow.noteRemoval();
1571
+ }
1572
+ let files = [];
1573
+ try {
1574
+ files = readdirSync(this.dataDir).filter((f) => f.endsWith(".members.json"));
1575
+ }
1576
+ catch { }
1577
+ for (const f of files) {
1578
+ const stem = f.slice(0, -".members.json".length);
1579
+ if (seen.has(stem))
1580
+ continue;
1581
+ try {
1582
+ const al = new Allowlist(this.dataDir, stem);
1583
+ if (al.get(u))
1584
+ al.noteRemoval();
1585
+ }
1586
+ catch { }
1587
+ }
1588
+ }
1589
+ readGate(req, url, ip) {
1590
+ const params = {};
1591
+ for (const [k, v] of url.searchParams) {
1592
+ if (k in params)
1593
+ return { ok: false, status: 400, error: `the query names "${k}" twice` };
1594
+ params[k] = v;
1595
+ }
1596
+ const name = params.space ?? "", token = params.token ?? "";
1597
+ const upgrade = (why) => ({
1598
+ ok: false, status: 401, code: "UPGRADE_REQUIRED",
1599
+ error: `${why} This pantheon is too old to read it: run pantheon upgrade (or npm i -g @join-pantheon/cli), then try again.`,
1600
+ });
1601
+ const target = this.readTarget(name, token);
1602
+ if (params.sig === undefined) {
1603
+ if (!target)
1604
+ return this.readsMode === "signed" ? upgrade("This relay serves only signed reads.") : { ok: false, status: 404, error: "no such space" };
1605
+ const why = this.unsignedRefusal(name, target.allow, target.st);
1606
+ if (why) {
1607
+ const head = target.live ? target.live.ledger.head : ledgerTailSeq(join(this.dataDir, `${safeName(name)}.jsonl`));
1608
+ return { ...upgrade(why), notice: { name, head, why } };
1609
+ }
1610
+ const space = target.live ?? this.getOrCreateSpace(name, token);
1611
+ if (!space)
1612
+ return { ok: false, status: 404, error: "no such space" };
1613
+ return { ok: true, space, reader: null };
1614
+ }
1615
+ if (!target)
1616
+ return { ok: false, status: 404, error: "no such space" };
1617
+ const reader = params.reader ?? "";
1618
+ const ts = Number(params.ts);
1619
+ const refused = { ok: false, status: 403, error: "a read must be signed, recently, by the key of a member admitted to this team" };
1620
+ const pub = reader ? target.allow.pubkeyOf(reader) : null;
1621
+ if (!pub || !Number.isFinite(ts) || Math.abs(Date.now() - ts) >= LIMITS.activityFreshMs)
1622
+ return refused;
1623
+ const { sig, ...signed } = params;
1624
+ if (!verifyRead(pub, req.method ?? "GET", url.pathname, signed, sig))
1625
+ return refused;
1626
+ if (this.requireAccounts && this.accounts && !this.localTrusted(ip)) {
1627
+ const auth = this.accounts.authorize(pub, reader);
1628
+ if (!auth.ok) {
1629
+ return { ok: false, status: 403, code: "NOT_SIGNED_IN", error: auth.reason === "suspended"
1630
+ ? "this account has been suspended by the relay operator"
1631
+ : "this device is not signed in to an account that may read here — run pantheon login" };
1632
+ }
1633
+ }
1634
+ const space = target.live ?? this.getOrCreateSpace(name, token, { retired: true });
1635
+ if (!space)
1636
+ return { ok: false, status: 404, error: "no such space" };
1637
+ return { ok: true, space, reader };
1638
+ }
1639
+ tombstoneStems = null;
1640
+ tombstoneFile(name) {
1641
+ if (!this.tombstoneStems) {
1642
+ this.tombstoneStems = new Map();
1643
+ try {
1644
+ for (const f of readdirSync(this.dataDir)) {
1645
+ if (!f.endsWith(".deleted.json"))
1646
+ continue;
1647
+ const stem = f.slice(0, -".deleted.json".length);
1648
+ this.tombstoneStems.set(stem.toLowerCase(), stem);
1649
+ }
1650
+ }
1651
+ catch { }
1652
+ }
1653
+ const stem = this.tombstoneStems.get(spaceKey(name));
1654
+ return stem ? join(this.dataDir, `${stem}.deleted.json`) : null;
1074
1655
  }
1075
1656
  tombstone(name, _token) {
1657
+ const f = this.tombstoneFile(name);
1658
+ if (!f)
1659
+ return null;
1076
1660
  try {
1077
- return JSON.parse(readFileSync(join(this.dataDir, `${safeName(name)}.deleted.json`), "utf8")).at;
1661
+ return JSON.parse(readFileSync(f, "utf8")).at;
1078
1662
  }
1079
1663
  catch {
1080
1664
  return null;
1081
1665
  }
1082
1666
  }
1083
1667
  tombstoneMatches(name, token) {
1668
+ const f = this.tombstoneFile(name);
1669
+ if (!f)
1670
+ return false;
1084
1671
  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;
1672
+ const t = JSON.parse(readFileSync(f, "utf8"));
1673
+ if (!token)
1674
+ return false;
1675
+ const h = createHash("sha256").update(token).digest("hex");
1676
+ return (!!t.tokenHash && h === t.tokenHash) || (Array.isArray(t.retiredTokenHashes) && t.retiredTokenHashes.includes(h));
1087
1677
  }
1088
1678
  catch {
1089
1679
  return false;
@@ -1101,20 +1691,50 @@ button:hover{background:#1d4ed8}.err{color:var(--bad);margin-top:12px}
1101
1691
  spaceOnDisk(name) {
1102
1692
  return existsSync(join(this.dataDir, `${name.replace(/[^a-z0-9._-]/gi, "_")}.members.json`));
1103
1693
  }
1694
+ live(name) {
1695
+ const sp = this.spaces.get(spaceKey(name));
1696
+ return sp && sp.name === name ? sp : undefined;
1697
+ }
1698
+ aliasOf(name) {
1699
+ const sp = this.spaces.get(spaceKey(name));
1700
+ if (sp)
1701
+ return sp.name !== name;
1702
+ if (!name || !this.spaceOnDisk(name))
1703
+ return false;
1704
+ const stem = safeName(name);
1705
+ try {
1706
+ if (!readdirSync(this.dataDir).includes(`${stem}.members.json`))
1707
+ return true;
1708
+ }
1709
+ catch { }
1710
+ const recorded = this.recordedName(stem);
1711
+ return recorded !== null && recorded !== name;
1712
+ }
1713
+ recordedName(stem) {
1714
+ try {
1715
+ const n = new Allowlist(this.dataDir, stem).recordedName();
1716
+ if (n)
1717
+ return n;
1718
+ }
1719
+ catch { }
1720
+ return firstLedgerName(join(this.dataDir, `${stem}.jsonl`));
1721
+ }
1104
1722
  spacePlan(name) {
1105
1723
  if (!this.accounts)
1106
1724
  return null;
1107
1725
  const owner = this.accounts.spaceOwner(name);
1108
- if (!owner)
1109
- return null;
1110
- return { name: owner.plan, limits: this.accounts.limitsFor(owner), owner };
1726
+ if (owner)
1727
+ return { name: owner.plan, limits: this.accounts.limitsFor(owner), owner };
1728
+ if (this.accounts.spaceOrphaned(name))
1729
+ return { name: "free", limits: planLimits(this.accounts.plans(), "free"), owner: null };
1730
+ return null;
1111
1731
  }
1112
1732
  planView(acct, sp) {
1113
1733
  const fresh = this.accounts?.accountById(acct.id) ?? acct;
1114
1734
  return { name: fresh.plan, limits: this.accounts.limitsFor(fresh), usage: { spaces: this.accounts.spacesOwned(fresh.id).length, members: sp.allow.admittedCount() } };
1115
1735
  }
1116
1736
  planRefusal(space, plan, target, msg, cid, fail, actor, ip) {
1117
- this.accounts?.audit({ event: "plan_limit", actor_account: actor ?? plan.owner.id, space: space.name, target, detail: `${plan.name}: ${msg.slice(0, 160)}`, ip });
1737
+ this.accounts?.audit({ event: "plan_limit", actor_account: actor ?? plan.owner?.id ?? null, space: space.name, target, detail: `${plan.name}: ${msg.slice(0, 160)}`, ip });
1118
1738
  this.log(`[${space.name}] ! PLAN_LIMIT (${target}, ${plan.name} plan)`);
1119
1739
  fail("PLAN_LIMIT", msg, cid);
1120
1740
  }
@@ -1171,7 +1791,7 @@ button:hover{background:#1d4ed8}.err{color:var(--bad);margin-top:12px}
1171
1791
  for (const d of devices)
1172
1792
  devByAcct.set(d.account_id, (devByAcct.get(d.account_id) ?? 0) + 1);
1173
1793
  const emailCap = Number(process.env.PANTHEON_EMAIL_MAX_PER_HOUR) || 200;
1174
- const emailsHour = this.emailSends.filter((t) => Date.now() - t < 3_600_000).length;
1794
+ const emailsHour = this.emailSends.filter((s) => Date.now() - s.at < 3_600_000).length;
1175
1795
  let snapshots = [];
1176
1796
  try {
1177
1797
  snapshots = readdirSync(join(this.dataDir, "backups")).filter((f) => f.startsWith("accounts-") && f.endsWith(".db")).sort();
@@ -1351,8 +1971,8 @@ ${done ? `<div class=done role=status>${esc(done)}</div>` : ""}
1351
1971
  }
1352
1972
  adminSpace(res, url, name) {
1353
1973
  const safe = name.replace(/[^a-z0-9._-]/gi, "_");
1354
- const sp = this.spaces.get(name) ?? null;
1355
- const tomb = this.deletedSpaces.get(name);
1974
+ const sp = this.spaces.get(spaceKey(name)) ?? null;
1975
+ const tomb = this.deletedSpaces.get(spaceKey(name));
1356
1976
  const onDisk = [".jsonl", ".state.json", ".members.json"].some((suf) => existsSync(join(this.dataDir, `${safe}${suf}`)));
1357
1977
  if (!name || (!sp && (!onDisk || (tomb && Date.now() - tomb < 3_600_000))))
1358
1978
  return this.json(res, 404, { error: `no space "${name}" on this relay` });
@@ -1534,7 +2154,12 @@ ${url.searchParams.get("done") ? `<div class=done role=status>${esc(url.searchPa
1534
2154
  return void res.end(JSON.stringify(data, null, 2));
1535
2155
  }
1536
2156
  const cols = data.length ? Object.keys(data[0]) : [];
1537
- const cell = (v) => { const s = String(v ?? ""); return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s; };
2157
+ const cell = (v) => {
2158
+ let s = String(v ?? "");
2159
+ if (/^[=+\-@\t\r]/.test(s))
2160
+ s = `'${s}`;
2161
+ return /[",\n\r]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
2162
+ };
1538
2163
  const csv = [cols.join(","), ...data.map((r) => cols.map((k) => cell(r[k])).join(","))].join("\n");
1539
2164
  res.writeHead(200, { "content-type": "text/csv", "content-disposition": `attachment; filename="${table}.csv"` });
1540
2165
  res.end(csv);
@@ -1599,22 +2224,59 @@ pantheon signup --email you@work.com --username you</code></pre>
1599
2224
  const touch = () => {
1600
2225
  if (dbSession && Date.now() - lastTouch > 60_000) {
1601
2226
  lastTouch = Date.now();
1602
- this.accounts?.touchSession(dbSession);
2227
+ try {
2228
+ this.accounts?.touchSession(dbSession);
2229
+ }
2230
+ catch (e) {
2231
+ this.noteStorageFailure("session touch", e);
2232
+ }
1603
2233
  }
1604
2234
  };
1605
2235
  let sessionId = "";
1606
2236
  let role = "interactive";
2237
+ let myPubkey = "";
2238
+ let authEpoch = -1, authOk = true;
1607
2239
  let lastMsg = "";
1608
2240
  let alive = true;
1609
2241
  const nonce = newNonce();
2242
+ const accountStillOk = () => {
2243
+ if (!acct || !this.accounts)
2244
+ return true;
2245
+ if (authEpoch !== this.authEpoch) {
2246
+ authEpoch = this.authEpoch;
2247
+ try {
2248
+ authOk = this.accounts.authorize(myPubkey, me?.id ?? "").ok;
2249
+ }
2250
+ catch {
2251
+ authOk = false;
2252
+ }
2253
+ }
2254
+ return authOk;
2255
+ };
1610
2256
  ws.on("pong", () => { alive = true; touch(); });
1611
2257
  const probe = ws;
1612
2258
  probe._alive = () => alive;
1613
2259
  probe._clear = () => { alive = false; };
2260
+ let refusalStamps = [];
2261
+ let refusedOut = false;
1614
2262
  const fail = (code, message, cid) => {
2263
+ if (refusedOut)
2264
+ return;
2265
+ const now = Date.now();
2266
+ refusalStamps.push(now);
2267
+ if (refusalStamps.length > REFUSAL_BUDGET) {
2268
+ refusalStamps = refusalStamps.filter((t) => now - t < REFUSAL_WINDOW_MS);
2269
+ if (refusalStamps.length > REFUSAL_BUDGET) {
2270
+ refusedOut = true;
2271
+ this.log(`[${space?.name ?? "?"}] ! ${me?.id ?? "an unauthenticated socket"} closed: ${refusalStamps.length} refused requests in ${REFUSAL_WINDOW_MS / 1000}s`);
2272
+ this.send(ws, { t: "error", code: "TOO_MANY_REFUSALS", message: `this connection had ${REFUSAL_BUDGET} requests refused within ${REFUSAL_WINDOW_MS / 1000}s, so the relay closed it. Reconnect, and fix the request that keeps being refused (the last one: ${code}).` });
2273
+ this.hangUp(ws, "TOO_MANY_REFUSALS");
2274
+ return;
2275
+ }
2276
+ }
1615
2277
  if (cid && space && me && !space.deleted) {
1616
- noteRefusal(space.store.refusals, { ts: Date.now(), member: me.id, act: lastMsg || "?", code });
1617
- space.store.flush(space.goals, space.obligations, space.profiles);
2278
+ noteRefusal(space.store.refusals, { ts: now, member: me.id, act: lastMsg || "?", code });
2279
+ space.store.save(space.goals, space.obligations, space.profiles);
1618
2280
  }
1619
2281
  this.send(ws, { t: "error", code, message, ...(cid ? { cid } : {}) });
1620
2282
  };
@@ -1640,6 +2302,9 @@ pantheon signup --email you@work.com --username you</code></pre>
1640
2302
  ws.on("close", clearHelloTimer);
1641
2303
  this.send(ws, { t: "challenge", nonce, v: PROTOCOL_VERSION });
1642
2304
  ws.on("message", (raw) => {
2305
+ if (refusedOut)
2306
+ return;
2307
+ let reqCid;
1643
2308
  try {
1644
2309
  let msg;
1645
2310
  try {
@@ -1648,6 +2313,8 @@ pantheon signup --email you@work.com --username you</code></pre>
1648
2313
  catch {
1649
2314
  return fail("BAD_JSON", "message was not JSON");
1650
2315
  }
2316
+ if (typeof msg?.cid === "string")
2317
+ reqCid = (msg.cid).slice(0, 64);
1651
2318
  const parsed = Inbound.safeParse(msg);
1652
2319
  if (!parsed.success) {
1653
2320
  const cid = typeof msg?.cid === "string"
@@ -1672,26 +2339,32 @@ pantheon signup --email you@work.com --username you</code></pre>
1672
2339
  const gated = this.requireAccounts && !!this.accounts && !this.localTrusted(ip);
1673
2340
  const pre = gated ? this.accounts.authorize(m.pubkey, m.member) : null;
1674
2341
  let sp;
2342
+ let staleToken = false;
1675
2343
  if (m.enroll) {
1676
- sp = this.spaces.get(m.space) ?? null;
2344
+ sp = this.live(m.space) ?? null;
2345
+ if (!sp && this.aliasOf(m.space))
2346
+ return reject("NO_SUCH_SPACE", `no space "${m.space}" on this relay`);
1677
2347
  if (!sp && this.spaceOnDisk(m.space)) {
1678
2348
  const inv = new Allowlist(this.dataDir, safeName(m.space)).findInvite(m.enroll);
1679
2349
  if (inv?.token === m.enroll && inv.space_token)
1680
- sp = this.getOrCreateSpace(m.space, inv.space_token);
2350
+ sp = this.getOrCreateSpace(m.space, inv.space_token, { claim: "invite", retired: true });
1681
2351
  }
1682
2352
  if (!sp)
1683
2353
  return reject("NO_SUCH_SPACE", `no space "${m.space}" on this relay`);
1684
2354
  }
1685
2355
  else {
1686
- const tomb = this.deletedSpaces.get(m.space) ?? this.tombstone(m.space, m.token ?? "");
2356
+ const tomb = this.deletedSpaces.get(spaceKey(m.space)) ?? this.tombstone(m.space, m.token ?? "");
1687
2357
  if (tomb && Date.now() - tomb < 3_600_000 || (tomb && this.tombstoneMatches(m.space, m.token ?? ""))) {
1688
2358
  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
2359
  }
1690
- const isNew = !this.spaces.has(m.space) && !this.spaceOnDisk(m.space);
2360
+ if (this.aliasOf(m.space)) {
2361
+ return reject("NAME_TAKEN", `a team with a name too close to "${m.space}" already exists here — use its exact name, or pick another`);
2362
+ }
2363
+ const isNew = !this.live(m.space) && !this.spaceOnDisk(m.space);
1691
2364
  if (isNew && this.nameCollides(m.space)) {
1692
2365
  return reject("NAME_TAKEN", `a team with a name too close to "${m.space}" already exists here — pick another name`);
1693
2366
  }
1694
- if (!this.spaces.has(m.space)) {
2367
+ if (!this.live(m.space)) {
1695
2368
  const denied = this.mayCreateSpace(ip);
1696
2369
  if (denied)
1697
2370
  return reject(denied.code, denied.message);
@@ -1710,9 +2383,19 @@ pantheon signup --email you@work.com --username you</code></pre>
1710
2383
  return reject("PLAN_LIMIT", msg);
1711
2384
  }
1712
2385
  }
1713
- sp = this.getOrCreateSpace(m.space, m.token);
2386
+ if (!this.live(m.space) && this.unboundOnDisk(safeName(m.space))) {
2387
+ const e = this.spaceOnDisk(m.space) ? new Allowlist(this.dataDir, safeName(m.space)).get(m.member) : undefined;
2388
+ if (!e?.admitted || e.publicKey !== m.pubkey) {
2389
+ this.unbound.add(safeName(m.space));
2390
+ this.log(`[${m.space}] ! REFUSED ${m.member}: the space has no bound token, and only an admitted member of it (with their key) may bind one`);
2391
+ return reject("UNBOUND_SPACE", `"${m.space}" was created before this relay kept a space's token, so it can only be reopened by one of its admitted members, from their own machine. ` +
2392
+ `If you are one, connect with your own identity; otherwise ask a member of "${m.space}" for an invite, or the relay operator.`);
2393
+ }
2394
+ }
2395
+ sp = this.getOrCreateSpace(m.space, m.token, { claim: { member: m.member, pubkey: m.pubkey }, retired: true });
1714
2396
  if (!sp)
1715
2397
  return reject("BAD_TOKEN", "wrong token for this space");
2398
+ staleToken = this.tokenState(sp, m.token) === "retired";
1716
2399
  if (isNew) {
1717
2400
  this.noteSpaceCreate(ip);
1718
2401
  this.accounts?.recordSpace(sp.name, pre?.ok ? pre.account.id : null);
@@ -1741,20 +2424,38 @@ pantheon signup --email you@work.com --username you</code></pre>
1741
2424
  const plan = this.spacePlan(sp.name);
1742
2425
  if (plan && !sp.allow.get(m.member)?.admitted && sp.allow.admittedCount() >= plan.limits.members) {
1743
2426
  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`);
2427
+ return reject("PLAN_LIMIT", `"${sp.name}" is full: its owner's ${plan.name} plan allows ${fmtLimit(plan.limits.members)} people. ${plan.owner ? `Ask ${plan.owner.username} to upgrade at https://www.joinpantheon.network` : "Its owner's account was deleted; the relay operator can move it to another plan."}`);
1745
2428
  }
1746
2429
  const r = sp.allow.redeem(m.enroll, m.member, m.pubkey);
1747
2430
  if (!r.ok) {
1748
2431
  this.log(`[${sp.name}] ! invite refused for ${m.member}: ${r.why}`);
1749
2432
  return reject("BAD_INVITE", r.fix ? `${r.why}\n${r.fix}` : r.why);
1750
2433
  }
1751
- enrolled = sp.token;
2434
+ enrolled = this.ensureToken(sp);
1752
2435
  this.log(`[${sp.name}] * ENROLLED ${m.member} via invite from ${r.entry.admitted_by}${r.entry.may_wake ? " (may wake)" : ""}`);
1753
2436
  }
2437
+ if (staleToken && !enrolled) {
2438
+ const e = sp.allow.get(m.member);
2439
+ if (e && e.publicKey === m.pubkey && !e.admitted && e.revoked_at) {
2440
+ this.log(`[${sp.name}] ! REFUSED ${m.member}: removed (earlier token)`);
2441
+ 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.`);
2442
+ }
2443
+ if (!e || e.publicKey !== m.pubkey || !e.admitted) {
2444
+ this.log(`[${sp.name}] ! REFUSED ${m.member}: an earlier token, and not an admitted member with this key`);
2445
+ return reject("BAD_TOKEN", "wrong token for this space — this team's token has changed since, and only its admitted members are handed the new one. Ask a member for a fresh invite.");
2446
+ }
2447
+ }
2448
+ if (m.member.toLowerCase() === RELAY_MEMBER && !sp.allow.get(m.member)) {
2449
+ return reject("RESERVED_NAME", `"${m.member}" is the name the relay itself speaks as — choose another name`);
2450
+ }
1754
2451
  const verdict = enrolled ? "ok" : sp.allow.present(m.member, m.pubkey);
2452
+ if (verdict === "pending-full") {
2453
+ this.log(`[${sp.name}] ! REFUSED ${m.member}: too many names already waiting to be admitted`);
2454
+ return reject("PENDING_FULL", `"${sp.name}" already has too many names waiting to be admitted. Ask a member to admit you by invite instead (pantheon invite), or to clear the waiting list.`);
2455
+ }
1755
2456
  if (verdict === "key-mismatch") {
1756
2457
  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.`);
2458
+ return reject("KEY_MISMATCH", `"${m.member}" is already registered here with a different key. If you are someone else, join under your own name (--me <your-name>). If this is really you on another machine, an admitted member who has checked it is you runs pantheon admit ${m.member} --forget (it removes whoever holds the name now) and sends you a new invite.`);
1758
2459
  }
1759
2460
  if (verdict === "revoked") {
1760
2461
  const e = sp.allow.get(m.member);
@@ -1786,6 +2487,7 @@ pantheon signup --email you@work.com --username you</code></pre>
1786
2487
  }
1787
2488
  }
1788
2489
  space = sp;
2490
+ myPubkey = m.pubkey;
1789
2491
  clearHelloTimer();
1790
2492
  sessionId = acctSession ?? this.mintSession();
1791
2493
  role = m.role;
@@ -1828,8 +2530,13 @@ pantheon signup --email you@work.com --username you</code></pre>
1828
2530
  obligations: [...sp.obligations.values()], members: this.members(sp), profiles: [...sp.profiles.values()],
1829
2531
  space_id: sp.allow.spaceId(), repos: sp.allow.repoList(),
1830
2532
  ...(acct ? { plan: this.planView(acct, sp) } : {}),
1831
- ...(enrolled ? { token: enrolled } : {}),
2533
+ ...(enrolled ? { token: enrolled } : staleToken && this.knownToken(sp) ? { token: sp.token } : {}),
1832
2534
  });
2535
+ if (staleToken && !enrolled && !sp.token) {
2536
+ const mine = me.sessions.get(sessionId);
2537
+ if (mine)
2538
+ mine.wantsToken = true;
2539
+ }
1833
2540
  let from = m.from_seq && m.from_seq > 0 ? m.from_seq : sp.ledger.head;
1834
2541
  for (let page = sp.ledger.since(from); page.length; page = sp.ledger.since(from)) {
1835
2542
  for (const r of page) {
@@ -1839,6 +2546,11 @@ pantheon signup --email you@work.com --username you</code></pre>
1839
2546
  }
1840
2547
  from = page[page.length - 1].seq;
1841
2548
  }
2549
+ if (versionBelow(m.pkg, "0.30.0")) {
2550
+ const why = this.unsignedRefusal(sp.name, sp.allow, "current");
2551
+ if (why)
2552
+ this.send(ws, { t: "act", act: this.upgradeAct(sp.name, sp.ledger.head, null, why) });
2553
+ }
1842
2554
  if (firstSession && m.role !== "cli") {
1843
2555
  this.commit(sp, { from: me.id, act: "JOIN", body: `${me.display} online (${me.agent})`, to: null, goal: null, ref: null });
1844
2556
  }
@@ -1848,6 +2560,11 @@ pantheon signup --email you@work.com --username you</code></pre>
1848
2560
  }
1849
2561
  if (!space || !me)
1850
2562
  return reject("NOT_JOINED", "say hello first");
2563
+ if (!this.stillIn(space, me, sessionId, ws) || (acct && !accountStillOk())) {
2564
+ if (ws.readyState === 1)
2565
+ this.hangUp(ws, "REVOKED");
2566
+ return;
2567
+ }
1851
2568
  if (m.t === "publish_profile") {
1852
2569
  if (m.profile.member !== me.id) {
1853
2570
  return fail("PROFILE_NOT_YOURS", `you may publish only the profile for ${me.id}`, m.cid);
@@ -1893,19 +2610,16 @@ pantheon signup --email you@work.com --username you</code></pre>
1893
2610
  return fail("FOUNDER_ONLY", `only ${founder}, who founded "${space.name}", can remove a member`, m.cid);
1894
2611
  if (m.member === founder)
1895
2612
  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);
2613
+ const removed = m.forget ? space.allow.forget(m.member) : space.allow.revoke(m.member, me.id);
1900
2614
  if (space.profiles.delete(m.member))
1901
2615
  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
- }
2616
+ const gone = space.members.get(m.member);
2617
+ for (const s of gone?.sessions.values() ?? [])
2618
+ this.hangUp(s.ws, "REVOKED");
2619
+ gone?.sessions.clear();
1908
2620
  space.members.delete(m.member);
2621
+ if (removed && this.rotateOnRemove)
2622
+ this.rotateSpaceToken(space, `${m.member} ${m.forget ? "forgotten" : "removed"} by ${me.id}`);
1909
2623
  this.log(`[${space.name}] - ${m.forget ? "FORGOT" : "REVOKED"} ${m.member} by ${me.id}`);
1910
2624
  }
1911
2625
  else {
@@ -1935,7 +2649,15 @@ pantheon signup --email you@work.com --username you</code></pre>
1935
2649
  this.accounts?.audit({ event: "space_deleted", actor_account: acct?.id ?? null, space: name, detail: `by its founder ${me.id}`, ip });
1936
2650
  this.log(`[${name}] * DELETED by its founder ${me.id}`);
1937
2651
  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?.();
2652
+ setTimeout(() => {
2653
+ try {
2654
+ this.deleteSpace(name);
2655
+ this.accounts?.markSpaceDeleted(name);
2656
+ }
2657
+ catch (e) {
2658
+ this.noteStorageFailure(`[${name}] delete`, e);
2659
+ }
2660
+ }, 50).unref?.();
1939
2661
  return;
1940
2662
  }
1941
2663
  if (m.t === "invite") {
@@ -1956,7 +2678,7 @@ pantheon signup --email you@work.com --username you</code></pre>
1956
2678
  if (space.allow.openInvites().length >= LIMITS_SPACE.openInvites)
1957
2679
  return fail("TOO_MANY_INVITES", `this team already has ${LIMITS_SPACE.openInvites} unused invites — revoke some: pantheon invite --revoke all`, m.cid);
1958
2680
  const token = randomBytes(18).toString("base64url");
1959
- const inv = space.allow.mintInvite(me.id, m.may_wake, m.ttl_minutes, token, space.token);
2681
+ const inv = space.allow.mintInvite(me.id, m.may_wake, m.ttl_minutes, token, this.ensureToken(space));
1960
2682
  this.log(`[${space.name}] * invite minted by ${me.id}, valid ${m.ttl_minutes}m, single use`);
1961
2683
  this.send(ws, { t: "invite", url: token, expires_at: inv.expires_at });
1962
2684
  return;
@@ -2095,6 +2817,8 @@ pantheon signup --email you@work.com --username you</code></pre>
2095
2817
  if (m.t === "open_goal") {
2096
2818
  if (!this.writeOk(me))
2097
2819
  return fail("RATE_LIMIT", "too many goals opened too fast — wait a few seconds", m.cid);
2820
+ if (this.spaceFull(space))
2821
+ return fail("SPACE_FULL", this.spaceFullMessage(space), m.cid);
2098
2822
  const openCount = [...space.goals.values()].filter((g) => g.state !== "CLOSED").length;
2099
2823
  if (openCount >= LIMITS_SPACE.openGoals)
2100
2824
  return fail("TOO_MANY_GOALS", `this team has ${openCount} goals still open — close some first`, m.cid);
@@ -2127,11 +2851,15 @@ pantheon signup --email you@work.com --username you</code></pre>
2127
2851
  body: `goal opened: ${goal.title}\nacceptance: ${goal.acceptance || "(none stated)"}` +
2128
2852
  (goal.deadline ? `\ndeadline: ${new Date(goal.deadline).toISOString()}` : "") +
2129
2853
  (goal.kind === "worklist" ? `\nkind: worklist — ${me.id}'s own list; peers may read it, nobody owes anything in it` : ""),
2854
+ meta: { goal_members: goal.members, goal_budget: goal.budget },
2130
2855
  });
2131
2856
  this.send(ws, { t: "ack", ...(m.cid ? { cid: m.cid } : {}), id: goal.id, seq: rec.seq });
2132
2857
  return;
2133
2858
  }
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 });
2859
+ const storedMeta = m.meta || m.defers_to || m.affects
2860
+ ? { ...(m.meta ?? {}), ...(m.defers_to ? { defers_to: m.defers_to } : {}), ...(m.affects ? { affects: m.affects } : {}) }
2861
+ : undefined;
2862
+ const fp = actFingerprint(me.id, m.act, m.body, m.goal ?? null, m.ref ?? null, m.to ?? null, storedMeta);
2135
2863
  const prior = space.recent.get(fp);
2136
2864
  if (prior && Date.now() - prior.at < LIMITS.dedupeWindowMs) {
2137
2865
  this.send(ws, { t: "ack", ...(m.cid ? { cid: m.cid } : {}), id: prior.id, seq: prior.seq });
@@ -2155,6 +2883,16 @@ pantheon signup --email you@work.com --username you</code></pre>
2155
2883
  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
2884
  if (err)
2157
2885
  return fail(err.code, err.message, m.cid);
2886
+ if (m.act === "PROPOSE" && m.affects?.length) {
2887
+ const g = m.goal ? space.goals.get(m.goal) : undefined;
2888
+ const outside = m.affects.filter((x) => !g?.members.includes(x));
2889
+ if (outside.length) {
2890
+ return fail("NOT_IN_GOAL", `${outside.join(", ")} ${outside.length === 1 ? "is" : "are"} not in ${g ? `"${g.title}"` : "this goal"}, so could never ACCEPT this proposal there. Name only members of the goal in affects.`, m.cid);
2891
+ }
2892
+ }
2893
+ const wasFull = this.spaceFull(space);
2894
+ if (wasFull && !this.freesRoom(space, me.id, m.act, m.ref ?? null))
2895
+ return fail("SPACE_FULL", this.spaceFullMessage(space), m.cid);
2158
2896
  const rec = this.commit(space, {
2159
2897
  from: me.id, session: sessionId, role, act: m.act, body: m.body,
2160
2898
  ...(m.summary ? { summary: m.summary } : {}),
@@ -2165,23 +2903,54 @@ pantheon signup --email you@work.com --username you</code></pre>
2165
2903
  ...(m.affects ? { affects: m.affects } : {}) } }
2166
2904
  : {}),
2167
2905
  });
2906
+ if (wasFull && m.act === "CLOSE") {
2907
+ try {
2908
+ this.compactSpace(space);
2909
+ }
2910
+ catch (e) {
2911
+ this.noteStorageFailure(`[${space.name}] compacting after a close`, e);
2912
+ }
2913
+ }
2168
2914
  space.recent.set(fp, { id: rec.id, seq: rec.seq, at: Date.now() });
2169
2915
  for (const [k, v] of space.recent)
2170
2916
  if (Date.now() - v.at > LIMITS.dedupeWindowMs)
2171
2917
  space.recent.delete(k);
2918
+ for (const k of space.recent.keys()) {
2919
+ if (space.recent.size <= RECENT_MAX)
2920
+ break;
2921
+ space.recent.delete(k);
2922
+ }
2172
2923
  this.send(ws, { t: "ack", ...(m.cid ? { cid: m.cid } : {}), id: rec.id, seq: rec.seq });
2173
2924
  }
2174
2925
  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" }));
2926
+ const cidPart = reqCid ? { cid: reqCid } : {};
2927
+ const send = (code, message) => { try {
2928
+ ws.send(JSON.stringify({ t: "error", code, message, ...cidPart }));
2178
2929
  }
2179
- catch { }
2930
+ catch { } };
2931
+ if (e instanceof SpaceFull) {
2932
+ send("SPACE_FULL", `${e.message}. Nothing new can be recorded until finished goals are closed (the relay then compacts their old acts), or the relay operator raises PANTHEON_SPACE_MAX_BYTES.`);
2933
+ return;
2934
+ }
2935
+ if (e instanceof RosterUnreadable) {
2936
+ this.log(`!! ${e.message} — every member of this space is refused until it is repaired (it was left in place)`);
2937
+ if (!space)
2938
+ return reject("SPACE_UNREADABLE", "the relay cannot read this team's member list, so nobody can be let in until its operator repairs it. Nothing was changed; tell the relay operator.");
2939
+ send("SPACE_UNREADABLE", "the relay cannot read this team's member list; tell the relay operator.");
2940
+ return;
2941
+ }
2942
+ this.noteStorageFailure(`[${space?.name ?? "?"}] ${lastMsg || "message"}`, e);
2943
+ send("STORAGE", "the relay could not save that — try again in a moment");
2180
2944
  }
2181
2945
  });
2182
- ws.on("close", (code) => {
2946
+ const closeWork = (code) => {
2183
2947
  if (dbSession) {
2184
- this.accounts?.endSession(dbSession);
2948
+ try {
2949
+ this.accounts?.endSession(dbSession);
2950
+ }
2951
+ catch (e) {
2952
+ this.noteStorageFailure("session end", e);
2953
+ }
2185
2954
  dbSession = null;
2186
2955
  }
2187
2956
  if (!space || !me)
@@ -2204,14 +2973,21 @@ pantheon signup --email you@work.com --username you</code></pre>
2204
2973
  else
2205
2974
  v.at = Date.now();
2206
2975
  }
2207
- if (me.sessions.size === 0 && role !== "cli") {
2976
+ const stillMember = space.members.get(me.id) === me && !!space.allow.get(me.id)?.admitted;
2977
+ if (me.sessions.size === 0 && role !== "cli" && stillMember) {
2208
2978
  me.last_seen = Date.now();
2209
2979
  this.commit(space, { from: me.id, act: "LEAVE", body: `${me.display} offline`, to: null, goal: null, ref: null });
2210
2980
  }
2211
2981
  this.pushObligations(space);
2212
2982
  this.broadcast(space, { t: "roster", roster: this.roster(space) });
2213
2983
  this.log(`[${space.name}] - ${me.id}/${sessionId}`);
2214
- });
2984
+ };
2985
+ ws.on("close", (code) => { try {
2986
+ closeWork(code);
2987
+ }
2988
+ catch (e) {
2989
+ this.noteStorageFailure(`[${space?.name ?? "?"}] cleanup after a disconnect`, e);
2990
+ } });
2215
2991
  ws.on("error", () => { });
2216
2992
  }
2217
2993
  sweep() {
@@ -2229,11 +3005,22 @@ pantheon signup --email you@work.com --username you</code></pre>
2229
3005
  }
2230
3006
  const now = Date.now();
2231
3007
  for (const sp of this.spaces.values()) {
2232
- if (now - (this.compactedAt.get(sp.name) ?? 0) > LIMITS.ledgerCompactEveryMs)
2233
- this.compactSpace(sp);
2234
- this.lapseClaims(sp, now);
3008
+ try {
3009
+ const every = this.spaceFull(sp) ? 60_000 : LIMITS.ledgerCompactEveryMs;
3010
+ if (now - (this.compactedAt.get(sp.name) ?? 0) > every)
3011
+ this.compactSpace(sp);
3012
+ this.lapseClaims(sp, now);
3013
+ }
3014
+ catch (e) {
3015
+ this.noteStorageFailure(`[${sp.name}] housekeeping`, e);
3016
+ }
3017
+ }
3018
+ try {
3019
+ this.evictSpaces();
3020
+ }
3021
+ catch (e) {
3022
+ this.noteStorageFailure("evicting idle spaces", e);
2235
3023
  }
2236
- this.evictSpaces();
2237
3024
  }
2238
3025
  lapseClaims(sp, now) {
2239
3026
  const live = new Set();
@@ -2267,6 +3054,8 @@ pantheon signup --email you@work.com --username you</code></pre>
2267
3054
  return { code: "NO_SUCH_MEMBER", message: `nobody here is called "${id}" — members: ${[...sp.members.keys()].join(", ")}` };
2268
3055
  }
2269
3056
  }
3057
+ if (ref === UPGRADE_NOTE_ID)
3058
+ return { code: "UPGRADE_REQUIRED", message: `${UPGRADE_NOTE}. That notice is from the relay, not a teammate, so there is nothing to reply to.` };
2270
3059
  if (ref && !sp.ledger.find(ref))
2271
3060
  return { code: "BAD_REF", message: `no act with id ${ref}` };
2272
3061
  if (meta && JSON.stringify(meta).length > LIMITS_SPACE.metaBytes) {
@@ -2461,7 +3250,10 @@ pantheon signup --email you@work.com --username you</code></pre>
2461
3250
  commit(sp, partial) {
2462
3251
  if (sp.deleted)
2463
3252
  return { ...partial, id: newId("a"), seq: sp.ledger.head, ts: Date.now(), space: sp.name };
2464
- const rec = sp.ledger.append({ ...partial, id: newId("a"), ts: Date.now(), space: sp.name });
3253
+ if ((partial.act === "JOIN" || partial.act === "LEAVE") && this.spaceFull(sp)) {
3254
+ return { ...partial, id: newId("a"), seq: sp.ledger.head, ts: Date.now(), space: sp.name };
3255
+ }
3256
+ const rec = sp.ledger.append({ ...partial, id: newId("a"), ts: Date.now(), space: sp.name }, this.maxLedgerBytes + SPACE_HARD_SLACK);
2465
3257
  sp.lastTouch = rec.ts;
2466
3258
  const m = sp.members.get(rec.from);
2467
3259
  if (m) {
@@ -2469,13 +3261,22 @@ pantheon signup --email you@work.com --username you</code></pre>
2469
3261
  if (rec.act !== "JOIN" && rec.act !== "LEAVE" && (m.seen_seq ?? 0) < rec.seq - 1)
2470
3262
  m.seen_seq = rec.seq - 1;
2471
3263
  }
3264
+ this.effects(sp, rec);
3265
+ if (sp.store.rebuilt && rec.act !== "JOIN" && rec.act !== "LEAVE")
3266
+ sp.store.rebuilt = null;
3267
+ this.pushObligations(sp);
3268
+ this.broadcast(sp, { t: "act", act: rec });
3269
+ this.broadcast(sp, { t: "roster", roster: this.roster(sp) });
3270
+ return rec;
3271
+ }
3272
+ effects(sp, rec, ctx) {
2472
3273
  if (rec.goal) {
2473
3274
  const goal = sp.goals.get(rec.goal);
2474
3275
  if (goal) {
2475
3276
  goal.acts++;
2476
3277
  const run = sp.lastActor.get(rec.goal);
2477
3278
  sp.lastActor.set(rec.goal, run && run.member === rec.from ? { member: rec.from, run: run.run + 1 } : { member: rec.from, run: 1 });
2478
- this.applyObligations(sp, rec, goal);
3279
+ this.applyObligations(sp, rec, goal, ctx);
2479
3280
  this.broadcast(sp, { t: "goal", goal });
2480
3281
  if (goal.acts >= goal.budget && goal.state === "OPEN") {
2481
3282
  goal.state = "STALLED";
@@ -2486,14 +3287,10 @@ pantheon signup --email you@work.com --username you</code></pre>
2486
3287
  }
2487
3288
  }
2488
3289
  else {
2489
- this.applyObligations(sp, rec, null);
3290
+ this.applyObligations(sp, rec, null, ctx);
2490
3291
  }
2491
- this.pushObligations(sp);
2492
- this.broadcast(sp, { t: "act", act: rec });
2493
- this.broadcast(sp, { t: "roster", roster: this.roster(sp) });
2494
- return rec;
2495
3292
  }
2496
- applyObligations(sp, rec, goal) {
3293
+ applyObligations(sp, rec, goal, ctx) {
2497
3294
  const closeFor = (owner, ref) => {
2498
3295
  for (const [id, o] of sp.obligations)
2499
3296
  if (o.owner === owner && o.ref === ref)
@@ -2597,8 +3394,9 @@ pantheon signup --email you@work.com --username you</code></pre>
2597
3394
  const answer = sp.ledger.find(rec.ref);
2598
3395
  const asked = answer?.act === "ANSWER" && answer.from === rec.from && answer.ref ? sp.ledger.find(answer.ref) : undefined;
2599
3396
  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));
3397
+ const retracted = ctx?.retracted ?? new Set(sp.ledger.all().filter((r) => r.act === "RETRACT" && r.ref).map((r) => r.ref));
3398
+ const answersTo = asked ? (ctx ? ctx.answers.get(asked.id) ?? [] : sp.ledger.all().filter((r) => r.act === "ANSWER" && r.ref === asked.id)) : [];
3399
+ const stillAnswered = answersTo.some((r) => r.from === rec.from && r.id !== answer.id && !retracted.has(r.id));
2602
3400
  const alreadyLive = asked && [...sp.obligations.values()].some((o) => o.kind === "HUMAN" && o.ref === asked.id);
2603
3401
  if (asked && decider === rec.from && !stillAnswered && !alreadyLive && !retracted.has(asked.id)) {
2604
3402
  this.oblige(sp, { kind: "HUMAN", owner: `human:${decider}`, ref: asked.id, goal: asked.goal,
@@ -2636,6 +3434,23 @@ pantheon signup --email you@work.com --username you</code></pre>
2636
3434
  const id = newId("o");
2637
3435
  sp.obligations.set(id, { ...o, id, created_at: Date.now() });
2638
3436
  }
3437
+ freesRoom(sp, member, act, ref) {
3438
+ if (act === "CLOSE" || act === "RETRACT" || act === "WITHDRAW")
3439
+ return true;
3440
+ if (act === "ANSWER" && ref) {
3441
+ for (const o of sp.obligations.values())
3442
+ if (o.kind === "HUMAN" && o.ref === ref && o.owner === `human:${member}`)
3443
+ return true;
3444
+ }
3445
+ return false;
3446
+ }
3447
+ spaceFull(sp) {
3448
+ return sp.ledger.bytes >= this.maxLedgerBytes;
3449
+ }
3450
+ spaceFullMessage(sp) {
3451
+ return `"${sp.name}" has reached the relay's limit for one team's history (${(sp.ledger.bytes / 1e6).toFixed(1)} of ${(this.maxLedgerBytes / 1e6).toFixed(1)} MB), so nothing new can be recorded. ` +
3452
+ `Close finished goals: the relay compacts their old acts away, which frees room. Or ask the relay operator to raise PANTHEON_SPACE_MAX_BYTES.`;
3453
+ }
2639
3454
  pushObligations(sp) {
2640
3455
  sp.store.flush(sp.goals, sp.obligations, sp.profiles);
2641
3456
  this.broadcast(sp, { t: "obligations", list: [...sp.obligations.values()] });
@@ -2708,18 +3523,23 @@ pantheon signup --email you@work.com --username you</code></pre>
2708
3523
  continue;
2709
3524
  const idle = now - sp.lastTouch;
2710
3525
  const junk = this.isJunkSpace(sp);
2711
- if (junk && idle > this.limits.spaceJunkDeleteMs) {
2712
- sp.store.close(sp.goals, sp.obligations, sp.profiles);
2713
- this.spaces.delete(name);
2714
- this.compactedAt.delete(name);
2715
- this.deleteSpaceFiles(sp.name);
2716
- this.log(`[${name}] evicted junk space (idle ${Math.round(idle / 3_600_000)}h, no work, no members) — files removed`);
3526
+ try {
3527
+ if (junk && idle > this.limits.spaceJunkDeleteMs) {
3528
+ sp.store.close(sp.goals, sp.obligations, sp.profiles);
3529
+ this.spaces.delete(name);
3530
+ this.compactedAt.delete(name);
3531
+ this.deleteSpaceFiles(sp.name);
3532
+ this.log(`[${name}] evicted junk space (idle ${Math.round(idle / 3_600_000)}h, no work, no members) — files removed`);
3533
+ }
3534
+ else if (idle > this.limits.spaceIdleEvictMs) {
3535
+ sp.store.close(sp.goals, sp.obligations, sp.profiles);
3536
+ this.spaces.delete(name);
3537
+ this.compactedAt.delete(name);
3538
+ this.log(`[${name}] evicted from memory (idle ${Math.round(idle / 3_600_000)}h) — reloads from disk on next connect`);
3539
+ }
2717
3540
  }
2718
- else if (idle > this.limits.spaceIdleEvictMs) {
2719
- sp.store.close(sp.goals, sp.obligations, sp.profiles);
2720
- this.spaces.delete(name);
2721
- this.compactedAt.delete(name);
2722
- this.log(`[${name}] evicted from memory (idle ${Math.round(idle / 3_600_000)}h) — reloads from disk on next connect`);
3541
+ catch (e) {
3542
+ this.noteStorageFailure(`[${name}] eviction flush`, e);
2723
3543
  }
2724
3544
  }
2725
3545
  }
@@ -2732,22 +3552,49 @@ pantheon signup --email you@work.com --username you</code></pre>
2732
3552
  catch { }
2733
3553
  }
2734
3554
  }
2735
- getOrCreateSpace(name, token) {
2736
- const existing = this.spaces.get(name);
2737
- if (existing)
2738
- return secretEq(existing.token, token) ? existing : null;
3555
+ getOrCreateSpace(name, token, opts = {}) {
3556
+ const claim = opts.claim;
3557
+ const retired = !!opts.retired;
3558
+ const existing = this.spaces.get(spaceKey(name));
3559
+ if (existing) {
3560
+ if (existing.name !== name)
3561
+ return null;
3562
+ const st = this.tokenState(existing, token);
3563
+ return st === "current" || (retired && st === "retired") ? existing : null;
3564
+ }
3565
+ if (this.aliasOf(name))
3566
+ return null;
2739
3567
  const safe = name.replace(/[^a-z0-9._-]/gi, "_");
2740
3568
  const allow = new Allowlist(this.dataDir, safe);
2741
3569
  const match = allow.tokenMatches(token);
2742
- if (match === false)
2743
- return null;
2744
- if (match === null)
3570
+ let raw = token;
3571
+ if (match === false) {
3572
+ if (!(retired && allow.tokenRetired(token)))
3573
+ return null;
3574
+ raw = allow.currentTokenFromInvites() ?? "";
3575
+ }
3576
+ if (match === null) {
3577
+ if (this.unboundOnDisk(safe)) {
3578
+ const who = claim && claim !== "invite" ? claim : null;
3579
+ const e = who ? allow.get(who.member) : undefined;
3580
+ const ok = claim === "invite" || (!!who && !!e?.admitted && e.publicKey === who.pubkey);
3581
+ if (!ok) {
3582
+ this.unbound.add(safe);
3583
+ return null;
3584
+ }
3585
+ this.log(`[${name}] bound a token to a space that had none, on the reconnect of ${who ? `its admitted member ${who.member}` : "an invite it issued"}`);
3586
+ }
2745
3587
  allow.bindToken(token);
3588
+ this.unbound.delete(safe);
3589
+ }
3590
+ const ledger = new Ledger(this.dataDir, safe);
2746
3591
  const store = new StateStore(this.dataDir, safe);
2747
- const restored = store.load();
3592
+ store.headOf = () => ledger.head;
3593
+ store.onError = (e) => this.noteStorageFailure(`[${name}] snapshot save`, e);
3594
+ const restored = store.load({ quarantine: true, expectHistory: ledger.size > 0 });
2748
3595
  const sp = {
2749
- name, token,
2750
- ledger: new Ledger(this.dataDir, safe),
3596
+ name, token: raw,
3597
+ ledger,
2751
3598
  allow,
2752
3599
  store,
2753
3600
  members: new Map(),
@@ -2760,27 +3607,185 @@ pantheon signup --email you@work.com --username you</code></pre>
2760
3607
  createdAt: Date.now(),
2761
3608
  lastTouch: Date.now(),
2762
3609
  };
2763
- this.spaces.set(name, sp);
3610
+ if (restored.unclean) {
3611
+ sp.unclean = restored.unclean;
3612
+ const rebuilt = this.rebuildFromLedger(sp);
3613
+ this.log(`!! [${name}] UNCLEAN LOAD: ${restored.unclean}${restored.quarantined ? ` — moved aside to ${restored.quarantined}` : ""}. ` +
3614
+ `Rebuilt ${rebuilt.goals} goal(s) (${rebuilt.open} open) and ${sp.obligations.size} obligation(s) from ${ledger.size} act(s) in the ledger; ` +
3615
+ `ledger compaction is OFF for this space until a snapshot written from live state (after a real act) is loaded clean.`);
3616
+ store.rebuilt = restored.unclean;
3617
+ try {
3618
+ store.flush(sp.goals, sp.obligations, sp.profiles);
3619
+ }
3620
+ catch (e) {
3621
+ this.noteStorageFailure(`[${name}] rebuilt snapshot`, e);
3622
+ }
3623
+ }
3624
+ else if (restored.rebuilt) {
3625
+ sp.unclean = `its snapshot is still the one rebuilt from the ledger (${restored.rebuilt}), and no act has been built on it yet`;
3626
+ store.rebuilt = restored.rebuilt;
3627
+ this.log(`! [${name}] loaded a snapshot rebuilt from the ledger that no act has built on yet; ledger compaction stays OFF for this run.`);
3628
+ }
3629
+ else if (restored.ledgerHead !== undefined && ledger.head > restored.ledgerHead) {
3630
+ const after = restored.ledgerHead;
3631
+ const missed = ledger.all().filter((r) => r.seq > after);
3632
+ const had = new Set(sp.obligations.keys());
3633
+ let opened = 0;
3634
+ for (const r of missed) {
3635
+ if (r.goal && !sp.goals.has(r.goal)) {
3636
+ const g = this.goalFromOpening(r);
3637
+ if (g) {
3638
+ sp.goals.set(g.id, g);
3639
+ opened++;
3640
+ }
3641
+ }
3642
+ this.effects(sp, r);
3643
+ }
3644
+ for (const o of sp.obligations.values())
3645
+ if (!had.has(o.id))
3646
+ o.created_at = ledger.find(o.ref)?.ts ?? o.created_at;
3647
+ this.log(`! [${name}] the snapshot was ${missed.length} act(s) behind the ledger (seq ${after} < ${ledger.head}) — replayed them${opened ? `, and made again ${opened} goal(s) opened among them` : ""}`);
3648
+ try {
3649
+ store.flush(sp.goals, sp.obligations, sp.profiles);
3650
+ }
3651
+ catch (e) {
3652
+ this.noteStorageFailure(`[${name}] replayed snapshot`, e);
3653
+ }
3654
+ }
3655
+ this.spaces.set(spaceKey(name), sp);
3656
+ try {
3657
+ allow.recordName(name);
3658
+ }
3659
+ catch (e) {
3660
+ this.noteStorageFailure(`[${name}] recording its name`, e);
3661
+ }
2764
3662
  for (const e of allow.all()) {
2765
3663
  if (e.admitted && !sp.members.has(e.member)) {
2766
3664
  sp.members.set(e.member, { id: e.member, display: e.member, agent: "unknown", last_seen: e.first_seen, sessions: new Map(), activity: new Map(), delivered_seq: 0, stamps: [] });
2767
3665
  }
2768
3666
  }
3667
+ this.rebuildRecent(sp);
2769
3668
  const open = [...sp.goals.values()].filter((g) => g.state !== "CLOSED").length;
2770
3669
  this.log(`[${name}] space ready (seq ${sp.ledger.head}, ${sp.goals.size} goals — ${open} still open, ${sp.obligations.size} obligations restored)`);
3670
+ const founder = allow.founder();
3671
+ const guess = allow.founderGuess();
3672
+ if (founder && (guess || !allow.get(founder)?.admitted)) {
3673
+ this.log(`! [${name}] founder is "${founder}"${guess ? ` — a guess: ${guess} (an older relay rewrote who admitted whom)` : ""}` +
3674
+ `${allow.get(founder)?.admitted ? "" : `, and "${founder}" is not admitted here, so nobody can remove a member or delete the team until they are re-admitted (or the operator steps in)`}`);
3675
+ }
3676
+ if (ledger.outOfOrder) {
3677
+ this.log(`! [${name}] ledger seq is not strictly increasing: ${ledger.outOfOrder} act(s) at or below the one before (first at seq ${ledger.firstOutOfOrder}). ` +
3678
+ `Two relays or two names wrote this file (0.29.1 let "a b" and "a_b" share one); readers paging by seq may skip or repeat those acts. Nothing was renumbered.`);
3679
+ }
2771
3680
  this.compactSpace(sp);
2772
3681
  return sp;
2773
3682
  }
3683
+ rebuildRecent(sp) {
3684
+ const now = Date.now();
3685
+ const recs = sp.ledger.all();
3686
+ const retracted = new Set();
3687
+ const window = [];
3688
+ for (let i = recs.length - 1; i >= 0; i--) {
3689
+ const r = recs[i];
3690
+ if (now - r.ts > LIMITS.dedupeWindowMs)
3691
+ break;
3692
+ if (r.act === "RETRACT" && r.ref)
3693
+ retracted.add(r.ref);
3694
+ window.push(r);
3695
+ }
3696
+ for (const r of window.reverse()) {
3697
+ if (!r.session || !SENDABLE.includes(r.act) || retracted.has(r.id))
3698
+ continue;
3699
+ const fp = actFingerprint(r.from, r.act, r.body, r.goal ?? null, r.ref ?? null, r.to ?? null, r.meta);
3700
+ sp.recent.set(fp, { id: r.id, seq: r.seq, at: r.ts });
3701
+ }
3702
+ for (const k of sp.recent.keys()) {
3703
+ if (sp.recent.size <= RECENT_MAX)
3704
+ break;
3705
+ sp.recent.delete(k);
3706
+ }
3707
+ }
3708
+ rebuildFromLedger(sp) {
3709
+ sp.goals.clear();
3710
+ sp.obligations.clear();
3711
+ const ctx = { retracted: new Set(), answers: new Map() };
3712
+ for (const r of sp.ledger.all()) {
3713
+ if (r.act === "RETRACT" && r.ref)
3714
+ ctx.retracted.add(r.ref);
3715
+ if (r.act === "ANSWER" && r.ref) {
3716
+ const a = ctx.answers.get(r.ref);
3717
+ if (a)
3718
+ a.push(r);
3719
+ else
3720
+ ctx.answers.set(r.ref, [r]);
3721
+ }
3722
+ let goal = null;
3723
+ if (r.goal) {
3724
+ goal = sp.goals.get(r.goal) ?? null;
3725
+ if (!goal) {
3726
+ goal = this.goalFromOpening(r) ?? {
3727
+ id: r.goal, title: `(recovered goal ${r.goal})`, acceptance: "", members: [r.from],
3728
+ state: "OPEN", acts: 0, budget: LIMITS.goalBudget, opened_by: r.from, opened_at: r.ts,
3729
+ };
3730
+ sp.goals.set(goal.id, goal);
3731
+ }
3732
+ const affects = r.meta?.affects;
3733
+ for (const who of [r.from, ...(r.to ?? []), ...(Array.isArray(affects) ? affects.filter((x) => typeof x === "string") : [])]) {
3734
+ if (!who.startsWith("human:") && !goal.members.includes(who))
3735
+ goal.members.push(who);
3736
+ }
3737
+ goal.acts++;
3738
+ }
3739
+ this.applyObligations(sp, r, goal, ctx);
3740
+ }
3741
+ for (const o of sp.obligations.values())
3742
+ o.created_at = sp.ledger.find(o.ref)?.ts ?? o.created_at;
3743
+ let open = 0;
3744
+ for (const g of sp.goals.values()) {
3745
+ if (g.state === "CLOSED")
3746
+ continue;
3747
+ open++;
3748
+ g.budget = Math.max(g.budget, g.acts + Math.ceil(LIMITS.goalBudget / 2));
3749
+ }
3750
+ return { goals: sp.goals.size, open };
3751
+ }
3752
+ goalFromOpening(r) {
3753
+ if (!r.goal || r.act !== "NOTE" || r.session)
3754
+ return null;
3755
+ const mm = GOAL_OPENED.exec(r.body);
3756
+ if (!mm)
3757
+ return null;
3758
+ const meta = r.meta;
3759
+ const deadline = mm[3] ? Date.parse(mm[3]) : NaN;
3760
+ const listed = meta?.goal_members;
3761
+ const members = Array.isArray(listed) ? [...new Set([r.from, ...listed.filter((x) => typeof x === "string" && !x.startsWith("human:"))])] : [r.from];
3762
+ const budget = typeof meta?.goal_budget === "number" && Number.isInteger(meta.goal_budget) && meta.goal_budget > 0 ? meta.goal_budget : LIMITS.goalBudget;
3763
+ return {
3764
+ id: r.goal,
3765
+ title: mm[1],
3766
+ acceptance: mm[2] !== "(none stated)" ? mm[2] : "",
3767
+ members,
3768
+ state: "OPEN",
3769
+ acts: 0,
3770
+ budget,
3771
+ opened_by: r.from,
3772
+ opened_at: r.ts,
3773
+ ...(Number.isFinite(deadline) ? { deadline } : {}),
3774
+ ...(mm[4] === "worklist" ? { kind: "worklist" } : {}),
3775
+ };
3776
+ }
2774
3777
  compactSpace(sp) {
3778
+ this.compactedAt.set(sp.name, Date.now());
3779
+ if (sp.unclean)
3780
+ return;
2775
3781
  const isLive = (goal) => {
2776
3782
  if (!goal)
2777
3783
  return false;
2778
3784
  const g = sp.goals.get(goal);
2779
- return !!g && g.state !== "CLOSED";
3785
+ return !g || g.state !== "CLOSED";
2780
3786
  };
2781
3787
  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) });
2783
- this.compactedAt.set(sp.name, Date.now());
3788
+ const { dropped, kept } = sp.ledger.compact({ keepRecent: this.opts.ledgerKeepRecent ?? LIMITS.ledgerKeepRecent, isLive, keep: (id) => referenced.has(id) });
2784
3789
  if (dropped)
2785
3790
  this.log(`[${sp.name}] compacted ledger — dropped ${dropped} old act(s) from closed goals, ${kept} kept`);
2786
3791
  }
@@ -2817,6 +3822,9 @@ pantheon signup --email you@work.com --username you</code></pre>
2817
3822
  return sp.allow.all().map((e) => ({
2818
3823
  member: e.member, admitted: e.admitted, may_wake: e.may_wake,
2819
3824
  fingerprint: fingerprint(e.publicKey),
3825
+ ...(e.admitted_by ? { admitted_by: e.admitted_by } : {}),
3826
+ ...(e.revoked_at ? { revoked_at: e.revoked_at, ...(e.revoked_by ? { revoked_by: e.revoked_by } : {}) } : {}),
3827
+ ...(sp.allow.founder() === e.member ? { founder: true } : {}),
2820
3828
  }));
2821
3829
  }
2822
3830
  send(ws, msg) {