@join-pantheon/cli 0.29.1 → 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 -120
  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 +494 -32
  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 +82 -14
  29. package/dist/reads.js +30 -0
  30. package/dist/refusal.js +14 -1
  31. package/dist/relay.js +1143 -180
  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 +84 -34
  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;
@@ -81,6 +81,17 @@ export function isTrustedSource(ip) {
81
81
  function fingerprint_(from, act, body, goal, ref, extra = null) {
82
82
  return createHash("sha256").update([from, act, goal ?? "", ref ?? "", body, stableJson(extra)].join("\u0000")).digest("hex").slice(0, 32);
83
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
+ }
84
95
  function stableJson(v) {
85
96
  if (v === null || typeof v !== "object")
86
97
  return JSON.stringify(v ?? null);
@@ -89,6 +100,8 @@ function stableJson(v) {
89
100
  return `{${Object.keys(v).sort().map((k) => `${JSON.stringify(k)}:${stableJson(v[k])}`).join(",")}}`;
90
101
  }
91
102
  function clientIp(req) {
103
+ if (process.env.PANTHEON_TRUSTED_HOPS?.trim() === "0")
104
+ return req.socket.remoteAddress || "unknown";
92
105
  const raw = req.headers["x-forwarded-for"];
93
106
  const hops = (Array.isArray(raw) ? raw.join(",") : raw ?? "").split(",").map((x) => x.trim()).filter(Boolean);
94
107
  const trusted = Math.max(1, Number(process.env.PANTHEON_TRUSTED_HOPS ?? 1) || 1);
@@ -161,8 +174,119 @@ const fmtAgo = (t) => {
161
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`;
162
175
  };
163
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]*)?$/;
164
210
  const LIMITS_GOAL_READ = 1000;
165
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
+ }
166
290
  export class Relay {
167
291
  opts;
168
292
  spaces = new Map();
@@ -170,9 +294,22 @@ export class Relay {
170
294
  log;
171
295
  http = createServer((req, res) => {
172
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
+ }
173
308
  const bad = e instanceof TypeError || e instanceof URIError;
174
309
  if (!bad)
175
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);
176
313
  try {
177
314
  if (!res.headersSent)
178
315
  this.json(res, bad ? 400 : 500, { error: bad ? "bad request" : "the relay could not handle that request" });
@@ -202,20 +339,32 @@ export class Relay {
202
339
  pruneMs;
203
340
  accounts;
204
341
  requireAccounts;
342
+ readsMode;
205
343
  trustLocal;
206
344
  email;
207
345
  authHits = new Map();
208
346
  adminHits = new Map();
209
347
  emailSends = [];
348
+ emailSendsTo = new Map();
349
+ emailSendsFrom = new Map();
350
+ storageFailure = null;
351
+ unbound = new Set();
352
+ maxLedgerBytes;
210
353
  adminToken;
211
354
  adminAllow;
212
355
  startedAt = Date.now();
213
356
  deletedSpaces = new Map();
357
+ authEpoch = 0;
358
+ rotateOnRemove;
214
359
  backupTimer;
215
360
  constructor(opts = {}) {
216
361
  this.opts = opts;
217
362
  this.dataDir = opts.dataDir ?? "./pantheon-data";
218
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();
219
368
  this.requireAccounts = opts.requireAccounts ?? process.env.PANTHEON_REQUIRE_ACCOUNTS === "1";
220
369
  this.trustLocal = process.env.PANTHEON_TRUST_LOCAL !== undefined
221
370
  ? process.env.PANTHEON_TRUST_LOCAL === "1"
@@ -243,8 +392,14 @@ export class Relay {
243
392
  spaceJunkDeleteMs: opts.spaceLimits?.spaceJunkDeleteMs ?? LIMITS.spaceJunkDeleteMs,
244
393
  spaceMinAgeMs: opts.spaceLimits?.spaceMinAgeMs ?? LIMITS.spaceMinAgeMs,
245
394
  };
395
+ this.rotateOnRemove = opts.rotateOnRemove ?? process.env.PANTHEON_ROTATE_ON_REMOVE === "1";
246
396
  this.busyTtlMs = opts.activityBusyTtlMs ?? LIMITS.activityBusyTtlMs;
247
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
+ }
248
403
  this.http.on("upgrade", (req, socket, head) => {
249
404
  const ip = clientIp(req);
250
405
  this.wss.handleUpgrade(req, socket, head, (ws) => this.onSocket(ws, ip));
@@ -267,8 +422,14 @@ export class Relay {
267
422
  });
268
423
  }
269
424
  async close() {
270
- for (const sp of this.spaces.values())
271
- 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
+ }
272
433
  if (this.backupTimer)
273
434
  clearInterval(this.backupTimer);
274
435
  if (this.accounts && !this.opts.accounts)
@@ -280,6 +441,48 @@ export class Relay {
280
441
  await new Promise((r) => this.wss.close(() => r()));
281
442
  await new Promise((r) => this.http.close(() => r()));
282
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
+ }
283
486
  onHttp(req, res) {
284
487
  const url = new URL(req.url ?? "/", "http://x");
285
488
  const ip = clientIp(req);
@@ -298,11 +501,16 @@ export class Relay {
298
501
  seq: s.ledger.head,
299
502
  });
300
503
  const asked = url.searchParams.get("space");
301
- 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();
302
507
  return this.json(res, 200, {
303
- ok: true,
508
+ ok: !sf,
509
+ ...(sf ? { storage: { error: sf.message, where: sf.where, at: new Date(sf.at).toISOString() } } : {}),
304
510
  v: PROTOCOL_VERSION,
305
511
  pkg: VERSION,
512
+ ...(commit ? { commit } : {}),
513
+ ...(adminView && this.unbound.size ? { unbound_spaces: [...this.unbound].sort() } : {}),
306
514
  space_count: this.spaces.size,
307
515
  space_capacity: this.limits.maxSpaces,
308
516
  ...(adminView ? { spaces: [...this.spaces.values()].map(detail) }
@@ -322,10 +530,12 @@ export class Relay {
322
530
  : this.json(res, 404, { error: "no open invite matches that code — it may have been used already or expired" });
323
531
  }
324
532
  if (url.pathname === "/act") {
325
- const space = this.spaceForRead(url.searchParams.get("space") ?? "", url.searchParams.get("token") ?? "");
326
- const token = url.searchParams.get("token") ?? "";
327
- if (!space || !secretEq(space.token, token))
328
- 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;
329
539
  const act = space.ledger.find(url.searchParams.get("id") ?? "");
330
540
  if (!act)
331
541
  return this.json(res, 404, { error: "no such act" });
@@ -333,10 +543,12 @@ export class Relay {
333
543
  return this.json(res, 200, { act, ...(goal ? { goal: { id: goal.id, title: goal.title, state: goal.state } } : {}) });
334
544
  }
335
545
  if (url.pathname === "/history") {
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))
339
- 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;
340
552
  const goal = url.searchParams.get("goal");
341
553
  const sinceRaw = url.searchParams.get("since");
342
554
  const acts = goal ? space.ledger.byGoal(goal, LIMITS_GOAL_READ)
@@ -344,10 +556,12 @@ export class Relay {
344
556
  return this.json(res, 200, { acts, head: space.ledger.head });
345
557
  }
346
558
  if (url.pathname === "/obligations") {
347
- const space = this.spaceForRead(url.searchParams.get("space") ?? "", url.searchParams.get("token") ?? "");
348
- const token = url.searchParams.get("token") ?? "";
349
- if (!space || !secretEq(space.token, token))
350
- 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;
351
565
  const member = url.searchParams.get("member");
352
566
  const now = Date.now();
353
567
  const owed = [...space.obligations.values()]
@@ -362,25 +576,38 @@ export class Relay {
362
576
  if (url.pathname === "/activity") {
363
577
  if (req.method !== "POST")
364
578
  return this.json(res, 405, { error: "POST only" });
365
- const space = this.spaceForRead(url.searchParams.get("space") ?? "", url.searchParams.get("token") ?? "");
366
- const token = url.searchParams.get("token") ?? "";
367
- 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)
368
582
  return this.json(res, 404, { error: "no such space" });
369
583
  const memberId = url.searchParams.get("member") ?? "";
370
- const member = space.members.get(memberId);
371
584
  const session = (url.searchParams.get("session") ?? "").slice(0, 64);
372
585
  const state = url.searchParams.get("state");
373
- if (!member || !session || (state !== "busy" && state !== "idle")) {
586
+ if (!memberId || !session || (state !== "busy" && state !== "idle")) {
374
587
  return this.json(res, 400, { error: "member, session and state=busy|idle are required" });
375
588
  }
376
589
  const ts = Number(url.searchParams.get("ts"));
377
590
  const sig = url.searchParams.get("sig") ?? "";
378
591
  const seenParam = url.searchParams.get("seen") ?? "";
379
- const pub = space.allow.pubkeyOf(memberId);
592
+ const pub = target.allow.pubkeyOf(memberId);
380
593
  const fresh = Number.isFinite(ts) && Math.abs(Date.now() - ts) < LIMITS.activityFreshMs;
381
- 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)) {
382
595
  return this.json(res, 403, { error: "activity report must be signed by the member's key and recent" });
383
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" });
384
611
  if (member.activity.size > 64 && !member.activity.has(session)) {
385
612
  const oldest = [...member.activity.entries()].sort((a, b) => a[1].at - b[1].at)[0];
386
613
  if (oldest)
@@ -437,7 +664,7 @@ export class Relay {
437
664
  res.writeHead(429, { "content-type": "text/html; charset=utf-8", "retry-after": "60" });
438
665
  return void res.end("<p>Too many attempts. Wait a minute and try again.</p>");
439
666
  }
440
- return this.readForm(req, (body) => {
667
+ return this.readForm(req, res, (body) => {
441
668
  if (!tokenOk(body.token ?? "")) {
442
669
  this.noteAdminFail(ip);
443
670
  A0.audit({ event: "admin_login_failed", ip });
@@ -479,7 +706,7 @@ export class Relay {
479
706
  if (url.pathname === "/admin/action" && req.method === "POST") {
480
707
  if (!authed)
481
708
  return this.json(res, 403, { error: "sign in at /admin" });
482
- return this.readForm(req, (body) => this.adminAction(res, A0, body, ip));
709
+ return this.readForm(req, res, (body) => this.adminAction(res, A0, body, ip));
483
710
  }
484
711
  if (url.pathname === "/admin/space") {
485
712
  if (!authed)
@@ -498,7 +725,7 @@ export class Relay {
498
725
  if (url.pathname === "/auth/signup" && req.method === "POST") {
499
726
  if (!this.authRateOk(ip))
500
727
  return this.json(res, 429, { error: "too many attempts from your network — wait a minute and try again" });
501
- return this.readJson(req, async (b) => {
728
+ return this.readJson(req, res, async (b) => {
502
729
  if (!b || typeof b.email !== "string" || typeof b.username !== "string" || typeof b.pubkey !== "string") {
503
730
  return this.json(res, 400, { error: "email, username and pubkey are required" });
504
731
  }
@@ -506,63 +733,74 @@ export class Relay {
506
733
  return this.json(res, 400, { error: "that does not look like an email address" });
507
734
  if (!validUsername(normalizeUsername(b.username)))
508
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" });
509
739
  const started = A.startVerification(b.email, "signup", { username: b.username, pubkey: b.pubkey, label: String(b.label ?? "a device").slice(0, 60) });
510
740
  if (!started.ok) {
511
741
  if (started.reason === "blocked")
512
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 });
513
745
  const msg = started.reason === "too_soon" ? "a code was just sent — check your email, or wait a minute to resend"
514
- : started.reason === "in_progress_elsewhere" ? "a code for this email is already on its way to another device — use that one, or wait 10 minutes for it to expire"
515
- : started.reason === "username_taken" ? "that username is taken"
516
- : started.reason === "email_taken" ? "that email already has an account — use `pantheon login` from this machine instead"
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
517
749
  : "check the email and username";
518
750
  return this.json(res, 409, { error: msg, reason: started.reason });
519
751
  }
520
752
  A.audit({ event: "signup", target: normalizeUsername(b.username), ip });
521
- if (!this.emailBudgetOk())
522
- return this.json(res, 429, { error: "the relay is sending too many codes right now — try again shortly" });
523
753
  const sent = await this.email.sendCode(b.email, started.code, "signup");
524
754
  if (sent)
525
- this.emailSends.push(Date.now());
755
+ this.noteEmailSent("signup", b.email, ip);
526
756
  return this.json(res, 200, { ok: true, sent, via: this.email.kind });
527
757
  });
528
758
  }
529
759
  if (url.pathname === "/auth/login" && req.method === "POST") {
530
760
  if (!this.authRateOk(ip))
531
761
  return this.json(res, 429, { error: "too many attempts from your network — wait a minute and try again" });
532
- return this.readJson(req, async (b) => {
762
+ return this.readJson(req, res, async (b) => {
533
763
  if (!b || typeof b.email !== "string" || typeof b.pubkey !== "string")
534
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" });
535
769
  const started = A.startVerification(b.email, "login", { pubkey: b.pubkey, label: String(b.label ?? "a device").slice(0, 60) });
536
770
  if (!started.ok && started.reason === "blocked")
537
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 });
538
774
  if (!started.ok) {
539
- const code = started.reason === "too_soon" || started.reason === "in_progress_elsewhere" ? 429 : 404;
540
- const msg = started.reason === "too_soon" ? "a code was just sent — check your email, or wait a minute" : started.reason === "in_progress_elsewhere" ? "a code for this email is already on its way to another device — use that one, or wait 10 minutes for it to expire" : "no active account for that email — `pantheon signup` first";
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";
541
777
  return this.json(res, code, { error: msg, reason: started.reason });
542
778
  }
543
- if (!this.emailBudgetOk())
544
- return this.json(res, 429, { error: "the relay is sending too many codes right now — try again shortly" });
545
779
  const sent = await this.email.sendCode(b.email, started.code, "login");
546
780
  if (sent)
547
- this.emailSends.push(Date.now());
781
+ this.noteEmailSent("login", b.email, ip, b.pubkey, own);
548
782
  return this.json(res, 200, { ok: true, sent, via: this.email.kind });
549
783
  });
550
784
  }
551
785
  if (url.pathname === "/auth/verify" && req.method === "POST") {
552
786
  if (!this.authRateOk(ip))
553
787
  return this.json(res, 429, { error: "too many attempts from your network — wait a minute and try again" });
554
- return this.readJson(req, (b) => {
788
+ return this.readJson(req, res, (b) => {
555
789
  if (!b || typeof b.email !== "string" || typeof b.code !== "string")
556
790
  return this.json(res, 400, { error: "email and code are required" });
557
- 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));
558
792
  if (!r.ok) {
559
793
  const msg = r.reason === "expired" ? "that code has expired — request a new one"
560
794
  : r.reason === "other_device" ? "that code was requested from a different device — request a new one from this one"
561
795
  : r.reason === "suspended" ? "this account is suspended — contact the relay operator"
562
796
  : r.reason === "too_many_attempts" ? "too many wrong tries — request a new code"
563
- : r.reason === "no_pending" ? "no code is pending for that email — run signup or login first"
564
- : "that code is not right";
565
- 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 } : {}) });
566
804
  }
567
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 });
568
806
  });
@@ -587,8 +825,10 @@ export class Relay {
587
825
  const hit = A.revoke(who.account.id, kind, id, ip);
588
826
  for (const sid of hit.sessionIds)
589
827
  this.dropSessionEverywhere(sid);
590
- if (hit.deviceId)
828
+ if (hit.deviceId) {
591
829
  this.dropDeviceSockets(who.account.id, hit.deviceId);
830
+ this.noteAccountRemoval(who.account.username);
831
+ }
592
832
  return this.json(res, 200, { ok: true, revoked: hit.sessionIds.length, device_removed: !!hit.deviceId });
593
833
  }
594
834
  if (url.pathname === "/account/delete" && req.method === "POST") {
@@ -599,6 +839,7 @@ export class Relay {
599
839
  A.deleteAccount(who.account.id, ip);
600
840
  for (const s of sessions)
601
841
  this.dropSessionEverywhere(s.id);
842
+ this.noteAccountRemoval(who.account.username);
602
843
  return this.json(res, 200, { ok: true });
603
844
  }
604
845
  if (url.pathname === "/audit" && req.method === "GET") {
@@ -607,7 +848,7 @@ export class Relay {
607
848
  return this.json(res, 403, { error: "this request must be signed by a device on the account" });
608
849
  const space = url.searchParams.get("space") ?? undefined;
609
850
  if (space) {
610
- const sp = this.spaces.get(space);
851
+ const sp = this.live(space);
611
852
  const member = sp?.allow.get(who.account.username);
612
853
  if (!sp || !member?.admitted)
613
854
  return this.json(res, 403, { error: "you are not a member of that space" });
@@ -675,11 +916,47 @@ export class Relay {
675
916
  }
676
917
  }
677
918
  }
678
- emailBudgetOk() {
679
- const now = Date.now();
680
- 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);
681
923
  const cap = Number(process.env.PANTHEON_EMAIL_MAX_PER_HOUR) || 200;
682
- 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));
683
960
  }
684
961
  authRateOk(ip) {
685
962
  if (this.localTrusted(ip))
@@ -699,41 +976,134 @@ export class Relay {
699
976
  }
700
977
  return recent.length <= 20;
701
978
  }
702
- readJson(req, cb) {
703
- let raw = "";
704
- req.on("data", (c) => { raw += c; if (raw.length > 64_000)
705
- req.destroy(); });
706
- req.on("end", () => { try {
707
- 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;
708
997
  }
709
- catch {
710
- cb(null);
711
- } });
712
- 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
+ });
713
1060
  }
714
1061
  dropSessionEverywhere(sessionId) {
1062
+ this.authEpoch++;
715
1063
  for (const sp of this.spaces.values()) {
716
1064
  for (const m of sp.members.values()) {
717
1065
  const s = m.sessions.get(sessionId);
718
1066
  if (s) {
719
- try {
720
- s.ws.close(1008, "SESSION_REVOKED");
721
- }
722
- catch { }
1067
+ this.hangUp(s.ws, "SESSION_REVOKED");
723
1068
  m.sessions.delete(sessionId);
724
1069
  }
725
1070
  }
726
1071
  }
727
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
+ }
728
1093
  dropDeviceSockets(_accountId, _deviceId) {
729
1094
  }
730
- readForm(req, cb) {
731
- let raw = "";
732
- req.on("data", (c) => { raw += c; if (raw.length > 8_000)
733
- req.destroy(); });
734
- req.on("end", () => { const o = {}; for (const [k, v] of new URLSearchParams(raw))
735
- o[k] = v; cb(o); });
736
- 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
+ });
737
1107
  }
738
1108
  adminLoginPage(res, wrong) {
739
1109
  res.writeHead(wrong ? 401 : 200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store", "referrer-policy": "no-referrer" });
@@ -785,7 +1155,7 @@ button:hover{background:#1d4ed8}.err{color:var(--bad);margin-top:12px}
785
1155
  if (exact(sp.allow))
786
1156
  return sp.name;
787
1157
  const safeOf = (name) => name.replace(/[^a-z0-9._-]/gi, "_");
788
- const live = new Set([...this.spaces.keys()].map(safeOf));
1158
+ const live = new Set(this.spaces.keys());
789
1159
  let files = [];
790
1160
  try {
791
1161
  files = readdirSync(this.dataDir);
@@ -797,7 +1167,7 @@ button:hover{background:#1d4ed8}.err{color:var(--bad);margin-top:12px}
797
1167
  if (!f.endsWith(".members.json"))
798
1168
  continue;
799
1169
  const safe = f.slice(0, -".members.json".length);
800
- if (live.has(safe))
1170
+ if (live.has(safe.toLowerCase()))
801
1171
  continue;
802
1172
  try {
803
1173
  if (!exact(new Allowlist(this.dataDir, safe)))
@@ -939,28 +1309,27 @@ button:hover{background:#1d4ed8}.err{color:var(--bad);margin-top:12px}
939
1309
  return { goals, refusals: st.refusals };
940
1310
  }
941
1311
  deleteSpace(name) {
942
- const sp = this.spaces.get(name);
1312
+ const sp = this.spaces.get(spaceKey(name));
943
1313
  if (sp) {
944
1314
  sp.deleted = true;
945
1315
  for (const m of sp.members.values())
946
- for (const s of m.sessions.values()) {
947
- try {
948
- s.ws.close(1008, "SPACE_DELETED");
949
- }
950
- catch { }
951
- }
952
- 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));
953
1319
  }
954
1320
  setTimeout(() => this.deleteSpaceFiles(name), 600).unref?.();
955
1321
  const safe = name.replace(/[^a-z0-9._-]/gi, "_");
956
1322
  try {
957
1323
  const hash = sp?.allow ? JSON.parse(readFileSync(join(this.dataDir, `${safe}.members.json`), "utf8")).tokenHash ?? null : null;
958
- writeFileSync(join(this.dataDir, `${safe}.deleted.json`), JSON.stringify({ name, at: Date.now(), tokenHash: hash }));
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);
959
1328
  }
960
1329
  catch { }
961
1330
  const existed = !!sp || [".jsonl", ".state.json", ".members.json"].some((suf) => existsSync(join(this.dataDir, `${safe}${suf}`)));
962
1331
  this.deleteSpaceFiles(name);
963
- this.deletedSpaces.set(name, Date.now());
1332
+ this.deletedSpaces.set(spaceKey(name), Date.now());
964
1333
  if (this.deletedSpaces.size > 1000)
965
1334
  for (const [k, t] of this.deletedSpaces)
966
1335
  if (Date.now() - t > 3_600_000)
@@ -980,14 +1349,17 @@ button:hover{background:#1d4ed8}.err{color:var(--bad);margin-top:12px}
980
1349
  return this.json(res, 404, { error: "no such account" });
981
1350
  const live = A.sessionsFor(acct.id).filter((s) => !s.revoked_at);
982
1351
  A.setStatus(acct.id, act === "suspend" ? "suspended" : "active", ip);
983
- if (act === "suspend")
1352
+ this.authEpoch++;
1353
+ if (act === "suspend") {
984
1354
  for (const s of live)
985
1355
  this.dropSessionEverywhere(s.id);
1356
+ this.noteAccountRemoval(acct.username);
1357
+ }
986
1358
  this.log(`[admin] ${act} ${acct.username} from ${ip}`);
987
1359
  return done(`${acct.username} ${act === "suspend" ? "suspended and signed out everywhere" : "reinstated"}`);
988
1360
  }
989
1361
  case "revoke_invite": {
990
- const sp = this.spaces.get(id);
1362
+ const sp = this.spaces.get(spaceKey(id));
991
1363
  const allow = sp ? sp.allow : new Allowlist(this.dataDir, id.replace(/[^a-z0-9._-]/gi, "_"));
992
1364
  const token = (body.token ?? "").trim();
993
1365
  const inv = allow.findInvite(token);
@@ -1004,8 +1376,12 @@ button:hover{background:#1d4ed8}.err{color:var(--bad);margin-top:12px}
1004
1376
  const hit = A.adminRevoke(act === "revoke_session" ? "session" : "device", id, ip);
1005
1377
  for (const sid of hit.sessionIds)
1006
1378
  this.dropSessionEverywhere(sid);
1007
- if (hit.deviceId && hit.accountId)
1379
+ if (hit.deviceId && hit.accountId) {
1008
1380
  this.dropDeviceSockets(hit.accountId, hit.deviceId);
1381
+ const owner = A.accountById(hit.accountId);
1382
+ if (owner)
1383
+ this.noteAccountRemoval(owner.username);
1384
+ }
1009
1385
  return done(hit.sessionIds.length || hit.deviceId ? `revoked ${act === "revoke_session" ? "session" : "device"} ${id}` : `nothing to revoke for ${id}`);
1010
1386
  }
1011
1387
  case "block": {
@@ -1091,28 +1467,213 @@ button:hover{background:#1d4ed8}.err{color:var(--bad);margin-top:12px}
1091
1467
  default: return this.json(res, 400, { error: `unknown action "${act}"` });
1092
1468
  }
1093
1469
  }
1094
- spaceForRead(name, token) {
1095
- const live = this.spaces.get(name);
1096
- if (live)
1097
- return secretEq(live.token, token) ? live : null;
1098
- if (!name || !token || !this.spaceOnDisk(name))
1470
+ readTarget(name, token) {
1471
+ if (!name || !token)
1099
1472
  return null;
1100
- 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))
1479
+ return null;
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)
1101
1514
  return null;
1102
- return this.getOrCreateSpace(name, token);
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;
1103
1655
  }
1104
1656
  tombstone(name, _token) {
1657
+ const f = this.tombstoneFile(name);
1658
+ if (!f)
1659
+ return null;
1105
1660
  try {
1106
- return JSON.parse(readFileSync(join(this.dataDir, `${safeName(name)}.deleted.json`), "utf8")).at;
1661
+ return JSON.parse(readFileSync(f, "utf8")).at;
1107
1662
  }
1108
1663
  catch {
1109
1664
  return null;
1110
1665
  }
1111
1666
  }
1112
1667
  tombstoneMatches(name, token) {
1668
+ const f = this.tombstoneFile(name);
1669
+ if (!f)
1670
+ return false;
1113
1671
  try {
1114
- const t = JSON.parse(readFileSync(join(this.dataDir, `${safeName(name)}.deleted.json`), "utf8"));
1115
- return !!t.tokenHash && !!token && createHash("sha256").update(token).digest("hex") === t.tokenHash;
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));
1116
1677
  }
1117
1678
  catch {
1118
1679
  return false;
@@ -1130,20 +1691,50 @@ button:hover{background:#1d4ed8}.err{color:var(--bad);margin-top:12px}
1130
1691
  spaceOnDisk(name) {
1131
1692
  return existsSync(join(this.dataDir, `${name.replace(/[^a-z0-9._-]/gi, "_")}.members.json`));
1132
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
+ }
1133
1722
  spacePlan(name) {
1134
1723
  if (!this.accounts)
1135
1724
  return null;
1136
1725
  const owner = this.accounts.spaceOwner(name);
1137
- if (!owner)
1138
- return null;
1139
- 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;
1140
1731
  }
1141
1732
  planView(acct, sp) {
1142
1733
  const fresh = this.accounts?.accountById(acct.id) ?? acct;
1143
1734
  return { name: fresh.plan, limits: this.accounts.limitsFor(fresh), usage: { spaces: this.accounts.spacesOwned(fresh.id).length, members: sp.allow.admittedCount() } };
1144
1735
  }
1145
1736
  planRefusal(space, plan, target, msg, cid, fail, actor, ip) {
1146
- 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 });
1147
1738
  this.log(`[${space.name}] ! PLAN_LIMIT (${target}, ${plan.name} plan)`);
1148
1739
  fail("PLAN_LIMIT", msg, cid);
1149
1740
  }
@@ -1200,7 +1791,7 @@ button:hover{background:#1d4ed8}.err{color:var(--bad);margin-top:12px}
1200
1791
  for (const d of devices)
1201
1792
  devByAcct.set(d.account_id, (devByAcct.get(d.account_id) ?? 0) + 1);
1202
1793
  const emailCap = Number(process.env.PANTHEON_EMAIL_MAX_PER_HOUR) || 200;
1203
- 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;
1204
1795
  let snapshots = [];
1205
1796
  try {
1206
1797
  snapshots = readdirSync(join(this.dataDir, "backups")).filter((f) => f.startsWith("accounts-") && f.endsWith(".db")).sort();
@@ -1380,8 +1971,8 @@ ${done ? `<div class=done role=status>${esc(done)}</div>` : ""}
1380
1971
  }
1381
1972
  adminSpace(res, url, name) {
1382
1973
  const safe = name.replace(/[^a-z0-9._-]/gi, "_");
1383
- const sp = this.spaces.get(name) ?? null;
1384
- const tomb = this.deletedSpaces.get(name);
1974
+ const sp = this.spaces.get(spaceKey(name)) ?? null;
1975
+ const tomb = this.deletedSpaces.get(spaceKey(name));
1385
1976
  const onDisk = [".jsonl", ".state.json", ".members.json"].some((suf) => existsSync(join(this.dataDir, `${safe}${suf}`)));
1386
1977
  if (!name || (!sp && (!onDisk || (tomb && Date.now() - tomb < 3_600_000))))
1387
1978
  return this.json(res, 404, { error: `no space "${name}" on this relay` });
@@ -1563,7 +2154,12 @@ ${url.searchParams.get("done") ? `<div class=done role=status>${esc(url.searchPa
1563
2154
  return void res.end(JSON.stringify(data, null, 2));
1564
2155
  }
1565
2156
  const cols = data.length ? Object.keys(data[0]) : [];
1566
- 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
+ };
1567
2163
  const csv = [cols.join(","), ...data.map((r) => cols.map((k) => cell(r[k])).join(","))].join("\n");
1568
2164
  res.writeHead(200, { "content-type": "text/csv", "content-disposition": `attachment; filename="${table}.csv"` });
1569
2165
  res.end(csv);
@@ -1628,22 +2224,59 @@ pantheon signup --email you@work.com --username you</code></pre>
1628
2224
  const touch = () => {
1629
2225
  if (dbSession && Date.now() - lastTouch > 60_000) {
1630
2226
  lastTouch = Date.now();
1631
- this.accounts?.touchSession(dbSession);
2227
+ try {
2228
+ this.accounts?.touchSession(dbSession);
2229
+ }
2230
+ catch (e) {
2231
+ this.noteStorageFailure("session touch", e);
2232
+ }
1632
2233
  }
1633
2234
  };
1634
2235
  let sessionId = "";
1635
2236
  let role = "interactive";
2237
+ let myPubkey = "";
2238
+ let authEpoch = -1, authOk = true;
1636
2239
  let lastMsg = "";
1637
2240
  let alive = true;
1638
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
+ };
1639
2256
  ws.on("pong", () => { alive = true; touch(); });
1640
2257
  const probe = ws;
1641
2258
  probe._alive = () => alive;
1642
2259
  probe._clear = () => { alive = false; };
2260
+ let refusalStamps = [];
2261
+ let refusedOut = false;
1643
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
+ }
1644
2277
  if (cid && space && me && !space.deleted) {
1645
- noteRefusal(space.store.refusals, { ts: Date.now(), member: me.id, act: lastMsg || "?", code });
1646
- 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);
1647
2280
  }
1648
2281
  this.send(ws, { t: "error", code, message, ...(cid ? { cid } : {}) });
1649
2282
  };
