@join-pantheon/cli 0.20.0 → 0.21.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +21 -4
- package/dist/accounts.js +136 -9
- package/dist/agents.js +4 -0
- package/dist/cli.js +155 -18
- package/dist/client.js +23 -8
- package/dist/config.js +14 -0
- package/dist/doctor.js +9 -0
- package/dist/helpers-build.js +23 -0
- package/dist/mcp.js +43 -9
- package/dist/notifier.js +29 -6
- package/dist/notify.js +1 -1
- package/dist/pending.js +3 -1
- package/dist/plans.js +46 -0
- package/dist/profile.js +5 -0
- package/dist/protocol.js +13 -1
- package/dist/quickstart.js +2 -1
- package/dist/relay.js +247 -28
- package/dist/roster-file.js +15 -2
- package/dist/service.js +14 -0
- package/dist/setup-guide.js +268 -0
- package/dist/tray.js +3 -0
- package/dist/untrusted.js +16 -3
- package/dist/upgrade.js +11 -1
- package/dist/worker.js +78 -13
- package/helper/macos/menubar.swift +47 -14
- package/package.json +3 -3
package/dist/relay.js
CHANGED
|
@@ -13,6 +13,7 @@ import { emailSenderFromEnv } from "./email.js";
|
|
|
13
13
|
import { Allowlist } from "./roster-file.js";
|
|
14
14
|
import { StateStore } from "./store.js";
|
|
15
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";
|
|
16
17
|
import { routeDecision } from "./profile.js";
|
|
17
18
|
function workState(m, now = Date.now(), busyTtlMs = LIMITS.activityBusyTtlMs, pruneMs = LIMITS.activityPruneMs) {
|
|
18
19
|
let known = false;
|
|
@@ -144,6 +145,12 @@ export class Relay {
|
|
|
144
145
|
this.adminAllow = (process.env.PANTHEON_ADMIN_ALLOW_IPS ?? "").split(",").map((x) => x.trim()).filter(Boolean);
|
|
145
146
|
const wantStore = this.requireAccounts || this.adminToken !== null;
|
|
146
147
|
this.accounts = opts.accounts ?? (wantStore ? new Accounts(join(this.dataDir, "accounts.db")) : null);
|
|
148
|
+
if (this.accounts) {
|
|
149
|
+
this.backfillSpaceOwners();
|
|
150
|
+
const closed = this.accounts.endAllOpenSessions();
|
|
151
|
+
if (closed)
|
|
152
|
+
this.log(`ended ${closed} session${closed === 1 ? "" : "s"} left open by the previous relay process`);
|
|
153
|
+
}
|
|
147
154
|
this.email = opts.emailSender ?? emailSenderFromEnv(this.log);
|
|
148
155
|
const envNum = (k) => {
|
|
149
156
|
const v = process.env[k];
|
|
@@ -810,16 +817,109 @@ button{width:100%;margin-top:12px;padding:10px;border:0;border-radius:8px;backgr
|
|
|
810
817
|
return done(`unblocked ${id}`);
|
|
811
818
|
}
|
|
812
819
|
case "delete_space": {
|
|
813
|
-
if (body.confirm !== id)
|
|
814
|
-
return
|
|
820
|
+
if ((body.confirm ?? "").trim() !== id)
|
|
821
|
+
return done(`not deleted: type the space name exactly ("${id}") to confirm`);
|
|
815
822
|
const existed = this.deleteSpace(id);
|
|
823
|
+
A.markSpaceDeleted(id);
|
|
816
824
|
A.audit({ event: "admin_space_deleted", space: id, ip });
|
|
817
825
|
this.log(`[admin] deleted space "${id}" from ${ip}`);
|
|
818
826
|
return done(existed ? `space "${id}" deleted — its members were disconnected` : `no space "${id}"`);
|
|
819
827
|
}
|
|
828
|
+
case "set_plan": {
|
|
829
|
+
const target = A.accountById(id);
|
|
830
|
+
if (!target)
|
|
831
|
+
return this.json(res, 404, { error: "no such account" });
|
|
832
|
+
const plan = (body.plan ?? "").trim();
|
|
833
|
+
if (!A.plans()[plan])
|
|
834
|
+
return this.json(res, 400, { error: `no plan "${plan}"` });
|
|
835
|
+
A.setPlan(target.id, plan);
|
|
836
|
+
A.audit({ event: "admin_set_plan", target: target.username, detail: `${target.plan} → ${plan}`, ip });
|
|
837
|
+
this.log(`[admin] ${target.username}: plan ${target.plan} → ${plan} (${ip})`);
|
|
838
|
+
return done(`${target.username} is now on the ${plan} plan — in effect for their next space, invite or admit`);
|
|
839
|
+
}
|
|
840
|
+
case "set_limits": {
|
|
841
|
+
const target = A.accountById(id);
|
|
842
|
+
if (!target)
|
|
843
|
+
return this.json(res, 404, { error: "no such account" });
|
|
844
|
+
const raw = (body.json ?? "").trim();
|
|
845
|
+
if (!A.setLimits(target.id, raw || null))
|
|
846
|
+
return this.json(res, 400, { error: 'custom limits must look like {"spaces":3,"members":5,"wake":true}' });
|
|
847
|
+
A.audit({ event: "admin_set_limits", target: target.username, detail: raw || "(cleared)", ip });
|
|
848
|
+
return done(raw ? `${target.username} now has custom limits ${raw}` : `${target.username} is back on plain ${target.plan} limits`);
|
|
849
|
+
}
|
|
850
|
+
case "set_plan_limits": {
|
|
851
|
+
if (!PLAN_NAME.test(id))
|
|
852
|
+
return this.json(res, 400, { error: "plan names are lowercase letters, digits, - and _" });
|
|
853
|
+
const lim = parseLimits({ spaces: body.spaces, members: body.members, wake: body.wake ?? "off" });
|
|
854
|
+
if (!lim)
|
|
855
|
+
return this.json(res, 400, { error: "spaces must be 0 or more and members 1 or more" });
|
|
856
|
+
const plans = A.plans();
|
|
857
|
+
const prev = plans[id];
|
|
858
|
+
plans[id] = lim;
|
|
859
|
+
A.setPlans(plans);
|
|
860
|
+
A.audit({ event: "admin_set_plan_limits", target: id, detail: `${prev ? JSON.stringify(prev) : "(new plan)"} → ${JSON.stringify(lim)}`, ip });
|
|
861
|
+
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`);
|
|
862
|
+
}
|
|
863
|
+
case "delete_plan": {
|
|
864
|
+
if (id === "free")
|
|
865
|
+
return this.json(res, 400, { error: "the free plan is every account's default and cannot be removed" });
|
|
866
|
+
const plans = A.plans();
|
|
867
|
+
if (!plans[id])
|
|
868
|
+
return this.json(res, 404, { error: `no plan "${id}"` });
|
|
869
|
+
const onIt = A.planCounts()[id] ?? 0;
|
|
870
|
+
if (onIt)
|
|
871
|
+
return this.json(res, 400, { error: `${onIt} account${onIt === 1 ? " is" : "s are"} on "${id}" — move them to another plan first` });
|
|
872
|
+
delete plans[id];
|
|
873
|
+
A.setPlans(plans);
|
|
874
|
+
A.audit({ event: "admin_delete_plan", target: id, ip });
|
|
875
|
+
return done(`plan "${id}" removed`);
|
|
876
|
+
}
|
|
820
877
|
default: return this.json(res, 400, { error: `unknown action "${act}"` });
|
|
821
878
|
}
|
|
822
879
|
}
|
|
880
|
+
spaceOnDisk(name) {
|
|
881
|
+
return existsSync(join(this.dataDir, `${name.replace(/[^a-z0-9._-]/gi, "_")}.members.json`));
|
|
882
|
+
}
|
|
883
|
+
spacePlan(name) {
|
|
884
|
+
if (!this.accounts)
|
|
885
|
+
return null;
|
|
886
|
+
const owner = this.accounts.spaceOwner(name);
|
|
887
|
+
if (!owner)
|
|
888
|
+
return null;
|
|
889
|
+
return { name: owner.plan, limits: this.accounts.limitsFor(owner), owner };
|
|
890
|
+
}
|
|
891
|
+
planView(acct, sp) {
|
|
892
|
+
const fresh = this.accounts?.accountById(acct.id) ?? acct;
|
|
893
|
+
return { name: fresh.plan, limits: this.accounts.limitsFor(fresh), usage: { spaces: this.accounts.spacesOwned(fresh.id).length, members: sp.allow.admittedCount() } };
|
|
894
|
+
}
|
|
895
|
+
planRefusal(space, plan, target, msg, cid, fail, actor, ip) {
|
|
896
|
+
this.accounts?.audit({ event: "plan_limit", actor_account: actor ?? plan.owner.id, space: space.name, target, detail: `${plan.name}: ${msg.slice(0, 160)}`, ip });
|
|
897
|
+
this.log(`[${space.name}] ! PLAN_LIMIT (${target}, ${plan.name} plan)`);
|
|
898
|
+
fail("PLAN_LIMIT", msg, cid);
|
|
899
|
+
}
|
|
900
|
+
backfillSpaceOwners() {
|
|
901
|
+
let files = [];
|
|
902
|
+
try {
|
|
903
|
+
files = readdirSync(this.dataDir);
|
|
904
|
+
}
|
|
905
|
+
catch {
|
|
906
|
+
return;
|
|
907
|
+
}
|
|
908
|
+
for (const f of files) {
|
|
909
|
+
if (!f.endsWith(".members.json"))
|
|
910
|
+
continue;
|
|
911
|
+
const name = f.slice(0, -".members.json".length);
|
|
912
|
+
if (this.accounts.hasSpaceRecord(name))
|
|
913
|
+
continue;
|
|
914
|
+
try {
|
|
915
|
+
const founder = new Allowlist(this.dataDir, name).founder();
|
|
916
|
+
const owner = founder ? this.accounts.accountByUsername(founder) : null;
|
|
917
|
+
this.accounts.recordSpace(name, owner?.id ?? null);
|
|
918
|
+
this.log(`[${name}] owner recorded: ${owner ? owner.username : "unknown (no plan limits apply)"}`);
|
|
919
|
+
}
|
|
920
|
+
catch { }
|
|
921
|
+
}
|
|
922
|
+
}
|
|
823
923
|
adminDashboard(res, A, url) {
|
|
824
924
|
const c = A.counts();
|
|
825
925
|
const esc = (s) => String(s ?? "").replace(/[&<>"]/g, (m) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[m]));
|
|
@@ -829,7 +929,8 @@ button{width:100%;margin-top:12px;padding:10px;border:0;border-radius:8px;backgr
|
|
|
829
929
|
const kb = (n) => n < 1024 ? `${n} B` : n < 1048576 ? `${(n / 1024).toFixed(1)} KB` : `${(n / 1048576).toFixed(1)} MB`;
|
|
830
930
|
const accounts = A.allAccounts();
|
|
831
931
|
const devices = A.allDevices();
|
|
832
|
-
const sessions = A.
|
|
932
|
+
const sessions = A.liveSessions(200);
|
|
933
|
+
const endedToday = A.endedSessionsSince(Date.now() - DAY);
|
|
833
934
|
const audit = A.auditLog({ limit: 300 });
|
|
834
935
|
const blocks = A.allBlocks();
|
|
835
936
|
const verifs = A.recentVerifications(50);
|
|
@@ -838,6 +939,13 @@ button{width:100%;margin-top:12px;padding:10px;border:0;border-radius:8px;backgr
|
|
|
838
939
|
const dayAgo = Date.now() - DAY;
|
|
839
940
|
const refused24 = spaces.reduce((n, x) => n + refusalsSince(x.refusals, dayAgo), 0);
|
|
840
941
|
const refusedAll = spaces.reduce((n, x) => n + (x.refusals?.total ?? 0), 0);
|
|
942
|
+
const plans = A.plans();
|
|
943
|
+
const planCounts = A.planCounts();
|
|
944
|
+
const owners = A.spaceOwners();
|
|
945
|
+
const ownedCounts = A.ownedCounts();
|
|
946
|
+
const limitHits = A.planLimitHits();
|
|
947
|
+
const acctById = new Map(accounts.map((a) => [a.id, a]));
|
|
948
|
+
const planOptions = (sel) => Object.keys(plans).map((n) => `<option value="${esc(n)}"${n === sel ? " selected" : ""}>${esc(n)}</option>`).join("");
|
|
841
949
|
const devByAcct = new Map();
|
|
842
950
|
for (const d of devices)
|
|
843
951
|
devByAcct.set(d.account_id, (devByAcct.get(d.account_id) ?? 0) + 1);
|
|
@@ -866,7 +974,8 @@ ${done ? `<div class=done>${esc(done)}</div>` : ""}
|
|
|
866
974
|
<div class=stats>
|
|
867
975
|
<div class=stat><b>${c.accounts}</b><span>accounts</span></div>
|
|
868
976
|
<div class=stat><b>${c.devices}</b><span>devices</span></div>
|
|
869
|
-
<div class=stat><b>${
|
|
977
|
+
<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>
|
|
978
|
+
<div class=stat><b>${sessions.filter((s) => Date.now() - s.last_seen <= 15 * 60_000).length}<span class=dim style="font-size:14px"> / ${c.live_sessions}</span></b><span>connections heartbeating / open</span></div>
|
|
870
979
|
<div class=stat><b>${spaces.filter((s) => s.live).length}<span class=dim style="font-size:14px"> / ${spaces.length}</span></b><span>spaces live / total · cap ${this.limits.maxSpaces}</span></div>
|
|
871
980
|
<div class="stat ${emailsHour > emailCap * 0.8 ? "warn" : ""}"><b>${emailsHour}<span class=dim style="font-size:14px"> / ${emailCap}</span></b><span>emails this hour</span></div>
|
|
872
981
|
<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>
|
|
@@ -888,24 +997,32 @@ ${done ? `<div class=done>${esc(done)}</div>` : ""}
|
|
|
888
997
|
<p class=note>Messages are acts delegates sent; JOIN/LEAVE presence is not counted. Retained is what each space's ledger still holds — the most recent ${LIMITS.ledgerKeepRecent.toLocaleString()} acts plus everything in an open goal. Refused is what the relay handed back: wrong turn, missing reference, foreign repo, rate limit. Click a space for its conversation.</p>
|
|
889
998
|
|
|
890
999
|
<h2>Spaces <input placeholder="filter" data-filter="t-spaces"></h2><div class=wrap><table id=t-spaces>
|
|
891
|
-
<tr><th>space</th><th>state</th><th>members</th><th>online</th><th>acts</th><th>msgs today</th><th>delegates today</th><th>objections</th><th>refused</th><th>goals</th><th>owed</th><th>last activity</th><th>on disk</th><th></th></tr>
|
|
892
|
-
${rows(spaces.map((s) => `<tr><td><a href="/admin/space?name=${encodeURIComponent(s.name)}" title="members, refusals and the conversation"><code>${esc(s.name)}</code></a></td><td><span class="pill ${s.live ? "ok" : ""}">${s.live ? "live" : "sleeping"}</span></td><td>${s.members}</td><td>${s.online}</td><td>${s.acts}</td><td>${s.activity.messages_24h}</td><td>${s.activity.senders_24h.length}</td><td>${s.activity.by_act_24h.OBJECT ?? 0}<span class=dim> / ${s.activity.by_act.OBJECT ?? 0}</span></td><td>${refusalsSince(s.refusals, dayAgo)}<span class=dim> / ${s.refusals?.total ?? 0}</span></td><td>${s.open_goals} open / ${s.goals}</td><td>${s.live ? s.obligations : "<span class=dim>—</span>"}</td><td class=dim title="${when(s.last)}">${ago(s.last)}</td><td class=dim>${kb(s.bytes)}</td><td>${form("delete_space", s.name, "delete", `<input type=hidden name=confirm value="">`, "danger")}</td></tr>`))}
|
|
893
|
-
${spaces.length ? "" : "<tr><td colspan=
|
|
1000
|
+
<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>
|
|
1001
|
+
${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
|
+
${spaces.length ? "" : "<tr><td colspan=15 class=dim>no spaces yet</td></tr>"}
|
|
894
1003
|
</table></div>
|
|
895
1004
|
<p class=note>Sleeping spaces are on disk but idle; they reload on the next connect. Delete is irreversible and disconnects every member of that space.</p>
|
|
896
1005
|
|
|
897
1006
|
<h2>Accounts <input placeholder="filter" data-filter="t-accounts"></h2><div class=wrap><table id=t-accounts>
|
|
898
|
-
<tr><th>username</th><th>email</th><th>status</th><th>devices</th><th>created</th><th></th></tr>
|
|
899
|
-
${rows(accounts.map((a) => `<tr><td><code>${esc(a.username)}</code></td><td>${esc(a.email)}</td><td><span class="pill ${a.status === "suspended" ? "bad" : ""}">${esc(a.status)}</span></td><td>${devByAcct.get(a.id) ?? 0}</td><td class=dim>${when(a.created_at)}</td><td>${a.status === "suspended" ? form("reinstate", a.id, "reinstate") : form("suspend", a.id, "suspend", "", "danger")} ${form("block", a.email, "block email", "", "danger")}</td></tr>`))}
|
|
900
|
-
${accounts.length ? "" : "<tr><td colspan=
|
|
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>`))}
|
|
1009
|
+
${accounts.length ? "" : "<tr><td colspan=9 class=dim>no accounts yet</td></tr>"}
|
|
901
1010
|
</table></div>
|
|
902
|
-
<p class=note>Suspending signs the account out everywhere and refuses its handshakes until reinstated. Its messages stay in the ledgers.</p>
|
|
1011
|
+
<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
|
+
|
|
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>
|
|
1017
|
+
</table></div>
|
|
1018
|
+
<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>
|
|
903
1019
|
|
|
904
1020
|
<h2>Live sessions <input placeholder="filter" data-filter="t-sessions"></h2><div class=wrap><table id=t-sessions>
|
|
905
|
-
<tr><th>session</th><th>account</th><th>space</th><th>ip</th><th>started</th><th>last seen</th><th></th></tr>
|
|
906
|
-
${rows(sessions.filter((s) => !s.revoked_at).map((s) => `<tr><td><code>${esc(s.id)}</code></td><td><code>${esc(accounts.find((a) => a.id === s.account_id)?.username ?? s.account_id)}</code></td><td>${esc(s.space) || "<span class=dim>—</span>"}</td><td class=dim>${esc(s.ip)}</td><td class=dim>${when(s.created_at)}</td><td class=dim>${ago(s.last_seen)}${Date.now() - s.last_seen > 15 * 60_000 ? ' <span class="pill" title="no heartbeat for 15+ minutes — usually a socket that died with a restart; pruned after 30 days">stale</span>' : ""}</td><td>${form("revoke_session", s.id, "sign out", "", "danger")} ${form("revoke_device", s.device_id, "remove device", "", "danger")}</td></tr>`))}
|
|
907
|
-
${sessions.some((s) => !s.revoked_at) ? "" : "<tr><td colspan=
|
|
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>
|
|
1022
|
+
${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
|
+
${sessions.some((s) => !s.revoked_at) ? "" : "<tr><td colspan=9 class=dim>no open connections right now</td></tr>"}
|
|
908
1024
|
</table></div>
|
|
1025
|
+
<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>
|
|
909
1026
|
|
|
910
1027
|
<h2>Blocked emails and domains</h2><div class=wrap><table id=t-blocks>
|
|
911
1028
|
<tr><th>pattern</th><th>kind</th><th>since</th><th></th></tr>
|
|
@@ -936,6 +1053,8 @@ ${done ? `<div class=done>${esc(done)}</div>` : ""}
|
|
|
936
1053
|
});
|
|
937
1054
|
function confirmAct(f) {
|
|
938
1055
|
var a = f.querySelector("[name=action]").value, id = f.querySelector("[name=id]").value;
|
|
1056
|
+
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.");
|
|
1057
|
+
if (a === "delete_plan") return confirm('Remove the plan "' + id + '"?');
|
|
939
1058
|
if (a === "delete_space") { var typed = prompt("Delete space \\"" + id + "\\" and disconnect its members? Type the name to confirm."); if (typed !== id) return false; f.querySelector("[name=confirm]").value = typed; return true; }
|
|
940
1059
|
if (a === "suspend" || a === "revoke_session" || a === "revoke_device" || a === "block") return confirm(a.replace("_", " ") + " " + id + "?");
|
|
941
1060
|
return true;
|
|
@@ -1136,12 +1255,21 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
1136
1255
|
onSocket(ws, ip = "unknown") {
|
|
1137
1256
|
let space = null;
|
|
1138
1257
|
let me = null;
|
|
1258
|
+
let acct = null;
|
|
1259
|
+
let dbSession = null;
|
|
1260
|
+
let lastTouch = 0;
|
|
1261
|
+
const touch = () => {
|
|
1262
|
+
if (dbSession && Date.now() - lastTouch > 60_000) {
|
|
1263
|
+
lastTouch = Date.now();
|
|
1264
|
+
this.accounts?.touchSession(dbSession);
|
|
1265
|
+
}
|
|
1266
|
+
};
|
|
1139
1267
|
let sessionId = "";
|
|
1140
1268
|
let role = "interactive";
|
|
1141
1269
|
let lastMsg = "";
|
|
1142
1270
|
let alive = true;
|
|
1143
1271
|
const nonce = newNonce();
|
|
1144
|
-
ws.on("pong", () => { alive = true; });
|
|
1272
|
+
ws.on("pong", () => { alive = true; touch(); });
|
|
1145
1273
|
const probe = ws;
|
|
1146
1274
|
probe._alive = () => alive;
|
|
1147
1275
|
probe._clear = () => { alive = false; };
|
|
@@ -1189,6 +1317,7 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
1189
1317
|
}
|
|
1190
1318
|
const m = parsed.data;
|
|
1191
1319
|
lastMsg = m.t === "act" ? m.act : m.t;
|
|
1320
|
+
touch();
|
|
1192
1321
|
if (m.t === "ping")
|
|
1193
1322
|
return this.send(ws, { t: "pong" });
|
|
1194
1323
|
if (m.t === "hello") {
|
|
@@ -1201,6 +1330,8 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
1201
1330
|
if (!verifyChallenge(m.pubkey, m.space, m.member, m.nonce, m.sig)) {
|
|
1202
1331
|
return reject("BAD_SIGNATURE", `signature does not match the presented key for "${m.member}"`);
|
|
1203
1332
|
}
|
|
1333
|
+
const gated = this.requireAccounts && !!this.accounts && !this.localTrusted(ip);
|
|
1334
|
+
const pre = gated ? this.accounts.authorize(m.pubkey, m.member) : null;
|
|
1204
1335
|
let sp;
|
|
1205
1336
|
if (m.enroll) {
|
|
1206
1337
|
sp = this.spaces.get(m.space) ?? null;
|
|
@@ -1212,16 +1343,33 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
1212
1343
|
if (tomb && Date.now() - tomb < 3_600_000) {
|
|
1213
1344
|
return reject("SPACE_DELETED", `the space "${m.space}" was removed by the relay operator. Start a new one with a different name, or contact them.`);
|
|
1214
1345
|
}
|
|
1346
|
+
const isNew = !this.spaces.has(m.space) && !this.spaceOnDisk(m.space);
|
|
1215
1347
|
if (!this.spaces.has(m.space)) {
|
|
1216
1348
|
const denied = this.mayCreateSpace(ip);
|
|
1217
1349
|
if (denied)
|
|
1218
1350
|
return reject(denied.code, denied.message);
|
|
1219
1351
|
}
|
|
1352
|
+
if (isNew && gated && !pre?.ok) {
|
|
1353
|
+
this.log(`! REFUSED ${m.member}: cannot create "${m.space}" without a signed-in account (${pre?.ok === false ? pre.reason : "no account"})`);
|
|
1354
|
+
return reject("NOT_SIGNED_IN", "sign in first: run `pantheon signup` (new) or `pantheon login` (existing account) on this machine, then try again.");
|
|
1355
|
+
}
|
|
1356
|
+
if (isNew && pre?.ok) {
|
|
1357
|
+
const lim = this.accounts.limitsFor(pre.account);
|
|
1358
|
+
const owned = this.accounts.spacesOwned(pre.account.id);
|
|
1359
|
+
if (owned.length >= lim.spaces) {
|
|
1360
|
+
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`;
|
|
1361
|
+
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 });
|
|
1362
|
+
this.log(`! PLAN_LIMIT ${m.member}: cannot create "${m.space}" (${owned.length}/${fmtLimit(lim.spaces)} spaces on ${pre.account.plan})`);
|
|
1363
|
+
return reject("PLAN_LIMIT", msg);
|
|
1364
|
+
}
|
|
1365
|
+
}
|
|
1220
1366
|
sp = this.getOrCreateSpace(m.space, m.token);
|
|
1221
1367
|
if (!sp)
|
|
1222
1368
|
return reject("BAD_TOKEN", "wrong token for this space");
|
|
1223
|
-
if (
|
|
1369
|
+
if (isNew) {
|
|
1224
1370
|
this.noteSpaceCreate(ip);
|
|
1371
|
+
this.accounts?.recordSpace(sp.name, pre?.ok ? pre.account.id : null);
|
|
1372
|
+
}
|
|
1225
1373
|
}
|
|
1226
1374
|
let acctSession = null;
|
|
1227
1375
|
if (this.requireAccounts && this.accounts && !this.localTrusted(ip)) {
|
|
@@ -1236,10 +1384,18 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
1236
1384
|
return reject("NOT_SIGNED_IN", msg);
|
|
1237
1385
|
}
|
|
1238
1386
|
m.member = auth.account.username;
|
|
1239
|
-
|
|
1387
|
+
acct = auth.account;
|
|
1388
|
+
acctSession = this.accounts.openSession(auth.account.id, auth.device.id, sp.name, ip, m.role ?? "interactive").id;
|
|
1389
|
+
dbSession = acctSession;
|
|
1390
|
+
lastTouch = Date.now();
|
|
1240
1391
|
}
|
|
1241
1392
|
let enrolled = null;
|
|
1242
1393
|
if (m.enroll) {
|
|
1394
|
+
const plan = this.spacePlan(sp.name);
|
|
1395
|
+
if (plan && !sp.allow.get(m.member)?.admitted && sp.allow.admittedCount() >= plan.limits.members) {
|
|
1396
|
+
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 });
|
|
1397
|
+
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`);
|
|
1398
|
+
}
|
|
1243
1399
|
const r = sp.allow.redeem(m.enroll, m.member, m.pubkey);
|
|
1244
1400
|
if (!r.ok) {
|
|
1245
1401
|
this.log(`[${sp.name}] ! invite refused for ${m.member}: ${r.why}`);
|
|
@@ -1294,6 +1450,7 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
1294
1450
|
roster: this.roster(sp), goals: [...sp.goals.values()],
|
|
1295
1451
|
obligations: [...sp.obligations.values()], members: this.members(sp), profiles: [...sp.profiles.values()],
|
|
1296
1452
|
space_id: sp.allow.spaceId(), repos: sp.allow.repoList(),
|
|
1453
|
+
...(acct ? { plan: this.planView(acct, sp) } : {}),
|
|
1297
1454
|
...(enrolled ? { token: enrolled } : {}),
|
|
1298
1455
|
});
|
|
1299
1456
|
for (const r of sp.ledger.since(m.from_seq ?? sp.ledger.head)) {
|
|
@@ -1337,6 +1494,13 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
1337
1494
|
const target = space.allow.get(m.member);
|
|
1338
1495
|
if (!target)
|
|
1339
1496
|
return fail("NO_SUCH_MEMBER", `"${m.member}" has never connected, so there is no key to admit`, m.cid);
|
|
1497
|
+
if (!m.revoke && !m.forget) {
|
|
1498
|
+
const plan = this.spacePlan(space.name);
|
|
1499
|
+
if (plan && m.may_wake && !plan.limits.wake)
|
|
1500
|
+
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);
|
|
1501
|
+
if (plan && !target.admitted && space.allow.admittedCount() >= plan.limits.members)
|
|
1502
|
+
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);
|
|
1503
|
+
}
|
|
1340
1504
|
if (m.revoke || m.forget) {
|
|
1341
1505
|
if (m.forget)
|
|
1342
1506
|
space.allow.forget(m.member);
|
|
@@ -1360,10 +1524,40 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
1360
1524
|
this.send(ws, { t: "ack", ...(m.cid ? { cid: m.cid } : {}), id: m.member, seq: space.ledger.head });
|
|
1361
1525
|
return;
|
|
1362
1526
|
}
|
|
1527
|
+
if (m.t === "worker_state") {
|
|
1528
|
+
if (role !== "worker")
|
|
1529
|
+
return;
|
|
1530
|
+
me.worker = { state: m.state, ...(m.detail ? { detail: m.detail } : {}), ...(m.until ? { until: m.until } : {}), at: Date.now() };
|
|
1531
|
+
this.broadcast(space, { t: "roster", roster: this.roster(space) });
|
|
1532
|
+
return;
|
|
1533
|
+
}
|
|
1534
|
+
if (m.t === "delete_space") {
|
|
1535
|
+
if (m.confirm !== space.name)
|
|
1536
|
+
return fail("CONFIRM_MISMATCH", `type the space name ("${space.name}") to confirm`, m.cid);
|
|
1537
|
+
const owner = this.accounts?.spaceOwner(space.name) ?? null;
|
|
1538
|
+
const isOwner = owner ? owner.id === acct?.id : space.allow.founder() === me.id;
|
|
1539
|
+
if (!isOwner)
|
|
1540
|
+
return fail("NOT_OWNER", `only the founder of "${space.name}" can delete it${owner ? ` (that is ${owner.username})` : ""}`, m.cid);
|
|
1541
|
+
const name = space.name;
|
|
1542
|
+
this.accounts?.audit({ event: "space_deleted", actor_account: acct?.id ?? null, space: name, detail: `by its founder ${me.id}`, ip });
|
|
1543
|
+
this.log(`[${name}] * DELETED by its founder ${me.id}`);
|
|
1544
|
+
this.send(ws, { t: "ack", ...(m.cid ? { cid: m.cid } : {}), id: name, seq: space.ledger.head });
|
|
1545
|
+
setTimeout(() => { this.deleteSpace(name); this.accounts?.markSpaceDeleted(name); }, 50).unref?.();
|
|
1546
|
+
return;
|
|
1547
|
+
}
|
|
1363
1548
|
if (m.t === "invite") {
|
|
1364
1549
|
const self = space.allow.get(me.id);
|
|
1365
1550
|
if (!self?.admitted)
|
|
1366
1551
|
return fail("NOT_ADMITTED", "only an admitted member can invite", m.cid);
|
|
1552
|
+
const plan = this.spacePlan(space.name);
|
|
1553
|
+
if (plan) {
|
|
1554
|
+
if (m.may_wake && !plan.limits.wake)
|
|
1555
|
+
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);
|
|
1556
|
+
const open = space.allow.openInvites().length;
|
|
1557
|
+
if (space.allow.admittedCount() + open >= plan.limits.members) {
|
|
1558
|
+
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);
|
|
1559
|
+
}
|
|
1560
|
+
}
|
|
1367
1561
|
const token = randomBytes(18).toString("base64url");
|
|
1368
1562
|
const inv = space.allow.mintInvite(me.id, m.may_wake, m.ttl_minutes, token);
|
|
1369
1563
|
this.log(`[${space.name}] * invite minted by ${me.id}, valid ${m.ttl_minutes}m, single use`);
|
|
@@ -1374,9 +1568,13 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
1374
1568
|
const self = space.allow.get(me.id);
|
|
1375
1569
|
if (!self?.admitted)
|
|
1376
1570
|
return fail("NOT_ADMITTED", "only an admitted member can register a repo", m.cid);
|
|
1377
|
-
const added = space.allow.addRepo(m.repo, m.origin ?? null, me.id);
|
|
1571
|
+
const added = space.allow.addRepo(m.repo, m.origin ?? null, me.id, m.base ?? null);
|
|
1572
|
+
if (!added && m.base && space.allow.setRepoBase(m.repo, m.base)) {
|
|
1573
|
+
this.log(`[${space.name}] ~ repo ${m.origin ?? m.repo} base branch → ${m.base} (by ${me.id})`);
|
|
1574
|
+
this.broadcast(space, { t: "repos", repos: space.allow.repoList() });
|
|
1575
|
+
}
|
|
1378
1576
|
if (added) {
|
|
1379
|
-
this.log(`[${space.name}] + repo ${m.origin ?? m.repo} registered by ${me.id}`);
|
|
1577
|
+
this.log(`[${space.name}] + repo ${m.origin ?? m.repo} registered by ${me.id}${m.base ? ` (base ${m.base})` : ""}`);
|
|
1380
1578
|
this.commit(space, { from: me.id, session: sessionId, role, act: "NOTE", to: null, goal: null, ref: null, body: `repo registered with this space: ${m.origin ?? m.repo}`, meta: { repo: m.repo, repo_origin: m.origin ?? null } });
|
|
1381
1579
|
this.broadcast(space, { t: "repos", repos: space.allow.repoList() });
|
|
1382
1580
|
}
|
|
@@ -1384,6 +1582,11 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
1384
1582
|
return;
|
|
1385
1583
|
}
|
|
1386
1584
|
if (m.t === "claim") {
|
|
1585
|
+
if (role === "worker") {
|
|
1586
|
+
const plan = this.spacePlan(space.name);
|
|
1587
|
+
if (plan && !plan.limits.wake)
|
|
1588
|
+
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);
|
|
1589
|
+
}
|
|
1387
1590
|
if (m.obligation.startsWith("cont:")) {
|
|
1388
1591
|
const act = space.ledger.find(m.obligation.slice(5));
|
|
1389
1592
|
if (!act)
|
|
@@ -1484,7 +1687,11 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
1484
1687
|
if (m.after_seq !== undefined) {
|
|
1485
1688
|
const mine = me.id;
|
|
1486
1689
|
const missed = space.ledger.since(m.after_seq)
|
|
1487
|
-
.filter((r) => r.from !== mine && r.act !== "JOIN" && r.act !== "LEAVE")
|
|
1690
|
+
.filter((r) => r.from !== mine && r.act !== "JOIN" && r.act !== "LEAVE")
|
|
1691
|
+
.filter((r) => (m.goal ? r.goal === m.goal : !r.goal)
|
|
1692
|
+
|| (r.to?.includes(mine) ?? false)
|
|
1693
|
+
|| (m.to?.includes(r.from) ?? false)
|
|
1694
|
+
|| (!!m.ref && r.ref === m.ref));
|
|
1488
1695
|
if (missed.length) {
|
|
1489
1696
|
return fail("STALE_VIEW", `${missed.length} act(s) arrived while you were composing — read them before you answer:\n` +
|
|
1490
1697
|
missed.slice(0, 8).map((r) => ` [${r.seq}] ${r.act} from ${r.from}: ${first(r.body)}`).join("\n") +
|
|
@@ -1512,6 +1719,10 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
1512
1719
|
this.send(ws, { t: "ack", ...(m.cid ? { cid: m.cid } : {}), id: rec.id, seq: rec.seq });
|
|
1513
1720
|
});
|
|
1514
1721
|
ws.on("close", () => {
|
|
1722
|
+
if (dbSession) {
|
|
1723
|
+
this.accounts?.endSession(dbSession);
|
|
1724
|
+
dbSession = null;
|
|
1725
|
+
}
|
|
1515
1726
|
if (!space || !me)
|
|
1516
1727
|
return;
|
|
1517
1728
|
me.sessions.delete(sessionId);
|
|
@@ -1592,19 +1803,22 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
1592
1803
|
message: "HUMAN_REQUIRED must name decision_domains so the relay can verify the human owner. Call pantheon_route_decision first." };
|
|
1593
1804
|
}
|
|
1594
1805
|
const target = sp.profiles.get(to[0]);
|
|
1595
|
-
if (!target) {
|
|
1596
|
-
return { code: "NO_AUTHORITY_PROFILE",
|
|
1597
|
-
message: `${to[0]} has not published an organization profile, so their decision authority cannot be verified.` };
|
|
1598
|
-
}
|
|
1599
1806
|
const route = routeDecision(sp.profiles.values(), { domains });
|
|
1600
1807
|
if (route.conflicts.length) {
|
|
1601
1808
|
return { code: "AMBIGUOUS_DECISION_AUTHORITY",
|
|
1602
1809
|
message: `authority conflicts for ${route.conflicts.map((c) => `${c.domain} (${c.reason}: ${c.members.join(", ")})`).join("; ")}. Fix the profiles or ESCALATE instead of guessing.` };
|
|
1603
1810
|
}
|
|
1604
|
-
const
|
|
1605
|
-
if (
|
|
1811
|
+
const others = route.required_humans.filter((h) => h.member !== to[0] && h.domains.some((d) => domains.includes(d)));
|
|
1812
|
+
if (others.length) {
|
|
1813
|
+
const theirs = [...new Set(others.flatMap((h) => h.domains.filter((d) => domains.includes(d))))];
|
|
1606
1814
|
return { code: "WRONG_DECISION_OWNER",
|
|
1607
|
-
message: `${to[0]} is not the human-required owner for: ${
|
|
1815
|
+
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.` };
|
|
1816
|
+
}
|
|
1817
|
+
const owner = route.required_humans.find((x) => x.member === to[0]);
|
|
1818
|
+
const uncovered = domains.filter((d) => !owner?.domains.includes(d));
|
|
1819
|
+
if (uncovered.length && meta) {
|
|
1820
|
+
meta["authority"] = target ? "partly_unverified" : "unverified";
|
|
1821
|
+
meta["unverified_domains"] = uncovered;
|
|
1608
1822
|
}
|
|
1609
1823
|
const openDup = [...sp.obligations.values()].find((o) => {
|
|
1610
1824
|
if (o.kind !== "HUMAN" || o.owner !== `human:${to[0]}` || (o.goal ?? null) !== (goalId ?? null))
|
|
@@ -1735,8 +1949,11 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
1735
1949
|
const rec = sp.ledger.append({ ...partial, id: newId("a"), ts: Date.now(), space: sp.name });
|
|
1736
1950
|
sp.lastTouch = rec.ts;
|
|
1737
1951
|
const m = sp.members.get(rec.from);
|
|
1738
|
-
if (m)
|
|
1952
|
+
if (m) {
|
|
1739
1953
|
m.stamps.push(rec.ts);
|
|
1954
|
+
if (rec.act !== "JOIN" && rec.act !== "LEAVE" && (m.seen_seq ?? 0) < rec.seq - 1)
|
|
1955
|
+
m.seen_seq = rec.seq - 1;
|
|
1956
|
+
}
|
|
1740
1957
|
if (rec.goal) {
|
|
1741
1958
|
const goal = sp.goals.get(rec.goal);
|
|
1742
1959
|
if (goal) {
|
|
@@ -1744,6 +1961,7 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
1744
1961
|
const run = sp.lastActor.get(rec.goal);
|
|
1745
1962
|
sp.lastActor.set(rec.goal, run && run.member === rec.from ? { member: rec.from, run: run.run + 1 } : { member: rec.from, run: 1 });
|
|
1746
1963
|
this.applyObligations(sp, rec, goal);
|
|
1964
|
+
this.broadcast(sp, { t: "goal", goal });
|
|
1747
1965
|
if (goal.acts >= goal.budget && goal.state === "OPEN") {
|
|
1748
1966
|
goal.state = "STALLED";
|
|
1749
1967
|
this.oblige(sp, { kind: "HUMAN", owner: `human:${goal.opened_by}`, ref: rec.id, goal: goal.id,
|
|
@@ -2033,6 +2251,7 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
2033
2251
|
id: s.id, role: s.role, agent: s.agent, ...(s.pkg ? { pkg: s.pkg } : {}),
|
|
2034
2252
|
})),
|
|
2035
2253
|
...(this.work(m) ? { work: this.work(m) } : {}),
|
|
2254
|
+
...(m.worker && Date.now() - m.worker.at < 15 * 60_000 ? { worker: m.worker } : {}),
|
|
2036
2255
|
delivered_seq: m.delivered_seq,
|
|
2037
2256
|
...(m.seen_seq !== undefined ? { seen_seq: m.seen_seq } : {}),
|
|
2038
2257
|
}));
|
package/dist/roster-file.js
CHANGED
|
@@ -51,10 +51,18 @@ export class Allowlist {
|
|
|
51
51
|
repoList() { return [...this.repos.values()]; }
|
|
52
52
|
repoCount() { return this.repos.size; }
|
|
53
53
|
hasRepo(fingerprint) { return this.repos.has(fingerprint); }
|
|
54
|
-
addRepo(fingerprint, origin, by) {
|
|
54
|
+
addRepo(fingerprint, origin, by, base = null) {
|
|
55
55
|
if (this.repos.has(fingerprint))
|
|
56
56
|
return false;
|
|
57
|
-
this.repos.set(fingerprint, { fingerprint, origin, added_by: by, at: Date.now() });
|
|
57
|
+
this.repos.set(fingerprint, { fingerprint, origin, added_by: by, at: Date.now(), ...(base ? { base } : {}) });
|
|
58
|
+
this.flush();
|
|
59
|
+
return true;
|
|
60
|
+
}
|
|
61
|
+
setRepoBase(fingerprint, base) {
|
|
62
|
+
const r = this.repos.get(fingerprint);
|
|
63
|
+
if (!r || r.base === base)
|
|
64
|
+
return false;
|
|
65
|
+
this.repos.set(fingerprint, { ...r, base });
|
|
58
66
|
this.flush();
|
|
59
67
|
return true;
|
|
60
68
|
}
|
|
@@ -119,6 +127,11 @@ export class Allowlist {
|
|
|
119
127
|
openInvites() {
|
|
120
128
|
return [...this.invites.values()].filter((i) => !i.used_by && Date.now() < i.expires_at);
|
|
121
129
|
}
|
|
130
|
+
admittedCount() { return [...this.entries.values()].filter((e) => e.admitted).length; }
|
|
131
|
+
founder() {
|
|
132
|
+
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];
|
|
133
|
+
return f?.member ?? null;
|
|
134
|
+
}
|
|
122
135
|
get(member) {
|
|
123
136
|
return this.entries.get(member);
|
|
124
137
|
}
|
package/dist/service.js
CHANGED
|
@@ -138,6 +138,20 @@ export function uninstallService(kind) {
|
|
|
138
138
|
}
|
|
139
139
|
return out;
|
|
140
140
|
}
|
|
141
|
+
export function restartService(kind, exec = (c, a) => execFileSync(c, a, { stdio: "ignore" })) {
|
|
142
|
+
if (!serviceInstalled(kind))
|
|
143
|
+
return [` ${kind}: not installed here`];
|
|
144
|
+
try {
|
|
145
|
+
if (platform() === "darwin")
|
|
146
|
+
exec("launchctl", ["kickstart", "-k", `gui/${process.getuid?.() ?? 501}/${LABEL(kind)}`]);
|
|
147
|
+
else
|
|
148
|
+
exec("systemctl", ["--user", "restart", `pantheon-${kind}`]);
|
|
149
|
+
return [` ${kind}: restarted on the new code`];
|
|
150
|
+
}
|
|
151
|
+
catch (e) {
|
|
152
|
+
return [` ${kind}: could not restart (${e.message.split("\n")[0]}) — pantheon service restart ${kind}`];
|
|
153
|
+
}
|
|
154
|
+
}
|
|
141
155
|
export function serviceInstalled(kind) {
|
|
142
156
|
try {
|
|
143
157
|
return existsSync(platform() === "darwin" ? plistPath(kind) : unitPath(kind));
|