@join-pantheon/cli 0.20.0 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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,8 @@ 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();
147
150
  this.email = opts.emailSender ?? emailSenderFromEnv(this.log);
148
151
  const envNum = (k) => {
149
152
  const v = process.env[k];
@@ -810,16 +813,109 @@ button{width:100%;margin-top:12px;padding:10px;border:0;border-radius:8px;backgr
810
813
  return done(`unblocked ${id}`);
811
814
  }
812
815
  case "delete_space": {
813
- if (body.confirm !== id)
814
- return this.json(res, 400, { error: "type the space name to confirm" });
816
+ if ((body.confirm ?? "").trim() !== id)
817
+ return done(`not deleted: type the space name exactly ("${id}") to confirm`);
815
818
  const existed = this.deleteSpace(id);
819
+ A.markSpaceDeleted(id);
816
820
  A.audit({ event: "admin_space_deleted", space: id, ip });
817
821
  this.log(`[admin] deleted space "${id}" from ${ip}`);
818
822
  return done(existed ? `space "${id}" deleted — its members were disconnected` : `no space "${id}"`);
819
823
  }
824
+ case "set_plan": {
825
+ const target = A.accountById(id);
826
+ if (!target)
827
+ return this.json(res, 404, { error: "no such account" });
828
+ const plan = (body.plan ?? "").trim();
829
+ if (!A.plans()[plan])
830
+ return this.json(res, 400, { error: `no plan "${plan}"` });
831
+ A.setPlan(target.id, plan);
832
+ A.audit({ event: "admin_set_plan", target: target.username, detail: `${target.plan} → ${plan}`, ip });
833
+ this.log(`[admin] ${target.username}: plan ${target.plan} → ${plan} (${ip})`);
834
+ return done(`${target.username} is now on the ${plan} plan — in effect for their next space, invite or admit`);
835
+ }
836
+ case "set_limits": {
837
+ const target = A.accountById(id);
838
+ if (!target)
839
+ return this.json(res, 404, { error: "no such account" });
840
+ const raw = (body.json ?? "").trim();
841
+ if (!A.setLimits(target.id, raw || null))
842
+ return this.json(res, 400, { error: 'custom limits must look like {"spaces":3,"members":5,"wake":true}' });
843
+ A.audit({ event: "admin_set_limits", target: target.username, detail: raw || "(cleared)", ip });
844
+ return done(raw ? `${target.username} now has custom limits ${raw}` : `${target.username} is back on plain ${target.plan} limits`);
845
+ }
846
+ case "set_plan_limits": {
847
+ if (!PLAN_NAME.test(id))
848
+ return this.json(res, 400, { error: "plan names are lowercase letters, digits, - and _" });
849
+ const lim = parseLimits({ spaces: body.spaces, members: body.members, wake: body.wake ?? "off" });
850
+ if (!lim)
851
+ return this.json(res, 400, { error: "spaces must be 0 or more and members 1 or more" });
852
+ const plans = A.plans();
853
+ const prev = plans[id];
854
+ plans[id] = lim;
855
+ A.setPlans(plans);
856
+ A.audit({ event: "admin_set_plan_limits", target: id, detail: `${prev ? JSON.stringify(prev) : "(new plan)"} → ${JSON.stringify(lim)}`, ip });
857
+ return done(`${id} plan: ${fmtLimit(lim.spaces)} spaces · ${fmtLimit(lim.members)} people · unattended workers ${lim.wake ? "on" : "off"} — applies to the next create, invite or admit`);
858
+ }
859
+ case "delete_plan": {
860
+ if (id === "free")
861
+ return this.json(res, 400, { error: "the free plan is every account's default and cannot be removed" });
862
+ const plans = A.plans();
863
+ if (!plans[id])
864
+ return this.json(res, 404, { error: `no plan "${id}"` });
865
+ const onIt = A.planCounts()[id] ?? 0;
866
+ if (onIt)
867
+ return this.json(res, 400, { error: `${onIt} account${onIt === 1 ? " is" : "s are"} on "${id}" — move them to another plan first` });
868
+ delete plans[id];
869
+ A.setPlans(plans);
870
+ A.audit({ event: "admin_delete_plan", target: id, ip });
871
+ return done(`plan "${id}" removed`);
872
+ }
820
873
  default: return this.json(res, 400, { error: `unknown action "${act}"` });
821
874
  }
822
875
  }
