@join-pantheon/cli 0.19.1 → 0.21.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 +38 -1
- package/dist/accounts.js +167 -1
- package/dist/admin-stats.js +116 -0
- package/dist/agents.js +6 -0
- package/dist/cli.js +417 -94
- package/dist/client.js +52 -7
- package/dist/config.js +243 -23
- package/dist/doctor.js +22 -1
- package/dist/mcp.js +67 -24
- package/dist/notifier.js +27 -7
- package/dist/notify.js +1 -1
- package/dist/pending.js +3 -1
- package/dist/persona.js +4 -1
- package/dist/plans.js +46 -0
- package/dist/profile.js +33 -4
- package/dist/protocol.js +22 -1
- package/dist/quickstart.js +37 -7
- package/dist/relay.js +732 -49
- package/dist/roster-file.js +39 -0
- package/dist/setup-guide.js +238 -0
- package/dist/store.js +5 -0
- package/dist/tray.js +22 -1
- package/dist/untrusted.js +16 -3
- package/dist/worker-fleet.js +84 -0
- package/dist/worker.js +116 -19
- package/package.json +2 -2
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,8 @@ 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";
|
|
16
|
+
import { fmtLimit, parseLimits, PLAN_NAME } from "./plans.js";
|
|
15
17
|
import { routeDecision } from "./profile.js";
|
|
16
18
|
function workState(m, now = Date.now(), busyTtlMs = LIMITS.activityBusyTtlMs, pruneMs = LIMITS.activityPruneMs) {
|
|
17
19
|
let known = false;
|
|
@@ -73,6 +75,38 @@ export function isTrustedSource(ip) {
|
|
|
73
75
|
function fingerprint_(from, act, body, goal, ref) {
|
|
74
76
|
return createHash("sha256").update([from, act, goal ?? "", ref ?? "", body].join("\u0000")).digest("hex").slice(0, 32);
|
|
75
77
|
}
|
|
78
|
+
const ADMIN_CSS = `<style>
|
|
79
|
+
:root{--bg:#0b0c0e;--card:#15171b;--line:#262a30;--fg:#e7e9ec;--dim:#9aa1a9;--accent:#5b9dff;--warn:#e8b45c;--bad:#ef8b80;--ok:#7bd39a}
|
|
80
|
+
@media(prefers-color-scheme:light){:root{--bg:#f6f7f9;--card:#fff;--line:#e6e8eb;--fg:#1a1c1f;--dim:#6b7280;--accent:#2563eb;--warn:#b45309;--bad:#b91c1c;--ok:#15803d}}
|
|
81
|
+
*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--fg);font:14px/1.5 system-ui,-apple-system,sans-serif}
|
|
82
|
+
header{padding:20px 24px;border-bottom:1px solid var(--line);display:flex;align-items:baseline;gap:16px;flex-wrap:wrap}
|
|
83
|
+
h1{font-size:18px;margin:0}.dim{color:var(--dim)}
|
|
84
|
+
.stats{display:flex;gap:10px;flex-wrap:wrap;padding:16px 24px}
|
|
85
|
+
.stat{background:var(--card);border:1px solid var(--line);border-radius:10px;padding:10px 16px;min-width:110px}
|
|
86
|
+
.stat b{font-size:22px;display:block;font-variant-numeric:tabular-nums}.stat span{color:var(--dim);font-size:12px}
|
|
87
|
+
.stat.warn b{color:var(--warn)}
|
|
88
|
+
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}
|
|
89
|
+
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}
|
|
90
|
+
.wrap{overflow-x:auto;border:1px solid var(--line);border-radius:10px;background:var(--card)}
|
|
91
|
+
table{border-collapse:collapse;width:100%;font-variant-numeric:tabular-nums}
|
|
92
|
+
th,td{text-align:left;padding:8px 12px;border-bottom:1px solid var(--line);white-space:nowrap;vertical-align:middle}
|
|
93
|
+
th{color:var(--dim);font-weight:600;font-size:12px}tr:last-child td{border-bottom:0}
|
|
94
|
+
code{font-family:ui-monospace,monospace;font-size:12px}a{color:var(--accent)}
|
|
95
|
+
.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)}
|
|
96
|
+
.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}
|
|
97
|
+
.act:hover{border-color:var(--accent)}.act.danger{color:var(--bad)}.act.danger:hover{border-color:var(--bad)}
|
|
98
|
+
.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}
|
|
99
|
+
.done{margin:0 24px;padding:10px 14px;border:1px solid var(--ok);color:var(--ok);border-radius:10px;background:var(--card)}
|
|
100
|
+
.note{color:var(--dim);font-size:12px;margin:6px 0 0}
|
|
101
|
+
</style>`;
|
|
102
|
+
const escHtml = (v) => String(v ?? "").replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c]));
|
|
103
|
+
const fmtWhen = (t) => t ? new Date(t).toISOString().slice(0, 19).replace("T", " ") : "—";
|
|
104
|
+
const fmtAgo = (t) => {
|
|
105
|
+
if (!t)
|
|
106
|
+
return "—";
|
|
107
|
+
const m = Math.round((Date.now() - t) / 60_000);
|
|
108
|
+
return m < 1 ? "just now" : m < 60 ? `${m}m ago` : m < 1440 ? `${Math.round(m / 60)}h ago` : `${Math.round(m / 1440)}d ago`;
|
|
109
|
+
};
|
|
76
110
|
export class Relay {
|
|
77
111
|
opts;
|
|
78
112
|
spaces = new Map();
|
|
@@ -95,6 +129,9 @@ export class Relay {
|
|
|
95
129
|
adminHits = new Map();
|
|
96
130
|
emailSends = [];
|
|
97
131
|
adminToken;
|
|
132
|
+
adminAllow;
|
|
133
|
+
startedAt = Date.now();
|
|
134
|
+
deletedSpaces = new Map();
|
|
98
135
|
backupTimer;
|
|
99
136
|
constructor(opts = {}) {
|
|
100
137
|
this.opts = opts;
|
|
@@ -105,8 +142,11 @@ export class Relay {
|
|
|
105
142
|
? process.env.PANTHEON_TRUST_LOCAL === "1"
|
|
106
143
|
: !this.requireAccounts;
|
|
107
144
|
this.adminToken = process.env.PANTHEON_ADMIN_TOKEN?.trim() || null;
|
|
145
|
+
this.adminAllow = (process.env.PANTHEON_ADMIN_ALLOW_IPS ?? "").split(",").map((x) => x.trim()).filter(Boolean);
|
|
108
146
|
const wantStore = this.requireAccounts || this.adminToken !== null;
|
|
109
147
|
this.accounts = opts.accounts ?? (wantStore ? new Accounts(join(this.dataDir, "accounts.db")) : null);
|
|
148
|
+
if (this.accounts)
|
|
149
|
+
this.backfillSpaceOwners();
|
|
110
150
|
this.email = opts.emailSender ?? emailSenderFromEnv(this.log);
|
|
111
151
|
const envNum = (k) => {
|
|
112
152
|
const v = process.env[k];
|
|
@@ -276,10 +316,12 @@ export class Relay {
|
|
|
276
316
|
onAuthHttp(req, res, url, ip) {
|
|
277
317
|
if (url.pathname === "/")
|
|
278
318
|
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") {
|
|
319
|
+
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
320
|
const A0 = this.accounts;
|
|
281
321
|
if (!A0 || !this.adminToken)
|
|
282
322
|
return this.json(res, 404, { error: "not found" });
|
|
323
|
+
if (!this.adminIpOk(ip))
|
|
324
|
+
return this.json(res, 404, { error: "not found" });
|
|
283
325
|
const tokenOk = (t) => { const a = Buffer.from(t), b = Buffer.from(this.adminToken); return a.length === b.length && timingSafeEqualBuf(a, b); };
|
|
284
326
|
const fromCookie = cookieVal(req, "pantheon_admin");
|
|
285
327
|
const fromQuery = url.searchParams.get("token") ?? req.headers["authorization"]?.replace(/^Bearer\s+/i, "") ?? "";
|
|
@@ -291,8 +333,10 @@ export class Relay {
|
|
|
291
333
|
return this.readForm(req, (body) => {
|
|
292
334
|
if (!tokenOk(body.token ?? "")) {
|
|
293
335
|
this.noteAdminFail(ip);
|
|
336
|
+
A0.audit({ event: "admin_login_failed", ip });
|
|
294
337
|
return this.adminLoginPage(res, true);
|
|
295
338
|
}
|
|
339
|
+
A0.audit({ event: "admin_login", ip });
|
|
296
340
|
const secure = req.headers["x-forwarded-proto"] === "https" ? "; Secure" : "";
|
|
297
341
|
res.writeHead(302, { "set-cookie": `pantheon_admin=${encodeURIComponent(body.token)}; HttpOnly; SameSite=Strict; Path=/admin; Max-Age=43200${secure}`, location: "/admin" });
|
|
298
342
|
return void res.end();
|
|
@@ -306,16 +350,30 @@ export class Relay {
|
|
|
306
350
|
if (url.pathname === "/admin/export") {
|
|
307
351
|
if (!authed)
|
|
308
352
|
return this.json(res, 403, { error: "sign in at /admin, or append ?token=…" });
|
|
353
|
+
A0.audit({ event: "admin_export", target: url.searchParams.get("table") ?? "audit", detail: url.searchParams.get("format") ?? "json", ip });
|
|
309
354
|
return this.adminExport(res, A0, url);
|
|
310
355
|
}
|
|
311
356
|
if (url.pathname === "/admin/backup") {
|
|
312
357
|
if (!authed)
|
|
313
358
|
return this.json(res, 403, { error: "sign in at /admin, or append ?token=…" });
|
|
359
|
+
A0.audit({ event: "admin_backup", ip });
|
|
314
360
|
return this.adminBackup(res, A0);
|
|
315
361
|
}
|
|
362
|
+
if (url.pathname === "/admin/action" && req.method === "POST") {
|
|
363
|
+
if (!authed)
|
|
364
|
+
return this.json(res, 403, { error: "sign in at /admin" });
|
|
365
|
+
return this.readForm(req, (body) => this.adminAction(res, A0, body, ip));
|
|
366
|
+
}
|
|
367
|
+
if (url.pathname === "/admin/space") {
|
|
368
|
+
if (!authed)
|
|
369
|
+
return this.adminLoginPage(res, false);
|
|
370
|
+
const name = url.searchParams.get("name") ?? "";
|
|
371
|
+
A0.audit({ event: "admin_space_view", space: name, detail: url.search.replace(/^\?/, "").replace(/(^|&)token=[^&]*/g, "$1token=…") || null, ip });
|
|
372
|
+
return this.adminSpace(res, url, name);
|
|
373
|
+
}
|
|
316
374
|
if (!authed)
|
|
317
375
|
return this.adminLoginPage(res, false);
|
|
318
|
-
return this.adminDashboard(res, A0);
|
|
376
|
+
return this.adminDashboard(res, A0, url);
|
|
319
377
|
}
|
|
320
378
|
const A = this.accounts;
|
|
321
379
|
if (!A)
|
|
@@ -333,6 +391,8 @@ export class Relay {
|
|
|
333
391
|
return this.json(res, 400, { error: "username must be 2–32 chars: letters, digits, . _ - (not at the ends)" });
|
|
334
392
|
const started = A.startVerification(b.email, "signup", { username: b.username, pubkey: b.pubkey, label: String(b.label ?? "a device").slice(0, 60) });
|
|
335
393
|
if (!started.ok) {
|
|
394
|
+
if (started.reason === "blocked")
|
|
395
|
+
return this.json(res, 403, { error: "this email cannot sign up on this relay", reason: "blocked" });
|
|
336
396
|
const msg = started.reason === "too_soon" ? "a code was just sent — check your email, or wait a minute to resend"
|
|
337
397
|
: started.reason === "username_taken" ? "that username is taken"
|
|
338
398
|
: started.reason === "email_taken" ? "that email already has an account — use `pantheon login` from this machine instead"
|
|
@@ -355,6 +415,8 @@ export class Relay {
|
|
|
355
415
|
if (!b || typeof b.email !== "string" || typeof b.pubkey !== "string")
|
|
356
416
|
return this.json(res, 400, { error: "email and pubkey are required" });
|
|
357
417
|
const started = A.startVerification(b.email, "login", { pubkey: b.pubkey, label: String(b.label ?? "a device").slice(0, 60) });
|
|
418
|
+
if (!started.ok && started.reason === "blocked")
|
|
419
|
+
return this.json(res, 403, { error: "this email cannot sign in on this relay", reason: "blocked" });
|
|
358
420
|
if (!started.ok) {
|
|
359
421
|
const code = started.reason === "too_soon" ? 429 : 404;
|
|
360
422
|
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 +634,546 @@ button{width:100%;margin-top:12px;padding:10px;border:0;border-radius:8px;backgr
|
|
|
572
634
|
${wrong ? '<div class=err>That token is not right.</div>' : ''}
|
|
573
635
|
</form>`);
|
|
574
636
|
}
|
|
575
|
-
|
|
637
|
+
adminIpOk(ip) {
|
|
638
|
+
if (!this.adminAllow.length)
|
|
639
|
+
return true;
|
|
640
|
+
const h = ip.replace(/^::ffff:/, "");
|
|
641
|
+
const toN = (x) => x.split(".").reduce((n, o) => ((n << 8) + Number(o)) >>> 0, 0);
|
|
642
|
+
if (!/^\d+\.\d+\.\d+\.\d+$/.test(h))
|
|
643
|
+
return false;
|
|
644
|
+
return this.adminAllow.some((rule) => {
|
|
645
|
+
const [net, bits] = rule.split("/");
|
|
646
|
+
if (!/^\d+\.\d+\.\d+\.\d+$/.test(net ?? ""))
|
|
647
|
+
return false;
|
|
648
|
+
if (bits === undefined)
|
|
649
|
+
return net === h;
|
|
650
|
+
const n = Number(bits);
|
|
651
|
+
if (!(n >= 0 && n <= 32))
|
|
652
|
+
return false;
|
|
653
|
+
const mask = n === 0 ? 0 : (~0 << (32 - n)) >>> 0;
|
|
654
|
+
return (toN(h) & mask) === (toN(net) & mask);
|
|
655
|
+
});
|
|
656
|
+
}
|
|
657
|
+
adminCsrf() {
|
|
658
|
+
return createHash("sha256").update("pantheon-admin-csrf " + (this.adminToken ?? "")).digest("hex").slice(0, 32);
|
|
659
|
+
}
|
|
660
|
+
spacesOverview() {
|
|
661
|
+
const now = Date.now();
|
|
662
|
+
const out = new Map();
|
|
663
|
+
const safeOf = (name) => name.replace(/[^a-z0-9._-]/gi, "_");
|
|
664
|
+
const size = (safe) => [".jsonl", ".state.json", ".members.json"].reduce((n, suf) => { try {
|
|
665
|
+
return n + statSync(join(this.dataDir, `${safe}${suf}`)).size;
|
|
666
|
+
}
|
|
667
|
+
catch {
|
|
668
|
+
return n;
|
|
669
|
+
} }, 0);
|
|
670
|
+
for (const sp of this.spaces.values()) {
|
|
671
|
+
const name = sp.name;
|
|
672
|
+
out.set(safeOf(name), {
|
|
673
|
+
name, live: true, id: sp.allow.peekId(),
|
|
674
|
+
members: sp.allow.all().filter((e) => e.admitted).length, online: [...sp.members.values()].filter((m) => m.sessions.size > 0).length,
|
|
675
|
+
acts: sp.ledger.head, goals: sp.goals.size, open_goals: [...sp.goals.values()].filter((g) => g.state !== "CLOSED").length, obligations: sp.obligations.size,
|
|
676
|
+
last: sp.lastTouch, bytes: size(safeOf(name)),
|
|
677
|
+
activity: activity(sp.ledger.all(), now, (r) => `${name}/${r.from}`), refusals: sp.store.refusals,
|
|
678
|
+
});
|
|
679
|
+
}
|
|
680
|
+
let files = [];
|
|
681
|
+
try {
|
|
682
|
+
files = readdirSync(this.dataDir);
|
|
683
|
+
}
|
|
684
|
+
catch { }
|
|
685
|
+
for (const f of files) {
|
|
686
|
+
if (!f.endsWith(".members.json"))
|
|
687
|
+
continue;
|
|
688
|
+
const safe = f.slice(0, -".members.json".length);
|
|
689
|
+
if (out.has(safe))
|
|
690
|
+
continue;
|
|
691
|
+
let members = 0, last = null, id = null;
|
|
692
|
+
try {
|
|
693
|
+
const al = new Allowlist(this.dataDir, safe);
|
|
694
|
+
members = al.all().filter((e) => e.admitted).length;
|
|
695
|
+
id = al.peekId();
|
|
696
|
+
}
|
|
697
|
+
catch { }
|
|
698
|
+
try {
|
|
699
|
+
last = statSync(join(this.dataDir, `${safe}.jsonl`)).mtimeMs;
|
|
700
|
+
}
|
|
701
|
+
catch {
|
|
702
|
+
try {
|
|
703
|
+
last = statSync(join(this.dataDir, f)).mtimeMs;
|
|
704
|
+
}
|
|
705
|
+
catch { }
|
|
706
|
+
}
|
|
707
|
+
const records = this.sleepingRecords(safe);
|
|
708
|
+
const st = this.sleepingState(safe);
|
|
709
|
+
out.set(safe, {
|
|
710
|
+
name: safe, live: false, id, members, online: 0,
|
|
711
|
+
acts: records.length ? records[records.length - 1].seq : 0,
|
|
712
|
+
goals: st.goals.size, open_goals: [...st.goals.values()].filter((g) => g.state !== "CLOSED").length, obligations: 0,
|
|
713
|
+
last, bytes: size(safe),
|
|
714
|
+
activity: activity(records, now, (r) => `${safe}/${r.from}`), refusals: st.refusals,
|
|
715
|
+
});
|
|
716
|
+
}
|
|
717
|
+
return [...out.values()].sort((a, b) => (b.last ?? 0) - (a.last ?? 0));
|
|
718
|
+
}
|
|
719
|
+
sleepingCache = new Map();
|
|
720
|
+
sleepingRecords(safe) {
|
|
721
|
+
const file = join(this.dataDir, `${safe}.jsonl`);
|
|
722
|
+
let st;
|
|
723
|
+
try {
|
|
724
|
+
st = statSync(file);
|
|
725
|
+
}
|
|
726
|
+
catch {
|
|
727
|
+
return [];
|
|
728
|
+
}
|
|
729
|
+
const key = `${st.size}:${st.mtimeMs}`;
|
|
730
|
+
const hit = this.sleepingCache.get(safe);
|
|
731
|
+
if (hit && hit.key === key)
|
|
732
|
+
return hit.records;
|
|
733
|
+
const records = [];
|
|
734
|
+
for (const line of readFileSync(file, "utf8").split("\n")) {
|
|
735
|
+
if (!line.trim())
|
|
736
|
+
continue;
|
|
737
|
+
try {
|
|
738
|
+
records.push(JSON.parse(line));
|
|
739
|
+
}
|
|
740
|
+
catch { }
|
|
741
|
+
}
|
|
742
|
+
this.sleepingCache.set(safe, { key, records });
|
|
743
|
+
if (this.sleepingCache.size > 200)
|
|
744
|
+
this.sleepingCache.delete(this.sleepingCache.keys().next().value);
|
|
745
|
+
return records;
|
|
746
|
+
}
|
|
747
|
+
sleepingState(safe) {
|
|
748
|
+
const st = new StateStore(this.dataDir, safe);
|
|
749
|
+
const { goals } = st.load();
|
|
750
|
+
return { goals, refusals: st.refusals };
|
|
751
|
+
}
|
|
752
|
+
deleteSpace(name) {
|
|
753
|
+
const sp = this.spaces.get(name);
|
|
754
|
+
if (sp) {
|
|
755
|
+
sp.deleted = true;
|
|
756
|
+
for (const m of sp.members.values())
|
|
757
|
+
for (const s of m.sessions.values()) {
|
|
758
|
+
try {
|
|
759
|
+
s.ws.close(1008, "SPACE_DELETED");
|
|
760
|
+
}
|
|
761
|
+
catch { }
|
|
762
|
+
}
|
|
763
|
+
this.spaces.delete(name);
|
|
764
|
+
}
|
|
765
|
+
setTimeout(() => this.deleteSpaceFiles(name), 600).unref?.();
|
|
766
|
+
const safe = name.replace(/[^a-z0-9._-]/gi, "_");
|
|
767
|
+
const existed = !!sp || [".jsonl", ".state.json", ".members.json"].some((suf) => existsSync(join(this.dataDir, `${safe}${suf}`)));
|
|
768
|
+
this.deleteSpaceFiles(name);
|
|
769
|
+
this.deletedSpaces.set(name, Date.now());
|
|
770
|
+
if (this.deletedSpaces.size > 1000)
|
|
771
|
+
for (const [k, t] of this.deletedSpaces)
|
|
772
|
+
if (Date.now() - t > 3_600_000)
|
|
773
|
+
this.deletedSpaces.delete(k);
|
|
774
|
+
return existed;
|
|
775
|
+
}
|
|
776
|
+
adminAction(res, A, body, ip) {
|
|
777
|
+
if (body.csrf !== this.adminCsrf())
|
|
778
|
+
return this.json(res, 403, { error: "stale form — reload the dashboard and try again" });
|
|
779
|
+
const act = body.action ?? "", id = (body.id ?? "").trim();
|
|
780
|
+
const done = (msg) => { res.writeHead(303, { location: `/admin?done=${encodeURIComponent(msg)}` }); res.end(); };
|
|
781
|
+
switch (act) {
|
|
782
|
+
case "suspend":
|
|
783
|
+
case "reinstate": {
|
|
784
|
+
const acct = A.accountById(id);
|
|
785
|
+
if (!acct)
|
|
786
|
+
return this.json(res, 404, { error: "no such account" });
|
|
787
|
+
const live = A.sessionsFor(acct.id).filter((s) => !s.revoked_at);
|
|
788
|
+
A.setStatus(acct.id, act === "suspend" ? "suspended" : "active", ip);
|
|
789
|
+
if (act === "suspend")
|
|
790
|
+
for (const s of live)
|
|
791
|
+
this.dropSessionEverywhere(s.id);
|
|
792
|
+
this.log(`[admin] ${act} ${acct.username} from ${ip}`);
|
|
793
|
+
return done(`${acct.username} ${act === "suspend" ? "suspended and signed out everywhere" : "reinstated"}`);
|
|
794
|
+
}
|
|
795
|
+
case "revoke_session":
|
|
796
|
+
case "revoke_device": {
|
|
797
|
+
const hit = A.adminRevoke(act === "revoke_session" ? "session" : "device", id, ip);
|
|
798
|
+
for (const sid of hit.sessionIds)
|
|
799
|
+
this.dropSessionEverywhere(sid);
|
|
800
|
+
if (hit.deviceId && hit.accountId)
|
|
801
|
+
this.dropDeviceSockets(hit.accountId, hit.deviceId);
|
|
802
|
+
return done(hit.sessionIds.length || hit.deviceId ? `revoked ${act === "revoke_session" ? "session" : "device"} ${id}` : `nothing to revoke for ${id}`);
|
|
803
|
+
}
|
|
804
|
+
case "block": {
|
|
805
|
+
const pattern = id.toLowerCase().replace(/^@/, "");
|
|
806
|
+
if (!/^([a-z0-9.-]+\.[a-z]{2,}|[^@\s]+@[a-z0-9.-]+\.[a-z]{2,})$/.test(pattern))
|
|
807
|
+
return this.json(res, 400, { error: "block takes an email (a@b.com) or a domain (b.com)" });
|
|
808
|
+
A.block(pattern, ip);
|
|
809
|
+
return done(`blocked ${pattern}`);
|
|
810
|
+
}
|
|
811
|
+
case "unblock": {
|
|
812
|
+
A.unblock(id.toLowerCase().replace(/^@/, ""), ip);
|
|
813
|
+
return done(`unblocked ${id}`);
|
|
814
|
+
}
|
|
815
|
+
case "delete_space": {
|
|
816
|
+
if ((body.confirm ?? "").trim() !== id)
|
|
817
|
+
return done(`not deleted: type the space name exactly ("${id}") to confirm`);
|
|
818
|
+
const existed = this.deleteSpace(id);
|
|
819
|
+
A.markSpaceDeleted(id);
|
|
820
|
+
A.audit({ event: "admin_space_deleted", space: id, ip });
|
|
821
|
+
this.log(`[admin] deleted space "${id}" from ${ip}`);
|
|
822
|
+
return done(existed ? `space "${id}" deleted — its members were disconnected` : `no space "${id}"`);
|
|
823
|
+
}
|
|
824
|
+
case "set_plan": {
|
|
825
|
+
const target = A.accountById(id);
|
|
826
|
+
if (!target)
|
|
827
|
+
return this.json(res, 404, { error: "no such account" });
|
|
828
|
+
const plan = (body.plan ?? "").trim();
|
|
829
|
+
if (!A.plans()[plan])
|
|
830
|
+
return this.json(res, 400, { error: `no plan "${plan}"` });
|
|
831
|
+
A.setPlan(target.id, plan);
|
|
832
|
+
A.audit({ event: "admin_set_plan", target: target.username, detail: `${target.plan} → ${plan}`, ip });
|
|
833
|
+
this.log(`[admin] ${target.username}: plan ${target.plan} → ${plan} (${ip})`);
|
|
834
|
+
return done(`${target.username} is now on the ${plan} plan — in effect for their next space, invite or admit`);
|
|
835
|
+
}
|
|
836
|
+
case "set_limits": {
|
|
837
|
+
const target = A.accountById(id);
|
|
838
|
+
if (!target)
|
|
839
|
+
return this.json(res, 404, { error: "no such account" });
|
|
840
|
+
const raw = (body.json ?? "").trim();
|
|
841
|
+
if (!A.setLimits(target.id, raw || null))
|
|
842
|
+
return this.json(res, 400, { error: 'custom limits must look like {"spaces":3,"members":5,"wake":true}' });
|
|
843
|
+
A.audit({ event: "admin_set_limits", target: target.username, detail: raw || "(cleared)", ip });
|
|
844
|
+
return done(raw ? `${target.username} now has custom limits ${raw}` : `${target.username} is back on plain ${target.plan} limits`);
|
|
845
|
+
}
|
|
846
|
+
case "set_plan_limits": {
|
|
847
|
+
if (!PLAN_NAME.test(id))
|
|
848
|
+
return this.json(res, 400, { error: "plan names are lowercase letters, digits, - and _" });
|
|
849
|
+
const lim = parseLimits({ spaces: body.spaces, members: body.members, wake: body.wake ?? "off" });
|
|
850
|
+
if (!lim)
|
|
851
|
+
return this.json(res, 400, { error: "spaces must be 0 or more and members 1 or more" });
|
|
852
|
+
const plans = A.plans();
|
|
853
|
+
const prev = plans[id];
|
|
854
|
+
plans[id] = lim;
|
|
855
|
+
A.setPlans(plans);
|
|
856
|
+
A.audit({ event: "admin_set_plan_limits", target: id, detail: `${prev ? JSON.stringify(prev) : "(new plan)"} → ${JSON.stringify(lim)}`, ip });
|
|
857
|
+
return done(`${id} plan: ${fmtLimit(lim.spaces)} spaces · ${fmtLimit(lim.members)} people · unattended workers ${lim.wake ? "on" : "off"} — applies to the next create, invite or admit`);
|
|
858
|
+
}
|
|
859
|
+
case "delete_plan": {
|
|
860
|
+
if (id === "free")
|
|
861
|
+
return this.json(res, 400, { error: "the free plan is every account's default and cannot be removed" });
|
|
862
|
+
const plans = A.plans();
|
|
863
|
+
if (!plans[id])
|
|
864
|
+
return this.json(res, 404, { error: `no plan "${id}"` });
|
|
865
|
+
const onIt = A.planCounts()[id] ?? 0;
|
|
866
|
+
if (onIt)
|
|
867
|
+
return this.json(res, 400, { error: `${onIt} account${onIt === 1 ? " is" : "s are"} on "${id}" — move them to another plan first` });
|
|
868
|
+
delete plans[id];
|
|
869
|
+
A.setPlans(plans);
|
|
870
|
+
A.audit({ event: "admin_delete_plan", target: id, ip });
|
|
871
|
+
return done(`plan "${id}" removed`);
|
|
872
|
+
}
|
|
873
|
+
default: return this.json(res, 400, { error: `unknown action "${act}"` });
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
spaceOnDisk(name) {
|
|
877
|
+
return existsSync(join(this.dataDir, `${name.replace(/[^a-z0-9._-]/gi, "_")}.members.json`));
|
|
878
|
+
}
|
|
879
|
+
spacePlan(name) {
|
|
880
|
+
if (!this.accounts)
|
|
881
|
+
return null;
|
|
882
|
+
const owner = this.accounts.spaceOwner(name);
|
|
883
|
+
if (!owner)
|
|
884
|
+
return null;
|
|
885
|
+
return { name: owner.plan, limits: this.accounts.limitsFor(owner), owner };
|
|
886
|
+
}
|
|
887
|
+
planView(acct, sp) {
|
|
888
|
+
const fresh = this.accounts?.accountById(acct.id) ?? acct;
|
|
889
|
+
return { name: fresh.plan, limits: this.accounts.limitsFor(fresh), usage: { spaces: this.accounts.spacesOwned(fresh.id).length, members: sp.allow.admittedCount() } };
|
|
890
|
+
}
|
|
891
|
+
planRefusal(space, plan, target, msg, cid, fail, actor, ip) {
|
|
892
|
+
this.accounts?.audit({ event: "plan_limit", actor_account: actor ?? plan.owner.id, space: space.name, target, detail: `${plan.name}: ${msg.slice(0, 160)}`, ip });
|
|
893
|
+
this.log(`[${space.name}] ! PLAN_LIMIT (${target}, ${plan.name} plan)`);
|
|
894
|
+
fail("PLAN_LIMIT", msg, cid);
|
|
895
|
+
}
|
|
896
|
+
backfillSpaceOwners() {
|
|
897
|
+
let files = [];
|
|
898
|
+
try {
|
|
899
|
+
files = readdirSync(this.dataDir);
|
|
900
|
+
}
|
|
901
|
+
catch {
|
|
902
|
+
return;
|
|
903
|
+
}
|
|
904
|
+
for (const f of files) {
|
|
905
|
+
if (!f.endsWith(".members.json"))
|
|
906
|
+
continue;
|
|
907
|
+
const name = f.slice(0, -".members.json".length);
|
|
908
|
+
if (this.accounts.hasSpaceRecord(name))
|
|
909
|
+
continue;
|
|
910
|
+
try {
|
|
911
|
+
const founder = new Allowlist(this.dataDir, name).founder();
|
|
912
|
+
const owner = founder ? this.accounts.accountByUsername(founder) : null;
|
|
913
|
+
this.accounts.recordSpace(name, owner?.id ?? null);
|
|
914
|
+
this.log(`[${name}] owner recorded: ${owner ? owner.username : "unknown (no plan limits apply)"}`);
|
|
915
|
+
}
|
|
916
|
+
catch { }
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
adminDashboard(res, A, url) {
|
|
576
920
|
const c = A.counts();
|
|
577
921
|
const esc = (s) => String(s ?? "").replace(/[&<>"]/g, (m) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[m]));
|
|
578
|
-
const when = (t) => new Date(t).toISOString().slice(0, 19).replace("T", " ");
|
|
922
|
+
const when = (t) => t ? new Date(t).toISOString().slice(0, 19).replace("T", " ") : "—";
|
|
923
|
+
const ago = (t) => { if (!t)
|
|
924
|
+
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`; };
|
|
925
|
+
const kb = (n) => n < 1024 ? `${n} B` : n < 1048576 ? `${(n / 1024).toFixed(1)} KB` : `${(n / 1048576).toFixed(1)} MB`;
|
|
579
926
|
const accounts = A.allAccounts();
|
|
580
927
|
const devices = A.allDevices();
|
|
581
928
|
const sessions = A.allSessions(200);
|
|
582
929
|
const audit = A.auditLog({ limit: 300 });
|
|
930
|
+
const blocks = A.allBlocks();
|
|
931
|
+
const verifs = A.recentVerifications(50);
|
|
932
|
+
const spaces = this.spacesOverview();
|
|
933
|
+
const act = mergeActivity(spaces.map((x) => x.activity));
|
|
934
|
+
const dayAgo = Date.now() - DAY;
|
|
935
|
+
const refused24 = spaces.reduce((n, x) => n + refusalsSince(x.refusals, dayAgo), 0);
|
|
936
|
+
const refusedAll = spaces.reduce((n, x) => n + (x.refusals?.total ?? 0), 0);
|
|
937
|
+
const plans = A.plans();
|
|
938
|
+
const planCounts = A.planCounts();
|
|
939
|
+
const owners = A.spaceOwners();
|
|
940
|
+
const ownedCounts = A.ownedCounts();
|
|
941
|
+
const limitHits = A.planLimitHits();
|
|
942
|
+
const acctById = new Map(accounts.map((a) => [a.id, a]));
|
|
943
|
+
const planOptions = (sel) => Object.keys(plans).map((n) => `<option value="${esc(n)}"${n === sel ? " selected" : ""}>${esc(n)}</option>`).join("");
|
|
583
944
|
const devByAcct = new Map();
|
|
584
945
|
for (const d of devices)
|
|
585
946
|
devByAcct.set(d.account_id, (devByAcct.get(d.account_id) ?? 0) + 1);
|
|
947
|
+
const emailCap = Number(process.env.PANTHEON_EMAIL_MAX_PER_HOUR) || 200;
|
|
948
|
+
const emailsHour = this.emailSends.filter((t) => Date.now() - t < 3_600_000).length;
|
|
949
|
+
let snapshots = [];
|
|
950
|
+
try {
|
|
951
|
+
snapshots = readdirSync(join(this.dataDir, "backups")).filter((f) => f.startsWith("accounts-") && f.endsWith(".db")).sort();
|
|
952
|
+
}
|
|
953
|
+
catch { }
|
|
954
|
+
const lastBackupPull = audit.find((e) => e.event === "admin_backup");
|
|
955
|
+
const upMin = Math.round((Date.now() - this.startedAt) / 60_000);
|
|
956
|
+
const csrf = this.adminCsrf();
|
|
957
|
+
const done = url?.searchParams.get("done");
|
|
586
958
|
const rows = (arr) => arr.join("");
|
|
959
|
+
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
960
|
const html = `<!doctype html><meta charset=utf-8><title>pantheon · admin</title>
|
|
588
961
|
<meta name=viewport content="width=device-width,initial-scale=1">
|
|
589
|
-
|
|
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>
|
|
962
|
+
${ADMIN_CSS}
|
|
606
963
|
<header>
|
|
607
964
|
<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>
|
|
965
|
+
<span class=dim>${esc(this.opts.host ?? "relay")} · pkg ${VERSION} · up ${upMin < 60 ? upMin + "m" : Math.round(upMin / 60) + "h"}</span>
|
|
966
|
+
<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
967
|
</header>
|
|
968
|
+
${done ? `<div class=done>${esc(done)}</div>` : ""}
|
|
611
969
|
<div class=stats>
|
|
612
970
|
<div class=stat><b>${c.accounts}</b><span>accounts</span></div>
|
|
613
971
|
<div class=stat><b>${c.devices}</b><span>devices</span></div>
|
|
614
|
-
<div class=stat><b>${c.
|
|
615
|
-
<div class=stat><b>${c.
|
|
972
|
+
<div class=stat><b>${Object.entries(planCounts).filter(([n]) => n !== "free").reduce((s, [, v]) => s + v, 0)}<span class=dim style="font-size:14px"> / ${c.accounts}</span></b><span>paid plans / accounts</span></div>
|
|
973
|
+
<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>
|
|
974
|
+
<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>
|
|
975
|
+
<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>
|
|
976
|
+
<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>
|
|
977
|
+
<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
978
|
<div class=stat><b>${c.audit}</b><span>audit events</span></div>
|
|
979
|
+
<div class=stat><b>${act.messages_24h}<span class=dim style="font-size:14px"> / ${act.messages}</span></b><span>messages today / retained</span></div>
|
|
980
|
+
<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>
|
|
981
|
+
<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>
|
|
982
|
+
<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>
|
|
983
|
+
<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>
|
|
984
|
+
<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
985
|
</div>
|
|
618
986
|
<section>
|
|
619
|
-
<h2>
|
|
620
|
-
<tr><th
|
|
621
|
-
|
|
622
|
-
|
|
987
|
+
<h2>Activity <span class=dim>(every space · today and retained history)</span></h2><div class=wrap><table id=t-activity>
|
|
988
|
+
<tr><th></th>${MESSAGE_ACTS.map((x) => `<th>${x}</th>`).join("")}<th>refused</th></tr>
|
|
989
|
+
<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>
|
|
990
|
+
<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>
|
|
991
|
+
</table></div>
|
|
992
|
+
<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>
|
|
993
|
+
|
|
994
|
+
<h2>Spaces <input placeholder="filter" data-filter="t-spaces"></h2><div class=wrap><table id=t-spaces>
|
|
995
|
+
<tr><th>space</th><th>state</th><th>owner</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>
|
|
996
|
+
${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>${(() => { const o = owners.get(s.name) ? acctById.get(owners.get(s.name)) : undefined; return o ? `<code>${esc(o.username)}</code> <span class=pill data-plan="${esc(o.plan)}">${esc(o.plan)}</span>` : "<span class=dim title=\"no owner on record — no plan limits apply\">—</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>`))}
|
|
997
|
+
${spaces.length ? "" : "<tr><td colspan=15 class=dim>no spaces yet</td></tr>"}
|
|
998
|
+
</table></div>
|
|
999
|
+
<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>
|
|
1000
|
+
|
|
1001
|
+
<h2>Accounts <input placeholder="filter" data-filter="t-accounts"></h2><div class=wrap><table id=t-accounts>
|
|
1002
|
+
<tr><th>username</th><th>email</th><th>status</th><th>plan</th><th>spaces</th><th>limit hits</th><th>devices</th><th>created</th><th></th></tr>
|
|
1003
|
+
${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><span class="pill ${a.plan === "free" ? "" : "ok"}" data-plan="${esc(a.plan)}">${esc(a.plan)}</span>${a.limits_json ? ' <span class="pill warn" title="custom limits">custom</span>' : ""} <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=set_plan><input type=hidden name=id value="${esc(a.id)}"><select name=plan class=act style="margin-left:4px">${planOptions(a.plan)}</select><button class=act>set</button></form><details class=inl style="margin-left:6px"><summary class="act dim" style="display:inline;list-style:none;cursor:pointer">custom…</summary><form method=POST action="/admin/action" class=inl><input type=hidden name=csrf value="${csrf}"><input type=hidden name=action value=set_limits><input type=hidden name=id value="${esc(a.id)}"><input name=json placeholder='{"spaces":3,"members":5,"wake":true}' value="${esc(a.limits_json ?? "")}" size=34 style="font:12px ui-monospace,monospace;padding:3px 6px;border:1px solid var(--line);border-radius:6px;background:var(--card);color:var(--fg)"><button class=act>save</button></form></details></td><td>${ownedCounts.get(a.id) ?? 0}<span class=dim> / ${fmtLimit(A.limitsFor(a).spaces)}</span></td><td>${limitHits.get(a.id) ? `<span class="pill warn">${limitHits.get(a.id)}</span>` : "<span class=dim>0</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>`))}
|
|
1004
|
+
${accounts.length ? "" : "<tr><td colspan=9 class=dim>no accounts yet</td></tr>"}
|
|
623
1005
|
</table></div>
|
|
1006
|
+
<p class=note>Suspending signs the account out everywhere and refuses its handshakes until reinstated. Its messages stay in the ledgers. A plan change takes effect at the account's next space, invite or admit; nothing they already have is removed. "Limit hits" counts the times the relay refused them for a plan limit — the people to talk to about upgrading.</p>
|
|
624
1007
|
|
|
625
|
-
<h2>
|
|
626
|
-
<tr><th>
|
|
627
|
-
${rows(
|
|
1008
|
+
<h2>Plans <span class=dim>(limits the relay enforces; the owner's plan governs a space)</span></h2><div class=wrap><table id=t-plans>
|
|
1009
|
+
<tr><th>plan</th><th>accounts</th><th>spaces created</th><th>people per space</th><th>unattended workers</th><th></th></tr>
|
|
1010
|
+
${rows(Object.entries(plans).map(([n, l]) => { const fid = `plan-${esc(n)}`; return `<tr><td><form id="${fid}" method=POST action="/admin/action" class=inl><input type=hidden name=csrf value="${csrf}"><input type=hidden name=action value=set_plan_limits><input type=hidden name=id value="${esc(n)}"></form><code>${esc(n)}</code></td><td>${planCounts[n] ?? 0}</td><td><input form="${fid}" name=spaces type=number min=0 value="${l.spaces}" class=act style="width:90px"></td><td><input form="${fid}" name=members type=number min=1 value="${l.members}" class=act style="width:90px"></td><td><label><input form="${fid}" type=checkbox name=wake value=on${l.wake ? " checked" : ""}> allowed</label></td><td><button form="${fid}" class=act>save</button> ${n === "free" ? "" : form("delete_plan", n, "remove", "", "danger")}</td></tr>`; }))}
|
|
1011
|
+
<tr><td><form id="plan-new" method=POST action="/admin/action" class=inl><input type=hidden name=csrf value="${csrf}"><input type=hidden name=action value=set_plan_limits></form><input form="plan-new" name=id placeholder="new plan name" pattern="[a-z][a-z0-9_-]{0,23}" required class=act style="width:130px"></td><td class=dim>—</td><td><input form="plan-new" name=spaces type=number min=0 value=3 class=act style="width:90px"></td><td><input form="plan-new" name=members type=number min=1 value=5 class=act style="width:90px"></td><td><label><input form="plan-new" type=checkbox name=wake value=on checked> allowed</label></td><td><button form="plan-new" class=act>add plan</button></td></tr>
|
|
628
1012
|
</table></div>
|
|
1013
|
+
<p class=note>Edits apply immediately to the next create, invite or admit of every account on that plan. Unused invites count toward "people per space" so nobody mints five at once. Personas are files on the user's machine and cannot be limited here. Every change is audited.</p>
|
|
629
1014
|
|
|
630
|
-
<h2>
|
|
631
|
-
<tr><th>
|
|
632
|
-
${rows(
|
|
1015
|
+
<h2>Live sessions <input placeholder="filter" data-filter="t-sessions"></h2><div class=wrap><table id=t-sessions>
|
|
1016
|
+
<tr><th>session</th><th>account</th><th>space</th><th>ip</th><th>started</th><th>last seen</th><th></th></tr>
|
|
1017
|
+
${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>`))}
|
|
1018
|
+
${sessions.some((s) => !s.revoked_at) ? "" : "<tr><td colspan=7 class=dim>no live sessions</td></tr>"}
|
|
633
1019
|
</table></div>
|
|
1020
|
+
|
|
1021
|
+
<h2>Blocked emails and domains</h2><div class=wrap><table id=t-blocks>
|
|
1022
|
+
<tr><th>pattern</th><th>kind</th><th>since</th><th></th></tr>
|
|
1023
|
+
${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>`))}
|
|
1024
|
+
${blocks.length ? "" : "<tr><td colspan=4 class=dim>nothing blocked</td></tr>"}
|
|
1025
|
+
</table></div>
|
|
1026
|
+
<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>
|
|
1027
|
+
<p class=note>A blocked email or domain cannot sign up or sign in. Existing accounts are not touched; suspend those separately.</p>
|
|
1028
|
+
|
|
1029
|
+
<h2>Recent verification codes <span class=dim>(support: "I never got my code")</span></h2><div class=wrap><table id=t-verifs>
|
|
1030
|
+
<tr><th>email</th><th>purpose</th><th>requested</th><th>expires</th><th>wrong tries</th><th>state</th></tr>
|
|
1031
|
+
${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>`))}
|
|
1032
|
+
${verifs.length ? "" : "<tr><td colspan=6 class=dim>no pending codes</td></tr>"}
|
|
1033
|
+
</table></div>
|
|
1034
|
+
<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>
|
|
1035
|
+
|
|
1036
|
+
<h2>Audit log <span class=dim>(latest 300)</span> <input placeholder="filter" data-filter="t-audit"></h2><div class=wrap><table id=t-audit>
|
|
1037
|
+
<tr><th>time</th><th>event</th><th>space</th><th>target</th><th>detail</th><th>ip</th></tr>
|
|
1038
|
+
${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>`))}
|
|
1039
|
+
</table></div>
|
|
1040
|
+
</section>
|
|
1041
|
+
<script>
|
|
1042
|
+
document.querySelectorAll("input[data-filter]").forEach(function (inp) {
|
|
1043
|
+
inp.addEventListener("input", function () {
|
|
1044
|
+
var q = inp.value.toLowerCase(), rowsEl = document.getElementById(inp.dataset.filter).querySelectorAll("tr");
|
|
1045
|
+
for (var i = 1; i < rowsEl.length; i++) rowsEl[i].style.display = !q || rowsEl[i].textContent.toLowerCase().indexOf(q) > -1 ? "" : "none";
|
|
1046
|
+
});
|
|
1047
|
+
});
|
|
1048
|
+
function confirmAct(f) {
|
|
1049
|
+
var a = f.querySelector("[name=action]").value, id = f.querySelector("[name=id]").value;
|
|
1050
|
+
if (a === "set_plan") return confirm("Move " + f.closest("tr").querySelector("code").textContent + " to the " + f.querySelector("[name=plan]").value + " plan? Takes effect on their next space, invite or admit.");
|
|
1051
|
+
if (a === "delete_plan") return confirm('Remove the plan "' + id + '"?');
|
|
1052
|
+
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; }
|
|
1053
|
+
if (a === "suspend" || a === "revoke_session" || a === "revoke_device" || a === "block") return confirm(a.replace("_", " ") + " " + id + "?");
|
|
1054
|
+
return true;
|
|
1055
|
+
}
|
|
1056
|
+
</script>`;
|
|
1057
|
+
res.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store", "referrer-policy": "no-referrer" });
|
|
1058
|
+
res.end(html);
|
|
1059
|
+
}
|
|
1060
|
+
adminSpace(res, url, name) {
|
|
1061
|
+
const safe = name.replace(/[^a-z0-9._-]/gi, "_");
|
|
1062
|
+
const sp = this.spaces.get(name) ?? null;
|
|
1063
|
+
const tomb = this.deletedSpaces.get(name);
|
|
1064
|
+
const onDisk = [".jsonl", ".state.json", ".members.json"].some((suf) => existsSync(join(this.dataDir, `${safe}${suf}`)));
|
|
1065
|
+
if (!name || (!sp && (!onDisk || (tomb && Date.now() - tomb < 3_600_000))))
|
|
1066
|
+
return this.json(res, 404, { error: `no space "${name}" on this relay` });
|
|
1067
|
+
const records = sp ? sp.ledger.all() : this.sleepingRecords(safe);
|
|
1068
|
+
const state = sp ? { goals: sp.goals, refusals: sp.store.refusals } : this.sleepingState(safe);
|
|
1069
|
+
const allow = sp ? sp.allow : new Allowlist(this.dataDir, safe);
|
|
1070
|
+
const now = Date.now(), dayAgo = now - DAY;
|
|
1071
|
+
const a = activity(records, now);
|
|
1072
|
+
const esc = escHtml, when = fmtWhen, ago = fmtAgo;
|
|
1073
|
+
const q = {
|
|
1074
|
+
act: url.searchParams.get("act") ?? "", member: url.searchParams.get("member") ?? "", goal: url.searchParams.get("goal") ?? "",
|
|
1075
|
+
q: url.searchParams.get("q") ?? "", presence: url.searchParams.get("presence") === "1", before: Number(url.searchParams.get("before")) || undefined,
|
|
1076
|
+
};
|
|
1077
|
+
const { rows: feedRows, next } = feed(records, { ...q, limit: 100 });
|
|
1078
|
+
const filtered = !!(q.act || q.member || q.goal || q.q || q.presence);
|
|
1079
|
+
const link = (extra) => {
|
|
1080
|
+
const p = new URLSearchParams({ name });
|
|
1081
|
+
for (const [k, v] of Object.entries({ act: q.act, member: q.member, goal: q.goal, q: q.q, presence: q.presence ? "1" : "", ...extra }))
|
|
1082
|
+
if (v !== undefined && String(v) !== "")
|
|
1083
|
+
p.set(k, String(v));
|
|
1084
|
+
return `/admin/space?${p.toString()}`;
|
|
1085
|
+
};
|
|
1086
|
+
const pill = (x) => `<span class="pill ${ACT_TONE[x]}">${x}</span>`;
|
|
1087
|
+
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>";
|
|
1088
|
+
const metaLine = (r) => {
|
|
1089
|
+
const bits = [];
|
|
1090
|
+
if (r.summary)
|
|
1091
|
+
bits.push(`summary: ${esc(r.summary)}`);
|
|
1092
|
+
const meta = (r.meta ?? {});
|
|
1093
|
+
for (const k of ["branch", "commit", "repo_origin", "decision_domains", "defers_to", "affects"])
|
|
1094
|
+
if (meta[k] !== undefined)
|
|
1095
|
+
bits.push(`${k}: ${esc(typeof meta[k] === "string" ? meta[k] : JSON.stringify(meta[k]))}`);
|
|
1096
|
+
if (r.ref)
|
|
1097
|
+
bits.push(`re <code>${esc(r.ref)}</code>`);
|
|
1098
|
+
return bits.length ? `<div class=note>${bits.join(" · ")}</div>` : "";
|
|
1099
|
+
};
|
|
1100
|
+
const said = (r) => r.body.length > 400
|
|
1101
|
+
? `<details><summary>${esc(r.body.slice(0, 400))}…</summary>${esc(r.body)}</details>`
|
|
1102
|
+
: (esc(r.body) || "<span class=dim>(empty)</span>");
|
|
1103
|
+
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("");
|
|
1104
|
+
const entries = allow.all();
|
|
1105
|
+
const memberRows = entries.map((e) => {
|
|
1106
|
+
const m = sp?.members.get(e.member);
|
|
1107
|
+
const online = m ? m.sessions.size : 0;
|
|
1108
|
+
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>`;
|
|
1109
|
+
}).join("");
|
|
1110
|
+
const repos = allow.repoList();
|
|
1111
|
+
const refusals = state.refusals;
|
|
1112
|
+
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("");
|
|
1113
|
+
const byCode = Object.entries(refusals.by_code).sort((x, y) => y[1] - x[1]).map(([c, n]) => `<code>${esc(c)}</code> ${n}`).join(" · ");
|
|
1114
|
+
const open = [...state.goals.values()].filter((g) => g.state !== "CLOSED").length;
|
|
1115
|
+
const options = MESSAGE_ACTS.map((x) => `<option value="${x}"${q.act === x ? " selected" : ""}>${x}</option>`).join("");
|
|
1116
|
+
const html = `<!doctype html><meta charset=utf-8><title>pantheon · admin · ${esc(name)}</title>
|
|
1117
|
+
<meta name=viewport content="width=device-width,initial-scale=1">
|
|
1118
|
+
${ADMIN_CSS}
|
|
1119
|
+
<style>
|
|
1120
|
+
.feed td{white-space:normal;vertical-align:top}.feed .body{min-width:320px;max-width:720px;overflow-wrap:anywhere}
|
|
1121
|
+
.feed details summary{cursor:pointer;color:var(--accent)}.feed details[open] summary{display:none}
|
|
1122
|
+
.pill.warn{color:var(--warn);border-color:var(--warn)}.pill.info{color:var(--accent);border-color:var(--accent)}.pill.dim{color:var(--dim)}
|
|
1123
|
+
.filters{display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin:8px 0 10px}
|
|
1124
|
+
.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)}
|
|
1125
|
+
.crumbs a{color:var(--dim);text-decoration:none}.crumbs a:hover{color:var(--accent)}
|
|
1126
|
+
td a{text-decoration:none}td a:hover{text-decoration:underline}
|
|
1127
|
+
</style>
|
|
1128
|
+
<header>
|
|
1129
|
+
<h1><span class=crumbs><a href="/admin">admin</a> › </span>${esc(name)} <span class="pill ${sp ? "ok" : ""}">${sp ? "live" : "sleeping"}</span></h1>
|
|
1130
|
+
<span class=dim>${esc(allow.peekId() ?? "no id yet")} · ${records.length} acts retained${a.first ? ` since ${when(a.first)}` : ""}</span>
|
|
1131
|
+
<span style="margin-left:auto"><a href="/admin">← dashboard</a> · <a href="/admin/logout">sign out</a></span>
|
|
1132
|
+
</header>
|
|
1133
|
+
<div class=stats>
|
|
1134
|
+
<div class=stat><b>${a.messages_24h}<span class=dim style="font-size:14px"> / ${a.messages}</span></b><span>messages today / retained</span></div>
|
|
1135
|
+
<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>
|
|
1136
|
+
<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>
|
|
1137
|
+
<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>
|
|
1138
|
+
<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>
|
|
1139
|
+
<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>
|
|
1140
|
+
<div class=stat><b>${open}<span class=dim style="font-size:14px"> / ${state.goals.size}</span></b><span>goals open / total</span></div>
|
|
1141
|
+
${sp ? `<div class=stat><b>${sp.obligations.size}</b><span>obligations owed</span></div>` : ""}
|
|
1142
|
+
</div>
|
|
1143
|
+
<section>
|
|
1144
|
+
<h2>Members <span class=dim>(${entries.filter((e) => e.admitted).length} admitted)</span></h2><div class=wrap><table>
|
|
1145
|
+
<tr><th>member</th><th>state</th><th>may wake</th><th>online</th><th>last seen</th></tr>
|
|
1146
|
+
${memberRows || "<tr><td colspan=5 class=dim>nobody yet</td></tr>"}
|
|
1147
|
+
</table></div>
|
|
1148
|
+
${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>` : ""}
|
|
1149
|
+
|
|
1150
|
+
<h2>Acts <span class=dim>(today · retained)</span></h2><div class=wrap><table id=t-acts>
|
|
1151
|
+
<tr><th></th>${MESSAGE_ACTS.map((x) => `<th>${x}</th>`).join("")}</tr>
|
|
1152
|
+
<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>
|
|
1153
|
+
<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>
|
|
1154
|
+
</table></div>
|
|
1155
|
+
|
|
1156
|
+
<h2>Refused by the relay <span class=dim>(latest ${refusals.recent.length} of ${refusals.total})</span></h2><div class=wrap><table>
|
|
1157
|
+
<tr><th>when</th><th>member</th><th>request</th><th>reason</th></tr>
|
|
1158
|
+
${refusalRows || "<tr><td colspan=4 class=dim>nothing refused</td></tr>"}
|
|
1159
|
+
</table></div>
|
|
1160
|
+
${byCode ? `<p class=note>All time: ${byCode}</p>` : ""}
|
|
1161
|
+
|
|
1162
|
+
<h2>Conversation <span class=dim>(newest first${filtered ? ", filtered" : ""})</span></h2>
|
|
1163
|
+
<form class=filters method=GET action="/admin/space"><input type=hidden name=name value="${esc(name)}">
|
|
1164
|
+
<select name=act><option value="">every act</option>${options}</select>
|
|
1165
|
+
<input name=member placeholder="member" value="${esc(q.member)}" size=12>
|
|
1166
|
+
<input name=goal placeholder="goal id" value="${esc(q.goal)}" size=16>
|
|
1167
|
+
<input name=q placeholder="search what was said" value="${esc(q.q)}" size=24>
|
|
1168
|
+
<label class=dim><input type=checkbox name=presence value=1${q.presence ? " checked" : ""}> show JOIN/LEAVE</label>
|
|
1169
|
+
<button class=act>filter</button>${filtered ? `<a href="/admin/space?name=${encodeURIComponent(name)}">clear</a>` : ""}
|
|
1170
|
+
</form>
|
|
1171
|
+
<div class=wrap><table class=feed>
|
|
1172
|
+
<tr><th>when</th><th>from</th><th>act</th><th>to</th><th>goal</th><th>said</th></tr>
|
|
1173
|
+
${rowsHtml || `<tr><td colspan=6 class=dim>${filtered ? "nothing matches" : "no messages yet"}</td></tr>`}
|
|
1174
|
+
</table></div>
|
|
1175
|
+
${next !== null ? `<p><a href="${link({ before: next })}">older ›</a></p>` : ""}
|
|
1176
|
+
<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
1177
|
</section>`;
|
|
635
1178
|
res.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store", "referrer-policy": "no-referrer" });
|
|
636
1179
|
res.end(html);
|
|
@@ -706,8 +1249,10 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
706
1249
|
onSocket(ws, ip = "unknown") {
|
|
707
1250
|
let space = null;
|
|
708
1251
|
let me = null;
|
|
1252
|
+
let acct = null;
|
|
709
1253
|
let sessionId = "";
|
|
710
1254
|
let role = "interactive";
|
|
1255
|
+
let lastMsg = "";
|
|
711
1256
|
let alive = true;
|
|
712
1257
|
const nonce = newNonce();
|
|
713
1258
|
ws.on("pong", () => { alive = true; });
|
|
@@ -715,6 +1260,10 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
715
1260
|
probe._alive = () => alive;
|
|
716
1261
|
probe._clear = () => { alive = false; };
|
|
717
1262
|
const fail = (code, message, cid) => {
|
|
1263
|
+
if (cid && space && me && !space.deleted) {
|
|
1264
|
+
noteRefusal(space.store.refusals, { ts: Date.now(), member: me.id, act: lastMsg || "?", code });
|
|
1265
|
+
space.store.save(space.goals, space.obligations, space.profiles);
|
|
1266
|
+
}
|
|
718
1267
|
this.send(ws, { t: "error", code, message, ...(cid ? { cid } : {}) });
|
|
719
1268
|
};
|
|
720
1269
|
const reject = (code, message) => {
|
|
@@ -753,6 +1302,7 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
753
1302
|
return fail("BAD_MESSAGE", parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; "), cid);
|
|
754
1303
|
}
|
|
755
1304
|
const m = parsed.data;
|
|
1305
|
+
lastMsg = m.t === "act" ? m.act : m.t;
|
|
756
1306
|
if (m.t === "ping")
|
|
757
1307
|
return this.send(ws, { t: "pong" });
|
|
758
1308
|
if (m.t === "hello") {
|
|
@@ -765,6 +1315,8 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
765
1315
|
if (!verifyChallenge(m.pubkey, m.space, m.member, m.nonce, m.sig)) {
|
|
766
1316
|
return reject("BAD_SIGNATURE", `signature does not match the presented key for "${m.member}"`);
|
|
767
1317
|
}
|
|
1318
|
+
const gated = this.requireAccounts && !!this.accounts && !this.localTrusted(ip);
|
|
1319
|
+
const pre = gated ? this.accounts.authorize(m.pubkey, m.member) : null;
|
|
768
1320
|
let sp;
|
|
769
1321
|
if (m.enroll) {
|
|
770
1322
|
sp = this.spaces.get(m.space) ?? null;
|
|
@@ -772,16 +1324,37 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
772
1324
|
return reject("NO_SUCH_SPACE", `no space "${m.space}" on this relay`);
|
|
773
1325
|
}
|
|
774
1326
|
else {
|
|
1327
|
+
const tomb = this.deletedSpaces.get(m.space);
|
|
1328
|
+
if (tomb && Date.now() - tomb < 3_600_000) {
|
|
1329
|
+
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.`);
|
|
1330
|
+
}
|
|
1331
|
+
const isNew = !this.spaces.has(m.space) && !this.spaceOnDisk(m.space);
|
|
775
1332
|
if (!this.spaces.has(m.space)) {
|
|
776
1333
|
const denied = this.mayCreateSpace(ip);
|
|
777
1334
|
if (denied)
|
|
778
1335
|
return reject(denied.code, denied.message);
|
|
779
1336
|
}
|
|
1337
|
+
if (isNew && gated && !pre?.ok) {
|
|
1338
|
+
this.log(`! REFUSED ${m.member}: cannot create "${m.space}" without a signed-in account (${pre?.ok === false ? pre.reason : "no account"})`);
|
|
1339
|
+
return reject("NOT_SIGNED_IN", "sign in first: run `pantheon signup` (new) or `pantheon login` (existing account) on this machine, then try again.");
|
|
1340
|
+
}
|
|
1341
|
+
if (isNew && pre?.ok) {
|
|
1342
|
+
const lim = this.accounts.limitsFor(pre.account);
|
|
1343
|
+
const owned = this.accounts.spacesOwned(pre.account.id);
|
|
1344
|
+
if (owned.length >= lim.spaces) {
|
|
1345
|
+
const msg = `your ${pre.account.plan} plan allows ${fmtLimit(lim.spaces)} space${lim.spaces === 1 ? "" : "s"} you create${owned.length ? ` (you own ${owned.map((n) => `"${n}"`).join(", ")})` : ""}. Upgrade at https://www.joinpantheon.network, or free a slot: pantheon space delete <team> --yes`;
|
|
1346
|
+
this.accounts.audit({ event: "plan_limit", actor_account: pre.account.id, space: m.space, target: "create_space", detail: `${pre.account.plan}: ${owned.length}/${lim.spaces} spaces`, ip });
|
|
1347
|
+
this.log(`! PLAN_LIMIT ${m.member}: cannot create "${m.space}" (${owned.length}/${fmtLimit(lim.spaces)} spaces on ${pre.account.plan})`);
|
|
1348
|
+
return reject("PLAN_LIMIT", msg);
|
|
1349
|
+
}
|
|
1350
|
+
}
|
|
780
1351
|
sp = this.getOrCreateSpace(m.space, m.token);
|
|
781
1352
|
if (!sp)
|
|
782
1353
|
return reject("BAD_TOKEN", "wrong token for this space");
|
|
783
|
-
if (
|
|
1354
|
+
if (isNew) {
|
|
784
1355
|
this.noteSpaceCreate(ip);
|
|
1356
|
+
this.accounts?.recordSpace(sp.name, pre?.ok ? pre.account.id : null);
|
|
1357
|
+
}
|
|
785
1358
|
}
|
|
786
1359
|
let acctSession = null;
|
|
787
1360
|
if (this.requireAccounts && this.accounts && !this.localTrusted(ip)) {
|
|
@@ -789,15 +1362,23 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
789
1362
|
if (!auth.ok) {
|
|
790
1363
|
const msg = auth.reason === "username_mismatch"
|
|
791
1364
|
? `this device is signed in as a different username than "${m.member}". Use your own username, or \`pantheon login\` on this machine.`
|
|
792
|
-
:
|
|
1365
|
+
: auth.reason === "suspended"
|
|
1366
|
+
? `this account has been suspended by the relay operator. Contact them to have it reinstated.`
|
|
1367
|
+
: `sign in first: run \`pantheon signup\` (new) or \`pantheon login\` (existing account) on this machine, then try again.`;
|
|
793
1368
|
this.log(`[${sp.name}] ! REFUSED ${m.member}: ${auth.reason}`);
|
|
794
1369
|
return reject("NOT_SIGNED_IN", msg);
|
|
795
1370
|
}
|
|
796
1371
|
m.member = auth.account.username;
|
|
1372
|
+
acct = auth.account;
|
|
797
1373
|
acctSession = this.accounts.openSession(auth.account.id, auth.device.id, sp.name, ip).id;
|
|
798
1374
|
}
|
|
799
1375
|
let enrolled = null;
|
|
800
1376
|
if (m.enroll) {
|
|
1377
|
+
const plan = this.spacePlan(sp.name);
|
|
1378
|
+
if (plan && !sp.allow.get(m.member)?.admitted && sp.allow.admittedCount() >= plan.limits.members) {
|
|
1379
|
+
this.accounts?.audit({ event: "plan_limit", actor_account: acct?.id ?? null, space: sp.name, target: "join", detail: `${plan.name}: ${sp.allow.admittedCount()}/${plan.limits.members} people`, ip });
|
|
1380
|
+
return reject("PLAN_LIMIT", `"${sp.name}" is full: its owner's ${plan.name} plan allows ${fmtLimit(plan.limits.members)} people. Ask ${plan.owner.username} to upgrade at https://www.joinpantheon.network`);
|
|
1381
|
+
}
|
|
801
1382
|
const r = sp.allow.redeem(m.enroll, m.member, m.pubkey);
|
|
802
1383
|
if (!r.ok) {
|
|
803
1384
|
this.log(`[${sp.name}] ! invite refused for ${m.member}: ${r.why}`);
|
|
@@ -823,6 +1404,18 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
823
1404
|
catch { } }, 20);
|
|
824
1405
|
return;
|
|
825
1406
|
}
|
|
1407
|
+
let newRepo = null;
|
|
1408
|
+
if (m.repo) {
|
|
1409
|
+
if (sp.allow.hasRepo(m.repo)) { }
|
|
1410
|
+
else if (sp.allow.repoCount() === 0 || enrolled || verdict === "founder")
|
|
1411
|
+
newRepo = m.repo;
|
|
1412
|
+
else {
|
|
1413
|
+
const known = sp.allow.repoList().map((r) => r.origin ?? r.fingerprint).join(", ");
|
|
1414
|
+
this.log(`[${sp.name}] ! REFUSED ${m.member}: repo ${m.repo_origin ?? m.repo} not bound to this space`);
|
|
1415
|
+
return reject("REPO_MISMATCH", `this checkout (${m.repo_origin ?? "no origin"}) is not one of the repos "${sp.name}" works from (${known || "none yet"}). ` +
|
|
1416
|
+
`Open your agent in the team's repo, or if this repo really belongs to the team: pantheon repo add`);
|
|
1417
|
+
}
|
|
1418
|
+
}
|
|
826
1419
|
space = sp;
|
|
827
1420
|
clearHelloTimer();
|
|
828
1421
|
sessionId = acctSession ?? this.mintSession();
|
|
@@ -830,10 +1423,17 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
830
1423
|
me = this.attach(sp, m.member, m.display ?? m.member, m.agent ?? "unknown", sessionId, m.role, ws, m.pkg);
|
|
831
1424
|
const firstSession = me.sessions.size === 1;
|
|
832
1425
|
sp.lastTouch = Date.now();
|
|
1426
|
+
if (newRepo && sp.allow.addRepo(newRepo, m.repo_origin ?? null, me.id)) {
|
|
1427
|
+
this.log(`[${sp.name}] + repo ${m.repo_origin ?? newRepo} registered by ${me.id}`);
|
|
1428
|
+
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 } });
|
|
1429
|
+
this.broadcast(sp, { t: "repos", repos: sp.allow.repoList() });
|
|
1430
|
+
}
|
|
833
1431
|
this.send(ws, {
|
|
834
1432
|
t: "welcome", member: me.id, session: sessionId, space: sp.name, seq: sp.ledger.head,
|
|
835
1433
|
roster: this.roster(sp), goals: [...sp.goals.values()],
|
|
836
1434
|
obligations: [...sp.obligations.values()], members: this.members(sp), profiles: [...sp.profiles.values()],
|
|
1435
|
+
space_id: sp.allow.spaceId(), repos: sp.allow.repoList(),
|
|
1436
|
+
...(acct ? { plan: this.planView(acct, sp) } : {}),
|
|
837
1437
|
...(enrolled ? { token: enrolled } : {}),
|
|
838
1438
|
});
|
|
839
1439
|
for (const r of sp.ledger.since(m.from_seq ?? sp.ledger.head)) {
|
|
@@ -877,6 +1477,13 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
877
1477
|
const target = space.allow.get(m.member);
|
|
878
1478
|
if (!target)
|
|
879
1479
|
return fail("NO_SUCH_MEMBER", `"${m.member}" has never connected, so there is no key to admit`, m.cid);
|
|
1480
|
+
if (!m.revoke && !m.forget) {
|
|
1481
|
+
const plan = this.spacePlan(space.name);
|
|
1482
|
+
if (plan && m.may_wake && !plan.limits.wake)
|
|
1483
|
+
return this.planRefusal(space, plan, "admit", `the ${plan.name} plan does not include unattended workers — admit without --may-wake`, m.cid, fail, acct?.id ?? null, ip);
|
|
1484
|
+
if (plan && !target.admitted && space.allow.admittedCount() >= plan.limits.members)
|
|
1485
|
+
return this.planRefusal(space, plan, "admit", `"${space.name}" is full for the ${plan.name} plan (${fmtLimit(plan.limits.members)} people). Upgrade at https://www.joinpantheon.network to admit more.`, m.cid, fail, acct?.id ?? null, ip);
|
|
1486
|
+
}
|
|
880
1487
|
if (m.revoke || m.forget) {
|
|
881
1488
|
if (m.forget)
|
|
882
1489
|
space.allow.forget(m.member);
|
|
@@ -900,17 +1507,69 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
900
1507
|
this.send(ws, { t: "ack", ...(m.cid ? { cid: m.cid } : {}), id: m.member, seq: space.ledger.head });
|
|
901
1508
|
return;
|
|
902
1509
|
}
|
|
1510
|
+
if (m.t === "worker_state") {
|
|
1511
|
+
if (role !== "worker")
|
|
1512
|
+
return;
|
|
1513
|
+
me.worker = { state: m.state, ...(m.detail ? { detail: m.detail } : {}), ...(m.until ? { until: m.until } : {}), at: Date.now() };
|
|
1514
|
+
this.broadcast(space, { t: "roster", roster: this.roster(space) });
|
|
1515
|
+
return;
|
|
1516
|
+
}
|
|
1517
|
+
if (m.t === "delete_space") {
|
|
1518
|
+
if (m.confirm !== space.name)
|
|
1519
|
+
return fail("CONFIRM_MISMATCH", `type the space name ("${space.name}") to confirm`, m.cid);
|
|
1520
|
+
const owner = this.accounts?.spaceOwner(space.name) ?? null;
|
|
1521
|
+
const isOwner = owner ? owner.id === acct?.id : space.allow.founder() === me.id;
|
|
1522
|
+
if (!isOwner)
|
|
1523
|
+
return fail("NOT_OWNER", `only the founder of "${space.name}" can delete it${owner ? ` (that is ${owner.username})` : ""}`, m.cid);
|
|
1524
|
+
const name = space.name;
|
|
1525
|
+
this.accounts?.audit({ event: "space_deleted", actor_account: acct?.id ?? null, space: name, detail: `by its founder ${me.id}`, ip });
|
|
1526
|
+
this.log(`[${name}] * DELETED by its founder ${me.id}`);
|
|
1527
|
+
this.send(ws, { t: "ack", ...(m.cid ? { cid: m.cid } : {}), id: name, seq: space.ledger.head });
|
|
1528
|
+
setTimeout(() => { this.deleteSpace(name); this.accounts?.markSpaceDeleted(name); }, 50).unref?.();
|
|
1529
|
+
return;
|
|
1530
|
+
}
|
|
903
1531
|
if (m.t === "invite") {
|
|
904
1532
|
const self = space.allow.get(me.id);
|
|
905
1533
|
if (!self?.admitted)
|
|
906
1534
|
return fail("NOT_ADMITTED", "only an admitted member can invite", m.cid);
|
|
1535
|
+
const plan = this.spacePlan(space.name);
|
|
1536
|
+
if (plan) {
|
|
1537
|
+
if (m.may_wake && !plan.limits.wake)
|
|
1538
|
+
return this.planRefusal(space, plan, "invite", `the ${plan.name} plan does not include unattended workers — invite without wake: pantheon invite --no-wake`, m.cid, fail, acct?.id ?? null, ip);
|
|
1539
|
+
const open = space.allow.openInvites().length;
|
|
1540
|
+
if (space.allow.admittedCount() + open >= plan.limits.members) {
|
|
1541
|
+
return this.planRefusal(space, plan, "invite", `"${space.name}" is full for the ${plan.name} plan: ${fmtLimit(plan.limits.members)} people${open ? `, counting ${open} unused invite${open === 1 ? "" : "s"}` : ""}. Upgrade at https://www.joinpantheon.network to invite more.`, m.cid, fail, acct?.id ?? null, ip);
|
|
1542
|
+
}
|
|
1543
|
+
}
|
|
907
1544
|
const token = randomBytes(18).toString("base64url");
|
|
908
1545
|
const inv = space.allow.mintInvite(me.id, m.may_wake, m.ttl_minutes, token);
|
|
909
1546
|
this.log(`[${space.name}] * invite minted by ${me.id}, valid ${m.ttl_minutes}m, single use`);
|
|
910
1547
|
this.send(ws, { t: "invite", url: token, expires_at: inv.expires_at });
|
|
911
1548
|
return;
|
|
912
1549
|
}
|
|
1550
|
+
if (m.t === "bind_repo") {
|
|
1551
|
+
const self = space.allow.get(me.id);
|
|
1552
|
+
if (!self?.admitted)
|
|
1553
|
+
return fail("NOT_ADMITTED", "only an admitted member can register a repo", m.cid);
|
|
1554
|
+
const added = space.allow.addRepo(m.repo, m.origin ?? null, me.id, m.base ?? null);
|
|
1555
|
+
if (!added && m.base && space.allow.setRepoBase(m.repo, m.base)) {
|
|
1556
|
+
this.log(`[${space.name}] ~ repo ${m.origin ?? m.repo} base branch → ${m.base} (by ${me.id})`);
|
|
1557
|
+
this.broadcast(space, { t: "repos", repos: space.allow.repoList() });
|
|
1558
|
+
}
|
|
1559
|
+
if (added) {
|
|
1560
|
+
this.log(`[${space.name}] + repo ${m.origin ?? m.repo} registered by ${me.id}${m.base ? ` (base ${m.base})` : ""}`);
|
|
1561
|
+
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 } });
|
|
1562
|
+
this.broadcast(space, { t: "repos", repos: space.allow.repoList() });
|
|
1563
|
+
}
|
|
1564
|
+
this.send(ws, { t: "ack", cid: m.cid, id: m.repo, seq: space.ledger.head });
|
|
1565
|
+
return;
|
|
1566
|
+
}
|
|
913
1567
|
if (m.t === "claim") {
|
|
1568
|
+
if (role === "worker") {
|
|
1569
|
+
const plan = this.spacePlan(space.name);
|
|
1570
|
+
if (plan && !plan.limits.wake)
|
|
1571
|
+
return this.planRefusal(space, plan, "wake", `unattended workers are not included in this space's ${plan.name} plan`, m.cid, fail, acct?.id ?? null, ip);
|
|
1572
|
+
}
|
|
914
1573
|
if (m.obligation.startsWith("cont:")) {
|
|
915
1574
|
const act = space.ledger.find(m.obligation.slice(5));
|
|
916
1575
|
if (!act)
|
|
@@ -1011,7 +1670,11 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
1011
1670
|
if (m.after_seq !== undefined) {
|
|
1012
1671
|
const mine = me.id;
|
|
1013
1672
|
const missed = space.ledger.since(m.after_seq)
|
|
1014
|
-
.filter((r) => r.from !== mine && r.act !== "JOIN" && r.act !== "LEAVE")
|
|
1673
|
+
.filter((r) => r.from !== mine && r.act !== "JOIN" && r.act !== "LEAVE")
|
|
1674
|
+
.filter((r) => (m.goal ? r.goal === m.goal : !r.goal)
|
|
1675
|
+
|| (r.to?.includes(mine) ?? false)
|
|
1676
|
+
|| (m.to?.includes(r.from) ?? false)
|
|
1677
|
+
|| (!!m.ref && r.ref === m.ref));
|
|
1015
1678
|
if (missed.length) {
|
|
1016
1679
|
return fail("STALE_VIEW", `${missed.length} act(s) arrived while you were composing — read them before you answer:\n` +
|
|
1017
1680
|
missed.slice(0, 8).map((r) => ` [${r.seq}] ${r.act} from ${r.from}: ${first(r.body)}`).join("\n") +
|
|
@@ -1019,7 +1682,7 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
1019
1682
|
`\nCall pantheon_wait, then send again.`, m.cid);
|
|
1020
1683
|
}
|
|
1021
1684
|
}
|
|
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);
|
|
1685
|
+
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
1686
|
if (err)
|
|
1024
1687
|
return fail(err.code, err.message, m.cid);
|
|
1025
1688
|
const rec = this.commit(space, {
|
|
@@ -1080,7 +1743,7 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
1080
1743
|
}
|
|
1081
1744
|
this.evictSpaces();
|
|
1082
1745
|
}
|
|
1083
|
-
validateAct(sp, me, act, goalId, ref, to, meta, deferTo, bodyLen = 0, summary) {
|
|
1746
|
+
validateAct(sp, me, act, goalId, ref, to, meta, deferTo, bodyLen = 0, summary, repo) {
|
|
1084
1747
|
if (!SENDABLE.includes(act))
|
|
1085
1748
|
return { code: "RESERVED_ACT", message: `${act} is emitted by the relay, not by a delegate` };
|
|
1086
1749
|
const now = Date.now();
|
|
@@ -1119,19 +1782,22 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
1119
1782
|
message: "HUMAN_REQUIRED must name decision_domains so the relay can verify the human owner. Call pantheon_route_decision first." };
|
|
1120
1783
|
}
|
|
1121
1784
|
const target = sp.profiles.get(to[0]);
|
|
1122
|
-
if (!target) {
|
|
1123
|
-
return { code: "NO_AUTHORITY_PROFILE",
|
|
1124
|
-
message: `${to[0]} has not published an organization profile, so their decision authority cannot be verified.` };
|
|
1125
|
-
}
|
|
1126
1785
|
const route = routeDecision(sp.profiles.values(), { domains });
|
|
1127
1786
|
if (route.conflicts.length) {
|
|
1128
1787
|
return { code: "AMBIGUOUS_DECISION_AUTHORITY",
|
|
1129
1788
|
message: `authority conflicts for ${route.conflicts.map((c) => `${c.domain} (${c.reason}: ${c.members.join(", ")})`).join("; ")}. Fix the profiles or ESCALATE instead of guessing.` };
|
|
1130
1789
|
}
|
|
1131
|
-
const
|
|
1132
|
-
if (
|
|
1790
|
+
const others = route.required_humans.filter((h) => h.member !== to[0] && h.domains.some((d) => domains.includes(d)));
|
|
1791
|
+
if (others.length) {
|
|
1792
|
+
const theirs = [...new Set(others.flatMap((h) => h.domains.filter((d) => domains.includes(d))))];
|
|
1133
1793
|
return { code: "WRONG_DECISION_OWNER",
|
|
1134
|
-
message: `${to[0]} is not the human-required owner for: ${
|
|
1794
|
+
message: `${to[0]} is not the human-required owner for: ${theirs.join(", ")} — ${others.map((h) => h.member).join(", ")} ${others.length === 1 ? "is" : "are"}. Call pantheon_route_decision and route each domain to its published owner.` };
|
|
1795
|
+
}
|
|
1796
|
+
const owner = route.required_humans.find((x) => x.member === to[0]);
|
|
1797
|
+
const uncovered = domains.filter((d) => !owner?.domains.includes(d));
|
|
1798
|
+
if (uncovered.length && meta) {
|
|
1799
|
+
meta["authority"] = target ? "partly_unverified" : "unverified";
|
|
1800
|
+
meta["unverified_domains"] = uncovered;
|
|
1135
1801
|
}
|
|
1136
1802
|
const openDup = [...sp.obligations.values()].find((o) => {
|
|
1137
1803
|
if (o.kind !== "HUMAN" || o.owner !== `human:${to[0]}` || (o.goal ?? null) !== (goalId ?? null))
|
|
@@ -1197,9 +1863,19 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
1197
1863
|
return { code: "ALREADY_RETRACTED", message: "a retraction cannot itself be retracted — send a new act saying what is true" };
|
|
1198
1864
|
}
|
|
1199
1865
|
}
|
|
1866
|
+
if (repo && sp.allow.repoCount() > 0 && !sp.allow.hasRepo(repo)) {
|
|
1867
|
+
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` };
|
|
1868
|
+
}
|
|
1200
1869
|
if (act === "DELIVERED") {
|
|
1201
1870
|
const branch = typeof meta?.["branch"] === "string" ? meta["branch"] : "";
|
|
1202
1871
|
const commit = typeof meta?.["commit"] === "string" ? meta["commit"] : "";
|
|
1872
|
+
if (sp.allow.repoCount() > 0) {
|
|
1873
|
+
const r = typeof meta?.["repo"] === "string" ? meta["repo"] : repo ?? "";
|
|
1874
|
+
if (!r)
|
|
1875
|
+
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`." };
|
|
1876
|
+
if (!sp.allow.hasRepo(r))
|
|
1877
|
+
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` };
|
|
1878
|
+
}
|
|
1203
1879
|
if (!branch || !commit) {
|
|
1204
1880
|
return {
|
|
1205
1881
|
code: "UNVERIFIABLE_DELIVERY",
|
|
@@ -1247,11 +1923,16 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
1247
1923
|
return null;
|
|
1248
1924
|
}
|
|
1249
1925
|
commit(sp, partial) {
|
|
1926
|
+
if (sp.deleted)
|
|
1927
|
+
return { ...partial, id: newId("a"), seq: sp.ledger.head, ts: Date.now(), space: sp.name };
|
|
1250
1928
|
const rec = sp.ledger.append({ ...partial, id: newId("a"), ts: Date.now(), space: sp.name });
|
|
1251
1929
|
sp.lastTouch = rec.ts;
|
|
1252
1930
|
const m = sp.members.get(rec.from);
|
|
1253
|
-
if (m)
|
|
1931
|
+
if (m) {
|
|
1254
1932
|
m.stamps.push(rec.ts);
|
|
1933
|
+
if (rec.act !== "JOIN" && rec.act !== "LEAVE" && (m.seen_seq ?? 0) < rec.seq - 1)
|
|
1934
|
+
m.seen_seq = rec.seq - 1;
|
|
1935
|
+
}
|
|
1255
1936
|
if (rec.goal) {
|
|
1256
1937
|
const goal = sp.goals.get(rec.goal);
|
|
1257
1938
|
if (goal) {
|
|
@@ -1259,6 +1940,7 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
1259
1940
|
const run = sp.lastActor.get(rec.goal);
|
|
1260
1941
|
sp.lastActor.set(rec.goal, run && run.member === rec.from ? { member: rec.from, run: run.run + 1 } : { member: rec.from, run: 1 });
|
|
1261
1942
|
this.applyObligations(sp, rec, goal);
|
|
1943
|
+
this.broadcast(sp, { t: "goal", goal });
|
|
1262
1944
|
if (goal.acts >= goal.budget && goal.state === "OPEN") {
|
|
1263
1945
|
goal.state = "STALLED";
|
|
1264
1946
|
this.oblige(sp, { kind: "HUMAN", owner: `human:${goal.opened_by}`, ref: rec.id, goal: goal.id,
|
|
@@ -1548,6 +2230,7 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
1548
2230
|
id: s.id, role: s.role, agent: s.agent, ...(s.pkg ? { pkg: s.pkg } : {}),
|
|
1549
2231
|
})),
|
|
1550
2232
|
...(this.work(m) ? { work: this.work(m) } : {}),
|
|
2233
|
+
...(m.worker && Date.now() - m.worker.at < 15 * 60_000 ? { worker: m.worker } : {}),
|
|
1551
2234
|
delivered_seq: m.delivered_seq,
|
|
1552
2235
|
...(m.seen_seq !== undefined ? { seen_seq: m.seen_seq } : {}),
|
|
1553
2236
|
}));
|