@join-pantheon/cli 0.33.3 → 0.33.4-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/relay.js CHANGED
@@ -1,8 +1,9 @@
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, openSync, readSync, closeSync, fstatSync } from "node:fs";
3
+ import { copyFileSync, readdirSync, readFileSync, statSync, existsSync, writeFileSync, openSync, readSync, closeSync, fstatSync } from "node:fs";
4
4
  import { spawn } from "node:child_process";
5
- import { join } from "node:path";
5
+ import { monitorEventLoopDelay } from "node:perf_hooks";
6
+ import { basename, join } from "node:path";
6
7
  import { WebSocketServer } from "ws";
7
8
  import { badRemote, Inbound, isContinuationFor, LIMITS, newId, PROTOCOL_VERSION, RELAY_FEATURES, SENDABLE, shouldDeferToHuman, } from "./protocol.js";
8
9
  import { VERSION } from "./version.js";
@@ -14,9 +15,11 @@ import { Accounts, normalizeUsername, validEmail, validUsername } from "./accoun
14
15
  import { emailSenderFromEnv } from "./email.js";
15
16
  import { Allowlist, RosterUnreadable } from "./roster-file.js";
16
17
  import { StateStore, ensurePrivateDir, setWaitForDisk, takeSlowestDiskWait, waitsForDisk } from "./store.js";
18
+ import { busyWriter, DiskBusy, flushWrites, onWriteError, pendingView, readLatest, recordWrites, whenLanded, writerFor } from "./disk-writer.js";
17
19
  import { activity, mergeActivity, feed, noteRefusal, refusalsSince, countActs, spaceHealth, fleetReport, ESCALATIONS, MESSAGE_ACTS, ACT_TONE, DAY } from "./admin-stats.js";
18
20
  import { fmtLimit, inviteLimit, parseLimits, planLimits, PLAN_NAME } from "./plans.js";
19
21
  import { FEEDBACK_ADDRESS } from "./feedback.js";
22
+ import { relayShellArg, teamNameProblem } from "./team-name.js";
20
23
  import { routeDecision } from "./profile.js";
21
24
  const presentSessions = (m) => [...m.sessions.values()].filter((s) => !s.quiet);
