@join-pantheon/cli 0.22.0 → 0.23.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 +5 -0
- package/dist/cli.js +54 -12
- package/dist/client.js +34 -0
- package/dist/config.js +15 -3
- package/dist/doctor.js +13 -0
- package/dist/email.js +47 -23
- package/dist/host-env.js +57 -0
- package/dist/protocol.js +10 -1
- package/dist/quickstart.js +14 -4
- package/dist/relay.js +180 -74
- package/dist/roster-file.js +19 -0
- package/dist/service.js +25 -2
- package/dist/setup-guide.js +4 -2
- package/dist/tray.js +8 -2
- package/helper/macos/menubar.swift +110 -43
- package/package.json +2 -2
package/dist/relay.js
CHANGED
|
@@ -77,7 +77,7 @@ function fingerprint_(from, act, body, goal, ref) {
|
|
|
77
77
|
}
|
|
78
78
|
const ADMIN_CSS = `<style>
|
|
79
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:#
|
|
80
|
+
@media(prefers-color-scheme:light){:root{--bg:#f6f7f9;--card:#fff;--line:#e6e8eb;--fg:#1a1c1f;--dim:#59616e;--accent:#2563eb;--warn:#b45309;--bad:#b91c1c;--ok:#15803d}}
|
|
81
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
82
|
header{padding:20px 24px;border-bottom:1px solid var(--line);display:flex;align-items:baseline;gap:16px;flex-wrap:wrap}
|
|
83
83
|
h1{font-size:18px;margin:0}.dim{color:var(--dim)}
|
|
@@ -85,7 +85,7 @@ const ADMIN_CSS = `<style>
|
|
|
85
85
|
.stat{background:var(--card);border:1px solid var(--line);border-radius:10px;padding:10px 16px;min-width:110px}
|
|
86
86
|
.stat b{font-size:22px;display:block;font-variant-numeric:tabular-nums}.stat span{color:var(--dim);font-size:12px}
|
|
87
87
|
.stat.warn b{color:var(--warn)}
|
|
88
|
-
section{padding:8px 24px 28px}h2{font-size:
|
|
88
|
+
section{padding:8px 24px 28px}h2{font-size:16px;color:var(--dim);margin:22px 0 8px;display:flex;align-items:center;gap:12px}
|
|
89
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
90
|
.wrap{overflow-x:auto;border:1px solid var(--line);border-radius:10px;background:var(--card)}
|
|
91
91
|
table{border-collapse:collapse;width:100%;font-variant-numeric:tabular-nums}
|
|
@@ -98,6 +98,18 @@ const ADMIN_CSS = `<style>
|
|
|
98
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
99
|
.done{margin:0 24px;padding:10px 14px;border:1px solid var(--ok);color:var(--ok);border-radius:10px;background:var(--card)}
|
|
100
100
|
.note{color:var(--dim);font-size:12px;margin:6px 0 0}
|
|
101
|
+
:root{color-scheme:dark} @media(prefers-color-scheme:light){:root{color-scheme:light}}
|
|
102
|
+
:focus-visible{outline:2px solid var(--accent);outline-offset:3px}
|
|
103
|
+
input::placeholder{color:var(--dim);opacity:1}
|
|
104
|
+
button,input,select,summary{min-height:36px}button,input,select{max-width:100%}
|
|
105
|
+
h1,h2{overflow-wrap:anywhere;text-wrap:balance}h2{flex-wrap:wrap}
|
|
106
|
+
.note{max-width:75ch}.done{margin-top:16px}
|
|
107
|
+
.diagnostics{margin:16px 24px;border:1px solid var(--line);border-radius:10px;background:var(--card)}
|
|
108
|
+
.diagnostics>summary{padding:12px 16px;cursor:pointer;font-weight:600}
|
|
109
|
+
.diagnostics>section{padding:0 16px 16px}
|
|
110
|
+
.jump{display:flex;flex-wrap:wrap;gap:16px;padding:12px 24px}
|
|
111
|
+
.wrap:focus-visible{outline-offset:-3px}table{scroll-margin-top:16px}
|
|
112
|
+
@media(max-width:600px){header{padding:16px;gap:8px}section{padding:8px 16px 20px}.stats{padding:12px 16px}.diagnostics{margin:12px 16px}.jump{padding:12px 16px}h2 input{width:100%;margin-left:0}.blockform{flex-wrap:wrap}}
|
|
101
113
|
</style>`;
|
|
102
114
|
const escHtml = (v) => String(v ?? "").replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c]));
|
|
103
115
|
const fmtWhen = (t) => t ? new Date(t).toISOString().slice(0, 19).replace("T", " ") : "—";
|
|
@@ -621,22 +633,24 @@ export class Relay {
|
|
|
621
633
|
}
|
|
622
634
|
adminLoginPage(res, wrong) {
|
|
623
635
|
res.writeHead(wrong ? 401 : 200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store", "referrer-policy": "no-referrer" });
|
|
624
|
-
res.end(`<!doctype html><meta charset=utf-8><title>pantheon · admin</title>
|
|
636
|
+
res.end(`<!doctype html><html lang=en><meta charset=utf-8><title>pantheon · admin</title>
|
|
625
637
|
<meta name=viewport content="width=device-width,initial-scale=1">
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
h1{
|
|
631
|
-
input{width:100%;padding:10px 12px;border-radius:8px;border:1px solid
|
|
632
|
-
button{width:100%;margin-top:
|
|
633
|
-
.err{color
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
<
|
|
638
|
+
${ADMIN_CSS}
|
|
639
|
+
<style>
|
|
640
|
+
body{min-height:100dvh;display:grid;place-items:center;padding:20px}
|
|
641
|
+
main{width:100%;max-width:360px}form{padding:24px;border:1px solid var(--line);border-radius:12px;background:var(--card)}
|
|
642
|
+
h1{margin:0 0 8px}p{color:var(--dim);margin:0 0 20px}label{display:block;margin-bottom:6px}
|
|
643
|
+
input{width:100%;padding:10px 12px;border-radius:8px;border:1px solid var(--line);background:var(--bg);color:var(--fg);font:inherit}
|
|
644
|
+
button{width:100%;margin-top:16px;padding:10px;border:0;border-radius:8px;background:#2563eb;color:#fff;font:inherit;font-weight:600;cursor:pointer}
|
|
645
|
+
button:hover{background:#1d4ed8}.err{color:var(--bad);margin-top:12px}
|
|
646
|
+
</style>
|
|
647
|
+
<main><form method=POST action="/admin/login">
|
|
648
|
+
<h1>pantheon · admin</h1><p>Sign in to manage your relay.</p>
|
|
649
|
+
<label for=admin-token>Admin token</label>
|
|
650
|
+
<input id=admin-token type=password name=token required autofocus autocomplete=current-password${wrong ? ' aria-invalid="true" aria-describedby="login-error"' : ''}>
|
|
637
651
|
<button>Sign in</button>
|
|
638
|
-
${wrong ? '<div class=err>That token
|
|
639
|
-
</form>`);
|
|
652
|
+
${wrong ? '<div id=login-error class=err role=alert>That token was not accepted. Check your admin token and try again.</div>' : ''}
|
|
653
|
+
</form></main>`);
|
|
640
654
|
}
|
|
641
655
|
adminIpOk(ip) {
|
|
642
656
|
if (!this.adminAllow.length)
|
|
@@ -796,6 +810,19 @@ button{width:100%;margin-top:12px;padding:10px;border:0;border-radius:8px;backgr
|
|
|
796
810
|
this.log(`[admin] ${act} ${acct.username} from ${ip}`);
|
|
797
811
|
return done(`${acct.username} ${act === "suspend" ? "suspended and signed out everywhere" : "reinstated"}`);
|
|
798
812
|
}
|
|
813
|
+
case "revoke_invite": {
|
|
814
|
+
const sp = this.spaces.get(id);
|
|
815
|
+
const allow = sp ? sp.allow : new Allowlist(this.dataDir, id.replace(/[^a-z0-9._-]/gi, "_"));
|
|
816
|
+
const token = (body.token ?? "").trim();
|
|
817
|
+
const inv = allow.findInvite(token);
|
|
818
|
+
const back = (msg) => { res.writeHead(303, { location: `/admin/space?name=${encodeURIComponent(id)}&done=${encodeURIComponent(msg)}` }); res.end(); };
|
|
819
|
+
if (!inv)
|
|
820
|
+
return back(`no open invite …${token.slice(-6)} in ${id}`);
|
|
821
|
+
allow.revokeInvite(inv.token);
|
|
822
|
+
this.log(`[admin] revoked invite …${inv.token.slice(-6)} in ${id} (minted by ${inv.created_by}) from ${ip}`);
|
|
823
|
+
A.audit({ event: "admin_invite_revoked", space: id, target: inv.created_by, detail: `…${inv.token.slice(-6)}`, ip });
|
|
824
|
+
return back(`revoked invite …${inv.token.slice(-6)}`);
|
|
825
|
+
}
|
|
799
826
|
case "revoke_session":
|
|
800
827
|
case "revoke_device": {
|
|
801
828
|
const hit = A.adminRevoke(act === "revoke_session" ? "session" : "device", id, ip);
|
|
@@ -962,7 +989,7 @@ button{width:100%;margin-top:12px;padding:10px;border:0;border-radius:8px;backgr
|
|
|
962
989
|
const done = url?.searchParams.get("done");
|
|
963
990
|
const rows = (arr) => arr.join("");
|
|
964
991
|
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>`;
|
|
965
|
-
const html = `<!doctype html><meta charset=utf-8><title>pantheon · admin</title>
|
|
992
|
+
const html = `<!doctype html><html lang=en><meta charset=utf-8><title>pantheon · admin</title>
|
|
966
993
|
<meta name=viewport content="width=device-width,initial-scale=1">
|
|
967
994
|
${ADMIN_CSS}
|
|
968
995
|
<header>
|
|
@@ -970,7 +997,9 @@ ${ADMIN_CSS}
|
|
|
970
997
|
<span class=dim>${esc(this.opts.host ?? "relay")} · pkg ${VERSION} · up ${upMin < 60 ? upMin + "m" : Math.round(upMin / 60) + "h"}</span>
|
|
971
998
|
<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>
|
|
972
999
|
</header>
|
|
973
|
-
${done ? `<div class=done>${esc(done)}</div>` : ""}
|
|
1000
|
+
${done ? `<div class=done role=status>${esc(done)}</div>` : ""}
|
|
1001
|
+
<main><nav class=jump aria-label="Dashboard sections"><a href="#t-spaces">Spaces</a><a href="#t-accounts">Accounts</a><a href="#t-sessions">Sessions</a><a href="#t-audit">Audit log</a></nav>
|
|
1002
|
+
<details class=diagnostics open><summary>Activity and service metrics</summary>
|
|
974
1003
|
<div class=stats>
|
|
975
1004
|
<div class=stat><b>${c.accounts}</b><span>accounts</span></div>
|
|
976
1005
|
<div class=stat><b>${c.devices}</b><span>devices</span></div>
|
|
@@ -989,66 +1018,80 @@ ${done ? `<div class=done>${esc(done)}</div>` : ""}
|
|
|
989
1018
|
<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>
|
|
990
1019
|
</div>
|
|
991
1020
|
<section>
|
|
992
|
-
<h2>Activity <span class=dim>(every space · today and retained history)</span></h2><div class=wrap><table id=t-activity>
|
|
993
|
-
<tr><th></th>${MESSAGE_ACTS.map((x) => `<th>${x}</th>`).join("")}<th>refused</th></tr>
|
|
1021
|
+
<h2>Activity <span class=dim>(every space · today and retained history)</span></h2><div class=wrap tabindex=0 role=region aria-label="Scrollable table"><table id=t-activity>
|
|
1022
|
+
<tr><th scope=col></th>${MESSAGE_ACTS.map((x) => `<th scope=col>${x}</th>`).join("")}<th scope=col>refused</th></tr>
|
|
994
1023
|
<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>
|
|
995
1024
|
<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>
|
|
996
1025
|
</table></div>
|
|
997
1026
|
<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>
|
|
998
1027
|
|
|
999
|
-
|
|
1000
|
-
|
|
1028
|
+
</section></details>
|
|
1029
|
+
<section>
|
|
1030
|
+
<h2>Spaces <input type=search aria-label="Filter spaces" placeholder="Filter spaces" data-filter="t-spaces"></h2><div class=wrap tabindex=0 role=region aria-label="Scrollable table"><table id=t-spaces>
|
|
1031
|
+
<tr><th scope=col>space</th><th scope=col>state</th><th scope=col>owner</th><th scope=col>members</th><th scope=col>online</th><th scope=col>acts</th><th scope=col>msgs today</th><th scope=col>delegates today</th><th scope=col>objections</th><th scope=col>refused</th><th scope=col>goals</th><th scope=col>owed</th><th scope=col>last activity</th><th scope=col>on disk</th><th scope=col></th></tr>
|
|
1001
1032
|
${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>`))}
|
|
1002
1033
|
${spaces.length ? "" : "<tr><td colspan=15 class=dim>no spaces yet</td></tr>"}
|
|
1003
1034
|
</table></div>
|
|
1004
1035
|
<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>
|
|
1005
1036
|
|
|
1006
|
-
<h2>Accounts <input placeholder="
|
|
1007
|
-
<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>
|
|
1008
|
-
${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>`))}
|
|
1037
|
+
<h2>Accounts <input type=search aria-label="Filter accounts" placeholder="Filter accounts" data-filter="t-accounts"></h2><div class=wrap tabindex=0 role=region aria-label="Scrollable table"><table id=t-accounts>
|
|
1038
|
+
<tr><th scope=col>username</th><th scope=col>email</th><th scope=col>status</th><th scope=col>plan</th><th scope=col>spaces</th><th scope=col>limit hits</th><th scope=col>devices</th><th scope=col>created</th><th scope=col></th></tr>
|
|
1039
|
+
${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 aria-label="Plan for ${esc(a.username)}" 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 aria-label="Custom limits for ${esc(a.username)}" 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>`))}
|
|
1009
1040
|
${accounts.length ? "" : "<tr><td colspan=9 class=dim>no accounts yet</td></tr>"}
|
|
1010
1041
|
</table></div>
|
|
1011
1042
|
<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>
|
|
1012
1043
|
|
|
1013
|
-
<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>
|
|
1014
|
-
<tr><th>plan</th><th>accounts</th><th>spaces created</th><th>people per space</th><th>unattended workers</th><th></th></tr>
|
|
1015
|
-
${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>`; }))}
|
|
1016
|
-
<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>
|
|
1044
|
+
<h2>Plans <span class=dim>(limits the relay enforces; the owner's plan governs a space)</span></h2><div class=wrap tabindex=0 role=region aria-label="Scrollable table"><table id=t-plans>
|
|
1045
|
+
<tr><th scope=col>plan</th><th scope=col>accounts</th><th scope=col>spaces created</th><th scope=col>people per space</th><th scope=col>unattended workers</th><th scope=col></th></tr>
|
|
1046
|
+
${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 aria-label="Maximum spaces" type=number min=0 value="${l.spaces}" class=act style="width:90px"></td><td><input form="${fid}" name=members aria-label="Maximum people per space" 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>`; }))}
|
|
1047
|
+
<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 aria-label="New plan name" 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 aria-label="Maximum spaces" type=number min=0 value=3 class=act style="width:90px"></td><td><input form="plan-new" name=members aria-label="Maximum people per space" 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>
|
|
1017
1048
|
</table></div>
|
|
1018
1049
|
<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>
|
|
1019
1050
|
|
|
1020
|
-
<h2>Live sessions <input placeholder="
|
|
1021
|
-
<tr><th>session</th><th>account</th><th>device</th><th>role</th><th>space</th><th>ip</th><th>started</th><th>last seen</th><th></th></tr>
|
|
1051
|
+
<h2>Live sessions <input type=search aria-label="Filter sessions" placeholder="Filter sessions" data-filter="t-sessions"></h2><div class=wrap tabindex=0 role=region aria-label="Scrollable table"><table id=t-sessions>
|
|
1052
|
+
<tr><th scope=col>session</th><th scope=col>account</th><th scope=col>device</th><th scope=col>role</th><th scope=col>space</th><th scope=col>ip</th><th scope=col>started</th><th scope=col>last seen</th><th scope=col></th></tr>
|
|
1022
1053
|
${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 class=dim>${esc(devices.find((d) => d.id === s.device_id)?.label ?? s.device_id.slice(0, 10))}</td><td>${s.role ? `<span class=pill>${esc(s.role)}</span>` : "<span class=dim>—</span>"}</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>`))}
|
|
1023
1054
|
${sessions.some((s) => !s.revoked_at) ? "" : "<tr><td colspan=9 class=dim>no open connections right now</td></tr>"}
|
|
1024
1055
|
</table></div>
|
|
1025
1056
|
<p class=note>One row per open connection — an agent window, a worker, or a CLI command while it runs. Rows leave when the socket closes; ${endedToday} connection${endedToday === 1 ? "" : "s"} came and went in the last 24 hours. "Stale" means no heartbeat for 15 minutes on a socket that has not closed, usually a machine that went to sleep.</p>
|
|
1026
1057
|
|
|
1027
|
-
<h2>Blocked emails and domains</h2><div class=wrap><table id=t-blocks>
|
|
1028
|
-
<tr><th>pattern</th><th>kind</th><th>since</th><th></th></tr>
|
|
1058
|
+
<h2>Blocked emails and domains</h2><div class=wrap tabindex=0 role=region aria-label="Scrollable table"><table id=t-blocks>
|
|
1059
|
+
<tr><th scope=col>pattern</th><th scope=col>kind</th><th scope=col>since</th><th scope=col></th></tr>
|
|
1029
1060
|
${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>`))}
|
|
1030
1061
|
${blocks.length ? "" : "<tr><td colspan=4 class=dim>nothing blocked</td></tr>"}
|
|
1031
1062
|
</table></div>
|
|
1032
|
-
<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>
|
|
1063
|
+
<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 aria-label="Email or domain to block" placeholder="someone@spam.example or spam.example" required><button class=act>block</button></form>
|
|
1033
1064
|
<p class=note>A blocked email or domain cannot sign up or sign in. Existing accounts are not touched; suspend those separately.</p>
|
|
1034
1065
|
|
|
1035
|
-
<h2>Recent verification codes <span class=dim>(support: "I never got my code")</span></h2><div class=wrap><table id=t-verifs>
|
|
1036
|
-
<tr><th>email</th><th>purpose</th><th>requested</th><th>expires</th><th>wrong tries</th><th>state</th></tr>
|
|
1066
|
+
<h2>Recent verification codes <span class=dim>(support: "I never got my code")</span></h2><div class=wrap tabindex=0 role=region aria-label="Scrollable table"><table id=t-verifs>
|
|
1067
|
+
<tr><th scope=col>email</th><th scope=col>purpose</th><th scope=col>requested</th><th scope=col>expires</th><th scope=col>wrong tries</th><th scope=col>state</th></tr>
|
|
1037
1068
|
${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>`))}
|
|
1038
1069
|
${verifs.length ? "" : "<tr><td colspan=6 class=dim>no pending codes</td></tr>"}
|
|
1039
1070
|
</table></div>
|
|
1040
1071
|
<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>
|
|
1041
1072
|
|
|
1042
|
-
<h2>Audit log <span class=dim>(latest 300)</span> <input placeholder="
|
|
1043
|
-
<tr><th>time</th><th>event</th><th>space</th><th>target</th><th>detail</th><th>ip</th></tr>
|
|
1073
|
+
<h2>Audit log <span class=dim>(latest 300)</span> <input type=search aria-label="Filter audit" placeholder="Filter audit" data-filter="t-audit"></h2><div class=wrap tabindex=0 role=region aria-label="Scrollable table"><table id=t-audit>
|
|
1074
|
+
<tr><th scope=col>time</th><th scope=col>event</th><th scope=col>space</th><th scope=col>target</th><th scope=col>detail</th><th scope=col>ip</th></tr>
|
|
1044
1075
|
${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>`))}
|
|
1045
1076
|
</table></div>
|
|
1046
1077
|
</section>
|
|
1078
|
+
</main>
|
|
1047
1079
|
<script>
|
|
1048
1080
|
document.querySelectorAll("input[data-filter]").forEach(function (inp) {
|
|
1081
|
+
var table = document.getElementById(inp.dataset.filter);
|
|
1082
|
+
var result = document.createElement("p");
|
|
1083
|
+
result.className = "note";
|
|
1084
|
+
result.setAttribute("role", "status");
|
|
1085
|
+
table.parentElement.insertAdjacentElement("afterend", result);
|
|
1049
1086
|
inp.addEventListener("input", function () {
|
|
1050
|
-
var q = inp.value.toLowerCase(), rowsEl =
|
|
1051
|
-
for (var i = 1; i < rowsEl.length; i++)
|
|
1087
|
+
var q = inp.value.trim().toLowerCase(), rowsEl = table.querySelectorAll("tr"), shown = 0;
|
|
1088
|
+
for (var i = 1; i < rowsEl.length; i++) {
|
|
1089
|
+
var match = (!q || rowsEl[i].textContent.toLowerCase().indexOf(q) > -1);
|
|
1090
|
+
var empty = rowsEl[i].querySelector("td[colspan]");
|
|
1091
|
+
rowsEl[i].hidden = !match;
|
|
1092
|
+
if (match && !empty) shown++;
|
|
1093
|
+
}
|
|
1094
|
+
result.textContent = q ? (shown ? shown + " matching rows" : "No matches. Try another search or clear the filter.") : "";
|
|
1052
1095
|
});
|
|
1053
1096
|
});
|
|
1054
1097
|
function confirmAct(f) {
|
|
@@ -1092,22 +1135,33 @@ ${done ? `<div class=done>${esc(done)}</div>` : ""}
|
|
|
1092
1135
|
const pill = (x) => `<span class="pill ${ACT_TONE[x]}">${x}</span>`;
|
|
1093
1136
|
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>";
|
|
1094
1137
|
const metaLine = (r) => {
|
|
1095
|
-
const bits = [];
|
|
1138
|
+
const bits = [`Message ID: <code>${esc(r.id)}</code>`];
|
|
1139
|
+
if (r.role)
|
|
1140
|
+
bits.push(`Role: ${esc(r.role)}`);
|
|
1141
|
+
const agent = sp?.members.get(r.from)?.agent;
|
|
1142
|
+
if (agent)
|
|
1143
|
+
bits.push(`Agent: ${esc(agent)}`);
|
|
1096
1144
|
if (r.summary)
|
|
1097
|
-
bits.push(`
|
|
1145
|
+
bits.push(`Summary: ${esc(r.summary)}`);
|
|
1098
1146
|
const meta = (r.meta ?? {});
|
|
1099
1147
|
for (const k of ["branch", "commit", "repo_origin", "decision_domains", "defers_to", "affects"])
|
|
1100
1148
|
if (meta[k] !== undefined)
|
|
1101
1149
|
bits.push(`${k}: ${esc(typeof meta[k] === "string" ? meta[k] : JSON.stringify(meta[k]))}`);
|
|
1102
1150
|
if (r.ref)
|
|
1103
|
-
bits.push(`
|
|
1104
|
-
return
|
|
1151
|
+
bits.push(`Reply to: <code>${esc(r.ref)}</code>`);
|
|
1152
|
+
return `<details class=message-meta><summary>Message details</summary><div>${bits.join("<br>")}</div></details>`;
|
|
1105
1153
|
};
|
|
1106
|
-
const said = (r) => r.body.length >
|
|
1107
|
-
? `<details><summary>${esc(r.body.slice(0, 400))}…</summary>${esc(r.body)}</details>`
|
|
1108
|
-
:
|
|
1109
|
-
const rowsHtml = feedRows.map((r) => `<
|
|
1154
|
+
const said = (r) => r.body.length > 600
|
|
1155
|
+
? `<details class=message-content><summary><span class=preview>${esc(r.body.slice(0, 400))}…</span><span class=read-more>Read full message</span><span class=read-less>Show less</span></summary><div class=message-body>${esc(r.body)}</div></details>`
|
|
1156
|
+
: `<div class=message-body>${esc(r.body) || "<span class=dim>(empty message)</span>"}</div>`;
|
|
1157
|
+
const rowsHtml = feedRows.map((r) => `<article class=message aria-label="Message ${r.seq} from ${esc(r.from)}">
|
|
1158
|
+
<div class=message-heading><a href="${link({ member: r.from, before: undefined })}"><strong>${esc(r.from)}</strong></a><span class=dim>to ${r.to?.length ? r.to.map((t) => esc(t)).join(", ") : "everyone"}</span>${pill(r.act)}<time datetime="${new Date(r.ts).toISOString()}" title="${when(r.ts)} UTC">${ago(r.ts)} · #${r.seq}</time></div>
|
|
1159
|
+
${r.goal ? `<div class=message-goal>${goalTitle(r.goal)}</div>` : ""}
|
|
1160
|
+
${said(r)}${metaLine(r)}</article>`).join("");
|
|
1110
1161
|
const entries = allow.all();
|
|
1162
|
+
const openInvites = allow.openInvites();
|
|
1163
|
+
const csrfTok = this.adminCsrf();
|
|
1164
|
+
const revokeForm = (token) => `<form method=POST action="/admin/action" class=inl onsubmit="return confirm('Revoke this invite? The link stops working immediately.')"><input type=hidden name=csrf value="${csrfTok}"><input type=hidden name=action value=revoke_invite><input type=hidden name=id value="${esc(name)}"><input type=hidden name=token value="${esc(token.slice(-6))}"><button class="act danger">revoke</button></form>`;
|
|
1111
1165
|
const memberRows = entries.map((e) => {
|
|
1112
1166
|
const m = sp?.members.get(e.member);
|
|
1113
1167
|
const online = m ? m.sessions.size : 0;
|
|
@@ -1119,12 +1173,27 @@ ${done ? `<div class=done>${esc(done)}</div>` : ""}
|
|
|
1119
1173
|
const byCode = Object.entries(refusals.by_code).sort((x, y) => y[1] - x[1]).map(([c, n]) => `<code>${esc(c)}</code> ${n}`).join(" · ");
|
|
1120
1174
|
const open = [...state.goals.values()].filter((g) => g.state !== "CLOSED").length;
|
|
1121
1175
|
const options = MESSAGE_ACTS.map((x) => `<option value="${x}"${q.act === x ? " selected" : ""}>${x}</option>`).join("");
|
|
1122
|
-
const html = `<!doctype html><meta charset=utf-8><title>pantheon · admin · ${esc(name)}</title>
|
|
1176
|
+
const html = `<!doctype html><html lang=en><meta charset=utf-8><title>pantheon · admin · ${esc(name)}</title>
|
|
1123
1177
|
<meta name=viewport content="width=device-width,initial-scale=1">
|
|
1124
1178
|
${ADMIN_CSS}
|
|
1125
1179
|
<style>
|
|
1126
|
-
.
|
|
1127
|
-
.
|
|
1180
|
+
.conversation{max-width:960px;margin:0 auto}
|
|
1181
|
+
.message{padding:20px 0;border-bottom:1px solid var(--line);overflow-wrap:anywhere}
|
|
1182
|
+
.message-heading{display:flex;align-items:baseline;flex-wrap:wrap;gap:8px}
|
|
1183
|
+
.message-heading time{margin-left:auto;color:var(--dim);font-size:12px}
|
|
1184
|
+
.message-goal{margin:6px 0 12px}.message-body,.preview{white-space:pre-wrap;max-width:75ch}
|
|
1185
|
+
.message-body{margin-top:12px}.message-content{margin-top:12px}
|
|
1186
|
+
.message-content>summary{cursor:pointer;list-style:none}.preview{display:block;color:var(--fg)}
|
|
1187
|
+
.read-more,.read-less{display:block;color:var(--accent);padding:8px 0}
|
|
1188
|
+
.read-less,.message-content[open] .read-more,.message-content[open] .preview{display:none}
|
|
1189
|
+
.message-content[open] .read-less{display:block}
|
|
1190
|
+
.message-meta{color:var(--dim);font-size:12px;margin-top:12px}.message-meta summary{cursor:pointer;padding:6px 0}
|
|
1191
|
+
.message-meta>div{max-width:75ch;white-space:pre-wrap}
|
|
1192
|
+
.filters label{display:flex;flex-direction:column;gap:4px;font-size:12px;color:var(--dim)}
|
|
1193
|
+
.filters .presence{flex-direction:row;align-items:center}.filters input[type=checkbox]{min-height:0}
|
|
1194
|
+
.pagination{display:flex;justify-content:space-between;gap:16px;margin:20px 0}
|
|
1195
|
+
.empty{padding:24px 0}.empty p{color:var(--dim)}
|
|
1196
|
+
@media(max-width:600px){.filters label{flex:1 1 140px}.filters input,.filters select{width:100%;min-width:0}.filters .presence{flex-basis:100%}.filters input[type=checkbox]{width:auto}.message-heading time{margin-left:0;width:100%}}
|
|
1128
1197
|
.pill.warn{color:var(--warn);border-color:var(--warn)}.pill.info{color:var(--accent);border-color:var(--accent)}.pill.dim{color:var(--dim)}
|
|
1129
1198
|
.filters{display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin:8px 0 10px}
|
|
1130
1199
|
.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)}
|
|
@@ -1136,6 +1205,23 @@ ${ADMIN_CSS}
|
|
|
1136
1205
|
<span class=dim>${esc(allow.peekId() ?? "no id yet")} · ${records.length} acts retained${a.first ? ` since ${when(a.first)}` : ""}</span>
|
|
1137
1206
|
<span style="margin-left:auto"><a href="/admin">← dashboard</a> · <a href="/admin/logout">sign out</a></span>
|
|
1138
1207
|
</header>
|
|
1208
|
+
<main id=conversation class=conversation>
|
|
1209
|
+
<section>
|
|
1210
|
+
<h2>Conversation <span class=dim>(${feedRows.length} shown${filtered ? " · filtered" : ""})</span></h2>
|
|
1211
|
+
<form class=filters method=GET action="/admin/space"><input type=hidden name=name value="${esc(name)}">
|
|
1212
|
+
<label>Message type<select name=act><option value="">All types</option>${options}</select></label>
|
|
1213
|
+
<label>Member<input name=member placeholder="Any member" value="${esc(q.member)}" size=12></label>
|
|
1214
|
+
<label>Goal<input name=goal placeholder="Any goal ID" value="${esc(q.goal)}" size=16></label>
|
|
1215
|
+
<label>Search messages<input type=search name=q placeholder="Find words or phrases" value="${esc(q.q)}" size=24></label>
|
|
1216
|
+
<label class=presence><input type=checkbox name=presence value=1${q.presence ? " checked" : ""}> Include arrivals and departures</label>
|
|
1217
|
+
<button class=act>Apply filters</button>${filtered ? `<a href="/admin/space?name=${encodeURIComponent(name)}">Clear filters</a>` : ""}
|
|
1218
|
+
</form>
|
|
1219
|
+
${rowsHtml || `<div class=empty><h3>${filtered ? "No messages match these filters" : "No messages yet"}</h3><p>${filtered ? "Try a different search or clear the filters to see the conversation." : "Messages will appear here when this team’s agents start working together."}</p></div>`}
|
|
1220
|
+
<nav class=pagination aria-label="Conversation pages">${q.before ? `<a href="${link({ before: undefined })}">← Latest messages</a>` : "<span></span>"}${next !== null ? `<a href="${link({ before: next })}">Older messages →</a>` : ""}</nav>
|
|
1221
|
+
<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. Messages are shown as sent. Expand Message details for IDs and delivery references.</p>
|
|
1222
|
+
</section>
|
|
1223
|
+
</main>
|
|
1224
|
+
<details class=diagnostics><summary>Team details and diagnostics · ${entries.length} members · ${open} open goals</summary>
|
|
1139
1225
|
<div class=stats>
|
|
1140
1226
|
<div class=stat><b>${a.messages_24h}<span class=dim style="font-size:14px"> / ${a.messages}</span></b><span>messages today / retained</span></div>
|
|
1141
1227
|
<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>
|
|
@@ -1147,40 +1233,32 @@ ${ADMIN_CSS}
|
|
|
1147
1233
|
${sp ? `<div class=stat><b>${sp.obligations.size}</b><span>obligations owed</span></div>` : ""}
|
|
1148
1234
|
</div>
|
|
1149
1235
|
<section>
|
|
1150
|
-
<h2>Members <span class=dim>(${entries.filter((e) => e.admitted).length} admitted)</span></h2><div class=wrap><table>
|
|
1151
|
-
<tr><th>member</th><th>state</th><th>may wake</th><th>online</th><th>last seen</th></tr>
|
|
1236
|
+
<h2>Members <span class=dim>(${entries.filter((e) => e.admitted).length} admitted)</span></h2><div class=wrap tabindex=0 role=region aria-label="Scrollable table"><table>
|
|
1237
|
+
<tr><th scope=col>member</th><th scope=col>state</th><th scope=col>may wake</th><th scope=col>online</th><th scope=col>last seen</th></tr>
|
|
1152
1238
|
${memberRows || "<tr><td colspan=5 class=dim>nobody yet</td></tr>"}
|
|
1153
1239
|
</table></div>
|
|
1154
|
-
${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>` : ""}
|
|
1155
1240
|
|
|
1156
|
-
<h2>
|
|
1157
|
-
<tr><th
|
|
1241
|
+
<h2>Open invites <span class=dim>(${openInvites.length} unused)</span></h2><div class=wrap tabindex=0 role=region aria-label="Scrollable table"><table>
|
|
1242
|
+
<tr><th scope=col>token</th><th scope=col>minted by</th><th scope=col>expires</th><th scope=col>may wake</th><th scope=col></th></tr>
|
|
1243
|
+
${openInvites.map((i) => `<tr><td><code>…${esc(i.token.slice(-6))}</code></td><td><code>${esc(i.created_by)}</code></td><td class=dim>${when(i.expires_at)}</td><td>${i.may_wake ? "yes" : "no"}</td><td>${revokeForm(i.token)}</td></tr>`).join("") || "<tr><td colspan=5 class=dim>none open</td></tr>"}
|
|
1244
|
+
</table></div>
|
|
1245
|
+
<p class=note>An unused invite counts toward the plan's people limit until it expires. Revoking makes the link inert immediately; members see theirs with <code>pantheon invite --list</code> and cancel with <code>--revoke</code>.</p>
|
|
1246
|
+
${repos.length ? `<h2>Repos this team works from</h2><div class=wrap tabindex=0 role=region aria-label="Scrollable table"><table><tr><th scope=col>origin</th><th scope=col>fingerprint</th><th scope=col>added by</th><th scope=col>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>` : ""}
|
|
1247
|
+
|
|
1248
|
+
<h2>Acts <span class=dim>(today · retained)</span></h2><div class=wrap tabindex=0 role=region aria-label="Scrollable table"><table id=t-acts>
|
|
1249
|
+
<tr><th scope=col></th>${MESSAGE_ACTS.map((x) => `<th scope=col>${x}</th>`).join("")}</tr>
|
|
1158
1250
|
<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>
|
|
1159
1251
|
<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>
|
|
1160
1252
|
</table></div>
|
|
1161
1253
|
|
|
1162
|
-
<h2>Refused by the relay <span class=dim>(latest ${refusals.recent.length} of ${refusals.total})</span></h2><div class=wrap><table>
|
|
1163
|
-
<tr><th>when</th><th>member</th><th>request</th><th>reason</th></tr>
|
|
1254
|
+
<h2>Refused by the relay <span class=dim>(latest ${refusals.recent.length} of ${refusals.total})</span></h2><div class=wrap tabindex=0 role=region aria-label="Scrollable table"><table>
|
|
1255
|
+
<tr><th scope=col>when</th><th scope=col>member</th><th scope=col>request</th><th scope=col>reason</th></tr>
|
|
1164
1256
|
${refusalRows || "<tr><td colspan=4 class=dim>nothing refused</td></tr>"}
|
|
1165
1257
|
</table></div>
|
|
1166
1258
|
${byCode ? `<p class=note>All time: ${byCode}</p>` : ""}
|
|
1167
1259
|
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
<select name=act><option value="">every act</option>${options}</select>
|
|
1171
|
-
<input name=member placeholder="member" value="${esc(q.member)}" size=12>
|
|
1172
|
-
<input name=goal placeholder="goal id" value="${esc(q.goal)}" size=16>
|
|
1173
|
-
<input name=q placeholder="search what was said" value="${esc(q.q)}" size=24>
|
|
1174
|
-
<label class=dim><input type=checkbox name=presence value=1${q.presence ? " checked" : ""}> show JOIN/LEAVE</label>
|
|
1175
|
-
<button class=act>filter</button>${filtered ? `<a href="/admin/space?name=${encodeURIComponent(name)}">clear</a>` : ""}
|
|
1176
|
-
</form>
|
|
1177
|
-
<div class=wrap><table class=feed>
|
|
1178
|
-
<tr><th>when</th><th>from</th><th>act</th><th>to</th><th>goal</th><th>said</th></tr>
|
|
1179
|
-
${rowsHtml || `<tr><td colspan=6 class=dim>${filtered ? "nothing matches" : "no messages yet"}</td></tr>`}
|
|
1180
|
-
</table></div>
|
|
1181
|
-
${next !== null ? `<p><a href="${link({ before: next })}">older ›</a></p>` : ""}
|
|
1182
|
-
<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>
|
|
1183
|
-
</section>`;
|
|
1260
|
+
</section>
|
|
1261
|
+
</details>`;
|
|
1184
1262
|
res.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store", "referrer-policy": "no-referrer" });
|
|
1185
1263
|
res.end(html);
|
|
1186
1264
|
}
|
|
@@ -1564,6 +1642,34 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
1564
1642
|
this.send(ws, { t: "invite", url: token, expires_at: inv.expires_at });
|
|
1565
1643
|
return;
|
|
1566
1644
|
}
|
|
1645
|
+
if (m.t === "invites") {
|
|
1646
|
+
const self = space.allow.get(me.id);
|
|
1647
|
+
if (!self?.admitted)
|
|
1648
|
+
return fail("NOT_ADMITTED", "only an admitted member can see the open invites", m.cid);
|
|
1649
|
+
const founder = space.allow.founder();
|
|
1650
|
+
const viewer = me.id;
|
|
1651
|
+
const invites = space.allow.openInvites().map((i) => {
|
|
1652
|
+
const mine = i.created_by === viewer;
|
|
1653
|
+
return { token: mine || viewer === founder ? i.token : `…${i.token.slice(-6)}`, created_by: i.created_by, created_at: i.created_at, expires_at: i.expires_at, may_wake: i.may_wake, mine };
|
|
1654
|
+
});
|
|
1655
|
+
this.send(ws, { t: "invites", invites, ...(m.cid ? { cid: m.cid } : {}) });
|
|
1656
|
+
return;
|
|
1657
|
+
}
|
|
1658
|
+
if (m.t === "revoke_invite") {
|
|
1659
|
+
const self = space.allow.get(me.id);
|
|
1660
|
+
if (!self?.admitted)
|
|
1661
|
+
return fail("NOT_ADMITTED", "only an admitted member can revoke an invite", m.cid);
|
|
1662
|
+
const inv = space.allow.findInvite(m.token.replace(/^…/, ""));
|
|
1663
|
+
if (!inv)
|
|
1664
|
+
return fail("NO_SUCH_INVITE", "no open invite matches that token — it may already be used, expired or revoked (pantheon invite --list)", m.cid);
|
|
1665
|
+
if (inv.created_by !== me.id && me.id !== space.allow.founder())
|
|
1666
|
+
return fail("NOT_YOURS", `that invite was minted by ${inv.created_by}; only they or the founder can revoke it`, m.cid);
|
|
1667
|
+
space.allow.revokeInvite(inv.token);
|
|
1668
|
+
this.log(`[${space.name}] * invite …${inv.token.slice(-6)} (minted by ${inv.created_by}) revoked by ${me.id}`);
|
|
1669
|
+
this.accounts?.audit({ event: "invite_revoked", space: space.name, target: me.id, detail: `minted by ${inv.created_by}, …${inv.token.slice(-6)}`, ip });
|
|
1670
|
+
this.send(ws, { t: "ack", ...(m.cid ? { cid: m.cid } : {}), id: inv.token.slice(-6), seq: space.ledger.head });
|
|
1671
|
+
return;
|
|
1672
|
+
}
|
|
1567
1673
|
if (m.t === "bind_repo") {
|
|
1568
1674
|
const self = space.allow.get(me.id);
|
|
1569
1675
|
if (!self?.admitted)
|
package/dist/roster-file.js
CHANGED
|
@@ -127,6 +127,25 @@ export class Allowlist {
|
|
|
127
127
|
openInvites() {
|
|
128
128
|
return [...this.invites.values()].filter((i) => !i.used_by && Date.now() < i.expires_at);
|
|
129
129
|
}
|
|
130
|
+
findInvite(token) {
|
|
131
|
+
const exact = this.invites.get(token);
|
|
132
|
+
if (exact && !exact.used_by && Date.now() < exact.expires_at)
|
|
133
|
+
return exact;
|
|
134
|
+
if (token.length >= 6) {
|
|
135
|
+
const tail = this.openInvites().filter((i) => i.token.endsWith(token));
|
|
136
|
+
if (tail.length === 1)
|
|
137
|
+
return tail[0];
|
|
138
|
+
}
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
revokeInvite(token) {
|
|
142
|
+
const inv = this.invites.get(token);
|
|
143
|
+
if (!inv || inv.used_by)
|
|
144
|
+
return false;
|
|
145
|
+
this.invites.delete(token);
|
|
146
|
+
this.flush();
|
|
147
|
+
return true;
|
|
148
|
+
}
|
|
130
149
|
admittedCount() { return [...this.entries.values()].filter((e) => e.admitted).length; }
|
|
131
150
|
founder() {
|
|
132
151
|
const f = [...this.entries.values()].filter((e) => e.admitted && (e.admitted_by === e.member || e.admitted_by === undefined)).sort((a, b) => a.first_seen - b.first_seen)[0];
|
package/dist/service.js
CHANGED
|
@@ -109,11 +109,23 @@ export function installService(kind, cwd, args) {
|
|
|
109
109
|
out.push(` stop: pantheon service uninstall ${kind}`);
|
|
110
110
|
}
|
|
111
111
|
else {
|
|
112
|
+
if (kind === "menubar" || kind === "notifier") {
|
|
113
|
+
throw new Error(`${kind === "menubar" ? "the menu bar" : "desktop alerts"} run on macOS only, and this machine reports ${platform()}`);
|
|
114
|
+
}
|
|
112
115
|
const unit = unitPath(kind);
|
|
113
116
|
mkdirSync(join(homedir(), ".config", "systemd", "user"), { recursive: true });
|
|
114
117
|
writeFileSync(unit, systemdUnit(kind, abs, args));
|
|
115
118
|
out.push(` wrote ${unit}`);
|
|
116
|
-
|
|
119
|
+
try {
|
|
120
|
+
execFileSync("systemctl", ["--user", "daemon-reload"], { stdio: "ignore" });
|
|
121
|
+
execFileSync("systemctl", ["--user", "enable", "--now", `pantheon-${kind}`], { stdio: "ignore" });
|
|
122
|
+
out.push(` enabled — the ${kind} now starts at login and restarts if it dies`);
|
|
123
|
+
out.push(` logs: journalctl --user -u pantheon-${kind} -f`);
|
|
124
|
+
out.push(` stop: pantheon service uninstall ${kind}`);
|
|
125
|
+
}
|
|
126
|
+
catch (e) {
|
|
127
|
+
throw new Error(`the ${kind} unit is written at ${unit} but could not be enabled — systemd --user is not running here (${e.message.split("\n")[0]}). When it is, run: systemctl --user enable --now pantheon-${kind} (WSL: set [boot] systemd=true in /etc/wsl.conf and restart WSL)`);
|
|
128
|
+
}
|
|
117
129
|
}
|
|
118
130
|
return out;
|
|
119
131
|
}
|
|
@@ -134,7 +146,18 @@ export function uninstallService(kind) {
|
|
|
134
146
|
out.push(` removed ${p}`);
|
|
135
147
|
}
|
|
136
148
|
else {
|
|
137
|
-
|
|
149
|
+
const unit = unitPath(kind);
|
|
150
|
+
if (!existsSync(unit))
|
|
151
|
+
return [` no ${kind} service installed`];
|
|
152
|
+
try {
|
|
153
|
+
execFileSync("systemctl", ["--user", "disable", "--now", `pantheon-${kind}`], { stdio: "ignore" });
|
|
154
|
+
out.push(" disabled and stopped");
|
|
155
|
+
}
|
|
156
|
+
catch {
|
|
157
|
+
out.push(` could not reach systemd --user — if it runs elsewhere: systemctl --user disable --now pantheon-${kind}`);
|
|
158
|
+
}
|
|
159
|
+
unlinkSync(unit);
|
|
160
|
+
out.push(` removed ${unit}`);
|
|
138
161
|
}
|
|
139
162
|
return out;
|
|
140
163
|
}
|
package/dist/setup-guide.js
CHANGED
|
@@ -77,7 +77,7 @@ export function questionnaire() {
|
|
|
77
77
|
`4. Their role, in a word (${TITLE_OPTIONS.slice(0, -1).join(", ")}, or their own). → --title \"…\"`,
|
|
78
78
|
"5. Which of these do they decide personally? Agents will stop and ask them first. Any number, or none.",
|
|
79
79
|
...DECISION_AREAS.map((x) => ` ${x.label} → ${x.id}`),
|
|
80
|
-
" pass them comma-separated: --decides deployment,pricing (or --decides none)",
|
|
80
|
+
" pass them comma-separated: --decides deployment,pricing (or --decides none) — the full list; an area not named is released",
|
|
81
81
|
"6. Which desktop alerts do they want?",
|
|
82
82
|
...ALERT_OPTIONS.map((o) => opt("alerts", o.id, o.label)),
|
|
83
83
|
"7. Should their worker follow a short written playbook for this repo? (skip if --wake no)",
|
|
@@ -216,10 +216,12 @@ export async function runGuide(io, ctx, deps = {}, preset = {}) {
|
|
|
216
216
|
}
|
|
217
217
|
let next = a.title ? applyProfileInit(profile, { name: a.name, title: a.title }) : { ...profile, human: { ...profile.human, display_name: a.name } };
|
|
218
218
|
const authority = { ...next.authority };
|
|
219
|
+
const prior = guideDefaults(profile, ctx.cfg).decides;
|
|
220
|
+
const replace = !!io || preset.decides !== undefined;
|
|
219
221
|
for (const area of DECISION_AREAS) {
|
|
220
222
|
if (a.decides.includes(area.id))
|
|
221
223
|
authority[area.id] = "human_required";
|
|
222
|
-
else if (authority[area.id] === "human_required" &&
|
|
224
|
+
else if (authority[area.id] === "human_required" && prior.includes(area.id) && replace)
|
|
223
225
|
delete authority[area.id];
|
|
224
226
|
}
|
|
225
227
|
next = {
|
package/dist/tray.js
CHANGED
|
@@ -87,6 +87,8 @@ export async function trayStatus(cfg = load()) {
|
|
|
87
87
|
...first,
|
|
88
88
|
space: ok.map((p) => p.space).join(" · "),
|
|
89
89
|
relay_ok: ok.every((p) => p.relay_ok),
|
|
90
|
+
decisions_ok: ok.every((p) => p.decisions_ok),
|
|
91
|
+
unavailable_spaces: ok.flatMap((p) => p.unavailable_spaces ?? []),
|
|
90
92
|
online: ok.reduce((n, p) => n + (p.online ?? 0), 0),
|
|
91
93
|
members: ok.reduce((n, p) => n + (p.members ?? 0), 0),
|
|
92
94
|
decisions: ok.flatMap((p) => (p.decisions ?? []).map((d) => ({ ...d, body: `[${p.space}] ${d.body}` }))),
|
|
@@ -106,8 +108,10 @@ async function trayStatusOne(cfg) {
|
|
|
106
108
|
]);
|
|
107
109
|
const space = health
|
|
108
110
|
?.spaces?.find((s) => s.name === cfg.space);
|
|
109
|
-
const
|
|
110
|
-
|
|
111
|
+
const pending = obligations?.obligations;
|
|
112
|
+
const decisionsOk = Array.isArray(pending) && pending.every((o) => o && typeof o.id === "string" && typeof o.body === "string");
|
|
113
|
+
const decisions = (decisionsOk ? pending : [])
|
|
114
|
+
.map((o) => ({ id: o.id, body: decisionTitle(o.body), detail: o.body, prompt: decisionPrompt(o.id) }));
|
|
111
115
|
const profile = loadProfile(cfg);
|
|
112
116
|
const surface = profile.agent.primary_surface;
|
|
113
117
|
const surfaceName = surface === "claude-code" ? "Claude" : surface === "cursor" ? "Cursor" : "Codex";
|
|
@@ -119,6 +123,8 @@ async function trayStatusOne(cfg) {
|
|
|
119
123
|
member: cfg.member,
|
|
120
124
|
relay: cfg.relay,
|
|
121
125
|
relay_ok: !!space,
|
|
126
|
+
decisions_ok: decisionsOk,
|
|
127
|
+
unavailable_spaces: !space || !decisionsOk ? [cfg.space] : [],
|
|
122
128
|
online: space?.online ?? 0,
|
|
123
129
|
members: space?.members ?? 0,
|
|
124
130
|
worker_running: workerProc().running,
|