876
+ spaceOnDisk(name) {
877
+ return existsSync(join(this.dataDir, `${name.replace(/[^a-z0-9._-]/gi, "_")}.members.json`));
878
+ }
879
+ spacePlan(name) {
880
+ if (!this.accounts)
881
+ return null;
882
+ const owner = this.accounts.spaceOwner(name);
883
+ if (!owner)
884
+ return null;
885
+ return { name: owner.plan, limits: this.accounts.limitsFor(owner), owner };
886
+ }
887
+ planView(acct, sp) {
888
+ const fresh = this.accounts?.accountById(acct.id) ?? acct;
889
+ return { name: fresh.plan, limits: this.accounts.limitsFor(fresh), usage: { spaces: this.accounts.spacesOwned(fresh.id).length, members: sp.allow.admittedCount() } };
890
+ }
891
+ planRefusal(space, plan, target, msg, cid, fail, actor, ip) {
892
+ this.accounts?.audit({ event: "plan_limit", actor_account: actor ?? plan.owner.id, space: space.name, target, detail: `${plan.name}: ${msg.slice(0, 160)}`, ip });
893
+ this.log(`[${space.name}] ! PLAN_LIMIT (${target}, ${plan.name} plan)`);
894
+ fail("PLAN_LIMIT", msg, cid);
895
+ }
896
+ backfillSpaceOwners() {
897
+ let files = [];
898
+ try {
899
+ files = readdirSync(this.dataDir);
900
+ }
901
+ catch {
902
+ return;
903
+ }
904
+ for (const f of files) {
905
+ if (!f.endsWith(".members.json"))
906
+ continue;
907
+ const name = f.slice(0, -".members.json".length);
908
+ if (this.accounts.hasSpaceRecord(name))
909
+ continue;
910
+ try {
911
+ const founder = new Allowlist(this.dataDir, name).founder();
912
+ const owner = founder ? this.accounts.accountByUsername(founder) : null;
913
+ this.accounts.recordSpace(name, owner?.id ?? null);
914
+ this.log(`[${name}] owner recorded: ${owner ? owner.username : "unknown (no plan limits apply)"}`);
915
+ }
916
+ catch { }
917
+ }
918
+ }
823
919
  adminDashboard(res, A, url) {
824
920
  const c = A.counts();
825
921
  const esc = (s) => String(s ?? "").replace(/[&<>"]/g, (m) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[m]));
@@ -838,6 +934,13 @@ button{width:100%;margin-top:12px;padding:10px;border:0;border-radius:8px;backgr
838
934
  const dayAgo = Date.now() - DAY;
839
935
  const refused24 = spaces.reduce((n, x) => n + refusalsSince(x.refusals, dayAgo), 0);
840
936
  const refusedAll = spaces.reduce((n, x) => n + (x.refusals?.total ?? 0), 0);
937
+ const plans = A.plans();
938
+ const planCounts = A.planCounts();
939
+ const owners = A.spaceOwners();
940
+ const ownedCounts = A.ownedCounts();
941
+ const limitHits = A.planLimitHits();
942
+ const acctById = new Map(accounts.map((a) => [a.id, a]));
943
+ const planOptions = (sel) => Object.keys(plans).map((n) => `<option value="${esc(n)}"${n === sel ? " selected" : ""}>${esc(n)}</option>`).join("");
841
944
  const devByAcct = new Map();
842
945
  for (const d of devices)
843
946
  devByAcct.set(d.account_id, (devByAcct.get(d.account_id) ?? 0) + 1);
@@ -866,6 +969,7 @@ ${done ? `<div class=done>${esc(done)}</div>` : ""}
866
969
  <div class=stats>
867
970
  <div class=stat><b>${c.accounts}</b><span>accounts</span></div>
868
971
  <div class=stat><b>${c.devices}</b><span>devices</span></div>
972
+ <div class=stat><b>${Object.entries(planCounts).filter(([n]) => n !== "free").reduce((s, [, v]) => s + v, 0)}<span class=dim style="font-size:14px"> / ${c.accounts}</span></b><span>paid plans / accounts</span></div>
869
973
  <div class=stat><b>${sessions.filter((s) => !s.revoked_at && Date.now() - s.last_seen <= 15 * 60_000).length}<span class=dim style="font-size:14px"> / ${c.live_sessions}</span></b><span>sessions active / open</span></div>
870
974
  <div class=stat><b>${spaces.filter((s) => s.live).length}<span class=dim style="font-size:14px"> / ${spaces.length}</span></b><span>spaces live / total · cap ${this.limits.maxSpaces}</span></div>
871
975
  <div class="stat ${emailsHour > emailCap * 0.8 ? "warn" : ""}"><b>${emailsHour}<span class=dim style="font-size:14px"> / ${emailCap}</span></b><span>emails this hour</span></div>
@@ -888,18 +992,25 @@ ${done ? `<div class=done>${esc(done)}</div>` : ""}
888
992
  <p class=note>Messages are acts delegates sent; JOIN/LEAVE presence is not counted. Retained is what each space's ledger still holds — the most recent ${LIMITS.ledgerKeepRecent.toLocaleString()} acts plus everything in an open goal. Refused is what the relay handed back: wrong turn, missing reference, foreign repo, rate limit. Click a space for its conversation.</p>
889
993
 
890
994
  <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>"}
995
+ <tr><th>space</th><th>state</th><th>owner</th><th>members</th><th>online</th><th>acts</th><th>msgs today</th><th>delegates today</th><th>objections</th><th>refused</th><th>goals</th><th>owed</th><th>last activity</th><th>on disk</th><th></th></tr>
996
+ ${rows(spaces.map((s) => `<tr><td><a href="/admin/space?name=${encodeURIComponent(s.name)}" title="members, refusals and the conversation"><code>${esc(s.name)}</code></a></td><td><span class="pill ${s.live ? "ok" : ""}">${s.live ? "live" : "sleeping"}</span></td><td>${(() => { const o = owners.get(s.name) ? acctById.get(owners.get(s.name)) : undefined; return o ? `<code>${esc(o.username)}</code> <span class=pill data-plan="${esc(o.plan)}">${esc(o.plan)}</span>` : "<span class=dim title=\"no owner on record — no plan limits apply\">—</span>"; })()}</td><td>${s.members}</td><td>${s.online}</td><td>${s.acts}</td><td>${s.activity.messages_24h}</td><td>${s.activity.senders_24h.length}</td><td>${s.activity.by_act_24h.OBJECT ?? 0}<span class=dim> / ${s.activity.by_act.OBJECT ?? 0}</span></td><td>${refusalsSince(s.refusals, dayAgo)}<span class=dim> / ${s.refusals?.total ?? 0}</span></td><td>${s.open_goals} open / ${s.goals}</td><td>${s.live ? s.obligations : "<span class=dim>—</span>"}</td><td class=dim title="${when(s.last)}">${ago(s.last)}</td><td class=dim>${kb(s.bytes)}</td><td>${form("delete_space", s.name, "delete", `<input type=hidden name=confirm value="">`, "danger")}</td></tr>`))}
997
+ ${spaces.length ? "" : "<tr><td colspan=15 class=dim>no spaces yet</td></tr>"}
894
998
  </table></div>
895
999
  <p class=note>Sleeping spaces are on disk but idle; they reload on the next connect. Delete is irreversible and disconnects every member of that space.</p>
896
1000
 
897
1001
  <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>"}
1002
+ <tr><th>username</th><th>email</th><th>status</th><th>plan</th><th>spaces</th><th>limit hits</th><th>devices</th><th>created</th><th></th></tr>
1003
+ ${rows(accounts.map((a) => `<tr><td><code>${esc(a.username)}</code></td><td>${esc(a.email)}</td><td><span class="pill ${a.status === "suspended" ? "bad" : ""}">${esc(a.status)}</span></td><td><span class="pill ${a.plan === "free" ? "" : "ok"}" data-plan="${esc(a.plan)}">${esc(a.plan)}</span>${a.limits_json ? ' <span class="pill warn" title="custom limits">custom</span>' : ""} <form method=POST action="/admin/action" class=inl onsubmit="return confirmAct(this)"><input type=hidden name=csrf value="${csrf}"><input type=hidden name=action value=set_plan><input type=hidden name=id value="${esc(a.id)}"><select name=plan class=act style="margin-left:4px">${planOptions(a.plan)}</select><button class=act>set</button></form><details class=inl style="margin-left:6px"><summary class="act dim" style="display:inline;list-style:none;cursor:pointer">custom…</summary><form method=POST action="/admin/action" class=inl><input type=hidden name=csrf value="${csrf}"><input type=hidden name=action value=set_limits><input type=hidden name=id value="${esc(a.id)}"><input name=json placeholder='{"spaces":3,"members":5,"wake":true}' value="${esc(a.limits_json ?? "")}" size=34 style="font:12px ui-monospace,monospace;padding:3px 6px;border:1px solid var(--line);border-radius:6px;background:var(--card);color:var(--fg)"><button class=act>save</button></form></details></td><td>${ownedCounts.get(a.id) ?? 0}<span class=dim> / ${fmtLimit(A.limitsFor(a).spaces)}</span></td><td>${limitHits.get(a.id) ? `<span class="pill warn">${limitHits.get(a.id)}</span>` : "<span class=dim>0</span>"}</td><td>${devByAcct.get(a.id) ?? 0}</td><td class=dim>${when(a.created_at)}</td><td>${a.status === "suspended" ? form("reinstate", a.id, "reinstate") : form("suspend", a.id, "suspend", "", "danger")} ${form("block", a.email, "block email", "", "danger")}</td></tr>`))}
1004
+ ${accounts.length ? "" : "<tr><td colspan=9 class=dim>no accounts yet</td></tr>"}
1005
+ </table></div>
1006
+ <p class=note>Suspending signs the account out everywhere and refuses its handshakes until reinstated. Its messages stay in the ledgers. A plan change takes effect at the account's next space, invite or admit; nothing they already have is removed. "Limit hits" counts the times the relay refused them for a plan limit — the people to talk to about upgrading.</p>
1007
+
1008
+ <h2>Plans <span class=dim>(limits the relay enforces; the owner's plan governs a space)</span></h2><div class=wrap><table id=t-plans>
1009
+ <tr><th>plan</th><th>accounts</th><th>spaces created</th><th>people per space</th><th>unattended workers</th><th></th></tr>
1010
+ ${rows(Object.entries(plans).map(([n, l]) => { const fid = `plan-${esc(n)}`; return `<tr><td><form id="${fid}" method=POST action="/admin/action" class=inl><input type=hidden name=csrf value="${csrf}"><input type=hidden name=action value=set_plan_limits><input type=hidden name=id value="${esc(n)}"></form><code>${esc(n)}</code></td><td>${planCounts[n] ?? 0}</td><td><input form="${fid}" name=spaces type=number min=0 value="${l.spaces}" class=act style="width:90px"></td><td><input form="${fid}" name=members type=number min=1 value="${l.members}" class=act style="width:90px"></td><td><label><input form="${fid}" type=checkbox name=wake value=on${l.wake ? " checked" : ""}> allowed</label></td><td><button form="${fid}" class=act>save</button> ${n === "free" ? "" : form("delete_plan", n, "remove", "", "danger")}</td></tr>`; }))}
1011
+ <tr><td><form id="plan-new" method=POST action="/admin/action" class=inl><input type=hidden name=csrf value="${csrf}"><input type=hidden name=action value=set_plan_limits></form><input form="plan-new" name=id placeholder="new plan name" pattern="[a-z][a-z0-9_-]{0,23}" required class=act style="width:130px"></td><td class=dim>—</td><td><input form="plan-new" name=spaces type=number min=0 value=3 class=act style="width:90px"></td><td><input form="plan-new" name=members type=number min=1 value=5 class=act style="width:90px"></td><td><label><input form="plan-new" type=checkbox name=wake value=on checked> allowed</label></td><td><button form="plan-new" class=act>add plan</button></td></tr>
901
1012
  </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>
1013
+ <p class=note>Edits apply immediately to the next create, invite or admit of every account on that plan. Unused invites count toward "people per space" so nobody mints five at once. Personas are files on the user's machine and cannot be limited here. Every change is audited.</p>
903
1014
 
904
1015
  <h2>Live sessions <input placeholder="filter" data-filter="t-sessions"></h2><div class=wrap><table id=t-sessions>
905
1016
  <tr><th>session</th><th>account</th><th>space</th><th>ip</th><th>started</th><th>last seen</th><th></th></tr>
@@ -936,6 +1047,8 @@ ${done ? `<div class=done>${esc(done)}</div>` : ""}
936
1047
  });
937
1048
  function confirmAct(f) {
938
1049
  var a = f.querySelector("[name=action]").value, id = f.querySelector("[name=id]").value;
1050
+ if (a === "set_plan") return confirm("Move " + f.closest("tr").querySelector("code").textContent + " to the " + f.querySelector("[name=plan]").value + " plan? Takes effect on their next space, invite or admit.");
1051
+ if (a === "delete_plan") return confirm('Remove the plan "' + id + '"?');
939
1052
  if (a === "delete_space") { var typed = prompt("Delete space \\"" + id + "\\" and disconnect its members? Type the name to confirm."); if (typed !== id) return false; f.querySelector("[name=confirm]").value = typed; return true; }
940
1053
  if (a === "suspend" || a === "revoke_session" || a === "revoke_device" || a === "block") return confirm(a.replace("_", " ") + " " + id + "?");
941
1054
  return true;
@@ -1136,6 +1249,7 @@ pantheon signup --email you@work.com --username you</code></pre>
1136
1249
  onSocket(ws, ip = "unknown") {
1137
1250
  let space = null;
1138
1251
  let me = null;
1252
+ let acct = null;
1139
1253
  let sessionId = "";
1140
1254
  let role = "interactive";
1141
1255
  let lastMsg = "";
@@ -1201,6 +1315,8 @@ pantheon signup --email you@work.com --username you</code></pre>
1201
1315
  if (!verifyChallenge(m.pubkey, m.space, m.member, m.nonce, m.sig)) {
1202
1316
  return reject("BAD_SIGNATURE", `signature does not match the presented key for "${m.member}"`);
1203
1317
  }
1318
+ const gated = this.requireAccounts && !!this.accounts && !this.localTrusted(ip);
1319
+ const pre = gated ? this.accounts.authorize(m.pubkey, m.member) : null;
1204
1320
  let sp;
1205
1321
  if (m.enroll) {
1206
1322
  sp = this.spaces.get(m.space) ?? null;
@@ -1212,16 +1328,33 @@ pantheon signup --email you@work.com --username you</code></pre>
1212
1328
  if (tomb && Date.now() - tomb < 3_600_000) {
1213
1329
  return reject("SPACE_DELETED", `the space "${m.space}" was removed by the relay operator. Start a new one with a different name, or contact them.`);
1214
1330
  }
1331
+ const isNew = !this.spaces.has(m.space) && !this.spaceOnDisk(m.space);
1215
1332
  if (!this.spaces.has(m.space)) {
1216
1333
  const denied = this.mayCreateSpace(ip);
1217
1334
  if (denied)
1218
1335
  return reject(denied.code, denied.message);
1219
1336
  }
1337
+ if (isNew && gated && !pre?.ok) {
1338
+ this.log(`! REFUSED ${m.member}: cannot create "${m.space}" without a signed-in account (${pre?.ok === false ? pre.reason : "no account"})`);
1339
+ return reject("NOT_SIGNED_IN", "sign in first: run `pantheon signup` (new) or `pantheon login` (existing account) on this machine, then try again.");
1340
+ }
1341
+ if (isNew && pre?.ok) {
1342
+ const lim = this.accounts.limitsFor(pre.account);
1343
+ const owned = this.accounts.spacesOwned(pre.account.id);
1344
+ if (owned.length >= lim.spaces) {
1345
+ const msg = `your ${pre.account.plan} plan allows ${fmtLimit(lim.spaces)} space${lim.spaces === 1 ? "" : "s"} you create${owned.length ? ` (you own ${owned.map((n) => `"${n}"`).join(", ")})` : ""}. Upgrade at https://www.joinpantheon.network, or free a slot: pantheon space delete <team> --yes`;
1346
+ this.accounts.audit({ event: "plan_limit", actor_account: pre.account.id, space: m.space, target: "create_space", detail: `${pre.account.plan}: ${owned.length}/${lim.spaces} spaces`, ip });
1347
+ this.log(`! PLAN_LIMIT ${m.member}: cannot create "${m.space}" (${owned.length}/${fmtLimit(lim.spaces)} spaces on ${pre.account.plan})`);
1348
+ return reject("PLAN_LIMIT", msg);
1349
+ }
1350
+ }
1220
1351
  sp = this.getOrCreateSpace(m.space, m.token);
1221
1352
  if (!sp)
1222
1353
  return reject("BAD_TOKEN", "wrong token for this space");
1223
- if (sp.createdAt >= Date.now() - 1_000)
1354
+ if (isNew) {
1224
1355
  this.noteSpaceCreate(ip);
1356
+ this.accounts?.recordSpace(sp.name, pre?.ok ? pre.account.id : null);
1357
+ }
1225
1358
  }
1226
1359
  let acctSession = null;
1227
1360
  if (this.requireAccounts && this.accounts && !this.localTrusted(ip)) {
@@ -1236,10 +1369,16 @@ pantheon signup --email you@work.com --username you</code></pre>
1236
1369
  return reject("NOT_SIGNED_IN", msg);
1237
1370
  }
1238
1371
  m.member = auth.account.username;
1372
+ acct = auth.account;
1239
1373
  acctSession = this.accounts.openSession(auth.account.id, auth.device.id, sp.name, ip).id;
1240
1374
  }
1241
1375
  let enrolled = null;
1242
1376
  if (m.enroll) {
1377
+ const plan = this.spacePlan(sp.name);
1378
+ if (plan && !sp.allow.get(m.member)?.admitted && sp.allow.admittedCount() >= plan.limits.members) {
1379
+ this.accounts?.audit({ event: "plan_limit", actor_account: acct?.id ?? null, space: sp.name, target: "join", detail: `${plan.name}: ${sp.allow.admittedCount()}/${plan.limits.members} people`, ip });
1380
+ return reject("PLAN_LIMIT", `"${sp.name}" is full: its owner's ${plan.name} plan allows ${fmtLimit(plan.limits.members)} people. Ask ${plan.owner.username} to upgrade at https://www.joinpantheon.network`);
1381
+ }
1243
1382
  const r = sp.allow.redeem(m.enroll, m.member, m.pubkey);
1244
1383
  if (!r.ok) {
1245
1384
  this.log(`[${sp.name}] ! invite refused for ${m.member}: ${r.why}`);
@@ -1294,6 +1433,7 @@ pantheon signup --email you@work.com --username you</code></pre>
1294
1433
  roster: this.roster(sp), goals: [...sp.goals.values()],
1295
1434
  obligations: [...sp.obligations.values()], members: this.members(sp), profiles: [...sp.profiles.values()],
1296
1435
  space_id: sp.allow.spaceId(), repos: sp.allow.repoList(),
1436
+ ...(acct ? { plan: this.planView(acct, sp) } : {}),
1297
1437
  ...(enrolled ? { token: enrolled } : {}),
1298
1438
  });
1299
1439
  for (const r of sp.ledger.since(m.from_seq ?? sp.ledger.head)) {
@@ -1337,6 +1477,13 @@ pantheon signup --email you@work.com --username you</code></pre>
1337
1477
  const target = space.allow.get(m.member);
1338
1478
  if (!target)
1339
1479
  return fail("NO_SUCH_MEMBER", `"${m.member}" has never connected, so there is no key to admit`, m.cid);
1480
+ if (!m.revoke && !m.forget) {
1481
+ const plan = this.spacePlan(space.name);
1482
+ if (plan && m.may_wake && !plan.limits.wake)
1483
+ return this.planRefusal(space, plan, "admit", `the ${plan.name} plan does not include unattended workers — admit without --may-wake`, m.cid, fail, acct?.id ?? null, ip);
1484
+ if (plan && !target.admitted && space.allow.admittedCount() >= plan.limits.members)
1485
+ return this.planRefusal(space, plan, "admit", `"${space.name}" is full for the ${plan.name} plan (${fmtLimit(plan.limits.members)} people). Upgrade at https://www.joinpantheon.network to admit more.`, m.cid, fail, acct?.id ?? null, ip);
1486
+ }
1340
1487
  if (m.revoke || m.forget) {
1341
1488
  if (m.forget)
1342
1489
  space.allow.forget(m.member);
@@ -1360,10 +1507,40 @@ pantheon signup --email you@work.com --username you</code></pre>
1360
1507
  this.send(ws, { t: "ack", ...(m.cid ? { cid: m.cid } : {}), id: m.member, seq: space.ledger.head });
1361
1508
  return;
1362
1509
  }
1510
+ if (m.t === "worker_state") {
1511
+ if (role !== "worker")
1512
+ return;
1513
+ me.worker = { state: m.state, ...(m.detail ? { detail: m.detail } : {}), ...(m.until ? { until: m.until } : {}), at: Date.now() };
1514
+ this.broadcast(space, { t: "roster", roster: this.roster(space) });
1515
+ return;
1516
+ }
1517
+ if (m.t === "delete_space") {
1518
+ if (m.confirm !== space.name)
1519
+ return fail("CONFIRM_MISMATCH", `type the space name ("${space.name}") to confirm`, m.cid);
1520
+ const owner = this.accounts?.spaceOwner(space.name) ?? null;
1521
+ const isOwner = owner ? owner.id === acct?.id : space.allow.founder() === me.id;
1522
+ if (!isOwner)
1523
+ return fail("NOT_OWNER", `only the founder of "${space.name}" can delete it${owner ? ` (that is ${owner.username})` : ""}`, m.cid);
1524
+ const name = space.name;
1525
+ this.accounts?.audit({ event: "space_deleted", actor_account: acct?.id ?? null, space: name, detail: `by its founder ${me.id}`, ip });
1526
+ this.log(`[${name}] * DELETED by its founder ${me.id}`);
1527
+ this.send(ws, { t: "ack", ...(m.cid ? { cid: m.cid } : {}), id: name, seq: space.ledger.head });
1528
+ setTimeout(() => { this.deleteSpace(name); this.accounts?.markSpaceDeleted(name); }, 50).unref?.();
1529
+ return;
1530
+ }
1363
1531
  if (m.t === "invite") {
1364
1532
  const self = space.allow.get(me.id);
1365
1533
  if (!self?.admitted)
1366
1534
  return fail("NOT_ADMITTED", "only an admitted member can invite", m.cid);
1535
+ const plan = this.spacePlan(space.name);
1536
+ if (plan) {
1537
+ if (m.may_wake && !plan.limits.wake)
1538
+ return this.planRefusal(space, plan, "invite", `the ${plan.name} plan does not include unattended workers — invite without wake: pantheon invite --no-wake`, m.cid, fail, acct?.id ?? null, ip);
1539
+ const open = space.allow.openInvites().length;
1540
+ if (space.allow.admittedCount() + open >= plan.limits.members) {
1541
+ return this.planRefusal(space, plan, "invite", `"${space.name}" is full for the ${plan.name} plan: ${fmtLimit(plan.limits.members)} people${open ? `, counting ${open} unused invite${open === 1 ? "" : "s"}` : ""}. Upgrade at https://www.joinpantheon.network to invite more.`, m.cid, fail, acct?.id ?? null, ip);
1542
+ }
1543
+ }
1367
1544
  const token = randomBytes(18).toString("base64url");
1368
1545
  const inv = space.allow.mintInvite(me.id, m.may_wake, m.ttl_minutes, token);
1369
1546
  this.log(`[${space.name}] * invite minted by ${me.id}, valid ${m.ttl_minutes}m, single use`);
@@ -1374,9 +1551,13 @@ pantheon signup --email you@work.com --username you</code></pre>
1374
1551
  const self = space.allow.get(me.id);
1375
1552
  if (!self?.admitted)
1376
1553
  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);
1554
+ const added = space.allow.addRepo(m.repo, m.origin ?? null, me.id, m.base ?? null);
1555
+ if (!added && m.base && space.allow.setRepoBase(m.repo, m.base)) {
1556
+ this.log(`[${space.name}] ~ repo ${m.origin ?? m.repo} base branch → ${m.base} (by ${me.id})`);
1557
+ this.broadcast(space, { t: "repos", repos: space.allow.repoList() });
1558
+ }
1378
1559
  if (added) {
1379
- this.log(`[${space.name}] + repo ${m.origin ?? m.repo} registered by ${me.id}`);
1560
+ this.log(`[${space.name}] + repo ${m.origin ?? m.repo} registered by ${me.id}${m.base ? ` (base ${m.base})` : ""}`);
1380
1561
  this.commit(space, { from: me.id, session: sessionId, role, act: "NOTE", to: null, goal: null, ref: null, body: `repo registered with this space: ${m.origin ?? m.repo}`, meta: { repo: m.repo, repo_origin: m.origin ?? null } });
1381
1562
  this.broadcast(space, { t: "repos", repos: space.allow.repoList() });
1382
1563
  }
@@ -1384,6 +1565,11 @@ pantheon signup --email you@work.com --username you</code></pre>
1384
1565
  return;
1385
1566
  }
1386
1567
  if (m.t === "claim") {
1568
+ if (role === "worker") {
1569
+ const plan = this.spacePlan(space.name);
1570
+ if (plan && !plan.limits.wake)
1571
+ return this.planRefusal(space, plan, "wake", `unattended workers are not included in this space's ${plan.name} plan`, m.cid, fail, acct?.id ?? null, ip);
1572
+ }
1387
1573
  if (m.obligation.startsWith("cont:")) {
1388
1574
  const act = space.ledger.find(m.obligation.slice(5));
1389
1575
  if (!act)
@@ -1484,7 +1670,11 @@ pantheon signup --email you@work.com --username you</code></pre>
1484
1670
  if (m.after_seq !== undefined) {
1485
1671
  const mine = me.id;
1486
1672
  const missed = space.ledger.since(m.after_seq)
1487
- .filter((r) => r.from !== mine && r.act !== "JOIN" && r.act !== "LEAVE");
1673
+ .filter((r) => r.from !== mine && r.act !== "JOIN" && r.act !== "LEAVE")
1674
+ .filter((r) => (m.goal ? r.goal === m.goal : !r.goal)
1675
+ || (r.to?.includes(mine) ?? false)
1676
+ || (m.to?.includes(r.from) ?? false)
1677
+ || (!!m.ref && r.ref === m.ref));
1488
1678
  if (missed.length) {
1489
1679
  return fail("STALE_VIEW", `${missed.length} act(s) arrived while you were composing — read them before you answer:\n` +
1490
1680
  missed.slice(0, 8).map((r) => ` [${r.seq}] ${r.act} from ${r.from}: ${first(r.body)}`).join("\n") +
@@ -1592,19 +1782,22 @@ pantheon signup --email you@work.com --username you</code></pre>
1592
1782
  message: "HUMAN_REQUIRED must name decision_domains so the relay can verify the human owner. Call pantheon_route_decision first." };
1593
1783
  }
1594
1784
  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
1785
  const route = routeDecision(sp.profiles.values(), { domains });
1600
1786
  if (route.conflicts.length) {
1601
1787
  return { code: "AMBIGUOUS_DECISION_AUTHORITY",
1602
1788
  message: `authority conflicts for ${route.conflicts.map((c) => `${c.domain} (${c.reason}: ${c.members.join(", ")})`).join("; ")}. Fix the profiles or ESCALATE instead of guessing.` };
1603
1789
  }
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) {
1790
+ const others = route.required_humans.filter((h) => h.member !== to[0] && h.domains.some((d) => domains.includes(d)));
1791
+ if (others.length) {
1792
+ const theirs = [...new Set(others.flatMap((h) => h.domains.filter((d) => domains.includes(d))))];
1606
1793
  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.` };
1794
+ message: `${to[0]} is not the human-required owner for: ${theirs.join(", ")} — ${others.map((h) => h.member).join(", ")} ${others.length === 1 ? "is" : "are"}. Call pantheon_route_decision and route each domain to its published owner.` };
1795
+ }
1796
+ const owner = route.required_humans.find((x) => x.member === to[0]);
1797
+ const uncovered = domains.filter((d) => !owner?.domains.includes(d));
1798
+ if (uncovered.length && meta) {
1799
+ meta["authority"] = target ? "partly_unverified" : "unverified";
1800
+ meta["unverified_domains"] = uncovered;
1608
1801
  }
1609
1802
  const openDup = [...sp.obligations.values()].find((o) => {
1610
1803
  if (o.kind !== "HUMAN" || o.owner !== `human:${to[0]}` || (o.goal ?? null) !== (goalId ?? null))
@@ -1735,8 +1928,11 @@ pantheon signup --email you@work.com --username you</code></pre>
1735
1928
  const rec = sp.ledger.append({ ...partial, id: newId("a"), ts: Date.now(), space: sp.name });
1736
1929
  sp.lastTouch = rec.ts;
1737
1930
  const m = sp.members.get(rec.from);
1738
- if (m)
1931
+ if (m) {
1739
1932
  m.stamps.push(rec.ts);
1933
+ if (rec.act !== "JOIN" && rec.act !== "LEAVE" && (m.seen_seq ?? 0) < rec.seq - 1)
1934
+ m.seen_seq = rec.seq - 1;
1935
+ }
1740
1936
  if (rec.goal) {
1741
1937
  const goal = sp.goals.get(rec.goal);
1742
1938
  if (goal) {
@@ -1744,6 +1940,7 @@ pantheon signup --email you@work.com --username you</code></pre>
1744
1940
  const run = sp.lastActor.get(rec.goal);
1745
1941
  sp.lastActor.set(rec.goal, run && run.member === rec.from ? { member: rec.from, run: run.run + 1 } : { member: rec.from, run: 1 });
1746
1942
  this.applyObligations(sp, rec, goal);
1943
+ this.broadcast(sp, { t: "goal", goal });
1747
1944
  if (goal.acts >= goal.budget && goal.state === "OPEN") {
1748
1945
  goal.state = "STALLED";
1749
1946
  this.oblige(sp, { kind: "HUMAN", owner: `human:${goal.opened_by}`, ref: rec.id, goal: goal.id,
@@ -2033,6 +2230,7 @@ pantheon signup --email you@work.com --username you</code></pre>
2033
2230
  id: s.id, role: s.role, agent: s.agent, ...(s.pkg ? { pkg: s.pkg } : {}),
2034
2231
  })),
2035
2232
  ...(this.work(m) ? { work: this.work(m) } : {}),
2233
+ ...(m.worker && Date.now() - m.worker.at < 15 * 60_000 ? { worker: m.worker } : {}),
2036
2234
  delivered_seq: m.delivered_seq,
2037
2235
  ...(m.seen_seq !== undefined ? { seen_seq: m.seen_seq } : {}),
2038
2236
  }));
@@ -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
  }