@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/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 this.json(res, 400, { error: "type the space name to confirm" });
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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[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.allSessions(200);
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>${sessions.filter((s) => !s.revoked_at && Date.now() - s.last_seen <= 15 * 60_000).length}<span class=dim style="font-size:14px"> / ${c.live_sessions}</span></b><span>sessions active / open</span></div>
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=14 class=dim>no spaces yet</td></tr>"}
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=6 class=dim>no accounts yet</td></tr>"}
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=7 class=dim>no live sessions</td></tr>"}
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 (sp.createdAt >= Date.now() - 1_000)
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
- acctSession = this.accounts.openSession(auth.account.id, auth.device.id, sp.name, ip).id;
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 owner = route.required_humans.find((x) => x.member === to[0]);
1605
- if (!owner || domains.some((d) => !owner.domains.includes(d)) || route.unmatched_domains.length) {
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: ${domains.join(", ")}. Call pantheon_route_decision and route each domain to its published owner.` };
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
  }));
@@ -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));