@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/README.md +14 -1
- package/dist/accounts.js +106 -1
- package/dist/agents.js +4 -0
- package/dist/cli.js +85 -10
- package/dist/client.js +23 -8
- package/dist/config.js +14 -0
- package/dist/doctor.js +9 -0
- package/dist/mcp.js +43 -9
- package/dist/notifier.js +26 -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 +219 -21
- package/dist/roster-file.js +15 -2
- package/dist/setup-guide.js +238 -0
- package/dist/untrusted.js +16 -3
- package/dist/worker.js +78 -13
- package/package.json +2 -2
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
|
|
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) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[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=
|
|
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=
|
|
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>
|
|
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 (
|
|
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
|
|
1605
|
-
if (
|
|
1790
|
+
const others = route.required_humans.filter((h) => h.member !== to[0] && h.domains.some((d) => domains.includes(d)));
|
|
1791
|
+
if (others.length) {
|
|
1792
|
+
const theirs = [...new Set(others.flatMap((h) => h.domains.filter((d) => domains.includes(d))))];
|
|
1606
1793
|
return { code: "WRONG_DECISION_OWNER",
|
|
1607
|
-
message: `${to[0]} is not the human-required owner for: ${
|
|
1794
|
+
message: `${to[0]} is not the human-required owner for: ${theirs.join(", ")} — ${others.map((h) => h.member).join(", ")} ${others.length === 1 ? "is" : "are"}. Call pantheon_route_decision and route each domain to its published owner.` };
|
|
1795
|
+
}
|
|
1796
|
+
const owner = route.required_humans.find((x) => x.member === to[0]);
|
|
1797
|
+
const uncovered = domains.filter((d) => !owner?.domains.includes(d));
|
|
1798
|
+
if (uncovered.length && meta) {
|
|
1799
|
+
meta["authority"] = target ? "partly_unverified" : "unverified";
|
|
1800
|
+
meta["unverified_domains"] = uncovered;
|
|
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
|
}));
|
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
|
}
|