@@ -1669,6 +2302,9 @@ pantheon signup --email you@work.com --username you</code></pre>
1669
2302
  ws.on("close", clearHelloTimer);
1670
2303
  this.send(ws, { t: "challenge", nonce, v: PROTOCOL_VERSION });
1671
2304
  ws.on("message", (raw) => {
2305
+ if (refusedOut)
2306
+ return;
2307
+ let reqCid;
1672
2308
  try {
1673
2309
  let msg;
1674
2310
  try {
@@ -1677,6 +2313,8 @@ pantheon signup --email you@work.com --username you</code></pre>
1677
2313
  catch {
1678
2314
  return fail("BAD_JSON", "message was not JSON");
1679
2315
  }
2316
+ if (typeof msg?.cid === "string")
2317
+ reqCid = (msg.cid).slice(0, 64);
1680
2318
  const parsed = Inbound.safeParse(msg);
1681
2319
  if (!parsed.success) {
1682
2320
  const cid = typeof msg?.cid === "string"
@@ -1701,26 +2339,32 @@ pantheon signup --email you@work.com --username you</code></pre>
1701
2339
  const gated = this.requireAccounts && !!this.accounts && !this.localTrusted(ip);
1702
2340
  const pre = gated ? this.accounts.authorize(m.pubkey, m.member) : null;
1703
2341
  let sp;
2342
+ let staleToken = false;
1704
2343
  if (m.enroll) {
1705
- 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`);
1706
2347
  if (!sp && this.spaceOnDisk(m.space)) {
1707
2348
  const inv = new Allowlist(this.dataDir, safeName(m.space)).findInvite(m.enroll);
1708
2349
  if (inv?.token === m.enroll && inv.space_token)
1709
- sp = this.getOrCreateSpace(m.space, inv.space_token);
2350
+ sp = this.getOrCreateSpace(m.space, inv.space_token, { claim: "invite", retired: true });
1710
2351
  }
1711
2352
  if (!sp)
1712
2353
  return reject("NO_SUCH_SPACE", `no space "${m.space}" on this relay`);
1713
2354
  }
1714
2355
  else {
1715
- 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 ?? "");
1716
2357
  if (tomb && Date.now() - tomb < 3_600_000 || (tomb && this.tombstoneMatches(m.space, m.token ?? ""))) {
1717
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.`);
1718
2359
  }
1719
- 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);
1720
2364
  if (isNew && this.nameCollides(m.space)) {
1721
2365
  return reject("NAME_TAKEN", `a team with a name too close to "${m.space}" already exists here — pick another name`);
1722
2366
  }
1723
- if (!this.spaces.has(m.space)) {
2367
+ if (!this.live(m.space)) {
1724
2368
  const denied = this.mayCreateSpace(ip);
1725
2369
  if (denied)
1726
2370
  return reject(denied.code, denied.message);
@@ -1739,9 +2383,19 @@ pantheon signup --email you@work.com --username you</code></pre>
1739
2383
  return reject("PLAN_LIMIT", msg);
1740
2384
  }
1741
2385
  }
1742
- 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 });
1743
2396
  if (!sp)
1744
2397
  return reject("BAD_TOKEN", "wrong token for this space");
2398
+ staleToken = this.tokenState(sp, m.token) === "retired";
1745
2399
  if (isNew) {
1746
2400
  this.noteSpaceCreate(ip);
1747
2401
  this.accounts?.recordSpace(sp.name, pre?.ok ? pre.account.id : null);
@@ -1770,20 +2424,38 @@ pantheon signup --email you@work.com --username you</code></pre>
1770
2424
  const plan = this.spacePlan(sp.name);
1771
2425
  if (plan && !sp.allow.get(m.member)?.admitted && sp.allow.admittedCount() >= plan.limits.members) {
1772
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 });
1773
- return reject("PLAN_LIMIT", `"${sp.name}" is full: its owner's ${plan.name} plan allows ${fmtLimit(plan.limits.members)} people. Ask ${plan.owner.username} to upgrade at https://www.joinpantheon.network`);
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."}`);
1774
2428
  }
