@join-pantheon/cli 0.19.1 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/relay.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { createServer } from "node:http";
2
2
  import { createHash, randomBytes, timingSafeEqual as timingSafeEqualBuf } from "node:crypto";
3
- import { rmSync } from "node:fs";
3
+ import { rmSync, readdirSync, readFileSync, statSync, existsSync } from "node:fs";
4
4
  import { spawn } from "node:child_process";
5
5
  import { join } from "node:path";
6
6
  import { WebSocketServer } from "ws";
@@ -12,6 +12,7 @@ import { Accounts, normalizeUsername, validEmail, validUsername } from "./accoun
12
12
  import { emailSenderFromEnv } from "./email.js";
13
13
  import { Allowlist } from "./roster-file.js";
14
14
  import { StateStore } from "./store.js";
15
+ import { activity, mergeActivity, feed, noteRefusal, refusalsSince, countActs, ESCALATIONS, MESSAGE_ACTS, ACT_TONE, DAY } from "./admin-stats.js";
15
16
  import { routeDecision } from "./profile.js";
16
17
  function workState(m, now = Date.now(), busyTtlMs = LIMITS.activityBusyTtlMs, pruneMs = LIMITS.activityPruneMs) {
17
18
  let known = false;
@@ -73,6 +74,38 @@ export function isTrustedSource(ip) {
73
74
  function fingerprint_(from, act, body, goal, ref) {
74
75
  return createHash("sha256").update([from, act, goal ?? "", ref ?? "", body].join("\u0000")).digest("hex").slice(0, 32);
75
76
  }
77
+ const ADMIN_CSS = `<style>
78
+ :root{--bg:#0b0c0e;--card:#15171b;--line:#262a30;--fg:#e7e9ec;--dim:#9aa1a9;--accent:#5b9dff;--warn:#e8b45c;--bad:#ef8b80;--ok:#7bd39a}
79
+ @media(prefers-color-scheme:light){:root{--bg:#f6f7f9;--card:#fff;--line:#e6e8eb;--fg:#1a1c1f;--dim:#6b7280;--accent:#2563eb;--warn:#b45309;--bad:#b91c1c;--ok:#15803d}}
80
+ *{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--fg);font:14px/1.5 system-ui,-apple-system,sans-serif}
81
+ header{padding:20px 24px;border-bottom:1px solid var(--line);display:flex;align-items:baseline;gap:16px;flex-wrap:wrap}
82
+ h1{font-size:18px;margin:0}.dim{color:var(--dim)}
83
+ .stats{display:flex;gap:10px;flex-wrap:wrap;padding:16px 24px}
84
+ .stat{background:var(--card);border:1px solid var(--line);border-radius:10px;padding:10px 16px;min-width:110px}
85
+ .stat b{font-size:22px;display:block;font-variant-numeric:tabular-nums}.stat span{color:var(--dim);font-size:12px}
86
+ .stat.warn b{color:var(--warn)}
87
+ section{padding:8px 24px 28px}h2{font-size:14px;text-transform:uppercase;letter-spacing:.04em;color:var(--dim);margin:22px 0 8px;display:flex;align-items:center;gap:12px}
88
+ h2 input{margin-left:auto;font:13px system-ui;padding:5px 9px;border:1px solid var(--line);border-radius:7px;background:var(--card);color:var(--fg);width:220px}
89
+ .wrap{overflow-x:auto;border:1px solid var(--line);border-radius:10px;background:var(--card)}
90
+ table{border-collapse:collapse;width:100%;font-variant-numeric:tabular-nums}
91
+ th,td{text-align:left;padding:8px 12px;border-bottom:1px solid var(--line);white-space:nowrap;vertical-align:middle}
92
+ th{color:var(--dim);font-weight:600;font-size:12px}tr:last-child td{border-bottom:0}
93
+ code{font-family:ui-monospace,monospace;font-size:12px}a{color:var(--accent)}
94
+ .pill{font-size:11px;padding:1px 7px;border-radius:999px;border:1px solid var(--line)}.pill.bad{color:var(--bad);border-color:var(--bad)}.pill.ok{color:var(--ok);border-color:var(--ok)}
95
+ .inl{display:inline}.act{font:12px system-ui;padding:4px 9px;border-radius:6px;border:1px solid var(--line);background:transparent;color:var(--fg);cursor:pointer;margin-left:6px}
96
+ .act:hover{border-color:var(--accent)}.act.danger{color:var(--bad)}.act.danger:hover{border-color:var(--bad)}
97
+ .blockform{display:flex;gap:8px;align-items:center;margin:8px 0 0}.blockform input{font:13px ui-monospace,monospace;padding:6px 9px;border:1px solid var(--line);border-radius:7px;background:var(--card);color:var(--fg);width:280px}
98
+ .done{margin:0 24px;padding:10px 14px;border:1px solid var(--ok);color:var(--ok);border-radius:10px;background:var(--card)}
99
+ .note{color:var(--dim);font-size:12px;margin:6px 0 0}
100
+ </style>`;
101
+ const escHtml = (v) => String(v ?? "").replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c]));
102
+ const fmtWhen = (t) => t ? new Date(t).toISOString().slice(0, 19).replace("T", " ") : "—";
103
+ const fmtAgo = (t) => {
104
+ if (!t)
105
+ return "—";
106
+ const m = Math.round((Date.now() - t) / 60_000);
107
+ return m < 1 ? "just now" : m < 60 ? `${m}m ago` : m < 1440 ? `${Math.round(m / 60)}h ago` : `${Math.round(m / 1440)}d ago`;
108
+ };
76
109
  export class Relay {
77
110
  opts;
78
111
  spaces = new Map();
@@ -95,6 +128,9 @@ export class Relay {
95
128
  adminHits = new Map();
96
129
  emailSends = [];
97
130
  adminToken;
131
+ adminAllow;
132
+ startedAt = Date.now();
133
+ deletedSpaces = new Map();
98
134
  backupTimer;
99
135
  constructor(opts = {}) {
100
136
  this.opts = opts;
@@ -105,6 +141,7 @@ export class Relay {
105
141
  ? process.env.PANTHEON_TRUST_LOCAL === "1"
106
142
  : !this.requireAccounts;
107
143
  this.adminToken = process.env.PANTHEON_ADMIN_TOKEN?.trim() || null;
144
+ this.adminAllow = (process.env.PANTHEON_ADMIN_ALLOW_IPS ?? "").split(",").map((x) => x.trim()).filter(Boolean);
108
145
  const wantStore = this.requireAccounts || this.adminToken !== null;
109
146
  this.accounts = opts.accounts ?? (wantStore ? new Accounts(join(this.dataDir, "accounts.db")) : null);
110
147
  this.email = opts.emailSender ?? emailSenderFromEnv(this.log);
@@ -276,10 +313,12 @@ export class Relay {
276
313
  onAuthHttp(req, res, url, ip) {
277
314
  if (url.pathname === "/")
278
315
  return this.landingPage(res);
279
- if (url.pathname === "/admin" || url.pathname === "/admin/export" || url.pathname === "/admin/backup" || url.pathname === "/admin/login" || url.pathname === "/admin/logout") {
316
+ if (url.pathname === "/admin" || url.pathname === "/admin/space" || url.pathname === "/admin/export" || url.pathname === "/admin/backup" || url.pathname === "/admin/action" || url.pathname === "/admin/login" || url.pathname === "/admin/logout") {
280
317
  const A0 = this.accounts;
281
318
  if (!A0 || !this.adminToken)
282
319
  return this.json(res, 404, { error: "not found" });
320
+ if (!this.adminIpOk(ip))
321
+ return this.json(res, 404, { error: "not found" });
283
322
  const tokenOk = (t) => { const a = Buffer.from(t), b = Buffer.from(this.adminToken); return a.length === b.length && timingSafeEqualBuf(a, b); };
284
323
  const fromCookie = cookieVal(req, "pantheon_admin");
285
324
  const fromQuery = url.searchParams.get("token") ?? req.headers["authorization"]?.replace(/^Bearer\s+/i, "") ?? "";
@@ -291,8 +330,10 @@ export class Relay {
291
330
  return this.readForm(req, (body) => {
292
331
  if (!tokenOk(body.token ?? "")) {
293
332
  this.noteAdminFail(ip);
333
+ A0.audit({ event: "admin_login_failed", ip });
294
334
  return this.adminLoginPage(res, true);
295
335
  }
336
+ A0.audit({ event: "admin_login", ip });
296
337
  const secure = req.headers["x-forwarded-proto"] === "https" ? "; Secure" : "";
297
338
  res.writeHead(302, { "set-cookie": `pantheon_admin=${encodeURIComponent(body.token)}; HttpOnly; SameSite=Strict; Path=/admin; Max-Age=43200${secure}`, location: "/admin" });
298
339
  return void res.end();
@@ -306,16 +347,30 @@ export class Relay {
306
347
  if (url.pathname === "/admin/export") {
307
348
  if (!authed)
308
349
  return this.json(res, 403, { error: "sign in at /admin, or append ?token=…" });
350
+ A0.audit({ event: "admin_export", target: url.searchParams.get("table") ?? "audit", detail: url.searchParams.get("format") ?? "json", ip });
309
351
  return this.adminExport(res, A0, url);
310
352
  }
311
353
  if (url.pathname === "/admin/backup") {
312
354
  if (!authed)
313
355
  return this.json(res, 403, { error: "sign in at /admin, or append ?token=…" });
356
+ A0.audit({ event: "admin_backup", ip });
314
357
  return this.adminBackup(res, A0);
315
358
  }
359
+ if (url.pathname === "/admin/action" && req.method === "POST") {
360
+ if (!authed)
361
+ return this.json(res, 403, { error: "sign in at /admin" });
362
+ return this.readForm(req, (body) => this.adminAction(res, A0, body, ip));
363
+ }
364
+ if (url.pathname === "/admin/space") {
365
+ if (!authed)
366
+ return this.adminLoginPage(res, false);
367
+ const name = url.searchParams.get("name") ?? "";
368
+ A0.audit({ event: "admin_space_view", space: name, detail: url.search.replace(/^\?/, "").replace(/(^|&)token=[^&]*/g, "$1token=…") || null, ip });
369
+ return this.adminSpace(res, url, name);
370
+ }
316
371
  if (!authed)
317
372
  return this.adminLoginPage(res, false);
318
- return this.adminDashboard(res, A0);
373
+ return this.adminDashboard(res, A0, url);
319
374
  }
320
375
  const A = this.accounts;
321
376
  if (!A)
@@ -333,6 +388,8 @@ export class Relay {
333
388
  return this.json(res, 400, { error: "username must be 2–32 chars: letters, digits, . _ - (not at the ends)" });
334
389
  const started = A.startVerification(b.email, "signup", { username: b.username, pubkey: b.pubkey, label: String(b.label ?? "a device").slice(0, 60) });
335
390
  if (!started.ok) {
391
+ if (started.reason === "blocked")
392
+ return this.json(res, 403, { error: "this email cannot sign up on this relay", reason: "blocked" });
336
393
  const msg = started.reason === "too_soon" ? "a code was just sent — check your email, or wait a minute to resend"
337
394
  : started.reason === "username_taken" ? "that username is taken"
338
395
  : started.reason === "email_taken" ? "that email already has an account — use `pantheon login` from this machine instead"
@@ -355,6 +412,8 @@ export class Relay {
355
412
  if (!b || typeof b.email !== "string" || typeof b.pubkey !== "string")
356
413
  return this.json(res, 400, { error: "email and pubkey are required" });
357
414
  const started = A.startVerification(b.email, "login", { pubkey: b.pubkey, label: String(b.label ?? "a device").slice(0, 60) });
415
+ if (!started.ok && started.reason === "blocked")
416
+ return this.json(res, 403, { error: "this email cannot sign in on this relay", reason: "blocked" });
358
417
  if (!started.ok) {
359
418
  const code = started.reason === "too_soon" ? 429 : 404;
360
419
  const msg = started.reason === "too_soon" ? "a code was just sent — check your email, or wait a minute" : "no active account for that email — `pantheon signup` first";
@@ -572,65 +631,436 @@ button{width:100%;margin-top:12px;padding:10px;border:0;border-radius:8px;backgr
572
631
  ${wrong ? '<div class=err>That token is not right.</div>' : ''}
573
632
  </form>`);
574
633
  }
575
- adminDashboard(res, A) {
634
+ adminIpOk(ip) {
635
+ if (!this.adminAllow.length)
636
+ return true;
637
+ const h = ip.replace(/^::ffff:/, "");
638
+ const toN = (x) => x.split(".").reduce((n, o) => ((n << 8) + Number(o)) >>> 0, 0);
639
+ if (!/^\d+\.\d+\.\d+\.\d+$/.test(h))
640
+ return false;
641
+ return this.adminAllow.some((rule) => {
642
+ const [net, bits] = rule.split("/");
643
+ if (!/^\d+\.\d+\.\d+\.\d+$/.test(net ?? ""))
644
+ return false;
645
+ if (bits === undefined)
646
+ return net === h;
647
+ const n = Number(bits);
648
+ if (!(n >= 0 && n <= 32))
649
+ return false;
650
+ const mask = n === 0 ? 0 : (~0 << (32 - n)) >>> 0;
651
+ return (toN(h) & mask) === (toN(net) & mask);
652
+ });
653
+ }
654
+ adminCsrf() {
655
+ return createHash("sha256").update("pantheon-admin-csrf " + (this.adminToken ?? "")).digest("hex").slice(0, 32);
656
+ }
657
+ spacesOverview() {
658
+ const now = Date.now();
659
+ const out = new Map();
660
+ const safeOf = (name) => name.replace(/[^a-z0-9._-]/gi, "_");
661
+ const size = (safe) => [".jsonl", ".state.json", ".members.json"].reduce((n, suf) => { try {
662
+ return n + statSync(join(this.dataDir, `${safe}${suf}`)).size;
663
+ }
664
+ catch {
665
+ return n;
666
+ } }, 0);
667
+ for (const sp of this.spaces.values()) {
668
+ const name = sp.name;
669
+ out.set(safeOf(name), {
670
+ name, live: true, id: sp.allow.peekId(),
671
+ members: sp.allow.all().filter((e) => e.admitted).length, online: [...sp.members.values()].filter((m) => m.sessions.size > 0).length,
672
+ acts: sp.ledger.head, goals: sp.goals.size, open_goals: [...sp.goals.values()].filter((g) => g.state !== "CLOSED").length, obligations: sp.obligations.size,
673
+ last: sp.lastTouch, bytes: size(safeOf(name)),
674
+ activity: activity(sp.ledger.all(), now, (r) => `${name}/${r.from}`), refusals: sp.store.refusals,
675
+ });
676
+ }
677
+ let files = [];
678
+ try {
679
+ files = readdirSync(this.dataDir);
680
+ }
681
+ catch { }
682
+ for (const f of files) {
683
+ if (!f.endsWith(".members.json"))
684
+ continue;
685
+ const safe = f.slice(0, -".members.json".length);
686
+ if (out.has(safe))
687
+ continue;
688
+ let members = 0, last = null, id = null;
689
+ try {
690
+ const al = new Allowlist(this.dataDir, safe);
691
+ members = al.all().filter((e) => e.admitted).length;
692
+ id = al.peekId();
693
+ }
694
+ catch { }
695
+ try {
696
+ last = statSync(join(this.dataDir, `${safe}.jsonl`)).mtimeMs;
697
+ }
698
+ catch {
699
+ try {
700
+ last = statSync(join(this.dataDir, f)).mtimeMs;
701
+ }
702
+ catch { }
703
+ }
704
+ const records = this.sleepingRecords(safe);
705
+ const st = this.sleepingState(safe);
706
+ out.set(safe, {
707
+ name: safe, live: false, id, members, online: 0,
708
+ acts: records.length ? records[records.length - 1].seq : 0,
709
+ goals: st.goals.size, open_goals: [...st.goals.values()].filter((g) => g.state !== "CLOSED").length, obligations: 0,
710
+ last, bytes: size(safe),
711
+ activity: activity(records, now, (r) => `${safe}/${r.from}`), refusals: st.refusals,
712
+ });
713
+ }
714
+ return [...out.values()].sort((a, b) => (b.last ?? 0) - (a.last ?? 0));
715
+ }
716
+ sleepingCache = new Map();
717
+ sleepingRecords(safe) {
718
+ const file = join(this.dataDir, `${safe}.jsonl`);
719
+ let st;
720
+ try {
721
+ st = statSync(file);
722
+ }
723
+ catch {
724
+ return [];
725
+ }
726
+ const key = `${st.size}:${st.mtimeMs}`;
727
+ const hit = this.sleepingCache.get(safe);
728
+ if (hit && hit.key === key)
729
+ return hit.records;
730
+ const records = [];
731
+ for (const line of readFileSync(file, "utf8").split("\n")) {
732
+ if (!line.trim())
733
+ continue;
734
+ try {
735
+ records.push(JSON.parse(line));
736
+ }
737
+ catch { }
738
+ }
739
+ this.sleepingCache.set(safe, { key, records });
740
+ if (this.sleepingCache.size > 200)
741
+ this.sleepingCache.delete(this.sleepingCache.keys().next().value);
742
+ return records;
743
+ }
744
+ sleepingState(safe) {
745
+ const st = new StateStore(this.dataDir, safe);
746
+ const { goals } = st.load();
747
+ return { goals, refusals: st.refusals };
748
+ }
749
+ deleteSpace(name) {
750
+ const sp = this.spaces.get(name);
751
+ if (sp) {
752
+ sp.deleted = true;
753
+ for (const m of sp.members.values())
754
+ for (const s of m.sessions.values()) {
755
+ try {
756
+ s.ws.close(1008, "SPACE_DELETED");
757
+ }
758
+ catch { }
759
+ }
760
+ this.spaces.delete(name);
761
+ }
762
+ setTimeout(() => this.deleteSpaceFiles(name), 600).unref?.();
763
+ const safe = name.replace(/[^a-z0-9._-]/gi, "_");
764
+ const existed = !!sp || [".jsonl", ".state.json", ".members.json"].some((suf) => existsSync(join(this.dataDir, `${safe}${suf}`)));
765
+ this.deleteSpaceFiles(name);
766
+ this.deletedSpaces.set(name, Date.now());
767
+ if (this.deletedSpaces.size > 1000)
768
+ for (const [k, t] of this.deletedSpaces)
769
+ if (Date.now() - t > 3_600_000)
770
+ this.deletedSpaces.delete(k);
771
+ return existed;
772
+ }
773
+ adminAction(res, A, body, ip) {
774
+ if (body.csrf !== this.adminCsrf())
775
+ return this.json(res, 403, { error: "stale form — reload the dashboard and try again" });
776
+ const act = body.action ?? "", id = (body.id ?? "").trim();
777
+ const done = (msg) => { res.writeHead(303, { location: `/admin?done=${encodeURIComponent(msg)}` }); res.end(); };
778
+ switch (act) {
779
+ case "suspend":
780
+ case "reinstate": {
781
+ const acct = A.accountById(id);
782
+ if (!acct)
783
+ return this.json(res, 404, { error: "no such account" });
784
+ const live = A.sessionsFor(acct.id).filter((s) => !s.revoked_at);
785
+ A.setStatus(acct.id, act === "suspend" ? "suspended" : "active", ip);
786
+ if (act === "suspend")
787
+ for (const s of live)
788
+ this.dropSessionEverywhere(s.id);
789
+ this.log(`[admin] ${act} ${acct.username} from ${ip}`);
790
+ return done(`${acct.username} ${act === "suspend" ? "suspended and signed out everywhere" : "reinstated"}`);
791
+ }
792
+ case "revoke_session":
793
+ case "revoke_device": {
794
+ const hit = A.adminRevoke(act === "revoke_session" ? "session" : "device", id, ip);
795
+ for (const sid of hit.sessionIds)
796
+ this.dropSessionEverywhere(sid);
797
+ if (hit.deviceId && hit.accountId)
798
+ this.dropDeviceSockets(hit.accountId, hit.deviceId);
799
+ return done(hit.sessionIds.length || hit.deviceId ? `revoked ${act === "revoke_session" ? "session" : "device"} ${id}` : `nothing to revoke for ${id}`);
800
+ }
801
+ case "block": {
802
+ const pattern = id.toLowerCase().replace(/^@/, "");
803
+ if (!/^([a-z0-9.-]+\.[a-z]{2,}|[^@\s]+@[a-z0-9.-]+\.[a-z]{2,})$/.test(pattern))
804
+ return this.json(res, 400, { error: "block takes an email (a@b.com) or a domain (b.com)" });
805
+ A.block(pattern, ip);
806
+ return done(`blocked ${pattern}`);
807
+ }
808
+ case "unblock": {
809
+ A.unblock(id.toLowerCase().replace(/^@/, ""), ip);
810
+ return done(`unblocked ${id}`);
811
+ }
812
+ case "delete_space": {
813
+ if (body.confirm !== id)
814
+ return this.json(res, 400, { error: "type the space name to confirm" });
815
+ const existed = this.deleteSpace(id);
816
+ A.audit({ event: "admin_space_deleted", space: id, ip });
817
+ this.log(`[admin] deleted space "${id}" from ${ip}`);
818
+ return done(existed ? `space "${id}" deleted — its members were disconnected` : `no space "${id}"`);
819
+ }
820
+ default: return this.json(res, 400, { error: `unknown action "${act}"` });
821
+ }
822
+ }
823
+ adminDashboard(res, A, url) {
576
824
  const c = A.counts();
577
825
  const esc = (s) => String(s ?? "").replace(/[&<>"]/g, (m) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[m]));
578
- const when = (t) => new Date(t).toISOString().slice(0, 19).replace("T", " ");
826
+ const when = (t) => t ? new Date(t).toISOString().slice(0, 19).replace("T", " ") : "—";
827
+ const ago = (t) => { if (!t)
828
+ return "—"; const m = Math.round((Date.now() - t) / 60_000); return m < 1 ? "just now" : m < 60 ? `${m}m ago` : m < 1440 ? `${Math.round(m / 60)}h ago` : `${Math.round(m / 1440)}d ago`; };
829
+ const kb = (n) => n < 1024 ? `${n} B` : n < 1048576 ? `${(n / 1024).toFixed(1)} KB` : `${(n / 1048576).toFixed(1)} MB`;
579
830
  const accounts = A.allAccounts();
580
831
  const devices = A.allDevices();
581
832
  const sessions = A.allSessions(200);
582
833
  const audit = A.auditLog({ limit: 300 });
834
+ const blocks = A.allBlocks();
835
+ const verifs = A.recentVerifications(50);
836
+ const spaces = this.spacesOverview();
837
+ const act = mergeActivity(spaces.map((x) => x.activity));
838
+ const dayAgo = Date.now() - DAY;
839
+ const refused24 = spaces.reduce((n, x) => n + refusalsSince(x.refusals, dayAgo), 0);
840
+ const refusedAll = spaces.reduce((n, x) => n + (x.refusals?.total ?? 0), 0);
583
841
  const devByAcct = new Map();
584
842
  for (const d of devices)
585
843
  devByAcct.set(d.account_id, (devByAcct.get(d.account_id) ?? 0) + 1);
844
+ const emailCap = Number(process.env.PANTHEON_EMAIL_MAX_PER_HOUR) || 200;
845
+ const emailsHour = this.emailSends.filter((t) => Date.now() - t < 3_600_000).length;
846
+ let snapshots = [];
847
+ try {
848
+ snapshots = readdirSync(join(this.dataDir, "backups")).filter((f) => f.startsWith("accounts-") && f.endsWith(".db")).sort();
849
+ }
850
+ catch { }
851
+ const lastBackupPull = audit.find((e) => e.event === "admin_backup");
852
+ const upMin = Math.round((Date.now() - this.startedAt) / 60_000);
853
+ const csrf = this.adminCsrf();
854
+ const done = url?.searchParams.get("done");
586
855
  const rows = (arr) => arr.join("");
856
+ const form = (action, id, label, extra = "", cls = "") => `<form method=POST action="/admin/action" class=inl onsubmit="return confirmAct(this)"><input type=hidden name=csrf value="${csrf}"><input type=hidden name=action value="${action}"><input type=hidden name=id value="${esc(id)}">${extra}<button class="act ${cls}">${label}</button></form>`;
587
857
  const html = `<!doctype html><meta charset=utf-8><title>pantheon · admin</title>
588
858
  <meta name=viewport content="width=device-width,initial-scale=1">
589
- <style>
590
- :root{--bg:#0b0c0e;--card:#15171b;--line:#262a30;--fg:#e7e9ec;--dim:#9aa1a9;--accent:#5b9dff}
591
- @media(prefers-color-scheme:light){:root{--bg:#f6f7f9;--card:#fff;--line:#e6e8eb;--fg:#1a1c1f;--dim:#6b7280;--accent:#2563eb}}
592
- *{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--fg);font:14px/1.5 system-ui,-apple-system,sans-serif}
593
- header{padding:20px 24px;border-bottom:1px solid var(--line);display:flex;align-items:baseline;gap:16px;flex-wrap:wrap}
594
- h1{font-size:18px;margin:0}.dim{color:var(--dim)}
595
- .stats{display:flex;gap:10px;flex-wrap:wrap;padding:16px 24px}
596
- .stat{background:var(--card);border:1px solid var(--line);border-radius:10px;padding:10px 16px;min-width:96px}
597
- .stat b{font-size:22px;display:block}.stat span{color:var(--dim);font-size:12px}
598
- section{padding:8px 24px 28px}h2{font-size:14px;text-transform:uppercase;letter-spacing:.04em;color:var(--dim);margin:22px 0 8px}
599
- .wrap{overflow-x:auto;border:1px solid var(--line);border-radius:10px;background:var(--card)}
600
- table{border-collapse:collapse;width:100%;font-variant-numeric:tabular-nums}
601
- th,td{text-align:left;padding:8px 12px;border-bottom:1px solid var(--line);white-space:nowrap}
602
- th{color:var(--dim);font-weight:600;font-size:12px}tr:last-child td{border-bottom:0}
603
- code{font-family:ui-monospace,monospace;font-size:12px}a{color:var(--accent)}
604
- .pill{font-size:11px;padding:1px 7px;border-radius:999px;border:1px solid var(--line)}
605
- </style>
859
+ ${ADMIN_CSS}
606
860
  <header>
607
861
  <h1>pantheon · admin</h1>
608
- <span class=dim>${esc(this.opts.host ?? "relay")} · pkg ${VERSION}</span>
609
- <span style="margin-left:auto"><a href="/admin/export?table=audit&format=csv">export audit CSV</a> · <a href="/admin/export?table=accounts&format=json">accounts JSON</a> · <a href="/admin/logout">sign out</a></span>
862
+ <span class=dim>${esc(this.opts.host ?? "relay")} · pkg ${VERSION} · up ${upMin < 60 ? upMin + "m" : Math.round(upMin / 60) + "h"}</span>
863
+ <span style="margin-left:auto"><a href="/admin/export?table=audit&format=csv">export audit CSV</a> · <a href="/admin/export?table=accounts&format=json">accounts JSON</a> · <a href="/admin/backup">download backup</a> · <a href="/admin/logout">sign out</a></span>
610
864
  </header>
865
+ ${done ? `<div class=done>${esc(done)}</div>` : ""}
611
866
  <div class=stats>
612
867
  <div class=stat><b>${c.accounts}</b><span>accounts</span></div>
613
868
  <div class=stat><b>${c.devices}</b><span>devices</span></div>
614
- <div class=stat><b>${c.live_sessions}</b><span>live sessions</span></div>
615
- <div class=stat><b>${c.sessions}</b><span>sessions total</span></div>
869
+ <div class=stat><b>${sessions.filter((s) => !s.revoked_at && Date.now() - s.last_seen <= 15 * 60_000).length}<span class=dim style="font-size:14px"> / ${c.live_sessions}</span></b><span>sessions active / open</span></div>
870
+ <div class=stat><b>${spaces.filter((s) => s.live).length}<span class=dim style="font-size:14px"> / ${spaces.length}</span></b><span>spaces live / total · cap ${this.limits.maxSpaces}</span></div>
871
+ <div class="stat ${emailsHour > emailCap * 0.8 ? "warn" : ""}"><b>${emailsHour}<span class=dim style="font-size:14px"> / ${emailCap}</span></b><span>emails this hour</span></div>
872
+ <div class=stat><b>${snapshots.length}</b><span>snapshots · latest ${snapshots.length ? esc(snapshots[snapshots.length - 1].replace(/^accounts-|\\.db$/g, "").replace(/T/, " ").slice(0, 16)) : "—"}</span></div>
873
+ <div class="stat ${!lastBackupPull || Date.now() - lastBackupPull.ts > 36 * 3_600_000 ? "warn" : ""}"><b>${lastBackupPull ? ago(lastBackupPull.ts) : "never"}</b><span>last off-host backup pull</span></div>
616
874
  <div class=stat><b>${c.audit}</b><span>audit events</span></div>
875
+ <div class=stat><b>${act.messages_24h}<span class=dim style="font-size:14px"> / ${act.messages}</span></b><span>messages today / retained</span></div>
876
+ <div class=stat><b>${act.senders_24h.length}<span class=dim style="font-size:14px"> · ${act.sessions_24h} sessions</span></b><span>delegates active today</span></div>
877
+ <div class="stat ${countActs(act.by_act_24h, ["OBJECT"]) ? "warn" : ""}"><b>${countActs(act.by_act_24h, ["OBJECT"])}<span class=dim style="font-size:14px"> / ${countActs(act.by_act, ["OBJECT"])}</span></b><span>objections today / retained</span></div>
878
+ <div class="stat ${countActs(act.by_act_24h, ESCALATIONS) ? "warn" : ""}"><b>${countActs(act.by_act_24h, ESCALATIONS)}<span class=dim style="font-size:14px"> / ${countActs(act.by_act, ESCALATIONS)}</span></b><span>escalations today / retained</span></div>
879
+ <div class=stat><b>${countActs(act.by_act_24h, ["DELIVERED"])}<span class=dim style="font-size:14px"> / ${countActs(act.by_act, ["DELIVERED"])}</span></b><span>deliveries today / retained</span></div>
880
+ <div class="stat ${refused24 ? "warn" : ""}"><b data-refused="${refusedAll}">${refused24}<span class=dim style="font-size:14px"> / ${refusedAll}</span></b><span>refused by the relay today / total</span></div>
617
881
  </div>
618
882
  <section>
619
- <h2>Accounts</h2><div class=wrap><table>
620
- <tr><th>username</th><th>email</th><th>status</th><th>devices</th><th>created</th></tr>
621
- ${rows(accounts.map((a) => `<tr><td><code>${esc(a.username)}</code></td><td>${esc(a.email)}</td><td><span class=pill>${esc(a.status)}</span></td><td>${devByAcct.get(a.id) ?? 0}</td><td class=dim>${when(a.created_at)}</td></tr>`))}
622
- ${accounts.length ? "" : "<tr><td colspan=5 class=dim>no accounts yet</td></tr>"}
883
+ <h2>Activity <span class=dim>(every space · today and retained history)</span></h2><div class=wrap><table id=t-activity>
884
+ <tr><th></th>${MESSAGE_ACTS.map((x) => `<th>${x}</th>`).join("")}<th>refused</th></tr>
885
+ <tr><td class=dim>today</td>${MESSAGE_ACTS.map((x) => `<td data-act="${x}" data-window="24h">${act.by_act_24h[x] ?? 0}</td>`).join("")}<td>${refused24}</td></tr>
886
+ <tr><td class=dim>retained</td>${MESSAGE_ACTS.map((x) => `<td data-act="${x}" data-window="all">${act.by_act[x] ?? 0}</td>`).join("")}<td>${refusedAll}</td></tr>
887
+ </table></div>
888
+ <p class=note>Messages are acts delegates sent; JOIN/LEAVE presence is not counted. Retained is what each space's ledger still holds — the most recent ${LIMITS.ledgerKeepRecent.toLocaleString()} acts plus everything in an open goal. Refused is what the relay handed back: wrong turn, missing reference, foreign repo, rate limit. Click a space for its conversation.</p>
889
+
890
+ <h2>Spaces <input placeholder="filter" data-filter="t-spaces"></h2><div class=wrap><table id=t-spaces>
891
+ <tr><th>space</th><th>state</th><th>members</th><th>online</th><th>acts</th><th>msgs today</th><th>delegates today</th><th>objections</th><th>refused</th><th>goals</th><th>owed</th><th>last activity</th><th>on disk</th><th></th></tr>
892
+ ${rows(spaces.map((s) => `<tr><td><a href="/admin/space?name=${encodeURIComponent(s.name)}" title="members, refusals and the conversation"><code>${esc(s.name)}</code></a></td><td><span class="pill ${s.live ? "ok" : ""}">${s.live ? "live" : "sleeping"}</span></td><td>${s.members}</td><td>${s.online}</td><td>${s.acts}</td><td>${s.activity.messages_24h}</td><td>${s.activity.senders_24h.length}</td><td>${s.activity.by_act_24h.OBJECT ?? 0}<span class=dim> / ${s.activity.by_act.OBJECT ?? 0}</span></td><td>${refusalsSince(s.refusals, dayAgo)}<span class=dim> / ${s.refusals?.total ?? 0}</span></td><td>${s.open_goals} open / ${s.goals}</td><td>${s.live ? s.obligations : "<span class=dim>—</span>"}</td><td class=dim title="${when(s.last)}">${ago(s.last)}</td><td class=dim>${kb(s.bytes)}</td><td>${form("delete_space", s.name, "delete", `<input type=hidden name=confirm value="">`, "danger")}</td></tr>`))}
893
+ ${spaces.length ? "" : "<tr><td colspan=14 class=dim>no spaces yet</td></tr>"}
894
+ </table></div>
895
+ <p class=note>Sleeping spaces are on disk but idle; they reload on the next connect. Delete is irreversible and disconnects every member of that space.</p>
896
+
897
+ <h2>Accounts <input placeholder="filter" data-filter="t-accounts"></h2><div class=wrap><table id=t-accounts>
898
+ <tr><th>username</th><th>email</th><th>status</th><th>devices</th><th>created</th><th></th></tr>
899
+ ${rows(accounts.map((a) => `<tr><td><code>${esc(a.username)}</code></td><td>${esc(a.email)}</td><td><span class="pill ${a.status === "suspended" ? "bad" : ""}">${esc(a.status)}</span></td><td>${devByAcct.get(a.id) ?? 0}</td><td class=dim>${when(a.created_at)}</td><td>${a.status === "suspended" ? form("reinstate", a.id, "reinstate") : form("suspend", a.id, "suspend", "", "danger")} ${form("block", a.email, "block email", "", "danger")}</td></tr>`))}
900
+ ${accounts.length ? "" : "<tr><td colspan=6 class=dim>no accounts yet</td></tr>"}
901
+ </table></div>
902
+ <p class=note>Suspending signs the account out everywhere and refuses its handshakes until reinstated. Its messages stay in the ledgers.</p>
903
+
904
+ <h2>Live sessions <input placeholder="filter" data-filter="t-sessions"></h2><div class=wrap><table id=t-sessions>
905
+ <tr><th>session</th><th>account</th><th>space</th><th>ip</th><th>started</th><th>last seen</th><th></th></tr>
906
+ ${rows(sessions.filter((s) => !s.revoked_at).map((s) => `<tr><td><code>${esc(s.id)}</code></td><td><code>${esc(accounts.find((a) => a.id === s.account_id)?.username ?? s.account_id)}</code></td><td>${esc(s.space) || "<span class=dim>—</span>"}</td><td class=dim>${esc(s.ip)}</td><td class=dim>${when(s.created_at)}</td><td class=dim>${ago(s.last_seen)}${Date.now() - s.last_seen > 15 * 60_000 ? ' <span class="pill" title="no heartbeat for 15+ minutes — usually a socket that died with a restart; pruned after 30 days">stale</span>' : ""}</td><td>${form("revoke_session", s.id, "sign out", "", "danger")} ${form("revoke_device", s.device_id, "remove device", "", "danger")}</td></tr>`))}
907
+ ${sessions.some((s) => !s.revoked_at) ? "" : "<tr><td colspan=7 class=dim>no live sessions</td></tr>"}
623
908
  </table></div>
624
909
 
625
- <h2>Live sessions</h2><div class=wrap><table>
626
- <tr><th>session</th><th>space</th><th>ip</th><th>started</th><th>last seen</th></tr>
627
- ${rows(sessions.filter((s) => !s.revoked_at).map((s) => `<tr><td><code>${esc(s.id)}</code></td><td>${esc(s.space) || "<span class=dim>—</span>"}</td><td class=dim>${esc(s.ip)}</td><td class=dim>${when(s.created_at)}</td><td class=dim>${when(s.last_seen)}</td></tr>`))}
910
+ <h2>Blocked emails and domains</h2><div class=wrap><table id=t-blocks>
911
+ <tr><th>pattern</th><th>kind</th><th>since</th><th></th></tr>
912
+ ${rows(blocks.map((b) => `<tr><td><code>${esc(b.pattern)}</code></td><td>${esc(b.kind)}</td><td class=dim>${when(b.created_at)}</td><td>${form("unblock", b.pattern, "unblock")}</td></tr>`))}
913
+ ${blocks.length ? "" : "<tr><td colspan=4 class=dim>nothing blocked</td></tr>"}
628
914
  </table></div>
915
+ <form method=POST action="/admin/action" class=blockform><input type=hidden name=csrf value="${csrf}"><input type=hidden name=action value=block><input name=id placeholder="someone@spam.example or spam.example" required><button class=act>block</button></form>
916
+ <p class=note>A blocked email or domain cannot sign up or sign in. Existing accounts are not touched; suspend those separately.</p>
629
917
 
630
- <h2>Audit log <span class=dim>(latest 300)</span></h2><div class=wrap><table>
631
- <tr><th>time</th><th>event</th><th>space</th><th>target</th><th>ip</th></tr>
632
- ${rows(audit.map((e) => `<tr><td class=dim>${when(e.ts)}</td><td>${esc(e.event)}</td><td>${esc(e.space) || ""}</td><td><code>${esc(e.target) || ""}</code></td><td class=dim>${esc(e.ip) || ""}</td></tr>`))}
918
+ <h2>Recent verification codes <span class=dim>(support: "I never got my code")</span></h2><div class=wrap><table id=t-verifs>
919
+ <tr><th>email</th><th>purpose</th><th>requested</th><th>expires</th><th>wrong tries</th><th>state</th></tr>
920
+ ${rows(verifs.map((v) => `<tr><td>${esc(v.email)}</td><td>${esc(v.purpose)}</td><td class=dim>${when(v.created_at)}</td><td class=dim>${when(v.expires_at)}</td><td>${v.attempts}</td><td><span class="pill ${v.expires_at < Date.now() ? "bad" : "ok"}">${v.expires_at < Date.now() ? "expired" : "pending"}</span></td></tr>`))}
921
+ ${verifs.length ? "" : "<tr><td colspan=6 class=dim>no pending codes</td></tr>"}
633
922
  </table></div>
923
+ <p class=note>A row here means the relay generated a code and handed it to the email provider. No row means the request never reached the relay. Codes themselves are never stored.</p>
924
+
925
+ <h2>Audit log <span class=dim>(latest 300)</span> <input placeholder="filter" data-filter="t-audit"></h2><div class=wrap><table id=t-audit>
926
+ <tr><th>time</th><th>event</th><th>space</th><th>target</th><th>detail</th><th>ip</th></tr>
927
+ ${rows(audit.map((e) => `<tr><td class=dim>${when(e.ts)}</td><td>${e.event.startsWith("admin_") ? `<span class=pill>${esc(e.event)}</span>` : esc(e.event)}</td><td>${esc(e.space) || ""}</td><td><code>${esc(e.target) || ""}</code></td><td class=dim>${esc(e.detail) || ""}</td><td class=dim>${esc(e.ip) || ""}</td></tr>`))}
928
+ </table></div>
929
+ </section>
930
+ <script>
931
+ document.querySelectorAll("input[data-filter]").forEach(function (inp) {
932
+ inp.addEventListener("input", function () {
933
+ var q = inp.value.toLowerCase(), rowsEl = document.getElementById(inp.dataset.filter).querySelectorAll("tr");
934
+ for (var i = 1; i < rowsEl.length; i++) rowsEl[i].style.display = !q || rowsEl[i].textContent.toLowerCase().indexOf(q) > -1 ? "" : "none";
935
+ });
936
+ });
937
+ function confirmAct(f) {
938
+ var a = f.querySelector("[name=action]").value, id = f.querySelector("[name=id]").value;
939
+ if (a === "delete_space") { var typed = prompt("Delete space \\"" + id + "\\" and disconnect its members? Type the name to confirm."); if (typed !== id) return false; f.querySelector("[name=confirm]").value = typed; return true; }
940
+ if (a === "suspend" || a === "revoke_session" || a === "revoke_device" || a === "block") return confirm(a.replace("_", " ") + " " + id + "?");
941
+ return true;
942
+ }
943
+ </script>`;
944
+ res.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store", "referrer-policy": "no-referrer" });
945
+ res.end(html);
946
+ }
947
+ adminSpace(res, url, name) {
948
+ const safe = name.replace(/[^a-z0-9._-]/gi, "_");
949
+ const sp = this.spaces.get(name) ?? null;
950
+ const tomb = this.deletedSpaces.get(name);
951
+ const onDisk = [".jsonl", ".state.json", ".members.json"].some((suf) => existsSync(join(this.dataDir, `${safe}${suf}`)));
952
+ if (!name || (!sp && (!onDisk || (tomb && Date.now() - tomb < 3_600_000))))
953
+ return this.json(res, 404, { error: `no space "${name}" on this relay` });
954
+ const records = sp ? sp.ledger.all() : this.sleepingRecords(safe);
955
+ const state = sp ? { goals: sp.goals, refusals: sp.store.refusals } : this.sleepingState(safe);
956
+ const allow = sp ? sp.allow : new Allowlist(this.dataDir, safe);
957
+ const now = Date.now(), dayAgo = now - DAY;
958
+ const a = activity(records, now);
959
+ const esc = escHtml, when = fmtWhen, ago = fmtAgo;
960
+ const q = {
961
+ act: url.searchParams.get("act") ?? "", member: url.searchParams.get("member") ?? "", goal: url.searchParams.get("goal") ?? "",
962
+ q: url.searchParams.get("q") ?? "", presence: url.searchParams.get("presence") === "1", before: Number(url.searchParams.get("before")) || undefined,
963
+ };
964
+ const { rows: feedRows, next } = feed(records, { ...q, limit: 100 });
965
+ const filtered = !!(q.act || q.member || q.goal || q.q || q.presence);
966
+ const link = (extra) => {
967
+ const p = new URLSearchParams({ name });
968
+ for (const [k, v] of Object.entries({ act: q.act, member: q.member, goal: q.goal, q: q.q, presence: q.presence ? "1" : "", ...extra }))
969
+ if (v !== undefined && String(v) !== "")
970
+ p.set(k, String(v));
971
+ return `/admin/space?${p.toString()}`;
972
+ };
973
+ const pill = (x) => `<span class="pill ${ACT_TONE[x]}">${x}</span>`;
974
+ const goalTitle = (id) => id ? `<a href="${link({ goal: id, before: undefined })}" title="${esc(id)}">${esc(state.goals.get(id)?.title ?? id)}</a>` : "<span class=dim>—</span>";
975
+ const metaLine = (r) => {
976
+ const bits = [];
977
+ if (r.summary)
978
+ bits.push(`summary: ${esc(r.summary)}`);
979
+ const meta = (r.meta ?? {});
980
+ for (const k of ["branch", "commit", "repo_origin", "decision_domains", "defers_to", "affects"])
981
+ if (meta[k] !== undefined)
982
+ bits.push(`${k}: ${esc(typeof meta[k] === "string" ? meta[k] : JSON.stringify(meta[k]))}`);
983
+ if (r.ref)
984
+ bits.push(`re <code>${esc(r.ref)}</code>`);
985
+ return bits.length ? `<div class=note>${bits.join(" · ")}</div>` : "";
986
+ };
987
+ const said = (r) => r.body.length > 400
988
+ ? `<details><summary>${esc(r.body.slice(0, 400))}…</summary>${esc(r.body)}</details>`
989
+ : (esc(r.body) || "<span class=dim>(empty)</span>");
990
+ const rowsHtml = feedRows.map((r) => `<tr><td class=dim><span title="${when(r.ts)}">${ago(r.ts)}</span><div>#${r.seq}</div></td><td><a href="${link({ member: r.from, before: undefined })}"><code>${esc(r.from)}</code></a><div class=dim>${esc(sp?.members.get(r.from)?.agent ?? "")}${r.role ? ` · ${esc(r.role)}` : ""}</div></td><td>${pill(r.act)}</td><td>${r.to && r.to.length ? r.to.map((t) => `<code>${esc(t)}</code>`).join(", ") : "<span class=dim>everyone</span>"}</td><td>${goalTitle(r.goal)}</td><td class=body>${said(r)}${metaLine(r)}</td></tr>`).join("");
991
+ const entries = allow.all();
992
+ const memberRows = entries.map((e) => {
993
+ const m = sp?.members.get(e.member);
994
+ const online = m ? m.sessions.size : 0;
995
+ return `<tr><td><a href="${link({ member: e.member, before: undefined })}"><code>${esc(e.member)}</code></a></td><td>${e.admitted ? '<span class="pill ok">admitted</span>' : '<span class="pill">pending</span>'}</td><td>${e.may_wake ? "yes" : "no"}</td><td>${online ? `<span class="pill ok">${online} session${online === 1 ? "" : "s"}</span> ${esc(m.agent)}` : "<span class=dim>offline</span>"}</td><td class=dim>${m ? ago(m.last_seen) : when(e.first_seen)}</td></tr>`;
996
+ }).join("");
997
+ const repos = allow.repoList();
998
+ const refusals = state.refusals;
999
+ const refusalRows = [...refusals.recent].reverse().map((r) => `<tr><td class=dim>${when(r.ts)}</td><td><code>${esc(r.member)}</code></td><td>${esc(r.act)}</td><td><span class="pill bad">${esc(r.code)}</span></td></tr>`).join("");
1000
+ const byCode = Object.entries(refusals.by_code).sort((x, y) => y[1] - x[1]).map(([c, n]) => `<code>${esc(c)}</code> ${n}`).join(" · ");
1001
+ const open = [...state.goals.values()].filter((g) => g.state !== "CLOSED").length;
1002
+ const options = MESSAGE_ACTS.map((x) => `<option value="${x}"${q.act === x ? " selected" : ""}>${x}</option>`).join("");
1003
+ const html = `<!doctype html><meta charset=utf-8><title>pantheon · admin · ${esc(name)}</title>
1004
+ <meta name=viewport content="width=device-width,initial-scale=1">
1005
+ ${ADMIN_CSS}
1006
+ <style>
1007
+ .feed td{white-space:normal;vertical-align:top}.feed .body{min-width:320px;max-width:720px;overflow-wrap:anywhere}
1008
+ .feed details summary{cursor:pointer;color:var(--accent)}.feed details[open] summary{display:none}
1009
+ .pill.warn{color:var(--warn);border-color:var(--warn)}.pill.info{color:var(--accent);border-color:var(--accent)}.pill.dim{color:var(--dim)}
1010
+ .filters{display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin:8px 0 10px}
1011
+ .filters input,.filters select{font:13px system-ui;padding:5px 9px;border:1px solid var(--line);border-radius:7px;background:var(--card);color:var(--fg)}
1012
+ .crumbs a{color:var(--dim);text-decoration:none}.crumbs a:hover{color:var(--accent)}
1013
+ td a{text-decoration:none}td a:hover{text-decoration:underline}
1014
+ </style>
1015
+ <header>
1016
+ <h1><span class=crumbs><a href="/admin">admin</a> › </span>${esc(name)} <span class="pill ${sp ? "ok" : ""}">${sp ? "live" : "sleeping"}</span></h1>
1017
+ <span class=dim>${esc(allow.peekId() ?? "no id yet")} · ${records.length} acts retained${a.first ? ` since ${when(a.first)}` : ""}</span>
1018
+ <span style="margin-left:auto"><a href="/admin">← dashboard</a> · <a href="/admin/logout">sign out</a></span>
1019
+ </header>
1020
+ <div class=stats>
1021
+ <div class=stat><b>${a.messages_24h}<span class=dim style="font-size:14px"> / ${a.messages}</span></b><span>messages today / retained</span></div>
1022
+ <div class=stat><b>${a.senders_24h.length}<span class=dim style="font-size:14px"> · ${a.sessions_24h} sessions</span></b><span>delegates active today</span></div>
1023
+ <div class="stat ${a.by_act_24h.OBJECT ? "warn" : ""}"><b>${a.by_act_24h.OBJECT ?? 0}<span class=dim style="font-size:14px"> / ${a.by_act.OBJECT ?? 0}</span></b><span>objections today / retained</span></div>
1024
+ <div class="stat ${countActs(a.by_act_24h, ESCALATIONS) ? "warn" : ""}"><b>${countActs(a.by_act_24h, ESCALATIONS)}<span class=dim style="font-size:14px"> / ${countActs(a.by_act, ESCALATIONS)}</span></b><span>escalations today / retained</span></div>
1025
+ <div class=stat><b>${a.by_act_24h.DELIVERED ?? 0}<span class=dim style="font-size:14px"> / ${a.by_act.DELIVERED ?? 0}</span></b><span>deliveries today / retained</span></div>
1026
+ <div class="stat ${refusalsSince(refusals, dayAgo) ? "warn" : ""}"><b>${refusalsSince(refusals, dayAgo)}<span class=dim style="font-size:14px"> / ${refusals.total}</span></b><span>refused today / total</span></div>
1027
+ <div class=stat><b>${open}<span class=dim style="font-size:14px"> / ${state.goals.size}</span></b><span>goals open / total</span></div>
1028
+ ${sp ? `<div class=stat><b>${sp.obligations.size}</b><span>obligations owed</span></div>` : ""}
1029
+ </div>
1030
+ <section>
1031
+ <h2>Members <span class=dim>(${entries.filter((e) => e.admitted).length} admitted)</span></h2><div class=wrap><table>
1032
+ <tr><th>member</th><th>state</th><th>may wake</th><th>online</th><th>last seen</th></tr>
1033
+ ${memberRows || "<tr><td colspan=5 class=dim>nobody yet</td></tr>"}
1034
+ </table></div>
1035
+ ${repos.length ? `<h2>Repos this team works from</h2><div class=wrap><table><tr><th>origin</th><th>fingerprint</th><th>added by</th><th>when</th></tr>${repos.map((r) => `<tr><td>${esc(r.origin ?? "(no origin)")}</td><td><code>${esc(r.fingerprint)}</code></td><td><code>${esc(r.added_by)}</code></td><td class=dim>${when(r.at)}</td></tr>`).join("")}</table></div>` : ""}
1036
+
1037
+ <h2>Acts <span class=dim>(today · retained)</span></h2><div class=wrap><table id=t-acts>
1038
+ <tr><th></th>${MESSAGE_ACTS.map((x) => `<th>${x}</th>`).join("")}</tr>
1039
+ <tr><td class=dim>today</td>${MESSAGE_ACTS.map((x) => `<td data-act="${x}" data-window="24h">${a.by_act_24h[x] ?? 0}</td>`).join("")}</tr>
1040
+ <tr><td class=dim>retained</td>${MESSAGE_ACTS.map((x) => `<td data-act="${x}" data-window="all">${a.by_act[x] ?? 0}</td>`).join("")}</tr>
1041
+ </table></div>
1042
+
1043
+ <h2>Refused by the relay <span class=dim>(latest ${refusals.recent.length} of ${refusals.total})</span></h2><div class=wrap><table>
1044
+ <tr><th>when</th><th>member</th><th>request</th><th>reason</th></tr>
1045
+ ${refusalRows || "<tr><td colspan=4 class=dim>nothing refused</td></tr>"}
1046
+ </table></div>
1047
+ ${byCode ? `<p class=note>All time: ${byCode}</p>` : ""}
1048
+
1049
+ <h2>Conversation <span class=dim>(newest first${filtered ? ", filtered" : ""})</span></h2>
1050
+ <form class=filters method=GET action="/admin/space"><input type=hidden name=name value="${esc(name)}">
1051
+ <select name=act><option value="">every act</option>${options}</select>
1052
+ <input name=member placeholder="member" value="${esc(q.member)}" size=12>
1053
+ <input name=goal placeholder="goal id" value="${esc(q.goal)}" size=16>
1054
+ <input name=q placeholder="search what was said" value="${esc(q.q)}" size=24>
1055
+ <label class=dim><input type=checkbox name=presence value=1${q.presence ? " checked" : ""}> show JOIN/LEAVE</label>
1056
+ <button class=act>filter</button>${filtered ? `<a href="/admin/space?name=${encodeURIComponent(name)}">clear</a>` : ""}
1057
+ </form>
1058
+ <div class=wrap><table class=feed>
1059
+ <tr><th>when</th><th>from</th><th>act</th><th>to</th><th>goal</th><th>said</th></tr>
1060
+ ${rowsHtml || `<tr><td colspan=6 class=dim>${filtered ? "nothing matches" : "no messages yet"}</td></tr>`}
1061
+ </table></div>
1062
+ ${next !== null ? `<p><a href="${link({ before: next })}">older ›</a></p>` : ""}
1063
+ <p class=note>Retained history only: the relay keeps the most recent ${LIMITS.ledgerKeepRecent.toLocaleString()} acts of a space plus everything in an open goal. Bodies are shown as sent; nothing here is edited or summarised.</p>
634
1064
  </section>`;
635
1065
  res.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store", "referrer-policy": "no-referrer" });
636
1066
  res.end(html);
@@ -708,6 +1138,7 @@ pantheon signup --email you@work.com --username you</code></pre>
708
1138
  let me = null;
709
1139
  let sessionId = "";
710
1140
  let role = "interactive";
1141
+ let lastMsg = "";
711
1142
  let alive = true;
712
1143
  const nonce = newNonce();
713
1144
  ws.on("pong", () => { alive = true; });
@@ -715,6 +1146,10 @@ pantheon signup --email you@work.com --username you</code></pre>
715
1146
  probe._alive = () => alive;
716
1147
  probe._clear = () => { alive = false; };
717
1148
  const fail = (code, message, cid) => {
1149
+ if (cid && space && me && !space.deleted) {
1150
+ noteRefusal(space.store.refusals, { ts: Date.now(), member: me.id, act: lastMsg || "?", code });
1151
+ space.store.save(space.goals, space.obligations, space.profiles);
1152
+ }
718
1153
  this.send(ws, { t: "error", code, message, ...(cid ? { cid } : {}) });
719
1154
  };
720
1155
  const reject = (code, message) => {
@@ -753,6 +1188,7 @@ pantheon signup --email you@work.com --username you</code></pre>
753
1188
  return fail("BAD_MESSAGE", parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; "), cid);
754
1189
  }
755
1190
  const m = parsed.data;
1191
+ lastMsg = m.t === "act" ? m.act : m.t;
756
1192
  if (m.t === "ping")
757
1193
  return this.send(ws, { t: "pong" });
758
1194
  if (m.t === "hello") {
@@ -772,6 +1208,10 @@ pantheon signup --email you@work.com --username you</code></pre>
772
1208
  return reject("NO_SUCH_SPACE", `no space "${m.space}" on this relay`);
773
1209
  }
774
1210
  else {
1211
+ const tomb = this.deletedSpaces.get(m.space);
1212
+ if (tomb && Date.now() - tomb < 3_600_000) {
1213
+ 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.`);
1214
+ }
775
1215
  if (!this.spaces.has(m.space)) {
776
1216
  const denied = this.mayCreateSpace(ip);
777
1217
  if (denied)
@@ -789,7 +1229,9 @@ pantheon signup --email you@work.com --username you</code></pre>
789
1229
  if (!auth.ok) {
790
1230
  const msg = auth.reason === "username_mismatch"
791
1231
  ? `this device is signed in as a different username than "${m.member}". Use your own username, or \`pantheon login\` on this machine.`
792
- : `sign in first: run \`pantheon signup\` (new) or \`pantheon login\` (existing account) on this machine, then try again.`;
1232
+ : auth.reason === "suspended"
1233
+ ? `this account has been suspended by the relay operator. Contact them to have it reinstated.`
1234
+ : `sign in first: run \`pantheon signup\` (new) or \`pantheon login\` (existing account) on this machine, then try again.`;
793
1235
  this.log(`[${sp.name}] ! REFUSED ${m.member}: ${auth.reason}`);
794
1236
  return reject("NOT_SIGNED_IN", msg);
795
1237
  }
@@ -823,6 +1265,18 @@ pantheon signup --email you@work.com --username you</code></pre>
823
1265
  catch { } }, 20);
824
1266
  return;
825
1267
  }
1268
+ let newRepo = null;
1269
+ if (m.repo) {
1270
+ if (sp.allow.hasRepo(m.repo)) { }
1271
+ else if (sp.allow.repoCount() === 0 || enrolled || verdict === "founder")
1272
+ newRepo = m.repo;
1273
+ else {
1274
+ const known = sp.allow.repoList().map((r) => r.origin ?? r.fingerprint).join(", ");
1275
+ this.log(`[${sp.name}] ! REFUSED ${m.member}: repo ${m.repo_origin ?? m.repo} not bound to this space`);
1276
+ return reject("REPO_MISMATCH", `this checkout (${m.repo_origin ?? "no origin"}) is not one of the repos "${sp.name}" works from (${known || "none yet"}). ` +
1277
+ `Open your agent in the team's repo, or if this repo really belongs to the team: pantheon repo add`);
1278
+ }
1279
+ }
826
1280
  space = sp;
827
1281
  clearHelloTimer();
828
1282
  sessionId = acctSession ?? this.mintSession();
@@ -830,10 +1284,16 @@ pantheon signup --email you@work.com --username you</code></pre>
830
1284
  me = this.attach(sp, m.member, m.display ?? m.member, m.agent ?? "unknown", sessionId, m.role, ws, m.pkg);
831
1285
  const firstSession = me.sessions.size === 1;
832
1286
  sp.lastTouch = Date.now();
1287
+ if (newRepo && sp.allow.addRepo(newRepo, m.repo_origin ?? null, me.id)) {
1288
+ this.log(`[${sp.name}] + repo ${m.repo_origin ?? newRepo} registered by ${me.id}`);
1289
+ 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 } });
1290
+ this.broadcast(sp, { t: "repos", repos: sp.allow.repoList() });
1291
+ }
833
1292
  this.send(ws, {
834
1293
  t: "welcome", member: me.id, session: sessionId, space: sp.name, seq: sp.ledger.head,
835
1294
  roster: this.roster(sp), goals: [...sp.goals.values()],
836
1295
  obligations: [...sp.obligations.values()], members: this.members(sp), profiles: [...sp.profiles.values()],
1296
+ space_id: sp.allow.spaceId(), repos: sp.allow.repoList(),
837
1297
  ...(enrolled ? { token: enrolled } : {}),
838
1298
  });
839
1299
  for (const r of sp.ledger.since(m.from_seq ?? sp.ledger.head)) {
@@ -910,6 +1370,19 @@ pantheon signup --email you@work.com --username you</code></pre>
910
1370
  this.send(ws, { t: "invite", url: token, expires_at: inv.expires_at });
911
1371
  return;
912
1372
  }
1373
+ if (m.t === "bind_repo") {
1374
+ const self = space.allow.get(me.id);
1375
+ if (!self?.admitted)
1376
+ return fail("NOT_ADMITTED", "only an admitted member can register a repo", m.cid);
1377
+ const added = space.allow.addRepo(m.repo, m.origin ?? null, me.id);
1378
+ if (added) {
1379
+ this.log(`[${space.name}] + repo ${m.origin ?? m.repo} registered by ${me.id}`);
1380
+ 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 } });
1381
+ this.broadcast(space, { t: "repos", repos: space.allow.repoList() });
1382
+ }
1383
+ this.send(ws, { t: "ack", cid: m.cid, id: m.repo, seq: space.ledger.head });
1384
+ return;
1385
+ }
913
1386
  if (m.t === "claim") {
914
1387
  if (m.obligation.startsWith("cont:")) {
915
1388
  const act = space.ledger.find(m.obligation.slice(5));
@@ -1019,7 +1492,7 @@ pantheon signup --email you@work.com --username you</code></pre>
1019
1492
  `\nCall pantheon_wait, then send again.`, m.cid);
1020
1493
  }
1021
1494
  }
1022
- 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);
1495
+ 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);
1023
1496
  if (err)
1024
1497
  return fail(err.code, err.message, m.cid);
1025
1498
  const rec = this.commit(space, {
@@ -1080,7 +1553,7 @@ pantheon signup --email you@work.com --username you</code></pre>
1080
1553
  }
1081
1554
  this.evictSpaces();
1082
1555
  }
1083
- validateAct(sp, me, act, goalId, ref, to, meta, deferTo, bodyLen = 0, summary) {
1556
+ validateAct(sp, me, act, goalId, ref, to, meta, deferTo, bodyLen = 0, summary, repo) {
1084
1557
  if (!SENDABLE.includes(act))
1085
1558
  return { code: "RESERVED_ACT", message: `${act} is emitted by the relay, not by a delegate` };
1086
1559
  const now = Date.now();
@@ -1197,9 +1670,19 @@ pantheon signup --email you@work.com --username you</code></pre>
1197
1670
  return { code: "ALREADY_RETRACTED", message: "a retraction cannot itself be retracted — send a new act saying what is true" };
1198
1671
  }
1199
1672
  }
1673
+ if (repo && sp.allow.repoCount() > 0 && !sp.allow.hasRepo(repo)) {
1674
+ return { code: "REPO_MISMATCH", message: `this act was composed in a repo that "${sp.name}" does not work from. Open your agent in the team's repo, or register this one: pantheon repo add` };
1675
+ }
1200
1676
  if (act === "DELIVERED") {
1201
1677
  const branch = typeof meta?.["branch"] === "string" ? meta["branch"] : "";
1202
1678
  const commit = typeof meta?.["commit"] === "string" ? meta["commit"] : "";
1679
+ if (sp.allow.repoCount() > 0) {
1680
+ const r = typeof meta?.["repo"] === "string" ? meta["repo"] : repo ?? "";
1681
+ if (!r)
1682
+ return { code: "REPO_REQUIRED", message: "DELIVERED must say which of the team's repos the commit is in (meta.repo). Send it from a session open in that repo — if this session already is, the checkout is not one the team knows yet: from it, run `pantheon use <team> --here`." };
1683
+ if (!sp.allow.hasRepo(r))
1684
+ return { code: "REPO_MISMATCH", message: `the commit is in a repo "${sp.name}" does not work from; a peer could not fetch it. Register the repo first: pantheon repo add` };
1685
+ }
1203
1686
  if (!branch || !commit) {
1204
1687
  return {
1205
1688
  code: "UNVERIFIABLE_DELIVERY",
@@ -1247,6 +1730,8 @@ pantheon signup --email you@work.com --username you</code></pre>
1247
1730
  return null;
1248
1731
  }
1249
1732
  commit(sp, partial) {
1733
+ if (sp.deleted)
1734
+ return { ...partial, id: newId("a"), seq: sp.ledger.head, ts: Date.now(), space: sp.name };
1250
1735
  const rec = sp.ledger.append({ ...partial, id: newId("a"), ts: Date.now(), space: sp.name });
1251
1736
  sp.lastTouch = rec.ts;
1252
1737
  const m = sp.members.get(rec.from);