@join-pantheon/cli 0.29.1 → 0.31.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 (57) hide show
  1. package/README.md +142 -40
  2. package/bin/pantheon.js +12 -0
  3. package/dist/accounts.js +151 -26
  4. package/dist/agents.js +224 -17
  5. package/dist/claude-confine.js +299 -0
  6. package/dist/cli.js +1116 -137
  7. package/dist/client.js +294 -10
  8. package/dist/command-state.js +102 -0
  9. package/dist/config.js +287 -14
  10. package/dist/continued.js +42 -10
  11. package/dist/doctor.js +191 -23
  12. package/dist/file-lock.js +197 -0
  13. package/dist/grants.js +1 -1
  14. package/dist/history-view.js +2 -1
  15. package/dist/home-guard.js +565 -0
  16. package/dist/hooks-install.js +2 -1
  17. package/dist/identity.js +101 -1
  18. package/dist/install.js +76 -26
  19. package/dist/kiro-files.js +2 -2
  20. package/dist/ledger.js +111 -28
  21. package/dist/machine.js +494 -32
  22. package/dist/mcp-supervisor.js +29 -7
  23. package/dist/mcp-windows.js +47 -25
  24. package/dist/mcp.js +99 -36
  25. package/dist/network.js +39 -8
  26. package/dist/nonces.js +130 -0
  27. package/dist/notify.js +26 -9
  28. package/dist/pending.js +121 -22
  29. package/dist/persona.js +21 -0
  30. package/dist/pins.js +161 -0
  31. package/dist/profile.js +22 -1
  32. package/dist/protocol.js +40 -1
  33. package/dist/quickstart.js +173 -24
  34. package/dist/reads.js +37 -0
  35. package/dist/refusal.js +14 -1
  36. package/dist/relay.js +1341 -207
  37. package/dist/repo-find.js +127 -0
  38. package/dist/repo-route.js +70 -0
  39. package/dist/roster-file.js +190 -25
  40. package/dist/run-failure.js +99 -3
  41. package/dist/secret-guard.js +141 -0
  42. package/dist/service.js +91 -35
  43. package/dist/setup-guide.js +91 -31
  44. package/dist/spend.js +6 -2
  45. package/dist/store.js +94 -10
  46. package/dist/text-file.js +74 -0
  47. package/dist/tray.js +24 -3
  48. package/dist/untrusted.js +24 -5
  49. package/dist/update-check.js +2 -0
  50. package/dist/upgrade.js +9 -5
  51. package/dist/verify-act.js +102 -0
  52. package/dist/which.js +69 -12
  53. package/dist/worker-fleet.js +30 -9
  54. package/dist/worker-policy.js +231 -7
  55. package/dist/worker.js +1717 -130
  56. package/helper/macos/menubar.swift +63 -2
  57. 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