1775
2429
  const r = sp.allow.redeem(m.enroll, m.member, m.pubkey);
1776
2430
  if (!r.ok) {
1777
2431
  this.log(`[${sp.name}] ! invite refused for ${m.member}: ${r.why}`);
1778
2432
  return reject("BAD_INVITE", r.fix ? `${r.why}\n${r.fix}` : r.why);
1779
2433
  }
1780
- enrolled = sp.token;
2434
+ enrolled = this.ensureToken(sp);
1781
2435
  this.log(`[${sp.name}] * ENROLLED ${m.member} via invite from ${r.entry.admitted_by}${r.entry.may_wake ? " (may wake)" : ""}`);
1782
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
+ }
1783
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
+ }
1784
2456
  if (verdict === "key-mismatch") {
1785
2457
  this.log(`[${sp.name}] ! REFUSED ${m.member}: key mismatch (${fingerprint(m.pubkey)})`);
1786
- return reject("KEY_MISMATCH", `"${m.member}" is already registered here with a different key — another machine, or this one before its key was replaced. If this is really you, an admitted member runs pantheon admit ${m.member} --forget and sends you a new invite.`);
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.`);
1787
2459
  }
1788
2460
  if (verdict === "revoked") {
1789
2461
  const e = sp.allow.get(m.member);
@@ -1815,6 +2487,7 @@ pantheon signup --email you@work.com --username you</code></pre>
1815
2487
  }
1816
2488
  }
1817
2489
  space = sp;
2490
+ myPubkey = m.pubkey;
1818
2491
  clearHelloTimer();
1819
2492
  sessionId = acctSession ?? this.mintSession();
1820
2493
  role = m.role;
@@ -1857,8 +2530,13 @@ pantheon signup --email you@work.com --username you</code></pre>
1857
2530
  obligations: [...sp.obligations.values()], members: this.members(sp), profiles: [...sp.profiles.values()],
1858
2531
  space_id: sp.allow.spaceId(), repos: sp.allow.repoList(),
1859
2532
  ...(acct ? { plan: this.planView(acct, sp) } : {}),
1860
- ...(enrolled ? { token: enrolled } : {}),
2533
+ ...(enrolled ? { token: enrolled } : staleToken && this.knownToken(sp) ? { token: sp.token } : {}),
1861
2534
  });
2535
+ if (staleToken && !enrolled && !sp.token) {
2536
+ const mine = me.sessions.get(sessionId);
2537
+ if (mine)
2538
+ mine.wantsToken = true;
2539
+ }
1862
2540
  let from = m.from_seq && m.from_seq > 0 ? m.from_seq : sp.ledger.head;
1863
2541
  for (let page = sp.ledger.since(from); page.length; page = sp.ledger.since(from)) {
1864
2542
  for (const r of page) {
@@ -1868,6 +2546,11 @@ pantheon signup --email you@work.com --username you</code></pre>
1868
2546
  }
1869
2547
  from = page[page.length - 1].seq;
1870
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
+ }
1871
2554
  if (firstSession && m.role !== "cli") {
1872
2555
  this.commit(sp, { from: me.id, act: "JOIN", body: `${me.display} online (${me.agent})`, to: null, goal: null, ref: null });
1873
2556
  }
@@ -1877,6 +2560,11 @@ pantheon signup --email you@work.com --username you</code></pre>
1877
2560
  }
1878
2561
  if (!space || !me)
1879
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
+ }
1880
2568
  if (m.t === "publish_profile") {
1881
2569
  if (m.profile.member !== me.id) {
1882
2570
  return fail("PROFILE_NOT_YOURS", `you may publish only the profile for ${me.id}`, m.cid);
@@ -1922,19 +2610,16 @@ pantheon signup --email you@work.com --username you</code></pre>
1922
2610
  return fail("FOUNDER_ONLY", `only ${founder}, who founded "${space.name}", can remove a member`, m.cid);
1923
2611
  if (m.member === founder)
1924
2612
  return fail("FOUNDER_STAYS", "the founder cannot be removed from their own team", m.cid);
1925
- if (m.forget)
1926
- space.allow.forget(m.member);
1927
- else
1928
- space.allow.revoke(m.member, me.id);
2613
+ const removed = m.forget ? space.allow.forget(m.member) : space.allow.revoke(m.member, me.id);
1929
2614
  if (space.profiles.delete(m.member))
1930
2615
  this.broadcast(space, { t: "profiles", profiles: [...space.profiles.values()] });
1931
- for (const s of space.members.get(m.member)?.sessions.values() ?? []) {
1932
- try {
1933
- s.ws.close(1008, "REVOKED");
1934
- }
1935
- catch { }
1936
- }
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();
1937
2620
  space.members.delete(m.member);
2621
+ if (removed && this.rotateOnRemove)
2622
+ this.rotateSpaceToken(space, `${m.member} ${m.forget ? "forgotten" : "removed"} by ${me.id}`);
1938
2623
  this.log(`[${space.name}] - ${m.forget ? "FORGOT" : "REVOKED"} ${m.member} by ${me.id}`);
1939
2624
  }
1940
2625
  else {
@@ -1964,7 +2649,15 @@ pantheon signup --email you@work.com --username you</code></pre>
1964
2649
  this.accounts?.audit({ event: "space_deleted", actor_account: acct?.id ?? null, space: name, detail: `by its founder ${me.id}`, ip });
1965
2650
  this.log(`[${name}] * DELETED by its founder ${me.id}`);
1966
2651
  this.send(ws, { t: "ack", ...(m.cid ? { cid: m.cid } : {}), id: name, seq: space.ledger.head });
1967
- setTimeout(() => { this.deleteSpace(name); this.accounts?.markSpaceDeleted(name); }, 50).unref?.();
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?.();
1968
2661
  return;
1969
2662
  }
1970
2663
  if (m.t === "invite") {
@@ -1985,7 +2678,7 @@ pantheon signup --email you@work.com --username you</code></pre>
1985
2678
  if (space.allow.openInvites().length >= LIMITS_SPACE.openInvites)
1986
2679
  return fail("TOO_MANY_INVITES", `this team already has ${LIMITS_SPACE.openInvites} unused invites — revoke some: pantheon invite --revoke all`, m.cid);
1987
2680
  const token = randomBytes(18).toString("base64url");
1988
- 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));
1989
2682
  this.log(`[${space.name}] * invite minted by ${me.id}, valid ${m.ttl_minutes}m, single use`);
1990
2683
  this.send(ws, { t: "invite", url: token, expires_at: inv.expires_at });
1991
2684
  return;
@@ -2124,6 +2817,8 @@ pantheon signup --email you@work.com --username you</code></pre>
2124
2817
  if (m.t === "open_goal") {
2125
2818
  if (!this.writeOk(me))
2126
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);
2127
2822
  const openCount = [...space.goals.values()].filter((g) => g.state !== "CLOSED").length;
2128
2823
  if (openCount >= LIMITS_SPACE.openGoals)
2129
2824
  return fail("TOO_MANY_GOALS", `this team has ${openCount} goals still open — close some first`, m.cid);
@@ -2156,11 +2851,15 @@ pantheon signup --email you@work.com --username you</code></pre>
2156
2851
  body: `goal opened: ${goal.title}\nacceptance: ${goal.acceptance || "(none stated)"}` +
2157
2852
  (goal.deadline ? `\ndeadline: ${new Date(goal.deadline).toISOString()}` : "") +
2158
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 },
2159
2855
  });
2160
2856
  this.send(ws, { t: "ack", ...(m.cid ? { cid: m.cid } : {}), id: goal.id, seq: rec.seq });
2161
2857
  return;
2162
2858
  }
2163
- const fp = fingerprint_(me.id, m.act, m.body, m.goal ?? null, m.ref ?? null, { to: m.to ?? null, meta: m.meta ?? null, defers_to: m.defers_to ?? null });
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);
2164
2863
  const prior = space.recent.get(fp);
2165
2864
  if (prior && Date.now() - prior.at < LIMITS.dedupeWindowMs) {
2166
2865
  this.send(ws, { t: "ack", ...(m.cid ? { cid: m.cid } : {}), id: prior.id, seq: prior.seq });
@@ -2184,6 +2883,16 @@ pantheon signup --email you@work.com --username you</code></pre>
2184
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);
2185
2884
  if (err)
2186
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);
2187
2896
  const rec = this.commit(space, {
2188
2897
  from: me.id, session: sessionId, role, act: m.act, body: m.body,
2189
2898
  ...(m.summary ? { summary: m.summary } : {}),
@@ -2194,23 +2903,54 @@ pantheon signup --email you@work.com --username you</code></pre>
2194
2903
  ...(m.affects ? { affects: m.affects } : {}) } }
2195
2904
  : {}),
2196
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
+ }
2197
2914
  space.recent.set(fp, { id: rec.id, seq: rec.seq, at: Date.now() });
2198
2915
  for (const [k, v] of space.recent)
2199
2916
  if (Date.now() - v.at > LIMITS.dedupeWindowMs)
2200
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
+ }
2201
2923
  this.send(ws, { t: "ack", ...(m.cid ? { cid: m.cid } : {}), id: rec.id, seq: rec.seq });
2202
2924
  }
2203
2925
  catch (e) {
2204
- this.log(`! message handling failed: ${e.message}`);
2205
- try {
2206
- ws.send(JSON.stringify({ t: "error", code: "STORAGE", message: "the relay could not save that — try again in a moment" }));
2926
+ const cidPart = reqCid ? { cid: reqCid } : {};
2927
+ const send = (code, message) => { try {
2928
+ ws.send(JSON.stringify({ t: "error", code, message, ...cidPart }));
2207
2929
  }
2208
- 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");
2209
2944
  }
2210
2945
  });
2211
2946
  const closeWork = (code) => {
2212
2947
  if (dbSession) {
2213
- this.accounts?.endSession(dbSession);
2948
+ try {
2949
+ this.accounts?.endSession(dbSession);
2950
+ }
2951
+ catch (e) {
2952
+ this.noteStorageFailure("session end", e);
2953
+ }
2214
2954
  dbSession = null;
2215
2955
  }
2216
2956
  if (!space || !me)
@@ -2233,7 +2973,8 @@ pantheon signup --email you@work.com --username you</code></pre>
2233
2973
  else
2234
2974
  v.at = Date.now();
2235
2975
  }
2236
- 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) {
2237
2978
  me.last_seen = Date.now();
2238
2979
  this.commit(space, { from: me.id, act: "LEAVE", body: `${me.display} offline`, to: null, goal: null, ref: null });
2239
2980
  }
@@ -2245,7 +2986,7 @@ pantheon signup --email you@work.com --username you</code></pre>
2245
2986
  closeWork(code);
2246
2987
  }
2247
2988
  catch (e) {
2248
- this.log(`! cleanup after a disconnect failed: ${e.message}`);
2989
+ this.noteStorageFailure(`[${space?.name ?? "?"}] cleanup after a disconnect`, e);
2249
2990
  } });
2250
2991
  ws.on("error", () => { });
2251
2992
  }
@@ -2265,19 +3006,20 @@ pantheon signup --email you@work.com --username you</code></pre>
2265
3006
  const now = Date.now();
2266
3007
  for (const sp of this.spaces.values()) {
2267
3008
  try {
2268
- if (now - (this.compactedAt.get(sp.name) ?? 0) > LIMITS.ledgerCompactEveryMs)
3009
+ const every = this.spaceFull(sp) ? 60_000 : LIMITS.ledgerCompactEveryMs;
3010
+ if (now - (this.compactedAt.get(sp.name) ?? 0) > every)
2269
3011
  this.compactSpace(sp);
2270
3012
  this.lapseClaims(sp, now);
2271
3013
  }
2272
3014
  catch (e) {
2273
- this.log(`! [${sp.name}] housekeeping failed: ${e.message}`);
3015
+ this.noteStorageFailure(`[${sp.name}] housekeeping`, e);
2274
3016
  }
2275
3017
  }
2276
3018
  try {
2277
3019
  this.evictSpaces();
2278
3020
  }
2279
3021
  catch (e) {
2280
- this.log(`! evicting idle spaces failed: ${e.message}`);
3022
+ this.noteStorageFailure("evicting idle spaces", e);
2281
3023
  }
2282
3024
  }
2283
3025
  lapseClaims(sp, now) {
@@ -2312,6 +3054,8 @@ pantheon signup --email you@work.com --username you</code></pre>
2312
3054
  return { code: "NO_SUCH_MEMBER", message: `nobody here is called "${id}" — members: ${[...sp.members.keys()].join(", ")}` };
2313
3055
  }
2314
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.` };
2315
3059
  if (ref && !sp.ledger.find(ref))