22
25
  function workState(m, now = Date.now(), busyTtlMs = LIMITS.activityBusyTtlMs, pruneMs = LIMITS.activityPruneMs) {
@@ -178,6 +181,19 @@ const fmtAgo = (t) => {
178
181
  return m < 1 ? "just now" : m < 60 ? `${m}m ago` : m < 1440 ? `${Math.round(m / 60)}h ago` : `${Math.round(m / 1440)}d ago`;
179
182
  };
180
183
  const safeName = (name) => name.replace(/[^a-z0-9._-]/gi, "_");
184
+ const quoteTeam = (name) => relayShellArg(name);
185
+ function parseLines(text) {
186
+ const records = [];
187
+ for (const line of text.split("\n")) {
188
+ if (!line.trim())
189
+ continue;
190
+ try {
191
+ records.push(JSON.parse(line));
192
+ }
193
+ catch { }
194
+ }
195
+ return records;
196
+ }
181
197
  const spaceKey = (name) => safeName(name).toLowerCase();
182
198
  function firstLedgerName(file) {
183
199
  let fd = null;
@@ -276,6 +292,26 @@ function versionBelow(a, b) {
276
292
  export const RECOVERY_PROOF_MS = 15 * 60_000;
277
293
  const RECOVERY_KEEP_MS = 7 * 86_400_000;
278
294
  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";
295
+ const LOOP_RESOLUTION_MS = 10;
296
+ const LOOP_WINDOW_MS = 60_000;
297
+ function loopMs(ns) {
298
+ return Math.max(0, Math.round((ns / 1e6 - LOOP_RESOLUTION_MS) * 100) / 100);
299
+ }
300
+ const STORAGE_HELD = "the relay received this but has not saved it yet — its disk is refusing writes. It is kept, and reaches the team when the disk recovers; do not send it again.";
301
+ export function backupExcludes(dir, keep) {
302
+ const always = ["--exclude=*.tmp"];
303
+ if (!keep)
304
+ return always;
305
+ let names = [];
306
+ try {
307
+ names = readdirSync(dir);
308
+ }
309
+ catch {
310
+ return always;
311
+ }
312
+ const kept = basename(keep);
313
+ return [...always, ...names.filter((f) => f !== kept && /^accounts-.*\.db$/.test(f)).map((f) => `--exclude=./backups/${f}`)];
314
+ }
279
315
  const STORAGE_UNHEALTHY_MS = 5 * 60_000;
280
316
  function isStorageError(e) {
281
317
  const code = e?.code;
@@ -428,6 +464,10 @@ export class Relay {
428
464
  catch { }
429
465
  };
430
466
  try {
467
+ if (this.closing && req.method !== "GET" && req.method !== "HEAD") {
468
+ res.writeHead(503, { "content-type": "application/json", "retry-after": "10" });
469
+ return void res.end(JSON.stringify({ code: "SHUTTING_DOWN", error: "the relay is restarting — try again in a few seconds" }));
470
+ }
431
471
  if (!URL.canParse(req.url ?? "/", "http://x"))
432
472
  return fail(new TypeError("unparseable request target"));
433
473
  const r = this.onHttp(req, res);
@@ -442,6 +482,12 @@ export class Relay {
442
482
  heartbeat;
443
483
  lagTimer;
444
484
  lagMax = 0;
485
+ loopDelay = null;
486
+ loopPrev = null;
487
+ loopTimer;
488
+ stopWriteErrors;
489
+ spaceOf = new WeakMap();
490
+ closing = false;
445
491
  fsyncOff = false;
446
492
  compactedAt = new Map();
447
493
  sessionSeq = 0;
@@ -483,6 +529,7 @@ export class Relay {
483
529
  this.log("* PANTHEON_RELAY_FSYNC=off: writes go to the operating system at once but the relay does not wait for the disk to confirm them (a relay crash loses nothing; a host power loss can lose the last seconds)");
484
530
  }
485
531
  ensurePrivateDir(this.dataDir);
532
+ this.stopWriteErrors = onWriteError(this.dataDir, (where, e, stem) => { this.noteStorageFailure(where, e); this.failHeld(stem); });
486
533
  this.maxLedgerBytes = opts.spaceMaxBytes
487
534
  ?? (Number(process.env.PANTHEON_SPACE_MAX_BYTES) > 0 ? Number(process.env.PANTHEON_SPACE_MAX_BYTES) : SPACE_MAX_BYTES_DEFAULT);
488
535
  this.scanUnbound();
@@ -544,6 +591,10 @@ export class Relay {
544
591
  let last = Date.now();
545
592
  this.lagTimer = setInterval(() => { const now = Date.now(); this.lagMax = Math.max(this.lagMax, now - last - 500); last = now; }, 500);
546
593
  this.lagTimer.unref?.();
594
+ this.loopDelay = monitorEventLoopDelay({ resolution: LOOP_RESOLUTION_MS });
595
+ this.loopDelay.enable();
596
+ this.loopTimer = setInterval(() => this.rotateLoopWindow(), LOOP_WINDOW_MS);
597
+ this.loopTimer.unref?.();
547
598
  this.startBackups();
548
599
  const addr = this.http.address();
549
600
  const port = typeof addr === "object" && addr ? addr.port : want;
@@ -552,6 +603,11 @@ export class Relay {
552
603
  });
553
604
  }
554
605
  async close() {
606
+ this.closing = true;
607
+ for (const ws of this.wss.clients)
608
+ ws.terminate();
609
+ await new Promise((r) => this.wss.close(() => r()));
610
+ await new Promise((r) => setImmediate(r));
555
611
  for (const sp of this.spaces.values()) {
556
612
  try {
557
613
  sp.store.close(sp.goals, sp.obligations, sp.profiles);
@@ -560,6 +616,9 @@ export class Relay {
560
616
  this.noteStorageFailure(`[${sp.name}] final flush`, e);
561
617
  }
562
618
  }
619
+ if (!(await flushWrites(this.dataDir, 30_000)))
620
+ this.log("! storage: writes were still queued when the relay closed (the disk did not take them in 30 s) — they are lost if the process exits now");
621
+ this.stopWriteErrors();
563
622
  if (this.backupTimer)
564
623
  clearInterval(this.backupTimer);
565
624
  this.installerGate.close();
@@ -569,15 +628,24 @@ export class Relay {
569
628
  clearInterval(this.heartbeat);
570
629
  if (this.lagTimer)
571
630
  clearInterval(this.lagTimer);
631
+ if (this.loopTimer)
632
+ clearInterval(this.loopTimer);
633
+ this.loopDelay?.disable();
572
634
  if (this.fsyncOff) {
573
635
  setWaitForDisk(true);
574
636
  this.fsyncOff = false;
575
637
  }
576
- for (const ws of this.wss.clients)
577
- ws.terminate();
578
- await new Promise((r) => this.wss.close(() => r()));
579
638
  await new Promise((r) => this.http.close(() => r()));
580
639
  }
640
+ replyLanded(res, writes, reply) {
641
+ void whenLanded(writes()).then((ok) => {
642
+ if (res.destroyed)
643
+ return;
644
+ if (ok)
645
+ return reply();
646
+ this.json(res, 503, { code: "STORAGE", error: "the relay made that change but has not saved it yet — its disk is refusing writes. It is kept, and is saved when the disk recovers; do not send it again." });
647
+ });
648
+ }
581
649
  noteStorageFailure(where, e) {
582
650
  const message = e?.message ?? String(e);
583
651
  this.log(`! storage: ${where} failed: ${message}`);
@@ -608,12 +676,17 @@ export class Relay {
608
676
  }
609
677
  }
610
678
  unboundOnDisk(safe) {
611
- const size = (suf) => { try {
612
- return statSync(join(this.dataDir, `${safe}${suf}`)).size;
613
- }
614
- catch {
615
- return -1;
616
- } };
679
+ const size = (suf) => {
680
+ const v = pendingView(this.dataDir, safe, join(this.dataDir, `${safe}${suf}`));
681
+ if (v !== undefined)
682
+ return v === null ? -1 : v.length;
683
+ try {
684
+ return statSync(join(this.dataDir, `${safe}${suf}`)).size;
685
+ }
686
+ catch {
687
+ return -1;
688
+ }
689
+ };
617
690
  const ledger = size(".jsonl");
618
691
  if (size(".members.json") < 0)
619
692
  return ledger > 0;
@@ -652,6 +725,7 @@ export class Relay {
652
725
  ...(adminView && this.unbound.size ? { unbound_spaces: [...this.unbound].sort() } : {}),
653
726
  space_count: this.spaces.size,
654
727
  space_capacity: this.limits.maxSpaces,
728
+ ...(this.loopDelay ? { loop_ms: this.loopStats() } : {}),
655
729
  ...(adminView ? { spaces: [...this.spaces.values()].map(detail) }
656
730
  : asked ? { spaces: one ? [detail(one)] : [] }
657
731
  : {}),
@@ -765,7 +839,7 @@ export class Relay {
765
839
  const seen = Number(url.searchParams.get("seen"));
766
840
  if (Number.isFinite(seen) && seen > (member.seen_seq ?? 0))
767
841
  member.seen_seq = seen;
768
- this.broadcast(space, { t: "roster", roster: this.roster(space) });
842
+ this.broadcast(space, () => ({ t: "roster", roster: this.roster(space) }));
769
843
  return this.json(res, 200, { ok: true });
770
844
  }
771
845
  this.json(res, 404, { error: "not found" });
@@ -779,19 +853,19 @@ export class Relay {
779
853
  const dir = join(this.dataDir, "backups");
780
854
  const sessionDays = Number(process.env.PANTHEON_SESSION_RETENTION_DAYS ?? "30");
781
855
  const auditDays = Number(process.env.PANTHEON_AUDIT_RETENTION_DAYS ?? "365");
782
- const run = () => {
856
+ const run = async () => {
783
857
  try {
784
858
  const pruned = this.accounts.prune({ sessionDays, auditDays });
785
859
  if (pruned.sessions || pruned.audit)
786
860
  this.log(`accounts pruned: ${pruned.sessions} stale session(s), ${pruned.audit} old audit row(s)`);
787
- const f = this.accounts.backup(dir);
861
+ const f = await this.accounts.backupAsync(dir);
788
862
  this.log(`accounts backup written: ${f}`);
789
863
  }
790
864
  catch (e) {
791
865
  this.log(`! accounts backup/prune failed: ${e.message}`);
792
866
  }
793
867
  };
794
- setTimeout(run, 60_000);
868
+ setTimeout(run, 60_000).unref?.();
795
869
  this.backupTimer = setInterval(run, hours * 3_600_000);
796
870
  this.backupTimer.unref?.();
797
871
  }
@@ -849,7 +923,14 @@ export class Relay {
849
923
  if (!authed)
850
924
  return this.json(res, 403, { error: "sign in at /admin, or append ?token=…" });
851
925
  A0.audit({ event: "admin_backup", ip });
852
- return this.adminBackup(res, A0);
926
+ void this.adminBackup(res, A0).catch((e) => {
927
+ this.log(`! backup archive failed: ${e.message}`);
928
+ if (!res.headersSent)
929
+ this.json(res, 500, { error: "the backup could not be made" });
930
+ else
931
+ res.end();
932
+ });
933
+ return;
853
934
  }
854
935
  if (url.pathname === "/admin/action" && req.method === "POST") {
855
936
  if (!authed)
@@ -965,6 +1046,7 @@ export class Relay {
965
1046
  });
966
1047
  }
967
1048
  if (url.pathname === "/account/revoke" && req.method === "POST") {
1049
+ const writes = recordWrites();
968
1050
  const who = this.verifySignedAccount(url, "POST", "/account/revoke", ip);
969
1051
  if (!who)
970
1052
  return this.json(res, 403, { error: "this request must be signed by a device on the account" });
@@ -977,9 +1059,10 @@ export class Relay {
977
1059
  this.dropDeviceSockets(who.account.id, hit.deviceId);
978
1060
  this.noteAccountRemoval(who.account.username);
979
1061
  }
980
- return this.json(res, 200, { ok: true, revoked: hit.sessionIds.length, device_removed: !!hit.deviceId });
1062
+ return this.replyLanded(res, writes, () => this.json(res, 200, { ok: true, revoked: hit.sessionIds.length, device_removed: !!hit.deviceId }));
981
1063
  }
982
1064
  if (url.pathname === "/account/delete" && req.method === "POST") {
1065
+ const writes = recordWrites();
983
1066
  const who = this.verifySignedAccount(url, "POST", "/account/delete", ip);
984
1067
  if (!who)
985
1068
  return this.json(res, 403, { error: "this request must be signed by a device on the account" });
@@ -988,7 +1071,7 @@ export class Relay {
988
1071
  for (const s of sessions)
989
1072
  this.dropSessionEverywhere(s.id);
990
1073
  this.noteAccountRemoval(who.account.username);
991
- return this.json(res, 200, { ok: true });
1074
+ return this.replyLanded(res, writes, () => this.json(res, 200, { ok: true }));
992
1075
  }
993
1076
  if (url.pathname === "/account/recover" || url.pathname === "/account/recover/withdraw") {
994
1077
  if (!this.authRateOk(ip))
@@ -1002,10 +1085,11 @@ export class Relay {
1002
1085
  if (method === "GET")
1003
1086
  return this.json(res, 200, { seats: this.recoverableSeats(who.account, who.device) });
1004
1087
  const name = url.searchParams.get("space") ?? "";
1088
+ const writes = recordWrites();
1005
1089
  const r = url.pathname === "/account/recover/withdraw"
1006
1090
  ? this.withdrawRecovery(who.account, who.device, name, ip)
1007
1091
  : this.recoverSeat(who.account, who.device, name, ip);
1008
- return this.json(res, r.status, r.body);
1092
+ return this.replyLanded(res, writes, () => this.json(res, r.status, r.body));
1009
1093
  }
1010
1094
  if (url.pathname === "/audit" && req.method === "GET") {
1011
1095
  const who = this.verifySignedAccount(url, "GET", "/audit", ip);
@@ -1446,6 +1530,9 @@ button:hover{background:#1d4ed8}.err{color:var(--bad);margin-top:12px}
1446
1530
  sleepingCache = new Map();
1447
1531
  sleepingRecords(safe) {
1448
1532
  const file = join(this.dataDir, `${safe}.jsonl`);
1533
+ const pending = pendingView(this.dataDir, safe, file);
1534
+ if (pending !== undefined)
1535
+ return parseLines(pending?.toString("utf8") ?? "");
1449
1536
  let st;
1450
1537
  try {
1451
1538
  st = statSync(file);
@@ -1457,15 +1544,7 @@ button:hover{background:#1d4ed8}.err{color:var(--bad);margin-top:12px}
1457
1544
  const hit = this.sleepingCache.get(safe);
1458
1545
  if (hit && hit.key === key)
1459
1546
  return hit.records;
1460
- const records = [];
1461
- for (const line of readFileSync(file, "utf8").split("\n")) {
1462
- if (!line.trim())
1463
- continue;
1464
- try {
1465
- records.push(JSON.parse(line));
1466
- }
1467
- catch { }
1468
- }
1547
+ const records = parseLines(readFileSync(file, "utf8"));
1469
1548
  this.sleepingCache.set(safe, { key, records });
1470
1549
  if (this.sleepingCache.size > 200)
1471
1550
  this.sleepingCache.delete(this.sleepingCache.keys().next().value);
@@ -1488,7 +1567,7 @@ button:hover{background:#1d4ed8}.err{color:var(--bad);margin-top:12px}
1488
1567
  setTimeout(() => this.deleteSpaceFiles(name), 600).unref?.();
1489
1568
  const safe = name.replace(/[^a-z0-9._-]/gi, "_");
1490
1569
  try {
1491
- const hash = sp?.allow ? JSON.parse(readFileSync(join(this.dataDir, `${safe}.members.json`), "utf8")).tokenHash ?? null : null;
1570
+ const hash = sp?.allow ? JSON.parse(readLatest(this.dataDir, safe, join(this.dataDir, `${safe}.members.json`))).tokenHash ?? null : null;
1492
1571
  const earlier = sp?.allow ? sp.allow.tokenHashes().filter((h) => h !== hash) : [];
1493
1572
  writeFileSync(join(this.dataDir, `${safe}.deleted.json`), JSON.stringify({ name, at: Date.now(), tokenHash: hash, ...(earlier.length ? { retiredTokenHashes: earlier } : {}) }));
1494
1573
  this.tombstoneFile(name);
@@ -1508,7 +1587,8 @@ button:hover{background:#1d4ed8}.err{color:var(--bad);margin-top:12px}
1508
1587
  if (body.csrf !== this.adminCsrf())
1509
1588
  return this.json(res, 403, { error: "stale form — reload the dashboard and try again" });
1510
1589
  const act = body.action ?? "", id = (body.id ?? "").trim();
1511
- const done = (msg) => { res.writeHead(303, { location: `/admin?done=${encodeURIComponent(msg)}` }); res.end(); };
1590
+ const writes = recordWrites();
1591
+ const done = (msg) => this.replyLanded(res, writes, () => { res.writeHead(303, { location: `/admin?done=${encodeURIComponent(msg)}` }); res.end(); });
1512
1592
  switch (act) {
1513
1593
  case "suspend":
1514
1594
  case "reinstate": {
@@ -1531,7 +1611,7 @@ button:hover{background:#1d4ed8}.err{color:var(--bad);margin-top:12px}
1531
1611
  const allow = sp ? sp.allow : new Allowlist(this.dataDir, id.replace(/[^a-z0-9._-]/gi, "_"));
1532
1612
  const token = (body.token ?? "").trim();
1533
1613
  const inv = allow.findInvite(token);
1534
- const back = (msg) => { res.writeHead(303, { location: `/admin/space?name=${encodeURIComponent(id)}&done=${encodeURIComponent(msg)}` }); res.end(); };
1614
+ const back = (msg) => this.replyLanded(res, writes, () => { res.writeHead(303, { location: `/admin/space?name=${encodeURIComponent(id)}&done=${encodeURIComponent(msg)}` }); res.end(); });
1535
1615
  if (!inv)
1536
1616
  return back(`no open invite …${token.slice(-6)} in ${id}`);
1537
1617
  allow.revokeInvite(inv.token);
@@ -1778,9 +1858,9 @@ button:hover{background:#1d4ed8}.err{color:var(--bad);margin-top:12px}
1778
1858
  if (seen.has(stem))
1779
1859
  continue;
1780
1860
  try {
1781
- const m = statSync(join(this.dataDir, f)).mtimeMs;
1861
+ const m = busyWriter(this.dataDir, stem) ? -1 : statSync(join(this.dataDir, f)).mtimeMs;
1782
1862
  let c = this.rosterCache.get(stem);
1783
- if (!c || c.mtimeMs !== m) {
1863
+ if (!c || c.mtimeMs !== m || m < 0) {
1784
1864
  c = { mtimeMs: m, allow: new Allowlist(this.dataDir, stem) };
1785
1865
  this.rosterCache.set(stem, c);
1786
1866
  }
@@ -1795,7 +1875,7 @@ button:hover{background:#1d4ed8}.err{color:var(--bad);margin-top:12px}
1795
1875
  const A = this.accounts;
1796
1876
  const u = acct.username;
1797
1877
  const out = [];
1798
- for (const { name, allow } of this.allRosters()) {
1878
+ for (const { name, allow, live } of this.allRosters()) {
1799
1879
  const e = allow.get(u);
1800
1880
  if (!e?.admitted)
1801
1881
  continue;
@@ -1804,15 +1884,18 @@ button:hover{background:#1d4ed8}.err{color:var(--bad);margin-top:12px}
1804
1884
  const fp = fingerprint(e.publicKey);
1805
1885
  if (e.publicKey === device.pubkey) {
1806
1886
  const inv = this.openRecoveryInvite(allow, u);
1807
- out.push({ space: name, state: inv ? "ready" : "here", founder, alone, fp, ...(inv ? { invite: inv.token } : {}) });
1887
+ out.push({ space: name, state: inv ? "ready" : "here", founder, alone, fp, in_use: false, ...(inv ? { invite: inv.token } : {}) });
1808
1888
  continue;
1809
1889
  }
1890
+ const in_use = (live?.members.get(u)?.sessions.size ?? 0) > 0;
1891
+ const dev = in_use ? A.deviceByPubkey(e.publicKey) : null;
1892
+ const use = { in_use, ...(dev && dev.account_id === acct.id && dev.label ? { in_use_label: dev.label } : {}) };
1810
1893
  const pend = allow.recoveryOf(u);
1811
1894
  if (pend && pend.pubkey === device.pubkey) {
1812
- out.push({ space: name, state: "waiting", founder, alone, fp, due_at: pend.due_at });
1895
+ out.push({ space: name, state: "waiting", founder, alone, fp, due_at: pend.due_at, ...use });
1813
1896
  continue;
1814
1897
  }
1815
- out.push({ space: name, state: A.wasDeviceOf(acct.id, fp, e.moved?.at ?? e.first_seen) ? "movable" : "unproven", founder, alone, fp });
1898
+ out.push({ space: name, state: A.wasDeviceOf(acct.id, fp, e.moved?.at ?? e.first_seen) ? "movable" : "unproven", founder, alone, fp, ...use });
1816
1899
  }
1817
1900
  return out.sort((a, b) => a.space.localeCompare(b.space));
1818
1901
  }
@@ -1853,7 +1936,7 @@ button:hover{background:#1d4ed8}.err{color:var(--bad);margin-top:12px}
1853
1936
  if (now >= pend.due_at) {
1854
1937
  if (now - pend.due_at > RECOVERY_KEEP_MS) {
1855
1938
  sp.allow.cancelRecovery(u);
1856
- return { status: 410, body: { reason: "lapsed", error: `that request lapsed (nobody finished it within a week of its time) — ask again: pantheon recover ${sp.name}` } };
1939
+ return { status: 410, body: { reason: "lapsed", error: `that request lapsed (nobody finished it within a week of its time) — ask again: pantheon recover ${quoteTeam(sp.name)}` } };
1857
1940
  }
1858
1941
  const inv = this.completeRecovery(sp, u, device.pubkey, "waited", null, null, ip, acct.id);
1859
1942
  return { status: 200, body: { state: "moved", space: sp.name, invite: inv.token, how: "waited" } };
@@ -1886,8 +1969,8 @@ button:hover{background:#1d4ed8}.err{color:var(--bad);margin-top:12px}
1886
1969
  from: RELAY_MEMBER, act: "NOTE", to: null, goal: null, ref: null,
1887
1970
  body: `${u} asked to move their seat in this team to a new device ("${r.label}", key ${r.fp}), after signing in with the code emailed to their account. ` +
1888
1971
  `It moves ${hours ? `in ${hours} hour${hours === 1 ? "" : "s"}` : "when they ask again"} unless someone cancels, or sooner when a teammate confirms it. ` +
1889
- `If you have checked with ${u} (a call, or a channel you trust) that the new key is theirs: pantheon recover confirm ${u} --space ${sp.name}. ` +
1890
- `If it is not them: pantheon recover cancel ${u} --space ${sp.name}.`,
1972
+ `If you have checked with ${u} (a call, or a channel you trust) that the new key is theirs: pantheon recover confirm ${u} --space ${quoteTeam(sp.name)}. ` +
1973
+ `If it is not them: pantheon recover cancel ${u} --space ${quoteTeam(sp.name)}.`,
1891
1974
  meta: { notice: "seat_recovery", recovery: { member: u, fp: r.fp, from_fp: oldFp, state: "waiting", due_at: r.due_at } },
1892
1975
  });
1893
1976
  this.broadcast(sp, { t: "members", members: this.members(sp) });
@@ -1896,7 +1979,7 @@ button:hover{background:#1d4ed8}.err{color:var(--bad);margin-top:12px}
1896
1979
  recoveryProofRefusal(A, acct, device, name, seat, founder) {
1897
1980
  const proof = A.lastEmailProof(acct.id, device.fingerprint);
1898
1981
  if (!proof || Date.now() - proof > RECOVERY_PROOF_MS) {
1899
- return { status: 403, body: { reason: "sign_in_again", error: `first prove it is you: run pantheon recover ${name} in a terminal (it emails a code to ${acct.email} and asks for it), or sign in with pantheon signin and within ${RECOVERY_PROOF_MS / 60_000} minutes run pantheon recover ${name} again` } };
1982
+ return { status: 403, body: { reason: "sign_in_again", error: `first prove it is you: run pantheon recover ${quoteTeam(name)} in a terminal (it emails a code to ${acct.email} and asks for it), or sign in with pantheon signin and within ${RECOVERY_PROOF_MS / 60_000} minutes run pantheon recover ${quoteTeam(name)} again` } };
1900
1983
  }
1901
1984
  const oldFp = fingerprint(seat.publicKey);
1902
1985
  if (!A.wasDeviceOf(acct.id, oldFp, seat.moved?.at ?? seat.first_seen)) {
@@ -1954,7 +2037,7 @@ button:hover{background:#1d4ed8}.err{color:var(--bad);margin-top:12px}
1954
2037
  meta: { notice: "seat_recovery", recovery: { member, fp: toFp, from_fp: fromFp, state: "moved", how, ...(by ? { by } : {}) } },
1955
2038
  });
1956
2039
  this.broadcast(sp, { t: "members", members: this.members(sp) });
1957
- this.broadcast(sp, { t: "roster", roster: this.roster(sp) });
2040
+ this.broadcast(sp, () => ({ t: "roster", roster: this.roster(sp) }));
1958
2041
  return inv;
1959
2042
  }
1960
2043
  mintRecoveryInvite(sp, member) {
@@ -2600,15 +2683,19 @@ ${url.searchParams.get("done") ? `<div class=done role=status>${esc(url.searchPa
2600
2683
  res.writeHead(200, { "content-type": "text/csv", "content-disposition": `attachment; filename="${table}.csv"` });
2601
2684
  res.end(csv);
2602
2685
  }
2603
- adminBackup(res, A) {
2686
+ async adminBackup(res, A) {
2687
+ const dir = join(this.dataDir, "backups");
2688
+ let fresh = null;
2604
2689
  try {
2605
- A.backup(join(this.dataDir, "backups"));
2690
+ fresh = await A.backupAsync(dir);
2606
2691
  }
2607
2692
  catch (e) {
2608
2693
  this.log(`! backup snapshot before archive failed: ${e.message}`);
2609
2694
  }
2695
+ if (res.destroyed)
2696
+ return;
2610
2697
  const stamp = new Date().toISOString().replace(/[:.]/g, "-");
2611
- const tar = spawn("tar", ["czf", "-", "-C", this.dataDir, "."], { stdio: ["ignore", "pipe", "pipe"] });
2698
+ const tar = spawn("tar", ["czf", "-", "-C", this.dataDir, ...backupExcludes(dir, fresh), "."], { stdio: ["ignore", "pipe", "pipe"] });
2612
2699
  let err = "";
2613
2700
  tar.stderr.on("data", (c) => { err += String(c); });
2614
2701
  tar.on("error", (e) => {
@@ -2864,6 +2951,11 @@ pantheon start</code></pre>
2864
2951
  return reject("NAME_TAKEN", `a team with a name too close to "${m.space}" already exists here — use its exact name, or pick another`);
2865
2952
  }
2866
2953
  const isNew = !this.live(m.space) && !this.spaceOnDisk(m.space);
2954
+ const badName = isNew ? teamNameProblem(m.space, { relay: true }) : null;
2955
+ if (badName) {
2956
+ this.log(`! REFUSED ${m.member}: cannot create a team called ${JSON.stringify(m.space.slice(0, 80))} (${badName.split(":")[0]})`);
2957
+ return reject("BAD_NAME", badName);
2958
+ }
2867
2959
  if (isNew && this.nameCollides(m.space)) {
2868
2960
  return reject("NAME_TAKEN", `a team with a name too close to "${m.space}" already exists here — pick another name`);
2869
2961
  }
@@ -2896,8 +2988,14 @@ pantheon start</code></pre>
2896
2988
  }
2897
2989
  }
2898
2990
  sp = this.getOrCreateSpace(m.space, m.token, { claim: { member: m.member, pubkey: m.pubkey }, retired: true });
2899
- if (!sp)
2991
+ if (!sp) {
2992
+ const own = pre?.ok ? this.rosterOf(m.space)?.get(pre.account.username) : undefined;
2993
+ if (own?.admitted && pre?.ok) {
2994
+ const name = this.live(m.space)?.name ?? m.space;
2995
+ return reject("BAD_TOKEN", `You're already a member of ${name}. On this machine run: pantheon recover ${quoteTeam(name)}`);
2996
+ }
2900
2997
  return reject("BAD_TOKEN", "wrong token for this space");
2998
+ }
2901
2999
  staleToken = this.tokenState(sp, m.token) === "retired";
2902
3000
  if (isNew) {
2903
3001
  this.noteSpaceCreate(ip);
@@ -2964,10 +3062,10 @@ pantheon start</code></pre>
2964
3062
  if (moved && moved.from_fp === fingerprint(m.pubkey)) {
2965
3063
  const how = moved.how === "confirmed" ? `, confirmed by ${moved.by}` : moved.how === "alone" ? "" : ", after nobody cancelled it";
2966
3064
  return reject("KEY_MISMATCH", `"${m.member}"'s seat in "${sp.name}" moved to another device on ${new Date(moved.at).toISOString().slice(0, 16).replace("T", " ")} UTC — signed in with the code emailed to ${m.member}'s account${how}. This machine's key no longer opens it. ` +
2967
- `If that was you, use the new machine (or run pantheon recover ${sp.name} here to move it back). If it was not you, your email account may be compromised: secure it, then tell your team.`);
3065
+ `If that was you, use the new machine (or run pantheon recover ${quoteTeam(sp.name)} here to move it back). If it was not you, your email account may be compromised: secure it, then tell your team.`);
2968
3066
  }
2969
3067
  const recover = this.requireAccounts && this.accounts
2970
- ? ` If this is really you on a new machine and the old one is gone: run pantheon recover ${sp.name} here, in a terminal — it signs you in with your email and moves your seat to this machine.`
3068
+ ? ` If this is really you on a new machine and the old one is gone: run pantheon recover ${quoteTeam(sp.name)} here, in a terminal — it signs you in with your email and moves your seat to this machine.`
2971
3069
  : "";
2972
3070
  if (sp.allow.founder() === m.member) {
2973
3071
  return reject("KEY_MISMATCH", `"${m.member}" founded "${sp.name}" and is registered here with a different key.` + (recover ||
@@ -3013,6 +3111,7 @@ pantheon start</code></pre>
3013
3111
  delegated = { pub: m.delegate.pub, run: m.delegate.run, expires: m.delegate.expires };
3014
3112
  }
3015
3113
  space = sp;
3114
+ this.spaceOf.set(ws, sp);
3016
3115
  myPubkey = m.pubkey;
3017
3116
  clearHelloTimer();
3018
3117
  sessionId = acctSession ?? this.mintSession();
@@ -3082,7 +3181,7 @@ pantheon start</code></pre>
3082
3181
  if (firstSession && m.role !== "cli") {
3083
3182
  this.commit(sp, { from: me.id, act: "JOIN", body: `${me.display} online (${me.agent})`, to: null, goal: null, ref: null });
3084
3183
  }
3085
- this.broadcast(sp, { t: "roster", roster: this.roster(sp) });
3184
+ this.broadcast(sp, () => ({ t: "roster", roster: this.roster(sp) }));
3086
3185
  this.log(`[${sp.name}] + ${me.id}/${sessionId} (${m.role}, ${me.agent})${verdict === "founder" ? " [founder, auto-admitted]" : ""}`);
3087
3186
  return;
3088
3187
  }
@@ -3170,7 +3269,7 @@ pantheon start</code></pre>
3170
3269
  this.log(`[${space.name}] * ADMITTED ${m.member} by ${me.id}${m.may_wake ? " (may wake workers)" : ""}`);
3171
3270
  }
3172
3271
  this.broadcast(space, { t: "members", members: this.members(space) });
3173
- this.broadcast(space, { t: "roster", roster: this.roster(space) });
3272
+ this.broadcast(space, () => ({ t: "roster", roster: this.roster(space) }));
3174
3273
  this.send(ws, { t: "ack", ...(m.cid ? { cid: m.cid } : {}), id: m.member, seq: space.ledger.head });
3175
3274
  return;
3176
3275
  }
@@ -3178,7 +3277,7 @@ pantheon start</code></pre>
3178
3277
  if (role !== "worker")
3179
3278
  return;
3180
3279
  me.worker = { state: m.state, ...(m.detail ? { detail: m.detail } : {}), ...(m.until ? { until: m.until } : {}), at: Date.now() };
3181
- this.broadcast(space, { t: "roster", roster: this.roster(space) });
3280
+ this.broadcast(space, () => ({ t: "roster", roster: this.roster(space) }));
3182
3281
  return;
3183
3282
  }
3184
3283
  if (m.t === "delete_space") {
@@ -3384,6 +3483,7 @@ pantheon start</code></pre>
3384
3483
  if (m.deadline !== undefined && m.deadline <= Date.now()) {
3385
3484
  return fail("DEADLINE_PAST", `deadline ${new Date(m.deadline).toISOString()} is already in the past`, m.cid);
3386
3485
  }
3486
+ space.ledger.checkBacklog();
3387
3487
  const goal = {
3388
3488
  id: m.id && !space.goals.has(m.id) ? m.id : newId("g"),
3389
3489
  title: m.title,
@@ -3489,6 +3589,7 @@ pantheon start</code></pre>
3489
3589
  const wasFull = this.spaceFull(space);
3490
3590
  if (wasFull && !recoveryStep && !this.freesRoom(space, me.id, m.act, m.ref ?? null))
3491
3591
  return fail("SPACE_FULL", this.spaceFullMessage(space), m.cid);
3592
+ space.ledger.checkBacklog();
3492
3593
  const rec = this.commit(space, {
3493
3594
  ...(m.sig && m.id ? { id: m.id } : {}),
3494
3595
  from: me.id, session: sessionId, role, act: m.act, body: m.body,
@@ -3540,6 +3641,11 @@ pantheon start</code></pre>
3540
3641
  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.`);
3541
3642
  return;
3542
3643
  }
3644
+ if (e instanceof DiskBusy) {
3645
+ this.log(`! [${space?.name ?? "?"}] ${e.message} — refusing new acts until it catches up`);
3646
+ send("STORAGE_BUSY", `${e.message}, so this was not recorded. Wait a few seconds, then send it again.`);
3647
+ return;
3648
+ }
3543
3649
  if (e instanceof RosterUnreadable) {
3544
3650
  this.log(`!! ${e.message} — every member of this space is refused until it is repaired (it was left in place)`);
3545
3651
  if (!space)
@@ -3548,7 +3654,7 @@ pantheon start</code></pre>
3548
3654
  return;
3549
3655
  }
3550
3656
  this.noteStorageFailure(`[${space?.name ?? "?"}] ${lastMsg || "message"}`, e);
3551
- send("STORAGE", "the relay could not save that — try again in a moment");
3657
+ send("STORAGE", "the relay's disk is refusing writes, so this was not recorded — try again in a moment");
3552
3658
  }
3553
3659
  });
3554
3660
  const closeWork = (code) => {
@@ -3587,7 +3693,7 @@ pantheon start</code></pre>
3587
3693
  this.commit(space, { from: me.id, act: "LEAVE", body: `${me.display} offline`, to: null, goal: null, ref: null });
3588
3694
  }
3589
3695
  this.pushObligations(space);
3590
- this.broadcast(space, { t: "roster", roster: this.roster(space) });
3696
+ this.broadcast(space, () => ({ t: "roster", roster: this.roster(space) }));
3591
3697
  this.log(`[${space.name}] - ${me.id}/${sessionId}`);
3592
3698
  };
3593
3699
  ws.on("close", (code) => { try {
@@ -3598,6 +3704,20 @@ pantheon start</code></pre>
3598
3704
  } });
3599
3705
  ws.on("error", () => { });
3600
3706
  }
3707
+ rotateLoopWindow() {
3708
+ const h = this.loopDelay;
3709
+ if (!h)
3710
+ return;
3711
+ if (h.count > 0)
3712
+ this.loopPrev = { p50: loopMs(h.percentile(50)), p99: loopMs(h.percentile(99)), max: loopMs(h.max) };
3713
+ h.reset();
3714
+ }
3715
+ loopStats() {
3716
+ const h = this.loopDelay;
3717
+ const cur = h.count > 0 ? { p50: loopMs(h.percentile(50)), p99: loopMs(h.percentile(99)), max: loopMs(h.max) } : null;
3718
+ const at = cur ?? this.loopPrev ?? { p50: 0, p99: 0, max: 0 };
3719
+ return { p50: at.p50, p99: at.p99, max_1m: Math.max(cur?.max ?? 0, this.loopPrev?.max ?? 0) };
3720
+ }
3601
3721
  noteStalls() {
3602
3722
  const worst = this.lagMax;
3603
3723
  this.lagMax = 0;
@@ -3896,7 +4016,7 @@ pantheon start</code></pre>
3896
4016
  sp.store.rebuilt = null;
3897
4017
  this.pushObligations(sp);
3898
4018
  this.broadcast(sp, { t: "act", act: rec });
3899
- this.broadcast(sp, { t: "roster", roster: this.roster(sp) });
4019
+ this.broadcast(sp, () => ({ t: "roster", roster: this.roster(sp) }));
3900
4020
  return rec;
3901
4021
  }
3902
4022
  effects(sp, rec, ctx) {
@@ -4158,6 +4278,8 @@ pantheon start</code></pre>
4158
4278
  continue;
4159
4279
  if (this.liveSockets(sp))
4160
4280
  continue;
4281
+ if (busyWriter(this.dataDir, safeName(sp.name)))
4282
+ continue;
4161
4283
  const idle = now - sp.lastTouch;
4162
4284
  const junk = this.isJunkSpace(sp);
4163
4285
  try {
@@ -4182,12 +4304,8 @@ pantheon start</code></pre>
4182
4304
  }
4183
4305
  deleteSpaceFiles(name) {
4184
4306
  const safe = name.replace(/[^a-z0-9._-]/gi, "_");
4185
- for (const suffix of [".jsonl", ".state.json", ".members.json"]) {
4186
- try {
4187
- rmSync(join(this.dataDir, `${safe}${suffix}`), { force: true });
4188
- }
4189
- catch { }
4190
- }
4307
+ for (const suffix of [".jsonl", ".state.json", ".members.json"])
4308
+ writerFor(this.dataDir, safe).remove(join(this.dataDir, `${safe}${suffix}`));
4191
4309
  }
4192
4310
  getOrCreateSpace(name, token, opts = {}) {
4193
4311
  const claim = opts.claim;
@@ -4229,6 +4347,17 @@ pantheon start</code></pre>
4229
4347
  store.headOf = () => ledger.head;
4230
4348
  store.onError = (e) => this.noteStorageFailure(`[${name}] snapshot save`, e);
4231
4349
  const restored = store.load({ quarantine: true, expectHistory: ledger.size > 0 });
4350
+ if (!restored.unclean && restored.ledgerHead !== undefined && restored.ledgerHead > ledger.head) {
4351
+ if (ledger.size > 0) {
4352
+ try {
4353
+ copyFileSync(store.file, `${store.file}.ahead-${Date.now()}`);
4354
+ }
4355
+ catch { }
4356
+ restored.unclean = `the state snapshot counts acts up to seq ${restored.ledgerHead}, but the ledger ends at seq ${ledger.head}`;
4357
+ }
4358
+ else
4359
+ this.log(`!! [${name}] the state snapshot counts acts up to seq ${restored.ledgerHead}, but the ledger is empty — kept the snapshot; the ledger file may have been lost`);
4360
+ }
4232
4361
  const sp = {
4233
4362
  name, token: raw,
4234
4363
  ledger,
@@ -4483,23 +4612,78 @@ pantheon start</code></pre>
4483
4612
  }));
4484
4613
  }
4485
4614
  send(ws, msg) {
4486
- if (ws.readyState === 1)
4487
- ws.send(JSON.stringify(msg));
4615
+ const sp = this.spaceOf.get(ws);
4616
+ const payload = JSON.stringify(msg);
4617
+ const run = () => { if (ws.readyState === 1)
4618
+ ws.send(payload); };
4619
+ if (!sp)
4620
+ return run();
4621
+ const instead = msg.t === "ack" && msg.cid ? { cid: msg.cid } : msg.t === "welcome" ? {} : null;
4622
+ const message = msg.t === "welcome" ? "the relay's disk is refusing writes, so it cannot let you in right now — reconnect in a moment" : STORAGE_HELD;
4623
+ this.out(sp, run, instead ? { ws, payload: JSON.stringify({ t: "error", code: "STORAGE", message, ...instead }), hangUp: msg.t === "welcome" } : undefined);
4488
4624
  }
4489
4625
  broadcast(sp, msg) {
4490
- const payload = JSON.stringify(msg);
4491
- const seq = msg.t === "act" ? msg.act.seq : null;
4492
- for (const m of sp.members.values()) {
4493
- let reached = false;
4494
- for (const s of m.sessions.values())
4495
- if (s.ws.readyState === 1) {
4496
- s.ws.send(payload);
4497
- if (!s.quiet)
4498
- reached = true;
4499
- }
4500
- if (reached && seq !== null && seq > m.delivered_seq)
4501
- m.delivered_seq = seq;
4626
+ this.out(sp, () => {
4627
+ const m = typeof msg === "function" ? msg() : msg;
4628
+ const payload = JSON.stringify(m);
4629
+ const seq = m.t === "act" ? m.act.seq : null;
4630
+ for (const mem of sp.members.values()) {
4631
+ let reached = false;
4632
+ for (const s of mem.sessions.values())
4633
+ if (s.ws.readyState === 1) {
4634
+ s.ws.send(payload);
4635
+ if (!s.quiet)
4636
+ reached = true;
4637
+ }
4638
+ if (reached && seq !== null && seq > mem.delivered_seq)
4639
+ mem.delivered_seq = seq;
4640
+ }
4641
+ });
4642
+ }
4643
+ out(sp, run, failed) {
4644
+ const w = busyWriter(this.dataDir, safeName(sp.name));
4645
+ if (!sp.outbox?.length && !w)
4646
+ return run();
4647
+ if (failed && w?.failing)
4648
+ return this.sendFailed(failed);
4649
+ (sp.outbox ??= []).push({ run, w, ticket: w ? w.ticket : 0, ...(failed ? { failed } : {}) });
4650
+ if (w)
4651
+ void w.whenWritten(w.ticket).then(() => this.release(sp));
4652
+ }
4653
+ release(sp) {
4654
+ const q = sp.outbox;
4655
+ while (q?.length) {
4656
+ const h = q[0];
4657
+ if (h.w && h.w.doneThrough < h.ticket)
4658
+ break;
4659
+ q.shift();
4660
+ try {
4661
+ h.run();
4662
+ }
4663
+ catch { }
4664
+ }
4665
+ }
4666
+ failHeld(stem) {
4667
+ const sp = [...this.spaces.values()].find((s) => safeName(s.name) === stem);
4668
+ if (!sp?.outbox?.length)
4669
+ return;
4670
+ const failing = sp.outbox.filter((h) => h.failed && h.w && h.w.doneThrough < h.ticket);
4671
+ if (!failing.length)
4672
+ return;
4673
+ sp.outbox = sp.outbox.filter((h) => !failing.includes(h));
4674
+ for (const h of failing)
4675
+ this.sendFailed(h.failed);
4676
+ this.release(sp);
4677
+ }
4678
+ sendFailed(failed) {
4679
+ if (failed.ws.readyState === 1) {
4680
+ try {
4681
+ failed.ws.send(failed.payload);
4682
+ }
4683
+ catch { }
4502
4684
  }
4685
+ if (failed.hangUp)
4686
+ this.hangUp(failed.ws, "STORAGE");
4503
4687
  }
4504
4688
  }
4505
4689
  function first(s, n = 120) {