@join-pantheon/cli 0.19.0 → 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/README.md +75 -27
- package/dist/accounts.js +61 -0
- package/dist/admin-stats.js +116 -0
- package/dist/agents.js +2 -0
- package/dist/cli.js +337 -89
- package/dist/client.js +31 -1
- package/dist/config.js +229 -23
- package/dist/doctor.js +13 -1
- package/dist/mcp.js +24 -15
- package/dist/notifier.js +1 -1
- package/dist/persona.js +4 -1
- package/dist/profile.js +28 -4
- package/dist/protocol.js +10 -1
- package/dist/quickstart.js +35 -6
- package/dist/relay.js +565 -39
- package/dist/roster-file.js +26 -0
- package/dist/store.js +5 -0
- package/dist/tray.js +22 -1
- package/dist/worker-fleet.js +84 -0
- package/dist/worker.js +41 -9
- package/package.json +4 -4
package/dist/relay.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
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
|
+
import { spawn } from "node:child_process";
|
|
4
5
|
import { join } from "node:path";
|
|
5
6
|
import { WebSocketServer } from "ws";
|
|
6
7
|
import { badRemote, Inbound, isContinuationFor, LIMITS, newId, PROTOCOL_VERSION, SENDABLE, shouldDeferToHuman, } from "./protocol.js";
|
|
@@ -11,6 +12,7 @@ import { Accounts, normalizeUsername, validEmail, validUsername } from "./accoun
|
|
|
11
12
|
import { emailSenderFromEnv } from "./email.js";
|
|
12
13
|
import { Allowlist } from "./roster-file.js";
|
|
13
14
|
import { StateStore } from "./store.js";
|
|
15
|
+
import { activity, mergeActivity, feed, noteRefusal, refusalsSince, countActs, ESCALATIONS, MESSAGE_ACTS, ACT_TONE, DAY } from "./admin-stats.js";
|
|
14
16
|
import { routeDecision } from "./profile.js";
|
|
15
17
|
function workState(m, now = Date.now(), busyTtlMs = LIMITS.activityBusyTtlMs, pruneMs = LIMITS.activityPruneMs) {
|
|
16
18
|
let known = false;
|
|
@@ -72,6 +74,38 @@ export function isTrustedSource(ip) {
|
|
|
72
74
|
function fingerprint_(from, act, body, goal, ref) {
|
|
73
75
|
return createHash("sha256").update([from, act, goal ?? "", ref ?? "", body].join("\u0000")).digest("hex").slice(0, 32);
|
|
74
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) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[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
|
+
};
|
|
75
109
|
export class Relay {
|
|
76
110
|
opts;
|
|
77
111
|
spaces = new Map();
|
|
@@ -94,6 +128,9 @@ export class Relay {
|
|
|
94
128
|
adminHits = new Map();
|
|
95
129
|
emailSends = [];
|
|
96
130
|
adminToken;
|
|
131
|
+
adminAllow;
|
|
132
|
+
startedAt = Date.now();
|
|
133
|
+
deletedSpaces = new Map();
|
|
97
134
|
backupTimer;
|
|
98
135
|
constructor(opts = {}) {
|
|
99
136
|
this.opts = opts;
|
|
@@ -104,6 +141,7 @@ export class Relay {
|
|
|
104
141
|
? process.env.PANTHEON_TRUST_LOCAL === "1"
|
|
105
142
|
: !this.requireAccounts;
|
|
106
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);
|
|
107
145
|
const wantStore = this.requireAccounts || this.adminToken !== null;
|
|
108
146
|
this.accounts = opts.accounts ?? (wantStore ? new Accounts(join(this.dataDir, "accounts.db")) : null);
|
|
109
147
|
this.email = opts.emailSender ?? emailSenderFromEnv(this.log);
|
|
@@ -275,10 +313,12 @@ export class Relay {
|
|
|
275
313
|
onAuthHttp(req, res, url, ip) {
|
|
276
314
|
if (url.pathname === "/")
|
|
277
315
|
return this.landingPage(res);
|
|
278
|
-
if (url.pathname === "/admin" || url.pathname === "/admin/export" || 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") {
|
|
279
317
|
const A0 = this.accounts;
|
|
280
318
|
if (!A0 || !this.adminToken)
|
|
281
319
|
return this.json(res, 404, { error: "not found" });
|
|
320
|
+
if (!this.adminIpOk(ip))
|
|
321
|
+
return this.json(res, 404, { error: "not found" });
|
|
282
322
|
const tokenOk = (t) => { const a = Buffer.from(t), b = Buffer.from(this.adminToken); return a.length === b.length && timingSafeEqualBuf(a, b); };
|
|
283
323
|
const fromCookie = cookieVal(req, "pantheon_admin");
|
|
284
324
|
const fromQuery = url.searchParams.get("token") ?? req.headers["authorization"]?.replace(/^Bearer\s+/i, "") ?? "";
|
|
@@ -290,8 +330,10 @@ export class Relay {
|
|
|
290
330
|
return this.readForm(req, (body) => {
|
|
291
331
|
if (!tokenOk(body.token ?? "")) {
|
|
292
332
|
this.noteAdminFail(ip);
|
|
333
|
+
A0.audit({ event: "admin_login_failed", ip });
|
|
293
334
|
return this.adminLoginPage(res, true);
|
|
294
335
|
}
|
|
336
|
+
A0.audit({ event: "admin_login", ip });
|
|
295
337
|
const secure = req.headers["x-forwarded-proto"] === "https" ? "; Secure" : "";
|
|
296
338
|
res.writeHead(302, { "set-cookie": `pantheon_admin=${encodeURIComponent(body.token)}; HttpOnly; SameSite=Strict; Path=/admin; Max-Age=43200${secure}`, location: "/admin" });
|
|
297
339
|
return void res.end();
|
|
@@ -305,11 +347,30 @@ export class Relay {
|
|
|
305
347
|
if (url.pathname === "/admin/export") {
|
|
306
348
|
if (!authed)
|
|
307
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 });
|
|
308
351
|
return this.adminExport(res, A0, url);
|
|
309
352
|
}
|
|
353
|
+
if (url.pathname === "/admin/backup") {
|
|
354
|
+
if (!authed)
|
|
355
|
+
return this.json(res, 403, { error: "sign in at /admin, or append ?token=…" });
|
|
356
|
+
A0.audit({ event: "admin_backup", ip });
|
|
357
|
+
return this.adminBackup(res, A0);
|
|
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
|
+
}
|
|
310
371
|
if (!authed)
|
|
311
372
|
return this.adminLoginPage(res, false);
|
|
312
|
-
return this.adminDashboard(res, A0);
|
|
373
|
+
return this.adminDashboard(res, A0, url);
|
|
313
374
|
}
|
|
314
375
|
const A = this.accounts;
|
|
315
376
|
if (!A)
|
|
@@ -327,6 +388,8 @@ export class Relay {
|
|
|
327
388
|
return this.json(res, 400, { error: "username must be 2–32 chars: letters, digits, . _ - (not at the ends)" });
|
|
328
389
|
const started = A.startVerification(b.email, "signup", { username: b.username, pubkey: b.pubkey, label: String(b.label ?? "a device").slice(0, 60) });
|
|
329
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" });
|
|
330
393
|
const msg = started.reason === "too_soon" ? "a code was just sent — check your email, or wait a minute to resend"
|
|
331
394
|
: started.reason === "username_taken" ? "that username is taken"
|
|
332
395
|
: started.reason === "email_taken" ? "that email already has an account — use `pantheon login` from this machine instead"
|
|
@@ -349,6 +412,8 @@ export class Relay {
|
|
|
349
412
|
if (!b || typeof b.email !== "string" || typeof b.pubkey !== "string")
|
|
350
413
|
return this.json(res, 400, { error: "email and pubkey are required" });
|
|
351
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" });
|
|
352
417
|
if (!started.ok) {
|
|
353
418
|
const code = started.reason === "too_soon" ? 429 : 404;
|
|
354
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";
|
|
@@ -566,65 +631,436 @@ button{width:100%;margin-top:12px;padding:10px;border:0;border-radius:8px;backgr
|
|
|
566
631
|
${wrong ? '<div class=err>That token is not right.</div>' : ''}
|
|
567
632
|
</form>`);
|
|
568
633
|
}
|
|
569
|
-
|
|
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) {
|
|
570
824
|
const c = A.counts();
|
|
571
825
|
const esc = (s) => String(s ?? "").replace(/[&<>"]/g, (m) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[m]));
|
|
572
|
-
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`;
|
|
573
830
|
const accounts = A.allAccounts();
|
|
574
831
|
const devices = A.allDevices();
|
|
575
832
|
const sessions = A.allSessions(200);
|
|
576
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);
|
|
577
841
|
const devByAcct = new Map();
|
|
578
842
|
for (const d of devices)
|
|
579
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");
|
|
580
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>`;
|
|
581
857
|
const html = `<!doctype html><meta charset=utf-8><title>pantheon · admin</title>
|
|
582
858
|
<meta name=viewport content="width=device-width,initial-scale=1">
|
|
583
|
-
|
|
584
|
-
:root{--bg:#0b0c0e;--card:#15171b;--line:#262a30;--fg:#e7e9ec;--dim:#9aa1a9;--accent:#5b9dff}
|
|
585
|
-
@media(prefers-color-scheme:light){:root{--bg:#f6f7f9;--card:#fff;--line:#e6e8eb;--fg:#1a1c1f;--dim:#6b7280;--accent:#2563eb}}
|
|
586
|
-
*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--fg);font:14px/1.5 system-ui,-apple-system,sans-serif}
|
|
587
|
-
header{padding:20px 24px;border-bottom:1px solid var(--line);display:flex;align-items:baseline;gap:16px;flex-wrap:wrap}
|
|
588
|
-
h1{font-size:18px;margin:0}.dim{color:var(--dim)}
|
|
589
|
-
.stats{display:flex;gap:10px;flex-wrap:wrap;padding:16px 24px}
|
|
590
|
-
.stat{background:var(--card);border:1px solid var(--line);border-radius:10px;padding:10px 16px;min-width:96px}
|
|
591
|
-
.stat b{font-size:22px;display:block}.stat span{color:var(--dim);font-size:12px}
|
|
592
|
-
section{padding:8px 24px 28px}h2{font-size:14px;text-transform:uppercase;letter-spacing:.04em;color:var(--dim);margin:22px 0 8px}
|
|
593
|
-
.wrap{overflow-x:auto;border:1px solid var(--line);border-radius:10px;background:var(--card)}
|
|
594
|
-
table{border-collapse:collapse;width:100%;font-variant-numeric:tabular-nums}
|
|
595
|
-
th,td{text-align:left;padding:8px 12px;border-bottom:1px solid var(--line);white-space:nowrap}
|
|
596
|
-
th{color:var(--dim);font-weight:600;font-size:12px}tr:last-child td{border-bottom:0}
|
|
597
|
-
code{font-family:ui-monospace,monospace;font-size:12px}a{color:var(--accent)}
|
|
598
|
-
.pill{font-size:11px;padding:1px 7px;border-radius:999px;border:1px solid var(--line)}
|
|
599
|
-
</style>
|
|
859
|
+
${ADMIN_CSS}
|
|
600
860
|
<header>
|
|
601
861
|
<h1>pantheon · admin</h1>
|
|
602
|
-
<span class=dim>${esc(this.opts.host ?? "relay")} · pkg ${VERSION}</span>
|
|
603
|
-
<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>
|
|
604
864
|
</header>
|
|
865
|
+
${done ? `<div class=done>${esc(done)}</div>` : ""}
|
|
605
866
|
<div class=stats>
|
|
606
867
|
<div class=stat><b>${c.accounts}</b><span>accounts</span></div>
|
|
607
868
|
<div class=stat><b>${c.devices}</b><span>devices</span></div>
|
|
608
|
-
<div class=stat><b>${c.live_sessions}</b><span>
|
|
609
|
-
<div class=stat><b>${
|
|
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>
|
|
610
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>
|
|
881
|
+
</div>
|
|
882
|
+
<section>
|
|
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>"}
|
|
908
|
+
</table></div>
|
|
909
|
+
|
|
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>"}
|
|
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>
|
|
917
|
+
|
|
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>"}
|
|
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>` : ""}
|
|
611
1029
|
</div>
|
|
612
1030
|
<section>
|
|
613
|
-
<h2>
|
|
614
|
-
<tr><th>
|
|
615
|
-
${
|
|
616
|
-
${accounts.length ? "" : "<tr><td colspan=5 class=dim>no accounts yet</td></tr>"}
|
|
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>"}
|
|
617
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>` : ""}
|
|
618
1036
|
|
|
619
|
-
<h2>
|
|
620
|
-
<tr><th
|
|
621
|
-
|
|
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>
|
|
622
1041
|
</table></div>
|
|
623
1042
|
|
|
624
|
-
<h2>
|
|
625
|
-
<tr><th>
|
|
626
|
-
${
|
|
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>"}
|
|
627
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>
|
|
628
1064
|
</section>`;
|
|
629
1065
|
res.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store", "referrer-policy": "no-referrer" });
|
|
630
1066
|
res.end(html);
|
|
@@ -646,6 +1082,41 @@ button{width:100%;margin-top:12px;padding:10px;border:0;border-radius:8px;backgr
|
|
|
646
1082
|
res.writeHead(200, { "content-type": "text/csv", "content-disposition": `attachment; filename="${table}.csv"` });
|
|
647
1083
|
res.end(csv);
|
|
648
1084
|
}
|
|
1085
|
+
adminBackup(res, A) {
|
|
1086
|
+
try {
|
|
1087
|
+
A.backup(join(this.dataDir, "backups"));
|
|
1088
|
+
}
|
|
1089
|
+
catch (e) {
|
|
1090
|
+
this.log(`! backup snapshot before archive failed: ${e.message}`);
|
|
1091
|
+
}
|
|
1092
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
1093
|
+
const tar = spawn("tar", ["czf", "-", "-C", this.dataDir, "."], { stdio: ["ignore", "pipe", "pipe"] });
|
|
1094
|
+
let err = "";
|
|
1095
|
+
tar.stderr.on("data", (c) => { err += String(c); });
|
|
1096
|
+
tar.on("error", (e) => {
|
|
1097
|
+
this.log(`! backup archive failed to start: ${e.message}`);
|
|
1098
|
+
if (!res.headersSent)
|
|
1099
|
+
this.json(res, 500, { error: `could not start tar: ${e.message}` });
|
|
1100
|
+
});
|
|
1101
|
+
tar.stdout.once("data", () => {
|
|
1102
|
+
if (!res.headersSent) {
|
|
1103
|
+
res.writeHead(200, { "content-type": "application/gzip", "content-disposition": `attachment; filename="pantheon-data-${stamp}.tgz"` });
|
|
1104
|
+
}
|
|
1105
|
+
});
|
|
1106
|
+
tar.stdout.pipe(res);
|
|
1107
|
+
tar.on("close", (code) => {
|
|
1108
|
+
if (code !== 0) {
|
|
1109
|
+
this.log(`! backup archive exited ${code}: ${err.trim()}`);
|
|
1110
|
+
if (!res.headersSent)
|
|
1111
|
+
this.json(res, 500, { error: `tar exited ${code}` });
|
|
1112
|
+
}
|
|
1113
|
+
res.end();
|
|
1114
|
+
});
|
|
1115
|
+
res.on("close", () => { try {
|
|
1116
|
+
tar.kill();
|
|
1117
|
+
}
|
|
1118
|
+
catch { } });
|
|
1119
|
+
}
|
|
649
1120
|
landingPage(res) {
|
|
650
1121
|
res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
|
|
651
1122
|
res.end(`<!doctype html><meta charset=utf-8><title>pantheon</title>
|
|
@@ -667,6 +1138,7 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
667
1138
|
let me = null;
|
|
668
1139
|
let sessionId = "";
|
|
669
1140
|
let role = "interactive";
|
|
1141
|
+
let lastMsg = "";
|
|
670
1142
|
let alive = true;
|
|
671
1143
|
const nonce = newNonce();
|
|
672
1144
|
ws.on("pong", () => { alive = true; });
|
|
@@ -674,6 +1146,10 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
674
1146
|
probe._alive = () => alive;
|
|
675
1147
|
probe._clear = () => { alive = false; };
|
|
676
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
|
+
}
|
|
677
1153
|
this.send(ws, { t: "error", code, message, ...(cid ? { cid } : {}) });
|
|
678
1154
|
};
|
|
679
1155
|
const reject = (code, message) => {
|
|
@@ -712,6 +1188,7 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
712
1188
|
return fail("BAD_MESSAGE", parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; "), cid);
|
|
713
1189
|
}
|
|
714
1190
|
const m = parsed.data;
|
|
1191
|
+
lastMsg = m.t === "act" ? m.act : m.t;
|
|
715
1192
|
if (m.t === "ping")
|
|
716
1193
|
return this.send(ws, { t: "pong" });
|
|
717
1194
|
if (m.t === "hello") {
|
|
@@ -731,6 +1208,10 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
731
1208
|
return reject("NO_SUCH_SPACE", `no space "${m.space}" on this relay`);
|
|
732
1209
|
}
|
|
733
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
|
+
}
|
|
734
1215
|
if (!this.spaces.has(m.space)) {
|
|
735
1216
|
const denied = this.mayCreateSpace(ip);
|
|
736
1217
|
if (denied)
|
|
@@ -748,7 +1229,9 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
748
1229
|
if (!auth.ok) {
|
|
749
1230
|
const msg = auth.reason === "username_mismatch"
|
|
750
1231
|
? `this device is signed in as a different username than "${m.member}". Use your own username, or \`pantheon login\` on this machine.`
|
|
751
|
-
:
|
|
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.`;
|
|
752
1235
|
this.log(`[${sp.name}] ! REFUSED ${m.member}: ${auth.reason}`);
|
|
753
1236
|
return reject("NOT_SIGNED_IN", msg);
|
|
754
1237
|
}
|
|
@@ -782,6 +1265,18 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
782
1265
|
catch { } }, 20);
|
|
783
1266
|
return;
|
|
784
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
|
+
}
|
|
785
1280
|
space = sp;
|
|
786
1281
|
clearHelloTimer();
|
|
787
1282
|
sessionId = acctSession ?? this.mintSession();
|
|
@@ -789,10 +1284,16 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
789
1284
|
me = this.attach(sp, m.member, m.display ?? m.member, m.agent ?? "unknown", sessionId, m.role, ws, m.pkg);
|
|
790
1285
|
const firstSession = me.sessions.size === 1;
|
|
791
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
|
+
}
|
|
792
1292
|
this.send(ws, {
|
|
793
1293
|
t: "welcome", member: me.id, session: sessionId, space: sp.name, seq: sp.ledger.head,
|
|
794
1294
|
roster: this.roster(sp), goals: [...sp.goals.values()],
|
|
795
1295
|
obligations: [...sp.obligations.values()], members: this.members(sp), profiles: [...sp.profiles.values()],
|
|
1296
|
+
space_id: sp.allow.spaceId(), repos: sp.allow.repoList(),
|
|
796
1297
|
...(enrolled ? { token: enrolled } : {}),
|
|
797
1298
|
});
|
|
798
1299
|
for (const r of sp.ledger.since(m.from_seq ?? sp.ledger.head)) {
|
|
@@ -869,6 +1370,19 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
869
1370
|
this.send(ws, { t: "invite", url: token, expires_at: inv.expires_at });
|
|
870
1371
|
return;
|
|
871
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
|
+
}
|
|
872
1386
|
if (m.t === "claim") {
|
|
873
1387
|
if (m.obligation.startsWith("cont:")) {
|
|
874
1388
|
const act = space.ledger.find(m.obligation.slice(5));
|
|
@@ -978,7 +1492,7 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
978
1492
|
`\nCall pantheon_wait, then send again.`, m.cid);
|
|
979
1493
|
}
|
|
980
1494
|
}
|
|
981
|
-
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);
|
|
982
1496
|
if (err)
|
|
983
1497
|
return fail(err.code, err.message, m.cid);
|
|
984
1498
|
const rec = this.commit(space, {
|
|
@@ -1039,7 +1553,7 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
1039
1553
|
}
|
|
1040
1554
|
this.evictSpaces();
|
|
1041
1555
|
}
|
|
1042
|
-
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) {
|
|
1043
1557
|
if (!SENDABLE.includes(act))
|
|
1044
1558
|
return { code: "RESERVED_ACT", message: `${act} is emitted by the relay, not by a delegate` };
|
|
1045
1559
|
const now = Date.now();
|
|
@@ -1156,9 +1670,19 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
1156
1670
|
return { code: "ALREADY_RETRACTED", message: "a retraction cannot itself be retracted — send a new act saying what is true" };
|
|
1157
1671
|
}
|
|
1158
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
|
+
}
|
|
1159
1676
|
if (act === "DELIVERED") {
|
|
1160
1677
|
const branch = typeof meta?.["branch"] === "string" ? meta["branch"] : "";
|
|
1161
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
|
+
}
|
|
1162
1686
|
if (!branch || !commit) {
|
|
1163
1687
|
return {
|
|
1164
1688
|
code: "UNVERIFIABLE_DELIVERY",
|
|
@@ -1206,6 +1730,8 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
1206
1730
|
return null;
|
|
1207
1731
|
}
|
|
1208
1732
|
commit(sp, partial) {
|
|
1733
|
+
if (sp.deleted)
|
|
1734
|
+
return { ...partial, id: newId("a"), seq: sp.ledger.head, ts: Date.now(), space: sp.name };
|
|
1209
1735
|
const rec = sp.ledger.append({ ...partial, id: newId("a"), ts: Date.now(), space: sp.name });
|
|
1210
1736
|
sp.lastTouch = rec.ts;
|
|
1211
1737
|
const m = sp.members.get(rec.from);
|