2316
3060
  return { code: "BAD_REF", message: `no act with id ${ref}` };
2317
3061
  if (meta && JSON.stringify(meta).length > LIMITS_SPACE.metaBytes) {
@@ -2506,7 +3250,10 @@ pantheon signup --email you@work.com --username you</code></pre>
2506
3250
  commit(sp, partial) {
2507
3251
  if (sp.deleted)
2508
3252
  return { ...partial, id: newId("a"), seq: sp.ledger.head, ts: Date.now(), space: sp.name };
2509
- 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);
2510
3257
  sp.lastTouch = rec.ts;
2511
3258
  const m = sp.members.get(rec.from);
2512
3259
  if (m) {
@@ -2514,13 +3261,22 @@ pantheon signup --email you@work.com --username you</code></pre>
2514
3261
  if (rec.act !== "JOIN" && rec.act !== "LEAVE" && (m.seen_seq ?? 0) < rec.seq - 1)
2515
3262
  m.seen_seq = rec.seq - 1;
2516
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) {
2517
3273
  if (rec.goal) {
2518
3274
  const goal = sp.goals.get(rec.goal);
2519
3275
  if (goal) {
2520
3276
  goal.acts++;
2521
3277
  const run = sp.lastActor.get(rec.goal);
2522
3278
  sp.lastActor.set(rec.goal, run && run.member === rec.from ? { member: rec.from, run: run.run + 1 } : { member: rec.from, run: 1 });
2523
- this.applyObligations(sp, rec, goal);
3279
+ this.applyObligations(sp, rec, goal, ctx);
2524
3280
  this.broadcast(sp, { t: "goal", goal });
2525
3281
  if (goal.acts >= goal.budget && goal.state === "OPEN") {
2526
3282
  goal.state = "STALLED";
@@ -2531,14 +3287,10 @@ pantheon signup --email you@work.com --username you</code></pre>
2531
3287
  }
2532
3288
  }
2533
3289
  else {
2534
- this.applyObligations(sp, rec, null);
3290
+ this.applyObligations(sp, rec, null, ctx);
2535
3291
  }
2536
- this.pushObligations(sp);
2537
- this.broadcast(sp, { t: "act", act: rec });
2538
- this.broadcast(sp, { t: "roster", roster: this.roster(sp) });
2539
- return rec;
2540
3292
  }
2541
- applyObligations(sp, rec, goal) {
3293
+ applyObligations(sp, rec, goal, ctx) {
2542
3294
  const closeFor = (owner, ref) => {
2543
3295
  for (const [id, o] of sp.obligations)
2544
3296
  if (o.owner === owner && o.ref === ref)
@@ -2642,8 +3394,9 @@ pantheon signup --email you@work.com --username you</code></pre>
2642
3394
  const answer = sp.ledger.find(rec.ref);
2643
3395
  const asked = answer?.act === "ANSWER" && answer.from === rec.from && answer.ref ? sp.ledger.find(answer.ref) : undefined;
2644
3396
  const decider = asked?.act === "ESCALATE" ? asked.from : asked?.act === "HUMAN_REQUIRED" ? asked.to?.[0] : undefined;
2645
- const retracted = new Set(sp.ledger.all().filter((r) => r.act === "RETRACT" && r.ref).map((r) => r.ref));
2646
- const stillAnswered = asked && sp.ledger.all().some((r) => r.act === "ANSWER" && r.ref === asked.id && r.from === rec.from && r.id !== answer.id && !retracted.has(r.id));
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));
2647
3400
  const alreadyLive = asked && [...sp.obligations.values()].some((o) => o.kind === "HUMAN" && o.ref === asked.id);
2648
3401
  if (asked && decider === rec.from && !stillAnswered && !alreadyLive && !retracted.has(asked.id)) {
2649
3402
  this.oblige(sp, { kind: "HUMAN", owner: `human:${decider}`, ref: asked.id, goal: asked.goal,
@@ -2681,6 +3434,23 @@ pantheon signup --email you@work.com --username you</code></pre>
2681
3434
  const id = newId("o");
2682
3435
  sp.obligations.set(id, { ...o, id, created_at: Date.now() });
2683
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
+ }
2684
3454
  pushObligations(sp) {
2685
3455
  sp.store.flush(sp.goals, sp.obligations, sp.profiles);
2686
3456
  this.broadcast(sp, { t: "obligations", list: [...sp.obligations.values()] });
@@ -2753,18 +3523,23 @@ pantheon signup --email you@work.com --username you</code></pre>
2753
3523
  continue;
2754
3524
  const idle = now - sp.lastTouch;
2755
3525
  const junk = this.isJunkSpace(sp);
2756
- if (junk && idle > this.limits.spaceJunkDeleteMs) {
2757
- sp.store.close(sp.goals, sp.obligations, sp.profiles);
2758
- this.spaces.delete(name);
2759
- this.compactedAt.delete(name);
2760
- this.deleteSpaceFiles(sp.name);
2761
- 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
+ }
2762
3540
  }
2763
- else if (idle > this.limits.spaceIdleEvictMs) {
2764
- sp.store.close(sp.goals, sp.obligations, sp.profiles);
2765
- this.spaces.delete(name);
2766
- this.compactedAt.delete(name);
2767
- 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);
2768
3543
  }
2769
3544
  }
2770
3545
  }
@@ -2777,22 +3552,49 @@ pantheon signup --email you@work.com --username you</code></pre>
2777
3552
  catch { }
2778
3553
  }
2779
3554
  }