- import { badRemote, Inbound, isContinuationFor, LIMITS, newId, PROTOCOL_VERSION, SENDABLE, shouldDeferToHuman, } from "./protocol.js";
7
+ import { badRemote, Inbound, isContinuationFor, LIMITS, newId, PROTOCOL_VERSION, RELAY_FEATURES, 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, actPayload, goalPayload, verifyBytes, verifyDelegate, canonicalJson } 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,195 @@ 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;
211
+ function repoLabelSafe(v) {
212
+ if (!v)
213
+ return null;
214
+ const s = v.normalize("NFKC").replace(/[^A-Za-z0-9._@:/+~-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 100);
215
+ return s || null;
216
+ }
165
217
  const LIMITS_SPACE = { metaBytes: 8192, openGoals: 500, repos: 100, openInvites: 50 };
218
+ const SPACE_MAX_BYTES_DEFAULT = 50 * 1024 * 1024;
219
+ const SPACE_HARD_SLACK = 1024 * 1024;
220
+ const REFUSAL_BUDGET = 100;
221
+ const REFUSAL_WINDOW_MS = 10_000;
222
+ const RECENT_MAX = 5_000;
223
+ const DEVICE_OTHER_ACCOUNT = [
224
+ "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.",
225
+ "To sign back in to that account: pantheon login --email <its email>.",
226
+ "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.",
227
+ "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).",
228
+ ].join("\n");
229
+ const UPGRADE_NOTE = "This team now needs Pantheon 0.30 or newer to read its history — run: pantheon upgrade";
230
+ const UPGRADE_NOTE_ID = "a_pantheon_upgrade_required";
231
+ const RELAY_MEMBER = "pantheon";
232
+ function ledgerTailSeq(file) {
233
+ let fd = null;
234
+ try {
235
+ fd = openSync(file, "r");
236
+ const size = fstatSync(fd).size;
237
+ const len = Math.min(size, 256 * 1024);
238
+ const buf = Buffer.allocUnsafe(len);
239
+ readSync(fd, buf, 0, len, size - len);
240
+ const lines = buf.toString("utf8").split("\n");
241
+ for (let i = lines.length - 1; i >= 0; i--) {
242
+ try {
243
+ const r = JSON.parse(lines[i]);
244
+ if (typeof r.seq === "number")
245
+ return r.seq;
246
+ }
247
+ catch { }
248
+ }
249
+ return 0;
250
+ }
251
+ catch {
252
+ return 0;
253
+ }
254
+ finally {
255
+ if (fd !== null)
256
+ try {
257
+ closeSync(fd);
258
+ }
259
+ catch { }
260
+ }
261
+ }
262
+ function versionBelow(a, b) {
263
+ if (!a)
264
+ return true;
265
+ const pa = a.split(/[.-]/).map((x) => Number.parseInt(x, 10) || 0), pb = b.split(".").map(Number);
266
+ for (let i = 0; i < 3; i++) {
267
+ if ((pa[i] ?? 0) !== (pb[i] ?? 0))
268
+ return (pa[i] ?? 0) < (pb[i] ?? 0);
269
+ }
270
+ return false;
271
+ }
272
+ 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";
273
+ const STORAGE_UNHEALTHY_MS = 5 * 60_000;
274
+ function isStorageError(e) {
275
+ const code = e?.code;
276
+ return typeof code === "string" && (/^E[A-Z0-9]+$/.test(code) || code.startsWith("ERR_SQLITE"));
277
+ }
278
+ function buildCommit() {
279
+ const c = process.env.PANTHEON_COMMIT ?? process.env.RAILWAY_GIT_COMMIT_SHA ?? process.env.SOURCE_COMMIT ?? process.env.GIT_COMMIT ?? "";
280
+ return /^[0-9a-f]{7,40}$/i.test(c.trim()) ? c.trim() : null;
281
+ }
282
+ export function netOf(ip) {
283
+ const h = ip.replace(/^::ffff:/i, "");
284
+ const v4 = /^(\d+)\.(\d+)\.(\d+)\.\d+$/.exec(h);
285
+ if (v4)
286
+ return `${v4[1]}.${v4[2]}.${v4[3]}.0/24`;
287
+ if (h.includes(":")) {
288
+ const [head, tail] = h.split("::");
289
+ const a = head ? head.split(":") : [];
290
+ const b = tail !== undefined ? (tail ? tail.split(":") : []) : [];
291
+ const groups = tail !== undefined ? [...a, ...Array(Math.max(0, 8 - a.length - b.length)).fill("0"), ...b] : a;
292
+ return `${groups.slice(0, 4).map((g) => (g || "0").toLowerCase().replace(/^0+(?=.)/, "")).join(":")}::/64`;
293
+ }
294
+ return h;
295
+ }
296
+ function idIsFresh(id) {
297
+ const body = /^a_([a-z0-9]+)[0-9a-f]{18}$/.exec(id)?.[1];
298
+ const t = body ? parseInt(body, 36) : NaN;
299
+ return Number.isFinite(t) && Math.abs(Date.now() - t) < DAY;
300
+ }
301
+ function actSigProblem(m, member, memberPub, teams, delegated) {
302
+ const sig = m.sig;
303
+ if (!sig)
304
+ return "a woken run's session must sign every act";
305
+ if (canonicalJson(sig.meta ?? {}) !== canonicalJson(m.meta ?? {}))
306
+ return "the signed meta is not the meta sent";
307
+ if ((sig.repo ?? null) !== (m.repo ?? null))
308
+ return "the signed repo is not the repo sent";
309
+ const keyOk = sigKeyProblem(sig, member, memberPub, teams, delegated);
310
+ if (keyOk)
311
+ return keyOk;
312
+ if (!m.id)
313
+ return "a signed act names its own id";
314
+ if (Math.abs(Date.now() - sig.ts) > DAY)
315
+ return "signed at a time more than a day from now — check this machine's clock";
316
+ const ok = teams.filter((t) => !!t).some((team) => verifyBytes(sig.pub, actPayload({
317
+ id: m.id, team, member, keyRole: sig.role, nonce: sig.nonce, ts: sig.ts,
318
+ act: m.act, goal: m.goal ?? null, ref: m.ref ?? null, to: m.to ?? null,
319
+ defers_to: m.defers_to ?? null, affects: m.affects ?? null, repo: m.repo ?? null,
320
+ summary: m.summary ?? null, body: m.body, meta: m.meta ?? {},
321
+ }), sig.s));
322
+ return ok ? null : "the signature does not match this act";
323
+ }
324
+ function goalSigProblem(m, member, memberPub, teams, delegated) {
325
+ const sig = m.sig;
326
+ if (!sig)
327
+ return "a woken run's session must sign the goals it opens";
328
+ const inviteSent = m.invite ? [...new Set(m.invite)].sort().join(",") : null;
329
+ const inviteSigned = sig.invite === null ? null : [...new Set(sig.invite)].sort().join(",");
330
+ if (inviteSent !== inviteSigned)
331
+ return "the signed invitations are not the ones sent";
332
+ if ((sig.budget ?? null) !== (m.budget ?? null) || (sig.deadline ?? null) !== (m.deadline ?? null) || (sig.kind ?? null) !== (m.kind ?? null))
333
+ return "the signed budget, deadline or kind is not what was sent";
334
+ const keyOk = sigKeyProblem(sig, member, memberPub, teams, delegated);
335
+ if (keyOk)
336
+ return keyOk;
337
+ if (!m.id)
338
+ return "a signed goal names its own id";
339
+ if (Math.abs(Date.now() - sig.ts) > DAY)
340
+ return "signed at a time more than a day from now — check this machine's clock";
341
+ const ok = teams.filter((t) => !!t).some((team) => verifyBytes(sig.pub, goalPayload({
342
+ id: m.id, team, member, keyRole: sig.role, nonce: sig.nonce, ts: sig.ts,
343
+ title: m.title, acceptance: m.acceptance ?? "", invite: sig.invite,
344
+ budget: sig.budget ?? null, deadline: sig.deadline ?? null, kind: sig.kind ?? null,
345
+ }), sig.s));
346
+ return ok ? null : "the signature does not match this goal";
347
+ }
348
+ function sigKeyProblem(sig, member, memberPub, teams, delegated) {
349
+ if (delegated) {
350
+ if (sig.role !== "run" || sig.pub !== delegated.pub || sig.cert?.pub !== delegated.pub)
351
+ return "a woken run signs with the key its session was opened with";
352
+ }
353
+ if (sig.role === "member")
354
+ return sig.pub === memberPub ? null : `signed with a key that is not ${member}'s`;
355
+ if (!sig.cert || sig.cert.pub !== sig.pub)
356
+ return "a run's signature needs its certificate";
357
+ const c = sig.cert;
358
+ return teams.filter((t) => !!t).some((t) => verifyDelegate(memberPub, t, member, c)) ? null : `that run key is not vouched for by ${member}, or it has expired`;
359
+ }
360
+ function withoutFromRepo(meta) {
361
+ if (!meta || typeof meta !== "object")
362
+ return {};
363
+ const { from_repo: _f, ...rest } = meta;
364
+ return rest;
365
+ }
166
366
  export class Relay {
167
367
  opts;
168
368
  spaces = new Map();
@@ -170,9 +370,22 @@ export class Relay {
170
370
  log;
171
371
  http = createServer((req, res) => {
172
372
  const fail = (e) => {
373
+ if (e instanceof RosterUnreadable) {
374
+ this.log(`!! ${e.message} — reads of this space are refused until it is repaired (it was left in place)`);
375
+ try {
376
+ if (!res.headersSent)
377
+ 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." });
378
+ else
379
+ res.end();
380
+ }
381
+ catch { }
382
+ return;
383
+ }
173
384
  const bad = e instanceof TypeError || e instanceof URIError;
174
385
  if (!bad)
175
386
  this.log(`! http ${req.method} ${String(req.url ?? "").slice(0, 120)} failed: ${e?.message ?? e}`);
387
+ if (isStorageError(e))
388
+ this.noteStorageFailure(`http ${String(req.url ?? "").split("?")[0].slice(0, 60)}`, e);
176
389
  try {
177
390
  if (!res.headersSent)
178
391
  this.json(res, bad ? 400 : 500, { error: bad ? "bad request" : "the relay could not handle that request" });
@@ -202,20 +415,33 @@ export class Relay {
202
415
  pruneMs;
203
416
  accounts;
204
417
  requireAccounts;
418
+ readsMode;
205
419
  trustLocal;
206
420
  email;
207
421
  authHits = new Map();
208
422
  adminHits = new Map();
209
423
  emailSends = [];
424
+ emailSendsTo = new Map();
425
+ emailSendsFrom = new Map();
426
+ storageFailure = null;
427
+ unbound = new Set();
428
+ maxLedgerBytes;
210
429
  adminToken;
211
430
  adminAllow;
212
431
  startedAt = Date.now();
213
432
  deletedSpaces = new Map();
433
+ authEpoch = 0;
434
+ rotateOnRemove;
214
435
  backupTimer;
436
+ get features() { return this.opts.features ?? RELAY_FEATURES; }
215
437
  constructor(opts = {}) {
216
438
  this.opts = opts;
217
439
  this.dataDir = opts.dataDir ?? "./pantheon-data";
218
440
  this.log = opts.log ?? ((l) => console.error(l));
441
+ ensurePrivateDir(this.dataDir);
442
+ this.maxLedgerBytes = opts.spaceMaxBytes
443
+ ?? (Number(process.env.PANTHEON_SPACE_MAX_BYTES) > 0 ? Number(process.env.PANTHEON_SPACE_MAX_BYTES) : SPACE_MAX_BYTES_DEFAULT);
444
+ this.scanUnbound();
219
445
  this.requireAccounts = opts.requireAccounts ?? process.env.PANTHEON_REQUIRE_ACCOUNTS === "1";
220
446
  this.trustLocal = process.env.PANTHEON_TRUST_LOCAL !== undefined
221
447
  ? process.env.PANTHEON_TRUST_LOCAL === "1"
@@ -243,8 +469,14 @@ export class Relay {
243
469
  spaceJunkDeleteMs: opts.spaceLimits?.spaceJunkDeleteMs ?? LIMITS.spaceJunkDeleteMs,
244
470
  spaceMinAgeMs: opts.spaceLimits?.spaceMinAgeMs ?? LIMITS.spaceMinAgeMs,
245
471
  };
472
+ this.rotateOnRemove = opts.rotateOnRemove ?? process.env.PANTHEON_ROTATE_ON_REMOVE === "1";
246
473
  this.busyTtlMs = opts.activityBusyTtlMs ?? LIMITS.activityBusyTtlMs;
247
474
  this.pruneMs = opts.activityPruneMs ?? LIMITS.activityPruneMs;
475
+ const readsEnv = (process.env.PANTHEON_READS ?? "").trim().toLowerCase();
476
+ this.readsMode = opts.reads ?? (readsEnv === "" || readsEnv === "transition" ? "transition" : "signed");
477
+ if (!opts.reads && readsEnv && readsEnv !== "transition" && readsEnv !== "signed") {
478
+ this.log(`! PANTHEON_READS="${process.env.PANTHEON_READS}" is not "signed" or "transition" — using "signed"`);
479
+ }
248
480
  this.http.on("upgrade", (req, socket, head) => {
249
481
  const ip = clientIp(req);
250
482
  this.wss.handleUpgrade(req, socket, head, (ws) => this.onSocket(ws, ip));
@@ -267,8 +499,14 @@ export class Relay {
267
499
  });
268
500
  }
269
501
  async close() {
270
- for (const sp of this.spaces.values())
271
- sp.store.close(sp.goals, sp.obligations, sp.profiles);
502
+ for (const sp of this.spaces.values()) {
503
+ try {
504
+ sp.store.close(sp.goals, sp.obligations, sp.profiles);
505
+ }
506
+ catch (e) {
507
+ this.noteStorageFailure(`[${sp.name}] final flush`, e);
508
+ }
509
+ }
272
510
  if (this.backupTimer)
273
511
  clearInterval(this.backupTimer);
274
512
  if (this.accounts && !this.opts.accounts)
@@ -280,6 +518,48 @@ export class Relay {
280
518
  await new Promise((r) => this.wss.close(() => r()));
281
519
  await new Promise((r) => this.http.close(() => r()));
282
520
  }
521
+ noteStorageFailure(where, e) {
522
+ const message = e?.message ?? String(e);
523
+ this.log(`! storage: ${where} failed: ${message}`);
524
+ if (isStorageError(e))
525
+ this.storageFailure = { at: Date.now(), where, message };
526
+ }
527
+ scanUnbound() {
528
+ let files = [];
529
+ try {
530
+ files = readdirSync(this.dataDir);
531
+ }
532
+ catch {
533
+ return;
534
+ }
535
+ for (const f of files) {
536
+ const safe = f.endsWith(".members.json") ? f.slice(0, -".members.json".length) : f.endsWith(".jsonl") ? f.slice(0, -".jsonl".length) : null;
537
+ if (safe === null || this.unbound.has(safe))
538
+ continue;
539
+ try {
540
+ if (this.unboundOnDisk(safe))
541
+ this.unbound.add(safe);
542
+ }
543
+ catch { }
544
+ }
545
+ if (this.unbound.size) {
546
+ this.log(`! ${this.unbound.size} space(s) on disk have no bound token: ${[...this.unbound].sort().join(", ")}. ` +
547
+ `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).`);
548
+ }
549
+ }
550
+ unboundOnDisk(safe) {
551
+ const size = (suf) => { try {
552
+ return statSync(join(this.dataDir, `${safe}${suf}`)).size;
553
+ }
554
+ catch {
555
+ return -1;
556
+ } };
557
+ const ledger = size(".jsonl");
558
+ if (size(".members.json") < 0)
559
+ return ledger > 0;
560
+ const al = new Allowlist(this.dataDir, safe);
561
+ return !al.hasToken && (!al.empty || ledger > 0);
562
+ }
283
563
  onHttp(req, res) {
284
564
  const url = new URL(req.url ?? "/", "http://x");
285
565
  const ip = clientIp(req);
@@ -298,11 +578,16 @@ export class Relay {
298
578
  seq: s.ledger.head,
299
579
  });
300
580
  const asked = url.searchParams.get("space");
301
- const one = asked ? this.spaces.get(asked) : undefined;
581
+ const one = asked ? this.live(asked) : undefined;
582
+ const sf = this.storageFailure && Date.now() - this.storageFailure.at < STORAGE_UNHEALTHY_MS ? this.storageFailure : null;
583
+ const commit = buildCommit();
302
584
  return this.json(res, 200, {
303
- ok: true,
585
+ ok: !sf,
586
+ ...(sf ? { storage: { error: sf.message, where: sf.where, at: new Date(sf.at).toISOString() } } : {}),
304
587
  v: PROTOCOL_VERSION,
305
588
  pkg: VERSION,
589
+ ...(commit ? { commit } : {}),
590
+ ...(adminView && this.unbound.size ? { unbound_spaces: [...this.unbound].sort() } : {}),
306
591
  space_count: this.spaces.size,
307
592
  space_capacity: this.limits.maxSpaces,
308
593
  ...(adminView ? { spaces: [...this.spaces.values()].map(detail) }
@@ -322,10 +607,12 @@ export class Relay {
322
607
  : this.json(res, 404, { error: "no open invite matches that code — it may have been used already or expired" });
323
608
  }
324
609
  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" });
610
+ const gate = this.readGate(req, url, ip);
611
+ if (!gate.ok && gate.notice)
612
+ return this.upgradeAnswer(res, url.pathname, url, gate.notice, gate.error);
613
+ if (!gate.ok)
614
+ return this.json(res, gate.status, { error: gate.error, ...(gate.code ? { code: gate.code } : {}) });
615
+ const space = gate.space;
329
616
  const act = space.ledger.find(url.searchParams.get("id") ?? "");
330
617
  if (!act)
331
618
  return this.json(res, 404, { error: "no such act" });
@@ -333,10 +620,12 @@ export class Relay {
333
620
  return this.json(res, 200, { act, ...(goal ? { goal: { id: goal.id, title: goal.title, state: goal.state } } : {}) });
334
621
  }
335
622
  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" });
623
+ const gate = this.readGate(req, url, ip);
624
+ if (!gate.ok && gate.notice)
625
+ return this.upgradeAnswer(res, url.pathname, url, gate.notice, gate.error);
626
+ if (!gate.ok)
627
+ return this.json(res, gate.status, { error: gate.error, ...(gate.code ? { code: gate.code } : {}) });
628
+ const space = gate.space;
340
629
  const goal = url.searchParams.get("goal");
341
630
  const sinceRaw = url.searchParams.get("since");
342
631
  const acts = goal ? space.ledger.byGoal(goal, LIMITS_GOAL_READ)
@@ -344,49 +633,67 @@ export class Relay {
344
633
  return this.json(res, 200, { acts, head: space.ledger.head });
345
634
  }
346
635
  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" });
636
+ const gate = this.readGate(req, url, ip);
637
+ if (!gate.ok && gate.notice)
638
+ return this.upgradeAnswer(res, url.pathname, url, gate.notice, gate.error);
639
+ if (!gate.ok)
640
+ return this.json(res, gate.status, { error: gate.error, ...(gate.code ? { code: gate.code } : {}) });
641
+ const space = gate.space;
351
642
  const member = url.searchParams.get("member");
352
643
  const now = Date.now();
353
644
  const owed = [...space.obligations.values()]
354
645
  .filter((o) => (member ? o.owner === member : true))
355
- .map((o) => ({ ...o, claimed: !!o.claimed_by && now - o.claimed_by.at < LIMITS.claimTtlMs }));
646
+ .map((o) => ({ ...o, claimed: !!o.claimed_by && this.claimHolds(space, o.claimed_by, now) }));
356
647
  const closed = [...space.goals.values()].filter((g) => g.state === "CLOSED").map((g) => g.id);
357
648
  const worker_online = member
358
649
  ? [...(space.members.get(member)?.sessions.values() ?? [])].some((s) => s.role === "worker")
359
650
  : false;
360
- return this.json(res, 200, { obligations: owed, closed_goals: closed, seq: space.ledger.head, worker_online });
651
+ return this.json(res, 200, { obligations: owed, closed_goals: closed, seq: space.ledger.head, worker_online, space_id: space.allow.spaceId(), members: this.members(space) });
361
652
  }
362
653
  if (url.pathname === "/activity") {
363
654
  if (req.method !== "POST")
364
655
  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))
656
+ const spaceName = url.searchParams.get("space") ?? "", spaceToken = url.searchParams.get("token") ?? "";
657
+ const target = this.readTarget(spaceName, spaceToken);
658
+ if (!target)
368
659
  return this.json(res, 404, { error: "no such space" });
369
660
  const memberId = url.searchParams.get("member") ?? "";
370
- const member = space.members.get(memberId);
371
661
  const session = (url.searchParams.get("session") ?? "").slice(0, 64);
372
662
  const state = url.searchParams.get("state");
373
- if (!member || !session || (state !== "busy" && state !== "idle")) {
663
+ if (!memberId || !session || (state !== "busy" && state !== "idle")) {
374
664
  return this.json(res, 400, { error: "member, session and state=busy|idle are required" });
375
665
  }
376
666
  const ts = Number(url.searchParams.get("ts"));
377
667
  const sig = url.searchParams.get("sig") ?? "";
378
668
  const seenParam = url.searchParams.get("seen") ?? "";
379
- const pub = space.allow.pubkeyOf(memberId);
669
+ const pub = target.allow.pubkeyOf(memberId);
380
670
  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)) {
671
+ if (!pub || !fresh || !verifyActivity(pub, spaceName, memberId, session, state, seenParam, ts, sig)) {
382
672
  return this.json(res, 403, { error: "activity report must be signed by the member's key and recent" });
383
673
  }
674
+ if (this.requireAccounts && this.accounts && !this.localTrusted(ip)) {
675
+ const auth = this.accounts.authorize(pub, memberId);
676
+ if (!auth.ok) {
677
+ return this.json(res, 403, { code: "NOT_SIGNED_IN", error: auth.reason === "suspended"
678
+ ? "this account has been suspended by the relay operator"
679
+ : "this device is not signed in to an account that may report here — run pantheon login" });
680
+ }
681
+ }
682
+ const space = target.live ?? this.getOrCreateSpace(spaceName, spaceToken, { retired: true });
683
+ if (!space)
684
+ return this.json(res, 404, { error: "no such space" });
685
+ const member = space.members.get(memberId);
686
+ if (!member)
687
+ return this.json(res, 400, { error: "member, session and state=busy|idle are required" });
384
688
  if (member.activity.size > 64 && !member.activity.has(session)) {
385
689
  const oldest = [...member.activity.entries()].sort((a, b) => a[1].at - b[1].at)[0];
386
690
  if (oldest)
387
691
  member.activity.delete(oldest[0]);
388
692
  }
389
- member.activity.set(session, { busy: state === "busy", at: Date.now() });
693
+ const prev = member.activity.get(session);
694
+ if (prev?.ts !== undefined && ts < prev.ts)
695
+ return this.json(res, 409, { error: "an activity report older than the last one from this session — ignored" });
696
+ member.activity.set(session, { busy: state === "busy", at: Date.now(), ts });
390
697
  const seen = Number(url.searchParams.get("seen"));
391
698
  if (Number.isFinite(seen) && seen > (member.seen_seq ?? 0))
392
699
  member.seen_seq = seen;
@@ -437,7 +744,7 @@ export class Relay {
437
744
  res.writeHead(429, { "content-type": "text/html; charset=utf-8", "retry-after": "60" });
438
745
  return void res.end("<p>Too many attempts. Wait a minute and try again.</p>");
439
746
  }
440
- return this.readForm(req, (body) => {
747
+ return this.readForm(req, res, (body) => {
441
748
  if (!tokenOk(body.token ?? "")) {
442
749
  this.noteAdminFail(ip);
443
750
  A0.audit({ event: "admin_login_failed", ip });
@@ -479,7 +786,7 @@ export class Relay {
479
786
  if (url.pathname === "/admin/action" && req.method === "POST") {
480
787
  if (!authed)
481
788
  return this.json(res, 403, { error: "sign in at /admin" });
482
- return this.readForm(req, (body) => this.adminAction(res, A0, body, ip));
789
+ return this.readForm(req, res, (body) => this.adminAction(res, A0, body, ip));
483
790
  }
484
791
  if (url.pathname === "/admin/space") {
485
792
  if (!authed)
@@ -498,7 +805,7 @@ export class Relay {
498
805
  if (url.pathname === "/auth/signup" && req.method === "POST") {
499
806
  if (!this.authRateOk(ip))
500
807
  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) => {
808
+ return this.readJson(req, res, async (b) => {
502
809
  if (!b || typeof b.email !== "string" || typeof b.username !== "string" || typeof b.pubkey !== "string") {
503
810
  return this.json(res, 400, { error: "email, username and pubkey are required" });
504
811
  }
@@ -506,63 +813,74 @@ export class Relay {
506
813
  return this.json(res, 400, { error: "that does not look like an email address" });
507
814
  if (!validUsername(normalizeUsername(b.username)))
508
815
  return this.json(res, 400, { error: "username must be 2–32 chars: letters, digits, . _ - (not at the ends)" });
816
+ const over = this.emailRefusal("signup", b.email, ip);
817
+ if (over)
818
+ return this.json(res, 429, { error: over, reason: "email_budget" });
509
819
  const started = A.startVerification(b.email, "signup", { username: b.username, pubkey: b.pubkey, label: String(b.label ?? "a device").slice(0, 60) });
510
820
  if (!started.ok) {
511
821
  if (started.reason === "blocked")
512
822
  return this.json(res, 403, { error: "this email cannot sign up on this relay", reason: "blocked" });
823
+ if (started.reason === "locked")
824
+ return this.json(res, 429, { error: LOCKED_MSG, reason: started.reason });
513
825
  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"
826
+ : started.reason === "username_taken" ? "that username is taken"
827
+ : started.reason === "email_taken" ? "that email already has an account — use `pantheon login` from this machine instead"
828
+ : started.reason === "device_other_account" ? DEVICE_OTHER_ACCOUNT
517
829
  : "check the email and username";
518
830
  return this.json(res, 409, { error: msg, reason: started.reason });
519
831
  }
520
832
  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
833
  const sent = await this.email.sendCode(b.email, started.code, "signup");
524
834
  if (sent)
525
- this.emailSends.push(Date.now());
835
+ this.noteEmailSent("signup", b.email, ip);
526
836
  return this.json(res, 200, { ok: true, sent, via: this.email.kind });
527
837
  });
528
838
  }
529
839
  if (url.pathname === "/auth/login" && req.method === "POST") {
530
840
  if (!this.authRateOk(ip))
531
841
  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) => {
842
+ return this.readJson(req, res, async (b) => {
533
843
  if (!b || typeof b.email !== "string" || typeof b.pubkey !== "string")
534
844
  return this.json(res, 400, { error: "email and pubkey are required" });
845
+ const own = A.isDeviceOf(b.email, b.pubkey);
846
+ const over = this.emailRefusal("login", b.email, ip, b.pubkey, own);
847
+ if (over)
848
+ return this.json(res, 429, { error: over, reason: "email_budget" });
535
849
  const started = A.startVerification(b.email, "login", { pubkey: b.pubkey, label: String(b.label ?? "a device").slice(0, 60) });
536
850
  if (!started.ok && started.reason === "blocked")
537
851
  return this.json(res, 403, { error: "this email cannot sign in on this relay", reason: "blocked" });
852
+ if (!started.ok && started.reason === "device_other_account")
853
+ return this.json(res, 409, { error: DEVICE_OTHER_ACCOUNT, reason: started.reason });
538
854
  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";
855
+ const code = started.reason === "too_soon" || started.reason === "locked" ? 429 : 404;
856
+ 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
857
  return this.json(res, code, { error: msg, reason: started.reason });
542
858
  }
543
- if (!this.emailBudgetOk())
544
- return this.json(res, 429, { error: "the relay is sending too many codes right now — try again shortly" });
545
859
  const sent = await this.email.sendCode(b.email, started.code, "login");
546
860
  if (sent)
547
- this.emailSends.push(Date.now());
861
+ this.noteEmailSent("login", b.email, ip, b.pubkey, own);
548
862
  return this.json(res, 200, { ok: true, sent, via: this.email.kind });
549
863
  });
550
864
  }
551
865
  if (url.pathname === "/auth/verify" && req.method === "POST") {
552
866
  if (!this.authRateOk(ip))
553
867
  return this.json(res, 429, { error: "too many attempts from your network — wait a minute and try again" });
554
- return this.readJson(req, (b) => {
868
+ return this.readJson(req, res, (b) => {
555
869
  if (!b || typeof b.email !== "string" || typeof b.code !== "string")
556
870
  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);
871
+ const r = A.verify(b.email, b.code.trim(), ip, typeof b.pubkey === "string" ? b.pubkey : undefined, this.localTrusted(ip) ? null : netOf(ip));
558
872
  if (!r.ok) {
559
873
  const msg = r.reason === "expired" ? "that code has expired — request a new one"
560
874
  : r.reason === "other_device" ? "that code was requested from a different device — request a new one from this one"
561
875
  : r.reason === "suspended" ? "this account is suspended — contact the relay operator"
562
876
  : 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 });
877
+ : r.reason === "locked" ? LOCKED_MSG
878
+ : 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"
879
+ : r.reason === "device_other_account" ? DEVICE_OTHER_ACCOUNT
880
+ : r.reason === "no_pending" ? "no code is pending for that email — run signup or login first"
881
+ : r.tries_left === 0 ? "that code is not right, and it was the last try — request a new code"
882
+ : `that code is not right — ${r.tries_left ?? "a few"} ${r.tries_left === 1 ? "try" : "tries"} left on it`;
883
+ return this.json(res, 400, { error: msg, reason: r.reason, ...(r.tries_left !== undefined ? { tries_left: r.tries_left } : {}) });
566
884
  }
567
885
  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
886
  });
@@ -587,8 +905,10 @@ export class Relay {
587
905
  const hit = A.revoke(who.account.id, kind, id, ip);
588
906
  for (const sid of hit.sessionIds)
589
907
  this.dropSessionEverywhere(sid);
590
- if (hit.deviceId)
908
+ if (hit.deviceId) {
591
909
  this.dropDeviceSockets(who.account.id, hit.deviceId);
910
+ this.noteAccountRemoval(who.account.username);
911
+ }
592
912
  return this.json(res, 200, { ok: true, revoked: hit.sessionIds.length, device_removed: !!hit.deviceId });
593
913
  }
594
914
  if (url.pathname === "/account/delete" && req.method === "POST") {
@@ -599,6 +919,7 @@ export class Relay {
599
919
  A.deleteAccount(who.account.id, ip);
600
920
  for (const s of sessions)
601
921
  this.dropSessionEverywhere(s.id);
922
+ this.noteAccountRemoval(who.account.username);
602
923
  return this.json(res, 200, { ok: true });
603
924
  }
604
925
  if (url.pathname === "/audit" && req.method === "GET") {
@@ -607,7 +928,7 @@ export class Relay {
607
928
  return this.json(res, 403, { error: "this request must be signed by a device on the account" });
608
929
  const space = url.searchParams.get("space") ?? undefined;
609
930
  if (space) {
610
- const sp = this.spaces.get(space);
931
+ const sp = this.live(space);
611
932
  const member = sp?.allow.get(who.account.username);
612
933
  if (!sp || !member?.admitted)
613
934
  return this.json(res, 403, { error: "you are not a member of that space" });
@@ -675,11 +996,47 @@ export class Relay {
675
996
  }
676
997
  }
677
998
  }
678
- emailBudgetOk() {
679
- const now = Date.now();
680
- this.emailSends = this.emailSends.filter((t) => now - t < 3_600_000);
999
+ emailRefusal(kind, email, ip, pubkey = "", own = false) {
1000
+ const now = Date.now(), hour = 3_600_000;
1001
+ const live = (v) => v.filter((t) => now - t < hour);
1002
+ this.emailSends = this.emailSends.filter((s) => now - s.at < hour);
681
1003
  const cap = Number(process.env.PANTHEON_EMAIL_MAX_PER_HOUR) || 200;
682
- return this.emailSends.length < cap;
1004
+ const reserved = Math.min(cap - 1, Math.max(0, Number(process.env.PANTHEON_EMAIL_RESERVED_PER_HOUR ?? Math.floor(cap / 4)) || 0));
1005
+ const perAddress = Number(process.env.PANTHEON_EMAIL_MAX_PER_ADDRESS) || 5;
1006
+ const perNet = Number(process.env.PANTHEON_EMAIL_MAX_PER_NETWORK) || 20;
1007
+ if (live(this.emailSendsTo.get(this.emailBucket(email, pubkey, own)) ?? []).length >= perAddress) {
1008
+ return own ? "this device has asked for too many codes in the last hour — use the latest one, or try again in an hour"
1009
+ : "too many codes were sent to this address in the last hour — use the latest one, or try again in an hour";
1010
+ }
1011
+ if (!own && !this.localTrusted(ip) && live(this.emailSendsFrom.get(netOf(ip)) ?? []).length >= perNet)
1012
+ return "too many codes were requested from your network in the last hour — try again later";
1013
+ if (this.emailSends.length >= cap)
1014
+ return "the relay is sending too many codes right now — try again shortly";
1015
+ if (kind === "signup" && this.emailSends.filter((s) => s.signup).length >= cap - reserved)
1016
+ return "the relay is taking too many new sign-ups right now — try again shortly";
1017
+ return null;
1018
+ }
1019
+ emailBucket(email, pubkey, own) {
1020
+ const to = email.trim().toLowerCase();
1021
+ return own ? `${to}\u0000${pubkey}` : to;
1022
+ }
1023
+ noteEmailSent(kind, email, ip, pubkey = "", own = false) {
1024
+ const now = Date.now(), hour = 3_600_000;
1025
+ this.emailSends.push({ at: now, signup: kind === "signup" });
1026
+ const bump = (m, k) => {
1027
+ m.set(k, [...(m.get(k) ?? []).filter((t) => now - t < hour), now]);
1028
+ if (m.size > 10_000)
1029
+ for (const [key, v] of m) {
1030
+ const keep = v.filter((t) => now - t < hour);
1031
+ if (keep.length)
1032
+ m.set(key, keep);
1033
+ else
1034
+ m.delete(key);
1035
+ }
1036
+ };
1037
+ bump(this.emailSendsTo, this.emailBucket(email, pubkey, own));
1038
+ if (!own && !this.localTrusted(ip))
1039
+ bump(this.emailSendsFrom, netOf(ip));
683
1040
  }
684
1041
  authRateOk(ip) {
685
1042
  if (this.localTrusted(ip))
@@ -699,41 +1056,134 @@ export class Relay {
699
1056
  }
700
1057
  return recent.length <= 20;
701
1058
  }
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) : {});
1059
+ readBody(req, res, limit, cb) {
1060
+ const HARD = 4 * 1024 * 1024;
1061
+ let done = false;
1062
+ const tooBig = () => {
1063
+ if (done)
1064
+ return;
1065
+ done = true;
1066
+ if (!res.headersSent) {
1067
+ res.setHeader("connection", "close");
1068
+ this.json(res, 413, { error: `request body too large: the limit here is ${limit} bytes` });
1069
+ }
1070
+ };
1071
+ const declared = Number(req.headers["content-length"]);
1072
+ if (Number.isFinite(declared) && declared > HARD) {
1073
+ tooBig();
1074
+ req.resume();
1075
+ res.on("finish", () => req.destroy());
1076
+ return;
1077
+ }
1078
+ const chunks = [];
1079
+ let size = 0, over = false;
1080
+ req.on("data", (c) => {
1081
+ size += c.length;
1082
+ if (size > HARD) {
1083
+ tooBig();
1084
+ res.on("finish", () => req.destroy());
1085
+ return;
1086
+ }
1087
+ if (size > limit) {
1088
+ over = true;
1089
+ chunks.length = 0;
1090
+ return;
1091
+ }
1092
+ if (!over)
1093
+ chunks.push(Buffer.isBuffer(c) ? c : Buffer.from(c));
1094
+ });
1095
+ req.on("end", () => {
1096
+ if (over)
1097
+ return tooBig();
1098
+ if (done)
1099
+ return;
1100
+ done = true;
1101
+ cb(Buffer.concat(chunks).toString("utf8"));
1102
+ });
1103
+ req.on("error", () => { if (done)
1104
+ return; done = true; cb(null); });
1105
+ }
1106
+ settle(res, r) {
1107
+ if (r && typeof r.catch === "function") {
1108
+ r.catch((e) => {
1109
+ this.log(`! http handler failed: ${e?.message ?? e}`);
1110
+ if (isStorageError(e))
1111
+ this.noteStorageFailure("http handler", e);
1112
+ try {
1113
+ if (!res.headersSent)
1114
+ this.json(res, 500, { error: "the relay could not handle that request" });
1115
+ else
1116
+ res.end();
1117
+ }
1118
+ catch { }
1119
+ });
708
1120
  }
709
- catch {
710
- cb(null);
711
- } });
712
- req.on("error", () => cb(null));
1121
+ }
1122
+ readJson(req, res, cb) {
1123
+ this.readBody(req, res, 64_000, (raw) => {
1124
+ let body = null;
1125
+ if (raw !== null) {
1126
+ try {
1127
+ body = raw ? JSON.parse(raw) : {};
1128
+ }
1129
+ catch {
1130
+ body = null;
1131
+ }
1132
+ }
1133
+ try {
1134
+ this.settle(res, cb(body));
1135
+ }
1136
+ catch (e) {
1137
+ this.settle(res, Promise.reject(e));
1138
+ }
1139
+ });
713
1140
  }
714
1141
  dropSessionEverywhere(sessionId) {
1142
+ this.authEpoch++;
715
1143
  for (const sp of this.spaces.values()) {
716
1144
  for (const m of sp.members.values()) {
717
1145
  const s = m.sessions.get(sessionId);
718
1146
  if (s) {
719
- try {
720
- s.ws.close(1008, "SESSION_REVOKED");
721
- }
722
- catch { }
1147
+ this.hangUp(s.ws, "SESSION_REVOKED");
723
1148
  m.sessions.delete(sessionId);
724
1149
  }
725
1150
  }
726
1151
  }
727
1152
  }
1153
+ hangUp(ws, reason) {
1154
+ try {
1155
+ ws.close(1008, reason);
1156
+ }
1157
+ catch { }
1158
+ const t = setTimeout(() => { try {
1159
+ ws.terminate();
1160
+ }
1161
+ catch { } }, 100);
1162
+ t.unref?.();
1163
+ }
1164
+ stillIn(sp, me, sessionId, ws) {
1165
+ if (ws.readyState !== 1)
1166
+ return false;
1167
+ if (sp.deleted || this.spaces.get(spaceKey(sp.name)) !== sp)
1168
+ return false;
1169
+ if (sp.members.get(me.id) !== me || me.sessions.get(sessionId)?.ws !== ws)
1170
+ return false;
1171
+ return !!sp.allow.get(me.id)?.admitted;
1172
+ }
728
1173
  dropDeviceSockets(_accountId, _deviceId) {
729
1174
  }
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({}));
1175
+ readForm(req, res, cb) {
1176
+ this.readBody(req, res, 8_000, (raw) => {
1177
+ const o = {};
1178
+ for (const [k, v] of new URLSearchParams(raw ?? ""))
1179
+ o[k] = v;
1180
+ try {
1181
+ this.settle(res, cb(o));
1182
+ }
1183
+ catch (e) {
1184
+ this.settle(res, Promise.reject(e));
1185
+ }
1186
+ });
737
1187
  }
738
1188
  adminLoginPage(res, wrong) {
739
1189
  res.writeHead(wrong ? 401 : 200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store", "referrer-policy": "no-referrer" });
@@ -785,7 +1235,7 @@ button:hover{background:#1d4ed8}.err{color:var(--bad);margin-top:12px}
785
1235
  if (exact(sp.allow))
786
1236
  return sp.name;
787
1237
  const safeOf = (name) => name.replace(/[^a-z0-9._-]/gi, "_");
788
- const live = new Set([...this.spaces.keys()].map(safeOf));
1238
+ const live = new Set(this.spaces.keys());
789
1239
  let files = [];
790
1240
  try {
791
1241
  files = readdirSync(this.dataDir);
@@ -797,7 +1247,7 @@ button:hover{background:#1d4ed8}.err{color:var(--bad);margin-top:12px}
797
1247
  if (!f.endsWith(".members.json"))
798
1248
  continue;
799
1249
  const safe = f.slice(0, -".members.json".length);
800
- if (live.has(safe))
1250
+ if (live.has(safe.toLowerCase()))
801
1251
  continue;
802
1252
  try {
803
1253
  if (!exact(new Allowlist(this.dataDir, safe)))
@@ -939,28 +1389,27 @@ button:hover{background:#1d4ed8}.err{color:var(--bad);margin-top:12px}
939
1389
  return { goals, refusals: st.refusals };
940
1390
  }
941
1391
  deleteSpace(name) {
942
- const sp = this.spaces.get(name);
1392
+ const sp = this.spaces.get(spaceKey(name));
943
1393
  if (sp) {
944
1394
  sp.deleted = true;
945
1395
  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);
1396
+ for (const s of m.sessions.values())
1397
+ this.hangUp(s.ws, "SPACE_DELETED");
1398
+ this.spaces.delete(spaceKey(name));
953
1399
  }
954
1400
  setTimeout(() => this.deleteSpaceFiles(name), 600).unref?.();
955
1401
  const safe = name.replace(/[^a-z0-9._-]/gi, "_");
956
1402
  try {
957
1403
  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 }));
1404
+ const earlier = sp?.allow ? sp.allow.tokenHashes().filter((h) => h !== hash) : [];
1405
+ writeFileSync(join(this.dataDir, `${safe}.deleted.json`), JSON.stringify({ name, at: Date.now(), tokenHash: hash, ...(earlier.length ? { retiredTokenHashes: earlier } : {}) }));
1406
+ this.tombstoneFile(name);
1407
+ this.tombstoneStems.set(spaceKey(name), safe);
959
1408
  }
960
1409
  catch { }
961
1410
  const existed = !!sp || [".jsonl", ".state.json", ".members.json"].some((suf) => existsSync(join(this.dataDir, `${safe}${suf}`)));
962
1411
  this.deleteSpaceFiles(name);
963
- this.deletedSpaces.set(name, Date.now());
1412
+ this.deletedSpaces.set(spaceKey(name), Date.now());
964
1413
  if (this.deletedSpaces.size > 1000)
965
1414
  for (const [k, t] of this.deletedSpaces)
966
1415
  if (Date.now() - t > 3_600_000)
@@ -980,14 +1429,17 @@ button:hover{background:#1d4ed8}.err{color:var(--bad);margin-top:12px}
980
1429
  return this.json(res, 404, { error: "no such account" });
981
1430
  const live = A.sessionsFor(acct.id).filter((s) => !s.revoked_at);
982
1431
  A.setStatus(acct.id, act === "suspend" ? "suspended" : "active", ip);
983
- if (act === "suspend")
1432
+ this.authEpoch++;
1433
+ if (act === "suspend") {
984
1434
  for (const s of live)
985
1435
  this.dropSessionEverywhere(s.id);
1436
+ this.noteAccountRemoval(acct.username);
1437
+ }
986
1438
  this.log(`[admin] ${act} ${acct.username} from ${ip}`);
987
1439
  return done(`${acct.username} ${act === "suspend" ? "suspended and signed out everywhere" : "reinstated"}`);
988
1440
  }
989
1441
  case "revoke_invite": {
990
- const sp = this.spaces.get(id);
1442
+ const sp = this.spaces.get(spaceKey(id));
991
1443
  const allow = sp ? sp.allow : new Allowlist(this.dataDir, id.replace(/[^a-z0-9._-]/gi, "_"));
992
1444
  const token = (body.token ?? "").trim();
993
1445
  const inv = allow.findInvite(token);
@@ -1004,8 +1456,12 @@ button:hover{background:#1d4ed8}.err{color:var(--bad);margin-top:12px}
1004
1456
  const hit = A.adminRevoke(act === "revoke_session" ? "session" : "device", id, ip);
1005
1457
  for (const sid of hit.sessionIds)
1006
1458
  this.dropSessionEverywhere(sid);
1007
- if (hit.deviceId && hit.accountId)
1459
+ if (hit.deviceId && hit.accountId) {
1008
1460
  this.dropDeviceSockets(hit.accountId, hit.deviceId);
1461
+ const owner = A.accountById(hit.accountId);
1462
+ if (owner)
1463
+ this.noteAccountRemoval(owner.username);
1464
+ }
1009
1465
  return done(hit.sessionIds.length || hit.deviceId ? `revoked ${act === "revoke_session" ? "session" : "device"} ${id}` : `nothing to revoke for ${id}`);
1010
1466
  }
1011
1467
  case "block": {
@@ -1091,28 +1547,220 @@ button:hover{background:#1d4ed8}.err{color:var(--bad);margin-top:12px}
1091
1547
  default: return this.json(res, 400, { error: `unknown action "${act}"` });
1092
1548
  }
1093
1549
  }
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))
1550
+ readTarget(name, token) {
1551
+ if (!name || !token)
1099
1552
  return null;
1100
- if (new Allowlist(this.dataDir, safeName(name)).tokenMatches(token) !== true)
1553
+ const live = this.live(name);
1554
+ if (live) {
1555
+ const st = this.tokenState(live, token);
1556
+ return st ? { live, allow: live.allow, st } : null;
1557
+ }
1558
+ if (this.aliasOf(name) || !this.spaceOnDisk(name))
1101
1559
  return null;
1102
- return this.getOrCreateSpace(name, token);
1560
+ const allow = new Allowlist(this.dataDir, safeName(name));
1561
+ const st = allow.tokenMatches(token) === true ? "current" : allow.tokenRetired(token) ? "retired" : null;
1562
+ return st ? { live: undefined, allow, st } : null;
1563
+ }
1564
+ unsignedRefusal(name, allow, st) {
1565
+ if (this.readsMode === "signed")
1566
+ return "This relay serves only signed reads.";
1567
+ if (allow.removalRecorded())
1568
+ return `"${name}" has removed a member, so it serves only signed reads.`;
1569
+ if (st !== "current")
1570
+ return `"${name}" has a new token since this machine last connected, so it serves this read only signed.`;
1571
+ return null;
1572
+ }
1573
+ upgradeAct(name, seq, goal, why) {
1574
+ return {
1575
+ id: UPGRADE_NOTE_ID, seq, ts: Date.now(), space: name, from: RELAY_MEMBER, act: "NOTE",
1576
+ body: `${UPGRADE_NOTE}\n${why}`, to: null, goal, ref: null, meta: { notice: "upgrade_required" },
1577
+ };
1578
+ }
1579
+ upgradeAnswer(res, path, url, n, error) {
1580
+ const since = url.searchParams.get("since");
1581
+ const seq = since !== null ? Math.max(0, Math.min(n.head, Number(since) || 0)) : n.head;
1582
+ const act = this.upgradeAct(n.name, seq, url.searchParams.get("goal"), n.why);
1583
+ const common = { code: "UPGRADE_REQUIRED", error, note: UPGRADE_NOTE };
1584
+ if (path === "/act")
1585
+ return this.json(res, 200, { act, ...common });
1586
+ if (path === "/obligations")
1587
+ return this.json(res, 200, { obligations: [], closed_goals: [], seq: n.head, worker_online: false, ...common });
1588
+ if (since !== null && Number(since) >= n.head)
1589
+ return this.json(res, 200, { acts: [], head: n.head, ...common });
1590
+ return this.json(res, 200, { acts: [act], head: n.head, ...common });
1591
+ }
1592
+ tokenState(sp, token) {
1593
+ if (!token)
1594
+ return null;
1595
+ if (sp.token && secretEq(sp.token, token))
1596
+ return "current";
1597
+ if (sp.allow.tokenMatches(token) === true) {
1598
+ sp.token = token;
1599
+ this.handTokenToWaiting(sp);
1600
+ return "current";
1601
+ }
1602
+ return sp.allow.tokenRetired(token) ? "retired" : null;
1603
+ }
1604
+ knownToken(sp) {
1605
+ if (!sp.token) {
1606
+ const t = sp.allow.currentTokenFromInvites();
1607
+ if (t)
1608
+ sp.token = t;
1609
+ }
1610
+ return sp.token;
1611
+ }
1612
+ handTokenToWaiting(sp) {
1613
+ for (const m of sp.members.values()) {
1614
+ if (!sp.allow.get(m.id)?.admitted)
1615
+ continue;
1616
+ for (const s of m.sessions.values())
1617
+ if (s.wantsToken) {
1618
+ s.wantsToken = false;
1619
+ this.send(s.ws, { t: "token", token: sp.token });
1620
+ }
1621
+ }
1622
+ }
1623
+ ensureToken(sp) {
1624
+ if (!this.knownToken(sp))
1625
+ this.rotateSpaceToken(sp, "its current token was not in memory");
1626
+ return sp.token;
1627
+ }
1628
+ rotateSpaceToken(sp, why) {
1629
+ const next = randomBytes(16).toString("hex");
1630
+ sp.allow.rotateToken(next);
1631
+ sp.token = next;
1632
+ let told = 0;
1633
+ for (const m of sp.members.values()) {
1634
+ if (!sp.allow.get(m.id)?.admitted)
1635
+ continue;
1636
+ for (const s of m.sessions.values()) {
1637
+ this.send(s.ws, { t: "token", token: next });
1638
+ told++;
1639
+ }
1640
+ }
1641
+ this.log(`[${sp.name}] * space token rotated (${why}); sent to ${told} live session${told === 1 ? "" : "s"}`);
1642
+ }
1643
+ noteAccountRemoval(username) {
1644
+ this.authEpoch++;
1645
+ const u = normalizeUsername(username);
1646
+ const seen = new Set();
1647
+ for (const sp of this.spaces.values()) {
1648
+ seen.add(safeName(sp.name));
1649
+ if (sp.allow.get(u))
1650
+ sp.allow.noteRemoval();
1651
+ }
1652
+ let files = [];
1653
+ try {
1654
+ files = readdirSync(this.dataDir).filter((f) => f.endsWith(".members.json"));
1655
+ }
1656
+ catch { }
1657
+ for (const f of files) {
1658
+ const stem = f.slice(0, -".members.json".length);
1659
+ if (seen.has(stem))
1660
+ continue;
1661
+ try {
1662
+ const al = new Allowlist(this.dataDir, stem);
1663
+ if (al.get(u))
1664
+ al.noteRemoval();
1665
+ }
1666
+ catch { }
1667
+ }
1668
+ }
1669
+ readGate(req, url, ip) {
1670
+ const params = {};
1671
+ for (const [k, v] of url.searchParams) {
1672
+ if (k in params)
1673
+ return { ok: false, status: 400, error: `the query names "${k}" twice` };
1674
+ params[k] = v;
1675
+ }
1676
+ const name = params.space ?? "", token = params.token ?? "";
1677
+ const upgrade = (why) => ({
1678
+ ok: false, status: 401, code: "UPGRADE_REQUIRED",
1679
+ error: `${why} This pantheon is too old to read it: run pantheon upgrade (or npm i -g @join-pantheon/cli), then try again.`,
1680
+ });
1681
+ const target = this.readTarget(name, token);
1682
+ if (params.sig === undefined) {
1683
+ if (!target)
1684
+ return this.readsMode === "signed" ? upgrade("This relay serves only signed reads.") : { ok: false, status: 404, error: "no such space" };
1685
+ const why = this.unsignedRefusal(name, target.allow, target.st);
1686
+ if (why) {
1687
+ const head = target.live ? target.live.ledger.head : ledgerTailSeq(join(this.dataDir, `${safeName(name)}.jsonl`));
1688
+ return { ...upgrade(why), notice: { name, head, why } };
1689
+ }
1690
+ const space = target.live ?? this.getOrCreateSpace(name, token);
1691
+ if (!space)
1692
+ return { ok: false, status: 404, error: "no such space" };
1693
+ return { ok: true, space, reader: null };
1694
+ }
1695
+ if (!target)
1696
+ return { ok: false, status: 404, error: "no such space" };
1697
+ const reader = params.reader ?? "";
1698
+ const ts = Number(params.ts);
1699
+ const refused = { ok: false, status: 403, error: "a read must be signed, recently, by the key of a member admitted to this team" };
1700
+ const pub = reader ? target.allow.pubkeyOf(reader) : null;
1701
+ if (!pub || !Number.isFinite(ts) || Math.abs(Date.now() - ts) >= LIMITS.activityFreshMs)
1702
+ return refused;
1703
+ const { sig, ...signed } = params;
1704
+ let signer = pub;
1705
+ if (signed.dpub) {
1706
+ const cert = { pub: signed.dpub, run: signed.drun ?? "", expires: Number(signed.dexp), sig: signed.dcert ?? "" };
1707
+ if (![target.allow.spaceId(), name].some((t) => t && verifyDelegate(pub, t, reader, cert)))
1708
+ return refused;
1709
+ signer = signed.dpub;
1710
+ }
1711
+ if (!verifyRead(signer, req.method ?? "GET", url.pathname, signed, sig))
1712
+ return refused;
1713
+ if (this.requireAccounts && this.accounts && !this.localTrusted(ip)) {
1714
+ const auth = this.accounts.authorize(pub, reader);
1715
+ if (!auth.ok) {
1716
+ return { ok: false, status: 403, code: "NOT_SIGNED_IN", error: auth.reason === "suspended"
1717
+ ? "this account has been suspended by the relay operator"
1718
+ : "this device is not signed in to an account that may read here — run pantheon login" };
1719
+ }
1720
+ }
1721
+ const space = target.live ?? this.getOrCreateSpace(name, token, { retired: true });
1722
+ if (!space)
1723
+ return { ok: false, status: 404, error: "no such space" };
1724
+ return { ok: true, space, reader };
1725
+ }
1726
+ tombstoneStems = null;
1727
+ tombstoneFile(name) {
1728
+ if (!this.tombstoneStems) {
1729
+ this.tombstoneStems = new Map();
1730
+ try {
1731
+ for (const f of readdirSync(this.dataDir)) {
1732
+ if (!f.endsWith(".deleted.json"))
1733
+ continue;
1734
+ const stem = f.slice(0, -".deleted.json".length);
1735
+ this.tombstoneStems.set(stem.toLowerCase(), stem);
1736
+ }
1737
+ }
1738
+ catch { }
1739
+ }
1740
+ const stem = this.tombstoneStems.get(spaceKey(name));
1741
+ return stem ? join(this.dataDir, `${stem}.deleted.json`) : null;
1103
1742
  }
1104
1743
  tombstone(name, _token) {
1744
+ const f = this.tombstoneFile(name);
1745
+ if (!f)
1746
+ return null;
1105
1747
  try {
1106
- return JSON.parse(readFileSync(join(this.dataDir, `${safeName(name)}.deleted.json`), "utf8")).at;
1748
+ return JSON.parse(readFileSync(f, "utf8")).at;
1107
1749
  }
1108
1750
  catch {
1109
1751
  return null;
1110
1752
  }
1111
1753
  }
1112
1754
  tombstoneMatches(name, token) {
1755
+ const f = this.tombstoneFile(name);
1756
+ if (!f)
1757
+ return false;
1113
1758
  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;
1759
+ const t = JSON.parse(readFileSync(f, "utf8"));
1760
+ if (!token)
1761
+ return false;
1762
+ const h = createHash("sha256").update(token).digest("hex");
1763
+ return (!!t.tokenHash && h === t.tokenHash) || (Array.isArray(t.retiredTokenHashes) && t.retiredTokenHashes.includes(h));
1116
1764
  }
1117
1765
  catch {
1118
1766
  return false;
@@ -1130,20 +1778,50 @@ button:hover{background:#1d4ed8}.err{color:var(--bad);margin-top:12px}
1130
1778
  spaceOnDisk(name) {
1131
1779
  return existsSync(join(this.dataDir, `${name.replace(/[^a-z0-9._-]/gi, "_")}.members.json`));
1132
1780
  }
1781
+ live(name) {
1782
+ const sp = this.spaces.get(spaceKey(name));
1783
+ return sp && sp.name === name ? sp : undefined;
1784
+ }
1785
+ aliasOf(name) {
1786
+ const sp = this.spaces.get(spaceKey(name));
1787
+ if (sp)
1788
+ return sp.name !== name;
1789
+ if (!name || !this.spaceOnDisk(name))
1790
+ return false;
1791
+ const stem = safeName(name);
1792
+ try {
1793
+ if (!readdirSync(this.dataDir).includes(`${stem}.members.json`))
1794
+ return true;
1795
+ }
1796
+ catch { }
1797
+ const recorded = this.recordedName(stem);
1798
+ return recorded !== null && recorded !== name;
1799
+ }
1800
+ recordedName(stem) {
1801
+ try {
1802
+ const n = new Allowlist(this.dataDir, stem).recordedName();
1803
+ if (n)
1804
+ return n;
1805
+ }
1806
+ catch { }
1807
+ return firstLedgerName(join(this.dataDir, `${stem}.jsonl`));
1808
+ }
1133
1809
  spacePlan(name) {
1134
1810
  if (!this.accounts)
1135
1811
  return null;
1136
1812
  const owner = this.accounts.spaceOwner(name);
1137
- if (!owner)
1138
- return null;
1139
- return { name: owner.plan, limits: this.accounts.limitsFor(owner), owner };
1813
+ if (owner)
1814
+ return { name: owner.plan, limits: this.accounts.limitsFor(owner), owner };
1815
+ if (this.accounts.spaceOrphaned(name))
1816
+ return { name: "free", limits: planLimits(this.accounts.plans(), "free"), owner: null };
1817
+ return null;
1140
1818
  }
1141
1819
  planView(acct, sp) {
1142
1820
  const fresh = this.accounts?.accountById(acct.id) ?? acct;
1143
1821
  return { name: fresh.plan, limits: this.accounts.limitsFor(fresh), usage: { spaces: this.accounts.spacesOwned(fresh.id).length, members: sp.allow.admittedCount() } };
1144
1822
  }
1145
1823
  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 });
1824
+ 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
1825
  this.log(`[${space.name}] ! PLAN_LIMIT (${target}, ${plan.name} plan)`);
1148
1826
  fail("PLAN_LIMIT", msg, cid);
1149
1827
  }
@@ -1200,7 +1878,7 @@ button:hover{background:#1d4ed8}.err{color:var(--bad);margin-top:12px}
1200
1878
  for (const d of devices)
1201
1879
  devByAcct.set(d.account_id, (devByAcct.get(d.account_id) ?? 0) + 1);
1202
1880
  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;
1881
+ const emailsHour = this.emailSends.filter((s) => Date.now() - s.at < 3_600_000).length;
1204
1882
  let snapshots = [];
1205
1883
  try {
1206
1884
  snapshots = readdirSync(join(this.dataDir, "backups")).filter((f) => f.startsWith("accounts-") && f.endsWith(".db")).sort();
@@ -1380,8 +2058,8 @@ ${done ? `<div class=done role=status>${esc(done)}</div>` : ""}
1380
2058
  }
1381
2059
  adminSpace(res, url, name) {
1382
2060
  const safe = name.replace(/[^a-z0-9._-]/gi, "_");
1383
- const sp = this.spaces.get(name) ?? null;
1384
- const tomb = this.deletedSpaces.get(name);
2061
+ const sp = this.spaces.get(spaceKey(name)) ?? null;
2062
+ const tomb = this.deletedSpaces.get(spaceKey(name));
1385
2063
  const onDisk = [".jsonl", ".state.json", ".members.json"].some((suf) => existsSync(join(this.dataDir, `${safe}${suf}`)));
1386
2064
  if (!name || (!sp && (!onDisk || (tomb && Date.now() - tomb < 3_600_000))))
1387
2065
  return this.json(res, 404, { error: `no space "${name}" on this relay` });
@@ -1563,7 +2241,12 @@ ${url.searchParams.get("done") ? `<div class=done role=status>${esc(url.searchPa
1563
2241
  return void res.end(JSON.stringify(data, null, 2));
1564
2242
  }
1565
2243
  const cols = data.length ? Object.keys(data[0]) : [];
1566
- const cell = (v) => { const s = String(v ?? ""); return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s; };
2244
+ const cell = (v) => {
2245
+ let s = String(v ?? "");
2246
+ if (/^[=+\-@\t\r]/.test(s))
2247
+ s = `'${s}`;
2248
+ return /[",\n\r]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
2249
+ };
1567
2250
  const csv = [cols.join(","), ...data.map((r) => cols.map((k) => cell(r[k])).join(","))].join("\n");
1568
2251
  res.writeHead(200, { "content-type": "text/csv", "content-disposition": `attachment; filename="${table}.csv"` });
1569
2252
  res.end(csv);
@@ -1628,22 +2311,60 @@ pantheon signup --email you@work.com --username you</code></pre>
1628
2311
  const touch = () => {
1629
2312
  if (dbSession && Date.now() - lastTouch > 60_000) {
1630
2313
  lastTouch = Date.now();
1631
- this.accounts?.touchSession(dbSession);
2314
+ try {
2315
+ this.accounts?.touchSession(dbSession);
2316
+ }
2317
+ catch (e) {
2318
+ this.noteStorageFailure("session touch", e);
2319
+ }
1632
2320
  }
1633
2321
  };
1634
2322
  let sessionId = "";
1635
2323
  let role = "interactive";
2324
+ let myPubkey = "";
2325
+ let delegated = null;
2326
+ let authEpoch = -1, authOk = true;
1636
2327
  let lastMsg = "";
1637
2328
  let alive = true;
1638
2329
  const nonce = newNonce();
2330
+ const accountStillOk = () => {
2331
+ if (!acct || !this.accounts)
2332
+ return true;
2333
+ if (authEpoch !== this.authEpoch) {
2334
+ authEpoch = this.authEpoch;
2335
+ try {
2336
+ authOk = this.accounts.authorize(myPubkey, me?.id ?? "").ok;
2337
+ }
2338
+ catch {
2339
+ authOk = false;
2340
+ }
2341
+ }
2342
+ return authOk;
2343
+ };
1639
2344
  ws.on("pong", () => { alive = true; touch(); });
1640
2345
  const probe = ws;
1641
2346
  probe._alive = () => alive;
1642
2347
  probe._clear = () => { alive = false; };
2348
+ let refusalStamps = [];
2349
+ let refusedOut = false;
1643
2350
  const fail = (code, message, cid) => {
2351
+ if (refusedOut)
2352
+ return;
2353
+ const now = Date.now();
2354
+ refusalStamps.push(now);
2355
+ if (refusalStamps.length > REFUSAL_BUDGET) {
2356
+ refusalStamps = refusalStamps.filter((t) => now - t < REFUSAL_WINDOW_MS);
2357
+ if (refusalStamps.length > REFUSAL_BUDGET) {
2358
+ refusedOut = true;
2359
+ this.log(`[${space?.name ?? "?"}] ! ${me?.id ?? "an unauthenticated socket"} closed: ${refusalStamps.length} refused requests in ${REFUSAL_WINDOW_MS / 1000}s`);
2360
+ 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}).` });
2361
+ this.hangUp(ws, "TOO_MANY_REFUSALS");
2362
+ return;
2363
+ }
2364
+ }
1644
2365
  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);
2366
+ noteRefusal(space.store.refusals, { ts: now, member: me.id, act: lastMsg || "?", code });
2367
+ space.store.save(space.goals, space.obligations, space.profiles);
1647
2368
  }
1648
2369
  this.send(ws, { t: "error", code, message, ...(cid ? { cid } : {}) });
1649
2370
  };
@@ -1667,8 +2388,11 @@ pantheon signup --email you@work.com --username you</code></pre>
1667
2388
  helloTimer = null;
1668
2389
  } };
1669
2390
  ws.on("close", clearHelloTimer);
1670
- this.send(ws, { t: "challenge", nonce, v: PROTOCOL_VERSION });
2391
+ this.send(ws, { t: "challenge", nonce, v: PROTOCOL_VERSION, features: [...this.features] });
1671
2392
  ws.on("message", (raw) => {
2393
+ if (refusedOut)
2394
+ return;
2395
+ let reqCid;
1672
2396
  try {
1673
2397
  let msg;
1674
2398
  try {
@@ -1677,6 +2401,8 @@ pantheon signup --email you@work.com --username you</code></pre>
1677
2401
  catch {
1678
2402
  return fail("BAD_JSON", "message was not JSON");
1679
2403
  }
2404
+ if (typeof msg?.cid === "string")
2405
+ reqCid = (msg.cid).slice(0, 64);
1680
2406
  const parsed = Inbound.safeParse(msg);
1681
2407
  if (!parsed.success) {
1682
2408
  const cid = typeof msg?.cid === "string"
@@ -1695,32 +2421,42 @@ pantheon signup --email you@work.com --username you</code></pre>
1695
2421
  return reject("BAD_NONCE", "challenge mismatch — reconnect");
1696
2422
  if (!m.token && !m.enroll)
1697
2423
  return reject("NO_CREDENTIAL", "present either a space token or an invite");
1698
- if (!verifyChallenge(m.pubkey, m.space, m.member, m.nonce, m.sig)) {
2424
+ if (m.delegate && !this.features.includes("delegate"))
2425
+ return reject("BAD_SIGNATURE", `signature does not match the presented key for "${m.member}"`);
2426
+ if (m.delegate && m.enroll)
2427
+ return reject("BAD_DELEGATE", "a woken run cannot redeem an invite");
2428
+ if (!verifyChallenge(m.delegate ? m.delegate.pub : m.pubkey, m.space, m.member, m.nonce, m.sig)) {
1699
2429
  return reject("BAD_SIGNATURE", `signature does not match the presented key for "${m.member}"`);
1700
2430
  }
1701
2431
  const gated = this.requireAccounts && !!this.accounts && !this.localTrusted(ip);
1702
2432
  const pre = gated ? this.accounts.authorize(m.pubkey, m.member) : null;
1703
2433
  let sp;
2434
+ let staleToken = false;
1704
2435
  if (m.enroll) {
1705
- sp = this.spaces.get(m.space) ?? null;
2436
+ sp = this.live(m.space) ?? null;
2437
+ if (!sp && this.aliasOf(m.space))
2438
+ return reject("NO_SUCH_SPACE", `no space "${m.space}" on this relay`);
1706
2439
  if (!sp && this.spaceOnDisk(m.space)) {
1707
2440
  const inv = new Allowlist(this.dataDir, safeName(m.space)).findInvite(m.enroll);
1708
2441
  if (inv?.token === m.enroll && inv.space_token)
1709
- sp = this.getOrCreateSpace(m.space, inv.space_token);
2442
+ sp = this.getOrCreateSpace(m.space, inv.space_token, { claim: "invite", retired: true });
1710
2443
  }
1711
2444
  if (!sp)
1712
2445
  return reject("NO_SUCH_SPACE", `no space "${m.space}" on this relay`);
1713
2446
  }
1714
2447
  else {
1715
- const tomb = this.deletedSpaces.get(m.space) ?? this.tombstone(m.space, m.token ?? "");
2448
+ const tomb = this.deletedSpaces.get(spaceKey(m.space)) ?? this.tombstone(m.space, m.token ?? "");
1716
2449
  if (tomb && Date.now() - tomb < 3_600_000 || (tomb && this.tombstoneMatches(m.space, m.token ?? ""))) {
1717
2450
  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
2451
  }
1719
- const isNew = !this.spaces.has(m.space) && !this.spaceOnDisk(m.space);
2452
+ if (this.aliasOf(m.space)) {
2453
+ return reject("NAME_TAKEN", `a team with a name too close to "${m.space}" already exists here — use its exact name, or pick another`);
2454
+ }
2455
+ const isNew = !this.live(m.space) && !this.spaceOnDisk(m.space);
1720
2456
  if (isNew && this.nameCollides(m.space)) {
1721
2457
  return reject("NAME_TAKEN", `a team with a name too close to "${m.space}" already exists here — pick another name`);
1722
2458
  }
1723
- if (!this.spaces.has(m.space)) {
2459
+ if (!this.live(m.space)) {
1724
2460
  const denied = this.mayCreateSpace(ip);
1725
2461
  if (denied)
1726
2462
  return reject(denied.code, denied.message);
@@ -1739,9 +2475,19 @@ pantheon signup --email you@work.com --username you</code></pre>
1739
2475
  return reject("PLAN_LIMIT", msg);
1740
2476
  }
1741
2477
  }
1742
- sp = this.getOrCreateSpace(m.space, m.token);
2478
+ if (!this.live(m.space) && this.unboundOnDisk(safeName(m.space))) {
2479
+ const e = this.spaceOnDisk(m.space) ? new Allowlist(this.dataDir, safeName(m.space)).get(m.member) : undefined;
2480
+ if (!e?.admitted || e.publicKey !== m.pubkey) {
2481
+ this.unbound.add(safeName(m.space));
2482
+ 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`);
2483
+ 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. ` +
2484
+ `If you are one, connect with your own identity; otherwise ask a member of "${m.space}" for an invite, or the relay operator.`);
2485
+ }
2486
+ }
2487
+ sp = this.getOrCreateSpace(m.space, m.token, { claim: { member: m.member, pubkey: m.pubkey }, retired: true });
1743
2488
  if (!sp)
1744
2489
  return reject("BAD_TOKEN", "wrong token for this space");
2490
+ staleToken = this.tokenState(sp, m.token) === "retired";
1745
2491
  if (isNew) {
1746
2492
  this.noteSpaceCreate(ip);
1747
2493
  this.accounts?.recordSpace(sp.name, pre?.ok ? pre.account.id : null);
@@ -1770,20 +2516,38 @@ pantheon signup --email you@work.com --username you</code></pre>
1770
2516
  const plan = this.spacePlan(sp.name);
1771
2517
  if (plan && !sp.allow.get(m.member)?.admitted && sp.allow.admittedCount() >= plan.limits.members) {
1772
2518
  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`);
2519
+ 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
2520
  }
1775
2521
  const r = sp.allow.redeem(m.enroll, m.member, m.pubkey);
1776
2522
  if (!r.ok) {
1777
2523
  this.log(`[${sp.name}] ! invite refused for ${m.member}: ${r.why}`);
1778
2524
  return reject("BAD_INVITE", r.fix ? `${r.why}\n${r.fix}` : r.why);
1779
2525
  }
1780
- enrolled = sp.token;
2526
+ enrolled = this.ensureToken(sp);
1781
2527
  this.log(`[${sp.name}] * ENROLLED ${m.member} via invite from ${r.entry.admitted_by}${r.entry.may_wake ? " (may wake)" : ""}`);
1782
2528
  }
2529
+ if (staleToken && !enrolled) {
2530
+ const e = sp.allow.get(m.member);
2531
+ if (e && e.publicKey === m.pubkey && !e.admitted && e.revoked_at) {
2532
+ this.log(`[${sp.name}] ! REFUSED ${m.member}: removed (earlier token)`);
2533
+ 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.`);
2534
+ }
2535
+ if (!e || e.publicKey !== m.pubkey || !e.admitted) {
2536
+ this.log(`[${sp.name}] ! REFUSED ${m.member}: an earlier token, and not an admitted member with this key`);
2537
+ 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.");
2538
+ }
2539
+ }
2540
+ if (m.member.toLowerCase() === RELAY_MEMBER && !sp.allow.get(m.member)) {
2541
+ return reject("RESERVED_NAME", `"${m.member}" is the name the relay itself speaks as — choose another name`);
2542
+ }
1783
2543
  const verdict = enrolled ? "ok" : sp.allow.present(m.member, m.pubkey);
2544
+ if (verdict === "pending-full") {
2545
+ this.log(`[${sp.name}] ! REFUSED ${m.member}: too many names already waiting to be admitted`);
2546
+ 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.`);
2547
+ }
1784
2548
  if (verdict === "key-mismatch") {
1785
2549
  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.`);
2550
+ 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
2551
  }
1788
2552
  if (verdict === "revoked") {
1789
2553
  const e = sp.allow.get(m.member);
@@ -1809,16 +2573,24 @@ pantheon signup --email you@work.com --username you</code></pre>
1809
2573
  newRepo = m.repo;
1810
2574
  else {
1811
2575
  const known = sp.allow.repoList().map((r) => r.origin ?? r.fingerprint).join(", ");
1812
- this.log(`[${sp.name}] ! REFUSED ${m.member}: repo ${m.repo_origin ?? m.repo} not bound to this space`);
2576
+ this.log(`[${sp.name}] ! REFUSED ${m.member}: repo ${repoLabelSafe(m.repo_origin) ?? m.repo} not bound to this space`);
1813
2577
  return reject("REPO_MISMATCH", `this checkout (${m.repo_origin ?? "no origin"}) is not one of the repos "${sp.name}" works from (${known || "none yet"}). ` +
1814
2578
  `Open your agent in the team's repo, or if this repo really belongs to the team: pantheon repo add`);
1815
2579
  }
1816
2580
  }
2581
+ if (m.delegate) {
2582
+ const teamIds = [sp.allow.spaceId(), sp.name];
2583
+ if (!teamIds.some((t) => verifyDelegate(m.pubkey, t, m.member, m.delegate))) {
2584
+ return reject("BAD_DELEGATE", `that run key is not vouched for by ${m.member}'s key for "${sp.name}", or its certificate has expired`);
2585
+ }
2586
+ delegated = { pub: m.delegate.pub, run: m.delegate.run, expires: m.delegate.expires };
2587
+ }
1817
2588
  space = sp;
2589
+ myPubkey = m.pubkey;
1818
2590
  clearHelloTimer();
1819
2591
  sessionId = acctSession ?? this.mintSession();
1820
- role = m.role;
1821
- me = this.attach(sp, m.member, m.display ?? m.member, m.agent ?? "unknown", sessionId, m.role, ws, m.pkg);
2592
+ role = delegated ? "worker" : m.role;
2593
+ me = this.attach(sp, m.member, m.display ?? m.member, m.agent ?? "unknown", sessionId, role, ws, m.pkg);
1822
2594
  const firstSession = me.sessions.size === 1;
1823
2595
  const prior = typeof m.session === "string" ? m.session : "";
1824
2596
  const stale = prior && prior !== sessionId ? me.sessions.get(prior) : undefined;
@@ -1846,19 +2618,25 @@ pantheon signup --email you@work.com --username you</code></pre>
1846
2618
  this.pushObligations(sp);
1847
2619
  }
1848
2620
  sp.lastTouch = Date.now();
1849
- if (newRepo && sp.allow.addRepo(newRepo, m.repo_origin ?? null, me.id)) {
1850
- this.log(`[${sp.name}] + repo ${m.repo_origin ?? newRepo} registered by ${me.id}`);
1851
- this.commit(sp, { from: me.id, act: "NOTE", to: null, goal: null, ref: null, body: `repo registered with this space: ${m.repo_origin ?? newRepo}`, meta: { repo: newRepo, repo_origin: m.repo_origin ?? null } });
2621
+ if (newRepo && sp.allow.addRepo(newRepo, repoLabelSafe(m.repo_origin), me.id)) {
2622
+ const label = repoLabelSafe(m.repo_origin);
2623
+ this.log(`[${sp.name}] + repo ${label ?? newRepo} registered by ${me.id}`);
2624
+ this.commit(sp, { from: me.id, act: "NOTE", to: null, goal: null, ref: null, body: `repo registered with this space: ${label ?? newRepo}`, meta: { repo: newRepo, repo_origin: label ?? null } });
1852
2625
  this.broadcast(sp, { t: "repos", repos: sp.allow.repoList() });
1853
2626
  }
1854
2627
  this.send(ws, {
1855
2628
  t: "welcome", member: me.id, session: sessionId, space: sp.name, seq: sp.ledger.head,
1856
2629
  roster: this.roster(sp), goals: [...sp.goals.values()],
1857
2630
  obligations: [...sp.obligations.values()], members: this.members(sp), profiles: [...sp.profiles.values()],
1858
- space_id: sp.allow.spaceId(), repos: sp.allow.repoList(),
2631
+ space_id: sp.allow.spaceId(), repos: sp.allow.repoList(), features: [...this.features],
1859
2632
  ...(acct ? { plan: this.planView(acct, sp) } : {}),
1860
- ...(enrolled ? { token: enrolled } : {}),
2633
+ ...(enrolled ? { token: enrolled } : staleToken && this.knownToken(sp) ? { token: sp.token } : {}),
1861
2634
  });
2635
+ if (staleToken && !enrolled && !sp.token) {
2636
+ const mine = me.sessions.get(sessionId);
2637
+ if (mine)
2638
+ mine.wantsToken = true;
2639
+ }
1862
2640
  let from = m.from_seq && m.from_seq > 0 ? m.from_seq : sp.ledger.head;
1863
2641
  for (let page = sp.ledger.since(from); page.length; page = sp.ledger.since(from)) {
1864
2642
  for (const r of page) {
@@ -1868,6 +2646,11 @@ pantheon signup --email you@work.com --username you</code></pre>
1868
2646
  }
1869
2647
  from = page[page.length - 1].seq;
1870
2648
  }
2649
+ if (versionBelow(m.pkg, "0.30.0")) {
2650
+ const why = this.unsignedRefusal(sp.name, sp.allow, "current");
2651
+ if (why)
2652
+ this.send(ws, { t: "act", act: this.upgradeAct(sp.name, sp.ledger.head, null, why) });
2653
+ }
1871
2654
  if (firstSession && m.role !== "cli") {
1872
2655
  this.commit(sp, { from: me.id, act: "JOIN", body: `${me.display} online (${me.agent})`, to: null, goal: null, ref: null });
1873
2656
  }
@@ -1877,6 +2660,19 @@ pantheon signup --email you@work.com --username you</code></pre>
1877
2660
  }
1878
2661
  if (!space || !me)
1879
2662
  return reject("NOT_JOINED", "say hello first");
2663
+ if (!this.stillIn(space, me, sessionId, ws) || (acct && !accountStillOk())) {
2664
+ if (ws.readyState === 1)
2665
+ this.hangUp(ws, "REVOKED");
2666
+ return;
2667
+ }
2668
+ if (delegated && Date.now() > delegated.expires + 10 * 60_000) {
2669
+ if (ws.readyState === 1)
2670
+ this.hangUp(ws, "REVOKED");
2671
+ return;
2672
+ }
2673
+ if (delegated && m.t !== "act" && m.t !== "open_goal" && m.t !== "claim" && m.t !== "worker_state") {
2674
+ return fail("NOT_FOR_A_RUN", `an unattended run cannot ${m.t.replace(/_/g, " ")} — its member does that from their own session`, m.cid);
2675
+ }
1880
2676
  if (m.t === "publish_profile") {
1881
2677
  if (m.profile.member !== me.id) {
1882
2678
  return fail("PROFILE_NOT_YOURS", `you may publish only the profile for ${me.id}`, m.cid);
@@ -1909,6 +2705,13 @@ pantheon signup --email you@work.com --username you</code></pre>
1909
2705
  const target = space.allow.get(m.member);
1910
2706
  if (!target)
1911
2707
  return fail("NO_SUCH_MEMBER", `"${m.member}" has never connected, so there is no key to admit`, m.cid);
2708
+ if (!m.revoke && !m.forget && !m.wake_only && !target.admitted && space.allow.removedWhen(m.member) !== null) {
2709
+ const founder = space.allow.founder();
2710
+ if (founder && me.id !== founder)
2711
+ return fail("FOUNDER_ONLY", `${m.member} was removed from "${space.name}"; only ${founder}, who founded it, can bring them back`, m.cid);
2712
+ }
2713
+ if (m.wake_only && (m.revoke || m.forget || !target.admitted))
2714
+ return fail("NOT_ADMITTED", `"${m.member}" is not in "${space.name}" now, so there is no wake permission to change`, m.cid);
1912
2715
  if (!m.revoke && !m.forget) {
1913
2716
  const plan = this.spacePlan(space.name);
1914
2717
  if (plan && m.may_wake && !plan.limits.wake)
@@ -1922,19 +2725,16 @@ pantheon signup --email you@work.com --username you</code></pre>
1922
2725
  return fail("FOUNDER_ONLY", `only ${founder}, who founded "${space.name}", can remove a member`, m.cid);
1923
2726
  if (m.member === founder)
1924
2727
  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);
2728
+ const removed = m.forget ? space.allow.forget(m.member) : space.allow.revoke(m.member, me.id);
1929
2729
  if (space.profiles.delete(m.member))
1930
2730
  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
- }
2731
+ const gone = space.members.get(m.member);
2732
+ for (const s of gone?.sessions.values() ?? [])
2733
+ this.hangUp(s.ws, "REVOKED");
2734
+ gone?.sessions.clear();
1937
2735
  space.members.delete(m.member);
2736
+ if (removed && this.rotateOnRemove)
2737
+ this.rotateSpaceToken(space, `${m.member} ${m.forget ? "forgotten" : "removed"} by ${me.id}`);
1938
2738
  this.log(`[${space.name}] - ${m.forget ? "FORGOT" : "REVOKED"} ${m.member} by ${me.id}`);
1939
2739
  }
1940
2740
  else {
@@ -1964,7 +2764,15 @@ pantheon signup --email you@work.com --username you</code></pre>
1964
2764
  this.accounts?.audit({ event: "space_deleted", actor_account: acct?.id ?? null, space: name, detail: `by its founder ${me.id}`, ip });
1965
2765
  this.log(`[${name}] * DELETED by its founder ${me.id}`);
1966
2766
  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?.();
2767
+ setTimeout(() => {
2768
+ try {
2769
+ this.deleteSpace(name);
2770
+ this.accounts?.markSpaceDeleted(name);
2771
+ }
2772
+ catch (e) {
2773
+ this.noteStorageFailure(`[${name}] delete`, e);
2774
+ }
2775
+ }, 50).unref?.();
1968
2776
  return;
1969
2777
  }
1970
2778
  if (m.t === "invite") {
@@ -1985,7 +2793,7 @@ pantheon signup --email you@work.com --username you</code></pre>
1985
2793
  if (space.allow.openInvites().length >= LIMITS_SPACE.openInvites)
1986
2794
  return fail("TOO_MANY_INVITES", `this team already has ${LIMITS_SPACE.openInvites} unused invites — revoke some: pantheon invite --revoke all`, m.cid);
1987
2795
  const token = randomBytes(18).toString("base64url");
1988
- const inv = space.allow.mintInvite(me.id, m.may_wake, m.ttl_minutes, token, space.token);
2796
+ const inv = space.allow.mintInvite(me.id, m.may_wake, m.ttl_minutes, token, this.ensureToken(space));
1989
2797
  this.log(`[${space.name}] * invite minted by ${me.id}, valid ${m.ttl_minutes}m, single use`);
1990
2798
  this.send(ws, { t: "invite", url: token, expires_at: inv.expires_at });
1991
2799
  return;
@@ -2034,14 +2842,14 @@ pantheon signup --email you@work.com --username you</code></pre>
2034
2842
  if (existing && m.base && existing.added_by !== me.id && space.allow.founder() !== me.id) {
2035
2843
  return fail("NOT_YOURS", `only ${existing.added_by} (who registered this repo) or the team's founder can change which branch its runs start from`, m.cid);
2036
2844
  }
2037
- const added = space.allow.addRepo(m.repo, m.origin ?? null, me.id, m.base ?? null);
2845
+ const added = space.allow.addRepo(m.repo, repoLabelSafe(m.origin), me.id, m.base ?? null);
2038
2846
  if (!added && m.base && space.allow.setRepoBase(m.repo, m.base)) {
2039
- this.log(`[${space.name}] ~ repo ${m.origin ?? m.repo} base branch → ${m.base} (by ${me.id})`);
2847
+ this.log(`[${space.name}] ~ repo ${repoLabelSafe(m.origin) ?? m.repo} base branch → ${m.base} (by ${me.id})`);
2040
2848
  this.broadcast(space, { t: "repos", repos: space.allow.repoList() });
2041
2849
  }
2042
2850
  if (added) {
2043
- this.log(`[${space.name}] + repo ${m.origin ?? m.repo} registered by ${me.id}${m.base ? ` (base ${m.base})` : ""}`);
2044
- this.commit(space, { from: me.id, session: sessionId, role, act: "NOTE", to: null, goal: null, ref: null, body: `repo registered with this space: ${m.origin ?? m.repo}`, meta: { repo: m.repo, repo_origin: m.origin ?? null } });
2851
+ this.log(`[${space.name}] + repo ${repoLabelSafe(m.origin) ?? m.repo} registered by ${me.id}${m.base ? ` (base ${m.base})` : ""}`);
2852
+ this.commit(space, { from: me.id, session: sessionId, role, act: "NOTE", to: null, goal: null, ref: null, body: `repo registered with this space: ${repoLabelSafe(m.origin) ?? m.repo}`, meta: { repo: m.repo, repo_origin: repoLabelSafe(m.origin) ?? null } });
2045
2853
  this.broadcast(space, { t: "repos", repos: space.allow.repoList() });
2046
2854
  }
2047
2855
  this.send(ws, { t: "ack", cid: m.cid, id: m.repo, seq: space.ledger.head });
@@ -2077,11 +2885,11 @@ pantheon signup --email you@work.com --username you</code></pre>
2077
2885
  }
2078
2886
  }
2079
2887
  const held = space.contLocks.get(m.obligation);
2080
- if (held && held.session !== sessionId && Date.now() - held.at < LIMITS.claimTtlMs) {
2888
+ if (held && held.session !== sessionId && this.claimHolds(space, held)) {
2081
2889
  return fail("CLAIMED", `another of your sessions (${held.session}) is handling this`, m.cid);
2082
2890
  }
2083
2891
  for (const [k, v] of space.contLocks) {
2084
- if (Date.now() - v.at > LIMITS.claimTtlMs)
2892
+ if (!this.claimHolds(space, v))
2085
2893
  space.contLocks.delete(k);
2086
2894
  }
2087
2895
  space.contLocks.set(m.obligation, { session: sessionId, member: me.id, at: Date.now() });
@@ -2094,7 +2902,7 @@ pantheon signup --email you@work.com --username you</code></pre>
2094
2902
  if (o.owner !== me.id)
2095
2903
  return fail("NOT_YOURS", `${o.owner} owes that, not you`, m.cid);
2096
2904
  if (o.claimed_by && o.claimed_by.session !== sessionId) {
2097
- if (Date.now() - o.claimed_by.at < LIMITS.claimTtlMs) {
2905
+ if (this.claimHolds(space, o.claimed_by)) {
2098
2906
  const holder = o.claimed_by;
2099
2907
  const live = [...me.sessions.keys()].includes(holder.session);
2100
2908
  const left = Math.ceil((LIMITS.claimTtlMs - (Date.now() - holder.at)) / 60_000);
@@ -2124,6 +2932,8 @@ pantheon signup --email you@work.com --username you</code></pre>
2124
2932
  if (m.t === "open_goal") {
2125
2933
  if (!this.writeOk(me))
2126
2934
  return fail("RATE_LIMIT", "too many goals opened too fast — wait a few seconds", m.cid);
2935
+ if (this.spaceFull(space))
2936
+ return fail("SPACE_FULL", this.spaceFullMessage(space), m.cid);
2127
2937
  const openCount = [...space.goals.values()].filter((g) => g.state !== "CLOSED").length;
2128
2938
  if (openCount >= LIMITS_SPACE.openGoals)
2129
2939
  return fail("TOO_MANY_GOALS", `this team has ${openCount} goals still open — close some first`, m.cid);
@@ -2131,12 +2941,19 @@ pantheon signup --email you@work.com --username you</code></pre>
2131
2941
  if (!space.members.has(id) && !space.allow.get(id)?.admitted)
2132
2942
  return fail("NO_SUCH_MEMBER", `nobody here is called "${id}"`, m.cid);
2133
2943
  }
2944
+ if (!this.features.includes("act-sig"))
2945
+ delete m.sig;
2946
+ if (m.sig || delegated) {
2947
+ const why = goalSigProblem(m, me.id, myPubkey, [space.allow.spaceId(), space.name], delegated);
2948
+ if (why)
2949
+ return fail("BAD_GOAL_SIGNATURE", why, m.cid);
2950
+ }
2134
2951
  const invited = (m.invite ?? [...space.members.keys()]).filter((x) => x !== me.id);
2135
2952
  if (m.deadline !== undefined && m.deadline <= Date.now()) {
2136
2953
  return fail("DEADLINE_PAST", `deadline ${new Date(m.deadline).toISOString()} is already in the past`, m.cid);
2137
2954
  }
2138
2955
  const goal = {
2139
- id: newId("g"),
2956
+ id: m.id && !space.goals.has(m.id) ? m.id : newId("g"),
2140
2957
  title: m.title,
2141
2958
  acceptance: m.acceptance,
2142
2959
  members: [me.id, ...new Set(invited)],
@@ -2147,6 +2964,7 @@ pantheon signup --email you@work.com --username you</code></pre>
2147
2964
  opened_at: Date.now(),
2148
2965
  ...(m.deadline !== undefined ? { deadline: m.deadline } : {}),
2149
2966
  ...(m.kind !== undefined ? { kind: m.kind } : {}),
2967
+ ...(m.sig ? { sig: m.sig } : {}),
2150
2968
  };
2151
2969
  space.goals.set(goal.id, goal);
2152
2970
  space.store.flush(space.goals, space.obligations, space.profiles);
@@ -2156,16 +2974,38 @@ pantheon signup --email you@work.com --username you</code></pre>
2156
2974
  body: `goal opened: ${goal.title}\nacceptance: ${goal.acceptance || "(none stated)"}` +
2157
2975
  (goal.deadline ? `\ndeadline: ${new Date(goal.deadline).toISOString()}` : "") +
2158
2976
  (goal.kind === "worklist" ? `\nkind: worklist — ${me.id}'s own list; peers may read it, nobody owes anything in it` : ""),
2977
+ meta: { goal_members: goal.members, goal_budget: goal.budget, ...(m.sig ? { goal_sig: m.sig, goal_text: { title: goal.title, acceptance: goal.acceptance } } : {}) },
2159
2978
  });
2160
2979
  this.send(ws, { t: "ack", ...(m.cid ? { cid: m.cid } : {}), id: goal.id, seq: rec.seq });
2161
2980
  return;
2162
2981
  }
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 });
2982
+ if (!this.features.includes("act-sig"))
2983
+ delete m.sig;
2984
+ if (m.sig || delegated) {
2985
+ const why = actSigProblem(m, me.id, myPubkey, [space.allow.spaceId(), space.name], delegated);
2986
+ if (why)
2987
+ return fail("BAD_ACT_SIGNATURE", why, m.cid);
2988
+ }
2989
+ if (m.act === "NOTE" && m.meta && ("goal_members" in m.meta || "organization_profile" in m.meta || "notice" in m.meta))
2990
+ return fail("BAD_META", "goal_members, organization_profile and notice are written by the relay, not sent", m.cid);
2991
+ if (m.act === "NOTE" && m.body.startsWith("repo registered with this space: "))
2992
+ return fail("BAD_META", "that line is written by the relay, not sent", m.cid);
2993
+ const fromRepo = m.repo && space.allow.hasRepo(m.repo) ? m.repo : null;
2994
+ const { from_repo: _forged, ...senderMeta } = (m.meta ?? {});
2995
+ const hasMeta = !!m.meta && (Object.keys(senderMeta).length > 0 || _forged === undefined);
2996
+ const storedMeta = hasMeta || m.defers_to || m.affects || fromRepo
2997
+ ? { ...senderMeta, ...(m.defers_to ? { defers_to: m.defers_to } : {}), ...(m.affects ? { affects: m.affects } : {}), ...(fromRepo ? { from_repo: fromRepo } : {}) }
2998
+ : undefined;
2999
+ const fp = actFingerprint(me.id, m.act, m.body, m.goal ?? null, m.ref ?? null, m.to ?? null, storedMeta);
2164
3000
  const prior = space.recent.get(fp);
2165
3001
  if (prior && Date.now() - prior.at < LIMITS.dedupeWindowMs) {
2166
3002
  this.send(ws, { t: "ack", ...(m.cid ? { cid: m.cid } : {}), id: prior.id, seq: prior.seq });
2167
3003
  return;
2168
3004
  }
3005
+ if (m.sig && m.id && (space.ledger.find(m.id) || space.droppedIds?.has(m.id)))
3006
+ return fail("ID_TAKEN", `an act with id ${m.id} already exists`, m.cid);
3007
+ if (m.sig && m.id && !idIsFresh(m.id))
3008
+ return fail("BAD_ACT_ID", `act id ${m.id} was not made in the last day — check this machine's clock`, m.cid);
2169
3009
  if (m.after_seq !== undefined) {
2170
3010
  const mine = me.id;
2171
3011
  const missed = space.ledger.since(m.after_seq)
@@ -2184,33 +3024,77 @@ pantheon signup --email you@work.com --username you</code></pre>
2184
3024
  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
3025
  if (err)
2186
3026
  return fail(err.code, err.message, m.cid);
3027
+ if (m.act === "PROPOSE" && m.affects?.length) {
3028
+ const g = m.goal ? space.goals.get(m.goal) : undefined;
3029
+ const outside = m.affects.filter((x) => !g?.members.includes(x));
3030
+ if (outside.length) {
3031
+ 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);
3032
+ }
3033
+ }
3034
+ const wasFull = this.spaceFull(space);
3035
+ if (wasFull && !this.freesRoom(space, me.id, m.act, m.ref ?? null))
3036
+ return fail("SPACE_FULL", this.spaceFullMessage(space), m.cid);
2187
3037
  const rec = this.commit(space, {
3038
+ ...(m.sig && m.id ? { id: m.id } : {}),
2188
3039
  from: me.id, session: sessionId, role, act: m.act, body: m.body,
2189
3040
  ...(m.summary ? { summary: m.summary } : {}),
3041
+ ...(m.sig ? { sig: m.sig } : {}),
2190
3042
  to: m.to ?? null, goal: m.goal ?? null, ref: m.ref ?? null,
2191
- ...(m.meta || m.defers_to || m.affects
2192
- ? { meta: { ...(m.meta ?? {}),
3043
+ ...((m.meta && (Object.keys(withoutFromRepo(m.meta)).length > 0 || !("from_repo" in m.meta))) || m.defers_to || m.affects || fromRepo
3044
+ ? { meta: { ...withoutFromRepo(m.meta),
2193
3045
  ...(m.defers_to ? { defers_to: m.defers_to } : {}),
2194
- ...(m.affects ? { affects: m.affects } : {}) } }
3046
+ ...(m.affects ? { affects: m.affects } : {}),
3047
+ ...(fromRepo ? { from_repo: fromRepo } : {}) } }
2195
3048
  : {}),
2196
3049
  });
3050
+ if (wasFull && m.act === "CLOSE") {
3051
+ try {
3052
+ this.compactSpace(space);
3053
+ }
3054
+ catch (e) {
3055
+ this.noteStorageFailure(`[${space.name}] compacting after a close`, e);
3056
+ }
3057
+ }
2197
3058
  space.recent.set(fp, { id: rec.id, seq: rec.seq, at: Date.now() });
2198
3059
  for (const [k, v] of space.recent)
2199
3060
  if (Date.now() - v.at > LIMITS.dedupeWindowMs)
2200
3061
  space.recent.delete(k);
3062
+ for (const k of space.recent.keys()) {
3063
+ if (space.recent.size <= RECENT_MAX)
3064
+ break;
3065
+ space.recent.delete(k);
3066
+ }
2201
3067
  this.send(ws, { t: "ack", ...(m.cid ? { cid: m.cid } : {}), id: rec.id, seq: rec.seq });
2202
3068
  }
2203
3069
  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" }));
3070
+ const cidPart = reqCid ? { cid: reqCid } : {};
3071
+ const send = (code, message) => { try {
3072
+ ws.send(JSON.stringify({ t: "error", code, message, ...cidPart }));
2207
3073
  }
2208
- catch { }
3074
+ catch { } };
3075
+ if (e instanceof SpaceFull) {
3076
+ 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.`);
3077
+ return;
3078
+ }
3079
+ if (e instanceof RosterUnreadable) {
3080
+ this.log(`!! ${e.message} — every member of this space is refused until it is repaired (it was left in place)`);
3081
+ if (!space)
3082
+ 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.");
3083
+ send("SPACE_UNREADABLE", "the relay cannot read this team's member list; tell the relay operator.");
3084
+ return;
3085
+ }
3086
+ this.noteStorageFailure(`[${space?.name ?? "?"}] ${lastMsg || "message"}`, e);
3087
+ send("STORAGE", "the relay could not save that — try again in a moment");
2209
3088
  }
2210
3089
  });
2211
3090
  const closeWork = (code) => {
2212
3091
  if (dbSession) {
2213
- this.accounts?.endSession(dbSession);
3092
+ try {
3093
+ this.accounts?.endSession(dbSession);
3094
+ }
3095
+ catch (e) {
3096
+ this.noteStorageFailure("session end", e);
3097
+ }
2214
3098
  dbSession = null;
2215
3099
  }
2216
3100
  if (!space || !me)
@@ -2233,7 +3117,8 @@ pantheon signup --email you@work.com --username you</code></pre>
2233
3117
  else
2234
3118
  v.at = Date.now();
2235
3119
  }
2236
- if (me.sessions.size === 0 && role !== "cli") {
3120
+ const stillMember = space.members.get(me.id) === me && !!space.allow.get(me.id)?.admitted;
3121
+ if (me.sessions.size === 0 && role !== "cli" && stillMember) {
2237
3122
  me.last_seen = Date.now();
2238
3123
  this.commit(space, { from: me.id, act: "LEAVE", body: `${me.display} offline`, to: null, goal: null, ref: null });
2239
3124
  }
@@ -2245,7 +3130,7 @@ pantheon signup --email you@work.com --username you</code></pre>
2245
3130
  closeWork(code);
2246
3131
  }
2247
3132
  catch (e) {
2248
- this.log(`! cleanup after a disconnect failed: ${e.message}`);
3133
+ this.noteStorageFailure(`[${space?.name ?? "?"}] cleanup after a disconnect`, e);
2249
3134
  } });
2250
3135
  ws.on("error", () => { });
2251
3136
  }
@@ -2265,21 +3150,26 @@ pantheon signup --email you@work.com --username you</code></pre>
2265
3150
  const now = Date.now();
2266
3151
  for (const sp of this.spaces.values()) {
2267
3152
  try {
2268
- if (now - (this.compactedAt.get(sp.name) ?? 0) > LIMITS.ledgerCompactEveryMs)
3153
+ const every = this.spaceFull(sp) ? 60_000 : LIMITS.ledgerCompactEveryMs;
3154
+ if (now - (this.compactedAt.get(sp.name) ?? 0) > every)
2269
3155
  this.compactSpace(sp);
2270
3156
  this.lapseClaims(sp, now);
2271
3157
  }
2272
3158
  catch (e) {
2273
- this.log(`! [${sp.name}] housekeeping failed: ${e.message}`);
3159
+ this.noteStorageFailure(`[${sp.name}] housekeeping`, e);
2274
3160
  }
2275
3161
  }
2276
3162
  try {
2277
3163
  this.evictSpaces();
2278
3164
  }
2279
3165
  catch (e) {
2280
- this.log(`! evicting idle spaces failed: ${e.message}`);
3166
+ this.noteStorageFailure("evicting idle spaces", e);
2281
3167
  }
2282
3168
  }
3169
+ claimHolds(sp, holder, now = Date.now()) {
3170
+ const live = [...sp.members.values()].some((m) => m.sessions.has(holder.session));
3171
+ return now - holder.at < (live ? LIMITS.claimLiveTtlMs : LIMITS.claimTtlMs);
3172
+ }
2283
3173
  lapseClaims(sp, now) {
2284
3174
  const live = new Set();
2285
3175
  for (const m of sp.members.values())
@@ -2287,14 +3177,15 @@ pantheon signup --email you@work.com --username you</code></pre>
2287
3177
  live.add(id);
2288
3178
  let lapsed = 0;
2289
3179
  for (const o of sp.obligations.values()) {
2290
- if (o.claimed_by && !live.has(o.claimed_by.session) && now - o.claimed_by.at >= LIMITS.claimTtlMs) {
3180
+ if (o.claimed_by && !this.claimHolds(sp, o.claimed_by, now)) {
2291
3181
  delete o.claimed_by;
2292
3182
  lapsed++;
2293
3183
  }
2294
3184
  }
2295
3185
  for (const [k, v] of sp.contLocks)
2296
- if (!live.has(v.session) && now - v.at >= LIMITS.claimTtlMs)
3186
+ if (!this.claimHolds(sp, v, now))
2297
3187
  sp.contLocks.delete(k);
3188
+ void live;
2298
3189
  if (lapsed)
2299
3190
  this.pushObligations(sp);
2300
3191
  }
@@ -2312,6 +3203,8 @@ pantheon signup --email you@work.com --username you</code></pre>
2312
3203
  return { code: "NO_SUCH_MEMBER", message: `nobody here is called "${id}" — members: ${[...sp.members.keys()].join(", ")}` };
2313
3204
  }
2314
3205
  }
3206
+ if (ref === UPGRADE_NOTE_ID)
3207
+ return { code: "UPGRADE_REQUIRED", message: `${UPGRADE_NOTE}. That notice is from the relay, not a teammate, so there is nothing to reply to.` };
2315
3208
  if (ref && !sp.ledger.find(ref))
2316
3209
  return { code: "BAD_REF", message: `no act with id ${ref}` };
2317
3210
  if (meta && JSON.stringify(meta).length > LIMITS_SPACE.metaBytes) {
@@ -2327,6 +3220,10 @@ pantheon signup --email you@work.com --username you</code></pre>
2327
3220
  return { code: "ESCALATE_IS_LOCAL",
2328
3221
  message: "ESCALATE is for your own human. Use HUMAN_REQUIRED to notify one named peer's human." };
2329
3222
  }
3223
+ if (meta && ("authority" in meta || "unverified_domains" in meta)) {
3224
+ delete meta["authority"];
3225
+ delete meta["unverified_domains"];
3226
+ }
2330
3227
  if (act === "HUMAN_REQUIRED" && (!to || to.length !== 1)) {
2331
3228
  return { code: "ONE_HUMAN_REQUIRED",
2332
3229
  message: "HUMAN_REQUIRED must name exactly one human owner. Send separate requests when separate people must decide." };
@@ -2335,6 +3232,9 @@ pantheon signup --email you@work.com --username you</code></pre>
2335
3232
  const domains = Array.isArray(meta?.decision_domains)
2336
3233
  ? [...new Set(meta.decision_domains.filter((x) => typeof x === "string" && x.trim().length > 0))]
2337
3234
  : [];
3235
+ if (domains.some((d) => d.length > 64 || /[\u0000-\u001f\u007f-\u009f\u2028\u2029]/.test(d))) {
3236
+ return { code: "BAD_DECISION_DOMAIN", message: "decision_domains are single-line names of up to 64 characters, as profiles publish them" };
3237
+ }
2338
3238
  if (!domains.length) {
2339
3239
  return { code: "DECISION_DOMAINS_REQUIRED",
2340
3240
  message: "HUMAN_REQUIRED must name decision_domains so the relay can verify the human owner. Call pantheon_route_decision first." };
@@ -2506,7 +3406,13 @@ pantheon signup --email you@work.com --username you</code></pre>
2506
3406
  commit(sp, partial) {
2507
3407
  if (sp.deleted)
2508
3408
  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 });
3409
+ if ((partial.act === "JOIN" || partial.act === "LEAVE") && this.spaceFull(sp)) {
3410
+ return { ...partial, id: newId("a"), seq: sp.ledger.head, ts: Date.now(), space: sp.name };
3411
+ }
3412
+ let id = partial.id ?? newId("a");
3413
+ while (!partial.id && sp.ledger.find(id))
3414
+ id = newId("a");
3415
+ const rec = sp.ledger.append({ ...partial, id, ts: Date.now(), space: sp.name }, this.maxLedgerBytes + SPACE_HARD_SLACK);
2510
3416
  sp.lastTouch = rec.ts;
2511
3417
  const m = sp.members.get(rec.from);
2512
3418
  if (m) {
@@ -2514,13 +3420,22 @@ pantheon signup --email you@work.com --username you</code></pre>
2514
3420
  if (rec.act !== "JOIN" && rec.act !== "LEAVE" && (m.seen_seq ?? 0) < rec.seq - 1)
2515
3421
  m.seen_seq = rec.seq - 1;
2516
3422
  }
3423
+ this.effects(sp, rec);
3424
+ if (sp.store.rebuilt && rec.act !== "JOIN" && rec.act !== "LEAVE")
3425
+ sp.store.rebuilt = null;
3426
+ this.pushObligations(sp);
3427
+ this.broadcast(sp, { t: "act", act: rec });
3428
+ this.broadcast(sp, { t: "roster", roster: this.roster(sp) });
3429
+ return rec;
3430
+ }
3431
+ effects(sp, rec, ctx) {
2517
3432
  if (rec.goal) {
2518
3433
  const goal = sp.goals.get(rec.goal);
2519
3434
  if (goal) {
2520
3435
  goal.acts++;
2521
3436
  const run = sp.lastActor.get(rec.goal);
2522
3437
  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);
3438
+ this.applyObligations(sp, rec, goal, ctx);
2524
3439
  this.broadcast(sp, { t: "goal", goal });
2525
3440
  if (goal.acts >= goal.budget && goal.state === "OPEN") {
2526
3441
  goal.state = "STALLED";
@@ -2531,14 +3446,10 @@ pantheon signup --email you@work.com --username you</code></pre>
2531
3446
  }
2532
3447
  }
2533
3448
  else {
2534
- this.applyObligations(sp, rec, null);
3449
+ this.applyObligations(sp, rec, null, ctx);
2535
3450
  }
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
3451
  }
2541
- applyObligations(sp, rec, goal) {
3452
+ applyObligations(sp, rec, goal, ctx) {
2542
3453
  const closeFor = (owner, ref) => {
2543
3454
  for (const [id, o] of sp.obligations)
2544
3455
  if (o.owner === owner && o.ref === ref)
@@ -2642,8 +3553,9 @@ pantheon signup --email you@work.com --username you</code></pre>
2642
3553
  const answer = sp.ledger.find(rec.ref);
2643
3554
  const asked = answer?.act === "ANSWER" && answer.from === rec.from && answer.ref ? sp.ledger.find(answer.ref) : undefined;
2644
3555
  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));
3556
+ const retracted = ctx?.retracted ?? new Set(sp.ledger.all().filter((r) => r.act === "RETRACT" && r.ref).map((r) => r.ref));
3557
+ const answersTo = asked ? (ctx ? ctx.answers.get(asked.id) ?? [] : sp.ledger.all().filter((r) => r.act === "ANSWER" && r.ref === asked.id)) : [];
3558
+ const stillAnswered = answersTo.some((r) => r.from === rec.from && r.id !== answer.id && !retracted.has(r.id));
2647
3559
  const alreadyLive = asked && [...sp.obligations.values()].some((o) => o.kind === "HUMAN" && o.ref === asked.id);
2648
3560
  if (asked && decider === rec.from && !stillAnswered && !alreadyLive && !retracted.has(asked.id)) {
2649
3561
  this.oblige(sp, { kind: "HUMAN", owner: `human:${decider}`, ref: asked.id, goal: asked.goal,
@@ -2681,6 +3593,23 @@ pantheon signup --email you@work.com --username you</code></pre>
2681
3593
  const id = newId("o");
2682
3594
  sp.obligations.set(id, { ...o, id, created_at: Date.now() });
2683
3595
  }
3596
+ freesRoom(sp, member, act, ref) {
3597
+ if (act === "CLOSE" || act === "RETRACT" || act === "WITHDRAW")
3598
+ return true;
3599
+ if (act === "ANSWER" && ref) {
3600
+ for (const o of sp.obligations.values())
3601
+ if (o.kind === "HUMAN" && o.ref === ref && o.owner === `human:${member}`)
3602
+ return true;
3603
+ }
3604
+ return false;
3605
+ }
3606
+ spaceFull(sp) {
3607
+ return sp.ledger.bytes >= this.maxLedgerBytes;
3608
+ }
3609
+ spaceFullMessage(sp) {
3610
+ 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. ` +
3611
+ `Close finished goals: the relay compacts their old acts away, which frees room. Or ask the relay operator to raise PANTHEON_SPACE_MAX_BYTES.`;
3612
+ }
2684
3613
  pushObligations(sp) {
2685
3614
  sp.store.flush(sp.goals, sp.obligations, sp.profiles);
2686
3615
  this.broadcast(sp, { t: "obligations", list: [...sp.obligations.values()] });
@@ -2753,18 +3682,23 @@ pantheon signup --email you@work.com --username you</code></pre>
2753
3682
  continue;
2754
3683
  const idle = now - sp.lastTouch;
2755
3684
  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`);
3685
+ try {
3686
+ if (junk && idle > this.limits.spaceJunkDeleteMs) {
3687
+ sp.store.close(sp.goals, sp.obligations, sp.profiles);
3688
+ this.spaces.delete(name);
3689
+ this.compactedAt.delete(name);
3690
+ this.deleteSpaceFiles(sp.name);
3691
+ this.log(`[${name}] evicted junk space (idle ${Math.round(idle / 3_600_000)}h, no work, no members) — files removed`);
3692
+ }
3693
+ else if (idle > this.limits.spaceIdleEvictMs) {
3694
+ sp.store.close(sp.goals, sp.obligations, sp.profiles);
3695
+ this.spaces.delete(name);
3696
+ this.compactedAt.delete(name);
3697
+ this.log(`[${name}] evicted from memory (idle ${Math.round(idle / 3_600_000)}h) — reloads from disk on next connect`);
3698
+ }
2762
3699
  }
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`);
3700
+ catch (e) {
3701
+ this.noteStorageFailure(`[${name}] eviction flush`, e);
2768
3702
  }
2769
3703
  }
2770
3704
  }
@@ -2777,22 +3711,49 @@ pantheon signup --email you@work.com --username you</code></pre>
2777
3711
  catch { }
2778
3712
  }
2779
3713
  }
2780
- getOrCreateSpace(name, token) {
2781
- const existing = this.spaces.get(name);
2782
- if (existing)
2783
- return secretEq(existing.token, token) ? existing : null;
3714
+ getOrCreateSpace(name, token, opts = {}) {
3715
+ const claim = opts.claim;
3716
+ const retired = !!opts.retired;
3717
+ const existing = this.spaces.get(spaceKey(name));
3718
+ if (existing) {
3719
+ if (existing.name !== name)
3720
+ return null;
3721
+ const st = this.tokenState(existing, token);
3722
+ return st === "current" || (retired && st === "retired") ? existing : null;
3723
+ }
3724
+ if (this.aliasOf(name))
3725
+ return null;
2784
3726
  const safe = name.replace(/[^a-z0-9._-]/gi, "_");
2785
3727
  const allow = new Allowlist(this.dataDir, safe);
2786
3728
  const match = allow.tokenMatches(token);
2787
- if (match === false)
2788
- return null;
2789
- if (match === null)
3729
+ let raw = token;
3730
+ if (match === false) {
3731
+ if (!(retired && allow.tokenRetired(token)))
3732
+ return null;
3733
+ raw = allow.currentTokenFromInvites() ?? "";
3734
+ }
3735
+ if (match === null) {
3736
+ if (this.unboundOnDisk(safe)) {
3737
+ const who = claim && claim !== "invite" ? claim : null;
3738
+ const e = who ? allow.get(who.member) : undefined;
3739
+ const ok = claim === "invite" || (!!who && !!e?.admitted && e.publicKey === who.pubkey);
3740
+ if (!ok) {
3741
+ this.unbound.add(safe);
3742
+ return null;
3743
+ }
3744
+ 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"}`);
3745
+ }
2790
3746
  allow.bindToken(token);
3747
+ this.unbound.delete(safe);
3748
+ }
3749
+ const ledger = new Ledger(this.dataDir, safe);
2791
3750
  const store = new StateStore(this.dataDir, safe);
2792
- const restored = store.load();
3751
+ store.headOf = () => ledger.head;
3752
+ store.onError = (e) => this.noteStorageFailure(`[${name}] snapshot save`, e);
3753
+ const restored = store.load({ quarantine: true, expectHistory: ledger.size > 0 });
2793
3754
  const sp = {
2794
- name, token,
2795
- ledger: new Ledger(this.dataDir, safe),
3755
+ name, token: raw,
3756
+ ledger,
2796
3757
  allow,
2797
3758
  store,
2798
3759
  members: new Map(),
@@ -2805,27 +3766,197 @@ pantheon signup --email you@work.com --username you</code></pre>
2805
3766
  createdAt: Date.now(),
2806
3767
  lastTouch: Date.now(),
2807
3768
  };
2808
- this.spaces.set(name, sp);
3769
+ if (restored.unclean) {
3770
+ sp.unclean = restored.unclean;
3771
+ const rebuilt = this.rebuildFromLedger(sp);
3772
+ this.log(`!! [${name}] UNCLEAN LOAD: ${restored.unclean}${restored.quarantined ? ` — moved aside to ${restored.quarantined}` : ""}. ` +
3773
+ `Rebuilt ${rebuilt.goals} goal(s) (${rebuilt.open} open) and ${sp.obligations.size} obligation(s) from ${ledger.size} act(s) in the ledger; ` +
3774
+ `ledger compaction is OFF for this space until a snapshot written from live state (after a real act) is loaded clean.`);
3775
+ store.rebuilt = restored.unclean;
3776
+ try {
3777
+ store.flush(sp.goals, sp.obligations, sp.profiles);
3778
+ }
3779
+ catch (e) {
3780
+ this.noteStorageFailure(`[${name}] rebuilt snapshot`, e);
3781
+ }
3782
+ }
3783
+ else if (restored.rebuilt) {
3784
+ sp.unclean = `its snapshot is still the one rebuilt from the ledger (${restored.rebuilt}), and no act has been built on it yet`;
3785
+ store.rebuilt = restored.rebuilt;
3786
+ this.log(`! [${name}] loaded a snapshot rebuilt from the ledger that no act has built on yet; ledger compaction stays OFF for this run.`);
3787
+ }
3788
+ else if (restored.ledgerHead !== undefined && ledger.head > restored.ledgerHead) {
3789
+ const after = restored.ledgerHead;
3790
+ const missed = ledger.all().filter((r) => r.seq > after);
3791
+ const had = new Set(sp.obligations.keys());
3792
+ let opened = 0;
3793
+ for (const r of missed) {
3794
+ if (r.goal && !sp.goals.has(r.goal)) {
3795
+ const g = this.goalFromOpening(r);
3796
+ if (g) {
3797
+ sp.goals.set(g.id, g);
3798
+ opened++;
3799
+ }
3800
+ }
3801
+ this.effects(sp, r);
3802
+ }
3803
+ for (const o of sp.obligations.values())
3804
+ if (!had.has(o.id))
3805
+ o.created_at = ledger.find(o.ref)?.ts ?? o.created_at;
3806
+ 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` : ""}`);
3807
+ try {
3808
+ store.flush(sp.goals, sp.obligations, sp.profiles);
3809
+ }
3810
+ catch (e) {
3811
+ this.noteStorageFailure(`[${name}] replayed snapshot`, e);
3812
+ }
3813
+ }
3814
+ this.spaces.set(spaceKey(name), sp);
3815
+ try {
3816
+ allow.recordName(name);
3817
+ }
3818
+ catch (e) {
3819
+ this.noteStorageFailure(`[${name}] recording its name`, e);
3820
+ }
2809
3821
  for (const e of allow.all()) {
2810
3822
  if (e.admitted && !sp.members.has(e.member)) {
2811
3823
  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
3824
  }
2813
3825
  }
3826
+ this.rebuildRecent(sp);
2814
3827
  const open = [...sp.goals.values()].filter((g) => g.state !== "CLOSED").length;
2815
3828
  this.log(`[${name}] space ready (seq ${sp.ledger.head}, ${sp.goals.size} goals — ${open} still open, ${sp.obligations.size} obligations restored)`);
3829
+ const founder = allow.founder();
3830
+ const guess = allow.founderGuess();
3831
+ if (founder && (guess || !allow.get(founder)?.admitted)) {
3832
+ this.log(`! [${name}] founder is "${founder}"${guess ? ` — a guess: ${guess} (an older relay rewrote who admitted whom)` : ""}` +
3833
+ `${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)`}`);
3834
+ }
3835
+ if (ledger.outOfOrder) {
3836
+ this.log(`! [${name}] ledger seq is not strictly increasing: ${ledger.outOfOrder} act(s) at or below the one before (first at seq ${ledger.firstOutOfOrder}). ` +
3837
+ `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.`);
3838
+ }
2816
3839
  this.compactSpace(sp);
2817
3840
  return sp;
2818
3841
  }
3842
+ rebuildRecent(sp) {
3843
+ const now = Date.now();
3844
+ const recs = sp.ledger.all();
3845
+ const retracted = new Set();
3846
+ const window = [];
3847
+ for (let i = recs.length - 1; i >= 0; i--) {
3848
+ const r = recs[i];
3849
+ if (now - r.ts > LIMITS.dedupeWindowMs)
3850
+ break;
3851
+ if (r.act === "RETRACT" && r.ref)
3852
+ retracted.add(r.ref);
3853
+ window.push(r);
3854
+ }
3855
+ for (const r of window.reverse()) {
3856
+ if (!r.session || !SENDABLE.includes(r.act) || retracted.has(r.id))
3857
+ continue;
3858
+ const fp = actFingerprint(r.from, r.act, r.body, r.goal ?? null, r.ref ?? null, r.to ?? null, r.meta);
3859
+ sp.recent.set(fp, { id: r.id, seq: r.seq, at: r.ts });
3860
+ }
3861
+ for (const k of sp.recent.keys()) {
3862
+ if (sp.recent.size <= RECENT_MAX)
3863
+ break;
3864
+ sp.recent.delete(k);
3865
+ }
3866
+ }
3867
+ rebuildFromLedger(sp) {
3868
+ sp.goals.clear();
3869
+ sp.obligations.clear();
3870
+ const ctx = { retracted: new Set(), answers: new Map() };
3871
+ for (const r of sp.ledger.all()) {
3872
+ if (r.act === "RETRACT" && r.ref)
3873
+ ctx.retracted.add(r.ref);
3874
+ if (r.act === "ANSWER" && r.ref) {
3875
+ const a = ctx.answers.get(r.ref);
3876
+ if (a)
3877
+ a.push(r);
3878
+ else
3879
+ ctx.answers.set(r.ref, [r]);
3880
+ }
3881
+ let goal = null;
3882
+ if (r.goal) {
3883
+ goal = sp.goals.get(r.goal) ?? null;
3884
+ if (!goal) {
3885
+ goal = this.goalFromOpening(r) ?? {
3886
+ id: r.goal, title: `(recovered goal ${r.goal})`, acceptance: "", members: [r.from],
3887
+ state: "OPEN", acts: 0, budget: LIMITS.goalBudget, opened_by: r.from, opened_at: r.ts,
3888
+ };
3889
+ sp.goals.set(goal.id, goal);
3890
+ }
3891
+ const affects = r.meta?.affects;
3892
+ for (const who of [r.from, ...(r.to ?? []), ...(Array.isArray(affects) ? affects.filter((x) => typeof x === "string") : [])]) {
3893
+ if (!who.startsWith("human:") && !goal.members.includes(who))
3894
+ goal.members.push(who);
3895
+ }
3896
+ goal.acts++;
3897
+ }
3898
+ this.applyObligations(sp, r, goal, ctx);
3899
+ }
3900
+ for (const o of sp.obligations.values())
3901
+ o.created_at = sp.ledger.find(o.ref)?.ts ?? o.created_at;
3902
+ let open = 0;
3903
+ for (const g of sp.goals.values()) {
3904
+ if (g.state === "CLOSED")
3905
+ continue;
3906
+ open++;
3907
+ g.budget = Math.max(g.budget, g.acts + Math.ceil(LIMITS.goalBudget / 2));
3908
+ }
3909
+ return { goals: sp.goals.size, open };
3910
+ }
3911
+ goalFromOpening(r) {
3912
+ if (!r.goal || r.act !== "NOTE" || r.session)
3913
+ return null;
3914
+ const mm = GOAL_OPENED.exec(r.body);
3915
+ if (!mm)
3916
+ return null;
3917
+ const meta = r.meta;
3918
+ const deadline = mm[3] ? Date.parse(mm[3]) : NaN;
3919
+ const listed = meta?.goal_members;
3920
+ const members = Array.isArray(listed) ? [...new Set([r.from, ...listed.filter((x) => typeof x === "string" && !x.startsWith("human:"))])] : [r.from];
3921
+ const budget = typeof meta?.goal_budget === "number" && Number.isInteger(meta.goal_budget) && meta.goal_budget > 0 ? meta.goal_budget : LIMITS.goalBudget;
3922
+ return {
3923
+ id: r.goal,
3924
+ title: typeof meta?.goal_text?.title === "string" ? meta.goal_text.title : mm[1],
3925
+ acceptance: typeof meta?.goal_text?.acceptance === "string" ? meta.goal_text.acceptance : (mm[2] !== "(none stated)" ? mm[2] : ""),
3926
+ ...(meta?.goal_sig && typeof meta.goal_sig === "object" ? { sig: meta.goal_sig } : {}),
3927
+ members,
3928
+ state: "OPEN",
3929
+ acts: 0,
3930
+ budget,
3931
+ opened_by: r.from,
3932
+ opened_at: r.ts,
3933
+ ...(Number.isFinite(deadline) ? { deadline } : {}),
3934
+ ...(mm[4] === "worklist" ? { kind: "worklist" } : {}),
3935
+ };
3936
+ }
2819
3937
  compactSpace(sp) {
3938
+ this.compactedAt.set(sp.name, Date.now());
3939
+ if (sp.unclean)
3940
+ return;
2820
3941
  const isLive = (goal) => {
2821
3942
  if (!goal)
2822
3943
  return false;
2823
3944
  const g = sp.goals.get(goal);
2824
- return !!g && g.state !== "CLOSED";
3945
+ return !g || g.state !== "CLOSED";
2825
3946
  };
2826
3947
  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());
3948
+ const before = sp.ledger.all().map((r) => r.id);
3949
+ const { dropped, kept } = sp.ledger.compact({ keepRecent: this.opts.ledgerKeepRecent ?? LIMITS.ledgerKeepRecent, isLive, keep: (id) => referenced.has(id) });
3950
+ if (dropped) {
3951
+ const now = Date.now();
3952
+ const d = (sp.droppedIds ??= new Map());
3953
+ for (const [id, at] of d)
3954
+ if (now - at > DAY)
3955
+ d.delete(id);
3956
+ for (const id of before)
3957
+ if (!sp.ledger.find(id))
3958
+ d.set(id, now);
3959
+ }
2829
3960
  if (dropped)
2830
3961
  this.log(`[${sp.name}] compacted ledger — dropped ${dropped} old act(s) from closed goals, ${kept} kept`);
2831
3962
  }
@@ -2861,7 +3992,10 @@ pantheon signup --email you@work.com --username you</code></pre>
2861
3992
  members(sp) {
2862
3993
  return sp.allow.all().map((e) => ({
2863
3994
  member: e.member, admitted: e.admitted, may_wake: e.may_wake,
2864
- fingerprint: fingerprint(e.publicKey),
3995
+ fingerprint: fingerprint(e.publicKey), pubkey: e.publicKey,
3996
+ ...(e.admitted_by ? { admitted_by: e.admitted_by } : {}),
3997
+ ...(e.revoked_at ? { revoked_at: e.revoked_at, ...(e.revoked_by ? { revoked_by: e.revoked_by } : {}) } : {}),
3998
+ ...(sp.allow.founder() === e.member ? { founder: true } : {}),
2865
3999
  }));
2866
4000
  }
2867
4001
  send(ws, msg) {