2780
- getOrCreateSpace(name, token) {
2781
- const existing = this.spaces.get(name);
2782
- if (existing)
2783
- 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;
2784
3567
  const safe = name.replace(/[^a-z0-9._-]/gi, "_");
2785
3568
  const allow = new Allowlist(this.dataDir, safe);
2786
3569
  const match = allow.tokenMatches(token);
2787
- if (match === false)
2788
- return null;
2789
- 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
+ }
2790
3587
  allow.bindToken(token);
3588
+ this.unbound.delete(safe);
3589
+ }
3590
+ const ledger = new Ledger(this.dataDir, safe);
2791
3591
  const store = new StateStore(this.dataDir, safe);
2792
- 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 });
2793
3595
  const sp = {
2794
- name, token,
2795
- ledger: new Ledger(this.dataDir, safe),
3596
+ name, token: raw,
3597
+ ledger,
2796
3598
  allow,
2797
3599
  store,
2798
3600
  members: new Map(),
@@ -2805,27 +3607,185 @@ pantheon signup --email you@work.com --username you</code></pre>
2805
3607
  createdAt: Date.now(),
2806
3608
  lastTouch: Date.now(),
2807
3609
  };
2808
- 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
+ }
2809
3662
  for (const e of allow.all()) {
2810
3663
  if (e.admitted && !sp.members.has(e.member)) {
2811
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: [] });
2812
3665
  }
2813
3666
  }
3667
+ this.rebuildRecent(sp);
2814
3668
  const open = [...sp.goals.values()].filter((g) => g.state !== "CLOSED").length;
2815
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
+ }
2816
3680
  this.compactSpace(sp);
2817
3681
  return sp;
2818
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
+ }
2819
3777
  compactSpace(sp) {
3778
+ this.compactedAt.set(sp.name, Date.now());
3779
+ if (sp.unclean)
3780
+ return;
2820
3781
  const isLive = (goal) => {
2821
3782
  if (!goal)
2822
3783
  return false;
2823
3784
  const g = sp.goals.get(goal);
2824
- return !!g && g.state !== "CLOSED";
3785
+ return !g || g.state !== "CLOSED";
2825
3786
  };
2826
3787
  const referenced = new Set([...sp.obligations.values()].map((o) => o.ref));
2827
- const { dropped, kept } = sp.ledger.compact({ keepRecent: LIMITS.ledgerKeepRecent, isLive, keep: (id) => referenced.has(id) });
2828
- 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) });
2829
3789
  if (dropped)
2830
3790
  this.log(`[${sp.name}] compacted ledger — dropped ${dropped} old act(s) from closed goals, ${kept} kept`);
2831
3791
  }
@@ -2862,6 +3822,9 @@ pantheon signup --email you@work.com --username you</code></pre>
2862
3822
  return sp.allow.all().map((e) => ({
2863
3823
  member: e.member, admitted: e.admitted, may_wake: e.may_wake,
2864
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 } : {}),
2865
3828
  }));
2866
3829
  }
2867
3830
  send(ws, msg) {