@join-pantheon/cli 0.28.0 → 0.29.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/accounts.js +13 -2
- package/dist/agents.js +377 -9
- package/dist/cli.js +377 -192
- package/dist/client.js +73 -12
- package/dist/config.js +15 -0
- package/dist/doctor.js +13 -10
- package/dist/grants.js +16 -1
- package/dist/hooks-install.js +4 -2
- package/dist/host-env.js +51 -6
- package/dist/install.js +216 -16
- package/dist/kiro-files.js +23 -6
- package/dist/ledger.js +8 -2
- package/dist/machine.js +273 -54
- package/dist/mcp-supervisor.js +5 -0
- package/dist/mcp.js +33 -6
- package/dist/network.js +258 -57
- package/dist/pending.js +14 -7
- package/dist/quickstart.js +180 -21
- package/dist/refusal.js +29 -0
- package/dist/relay.js +686 -440
- package/dist/roster-file.js +19 -3
- package/dist/run-failure.js +18 -7
- package/dist/service.js +173 -40
- package/dist/setup-guide.js +21 -4
- package/dist/store.js +9 -3
- package/dist/which.js +66 -4
- package/dist/workdir.js +7 -1
- package/dist/worker.js +522 -101
- package/helper/macos/menubar.swift +11 -3
- package/package.json +1 -1
package/dist/relay.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createServer } from "node:http";
|
|
2
2
|
import { createHash, randomBytes, timingSafeEqual as timingSafeEqualBuf } from "node:crypto";
|
|
3
|
-
import { rmSync, readdirSync, readFileSync, statSync, existsSync } from "node:fs";
|
|
3
|
+
import { rmSync, readdirSync, readFileSync, statSync, existsSync, writeFileSync } from "node:fs";
|
|
4
4
|
import { spawn } from "node:child_process";
|
|
5
5
|
import { join } from "node:path";
|
|
6
6
|
import { WebSocketServer } from "ws";
|
|
@@ -72,8 +72,25 @@ export function isTrustedSource(ip) {
|
|
|
72
72
|
return true;
|
|
73
73
|
return false;
|
|
74
74
|
}
|
|
75
|
-
function fingerprint_(from, act, body, goal, ref) {
|
|
76
|
-
return createHash("sha256").update([from, act, goal ?? "", ref ?? "", body].join("\u0000")).digest("hex").slice(0, 32);
|
|
75
|
+
function fingerprint_(from, act, body, goal, ref, extra = null) {
|
|
76
|
+
return createHash("sha256").update([from, act, goal ?? "", ref ?? "", body, stableJson(extra)].join("\u0000")).digest("hex").slice(0, 32);
|
|
77
|
+
}
|
|
78
|
+
function stableJson(v) {
|
|
79
|
+
if (v === null || typeof v !== "object")
|
|
80
|
+
return JSON.stringify(v ?? null);
|
|
81
|
+
if (Array.isArray(v))
|
|
82
|
+
return `[${v.map(stableJson).join(",")}]`;
|
|
83
|
+
return `{${Object.keys(v).sort().map((k) => `${JSON.stringify(k)}:${stableJson(v[k])}`).join(",")}}`;
|
|
84
|
+
}
|
|
85
|
+
function clientIp(req) {
|
|
86
|
+
const raw = req.headers["x-forwarded-for"];
|
|
87
|
+
const hops = (Array.isArray(raw) ? raw.join(",") : raw ?? "").split(",").map((x) => x.trim()).filter(Boolean);
|
|
88
|
+
const trusted = Math.max(1, Number(process.env.PANTHEON_TRUSTED_HOPS ?? 1) || 1);
|
|
89
|
+
return hops.length ? hops[Math.max(0, hops.length - trusted)] : req.socket.remoteAddress || "unknown";
|
|
90
|
+
}
|
|
91
|
+
function secretEq(a, b) {
|
|
92
|
+
const x = createHash("sha256").update(a).digest(), y = createHash("sha256").update(b).digest();
|
|
93
|
+
return timingSafeEqualBuf(x, y);
|
|
77
94
|
}
|
|
78
95
|
const ADMIN_CSS = `<style>
|
|
79
96
|
:root{--bg:#0b0c0e;--card:#14161a;--card2:#1b1e24;--line:#262a30;--fg:#e7e9ec;--dim:#9aa1a9;--accent:#5b9dff;--warn:#e8b45c;--bad:#ef8b80;--ok:#7bd39a;color-scheme:dark}
|
|
@@ -137,6 +154,9 @@ const fmtAgo = (t) => {
|
|
|
137
154
|
const m = Math.round((Date.now() - t) / 60_000);
|
|
138
155
|
return m < 1 ? "just now" : m < 60 ? `${m}m ago` : m < 1440 ? `${Math.round(m / 60)}h ago` : `${Math.round(m / 1440)}d ago`;
|
|
139
156
|
};
|
|
157
|
+
const safeName = (name) => name.replace(/[^a-z0-9._-]/gi, "_");
|
|
158
|
+
const LIMITS_GOAL_READ = 1000;
|
|
159
|
+
const LIMITS_SPACE = { metaBytes: 8192, openGoals: 500, repos: 100, openInvites: 50 };
|
|
140
160
|
export class Relay {
|
|
141
161
|
opts;
|
|
142
162
|
spaces = new Map();
|
|
@@ -197,8 +217,7 @@ export class Relay {
|
|
|
197
217
|
this.busyTtlMs = opts.activityBusyTtlMs ?? LIMITS.activityBusyTtlMs;
|
|
198
218
|
this.pruneMs = opts.activityPruneMs ?? LIMITS.activityPruneMs;
|
|
199
219
|
this.http.on("upgrade", (req, socket, head) => {
|
|
200
|
-
const
|
|
201
|
-
const ip = fwd || req.socket.remoteAddress || "unknown";
|
|
220
|
+
const ip = clientIp(req);
|
|
202
221
|
this.wss.handleUpgrade(req, socket, head, (ws) => this.onSocket(ws, ip));
|
|
203
222
|
});
|
|
204
223
|
}
|
|
@@ -234,8 +253,7 @@ export class Relay {
|
|
|
234
253
|
}
|
|
235
254
|
onHttp(req, res) {
|
|
236
255
|
const url = new URL(req.url ?? "/", "http://x");
|
|
237
|
-
const ip = (req
|
|
238
|
-
|| req.socket.remoteAddress || "unknown";
|
|
256
|
+
const ip = clientIp(req);
|
|
239
257
|
if (url.pathname === "/" || url.pathname.startsWith("/auth/") || url.pathname.startsWith("/account/")
|
|
240
258
|
|| url.pathname === "/audit" || url.pathname.startsWith("/admin")) {
|
|
241
259
|
return this.onAuthHttp(req, res, url, ip);
|
|
@@ -264,6 +282,8 @@ export class Relay {
|
|
|
264
282
|
});
|
|
265
283
|
}
|
|
266
284
|
if (url.pathname === "/join") {
|
|
285
|
+
if (!this.authRateOk(ip))
|
|
286
|
+
return this.json(res, 429, { error: "too many lookups from your network — wait a minute" });
|
|
267
287
|
const token = (url.searchParams.get("token") ?? "").trim();
|
|
268
288
|
if (!/^[A-Za-z0-9_-]{16,64}$/.test(token))
|
|
269
289
|
return this.json(res, 400, { error: "an invite code is required: /join?token=<code>" });
|
|
@@ -273,9 +293,9 @@ export class Relay {
|
|
|
273
293
|
: this.json(res, 404, { error: "no open invite matches that code — it may have been used already or expired" });
|
|
274
294
|
}
|
|
275
295
|
if (url.pathname === "/act") {
|
|
276
|
-
const space = this.
|
|
296
|
+
const space = this.spaceForRead(url.searchParams.get("space") ?? "", url.searchParams.get("token") ?? "");
|
|
277
297
|
const token = url.searchParams.get("token") ?? "";
|
|
278
|
-
if (!space || space.token
|
|
298
|
+
if (!space || !secretEq(space.token, token))
|
|
279
299
|
return this.json(res, 404, { error: "no such space" });
|
|
280
300
|
const act = space.ledger.find(url.searchParams.get("id") ?? "");
|
|
281
301
|
if (!act)
|
|
@@ -284,19 +304,20 @@ export class Relay {
|
|
|
284
304
|
return this.json(res, 200, { act, ...(goal ? { goal: { id: goal.id, title: goal.title, state: goal.state } } : {}) });
|
|
285
305
|
}
|
|
286
306
|
if (url.pathname === "/history") {
|
|
287
|
-
const space = this.
|
|
307
|
+
const space = this.spaceForRead(url.searchParams.get("space") ?? "", url.searchParams.get("token") ?? "");
|
|
288
308
|
const token = url.searchParams.get("token") ?? "";
|
|
289
|
-
if (!space || space.token
|
|
309
|
+
if (!space || !secretEq(space.token, token))
|
|
290
310
|
return this.json(res, 404, { error: "no such space" });
|
|
291
311
|
const goal = url.searchParams.get("goal");
|
|
292
|
-
const
|
|
293
|
-
const acts = goal ? space.ledger.byGoal(goal
|
|
312
|
+
const sinceRaw = url.searchParams.get("since");
|
|
313
|
+
const acts = goal ? space.ledger.byGoal(goal, LIMITS_GOAL_READ)
|
|
314
|
+
: sinceRaw !== null ? space.ledger.since(Number(sinceRaw) || 0) : space.ledger.latest();
|
|
294
315
|
return this.json(res, 200, { acts, head: space.ledger.head });
|
|
295
316
|
}
|
|
296
317
|
if (url.pathname === "/obligations") {
|
|
297
|
-
const space = this.
|
|
318
|
+
const space = this.spaceForRead(url.searchParams.get("space") ?? "", url.searchParams.get("token") ?? "");
|
|
298
319
|
const token = url.searchParams.get("token") ?? "";
|
|
299
|
-
if (!space || space.token
|
|
320
|
+
if (!space || !secretEq(space.token, token))
|
|
300
321
|
return this.json(res, 404, { error: "no such space" });
|
|
301
322
|
const member = url.searchParams.get("member");
|
|
302
323
|
const now = Date.now();
|
|
@@ -312,9 +333,9 @@ export class Relay {
|
|
|
312
333
|
if (url.pathname === "/activity") {
|
|
313
334
|
if (req.method !== "POST")
|
|
314
335
|
return this.json(res, 405, { error: "POST only" });
|
|
315
|
-
const space = this.
|
|
336
|
+
const space = this.spaceForRead(url.searchParams.get("space") ?? "", url.searchParams.get("token") ?? "");
|
|
316
337
|
const token = url.searchParams.get("token") ?? "";
|
|
317
|
-
if (!space || space.token
|
|
338
|
+
if (!space || !secretEq(space.token, token))
|
|
318
339
|
return this.json(res, 404, { error: "no such space" });
|
|
319
340
|
const memberId = url.searchParams.get("member") ?? "";
|
|
320
341
|
const member = space.members.get(memberId);
|
|
@@ -461,9 +482,10 @@ export class Relay {
|
|
|
461
482
|
if (started.reason === "blocked")
|
|
462
483
|
return this.json(res, 403, { error: "this email cannot sign up on this relay", reason: "blocked" });
|
|
463
484
|
const msg = started.reason === "too_soon" ? "a code was just sent — check your email, or wait a minute to resend"
|
|
464
|
-
: started.reason === "
|
|
465
|
-
: started.reason === "
|
|
466
|
-
: "
|
|
485
|
+
: started.reason === "in_progress_elsewhere" ? "a code for this email is already on its way to another device — use that one, or wait 10 minutes for it to expire"
|
|
486
|
+
: started.reason === "username_taken" ? "that username is taken"
|
|
487
|
+
: started.reason === "email_taken" ? "that email already has an account — use `pantheon login` from this machine instead"
|
|
488
|
+
: "check the email and username";
|
|
467
489
|
return this.json(res, 409, { error: msg, reason: started.reason });
|
|
468
490
|
}
|
|
469
491
|
A.audit({ event: "signup", target: normalizeUsername(b.username), ip });
|
|
@@ -485,8 +507,8 @@ export class Relay {
|
|
|
485
507
|
if (!started.ok && started.reason === "blocked")
|
|
486
508
|
return this.json(res, 403, { error: "this email cannot sign in on this relay", reason: "blocked" });
|
|
487
509
|
if (!started.ok) {
|
|
488
|
-
const code = started.reason === "too_soon" ? 429 : 404;
|
|
489
|
-
const msg = started.reason === "too_soon" ? "a code was just sent — check your email, or wait a minute" : "no active account for that email — `pantheon signup` first";
|
|
510
|
+
const code = started.reason === "too_soon" || started.reason === "in_progress_elsewhere" ? 429 : 404;
|
|
511
|
+
const msg = started.reason === "too_soon" ? "a code was just sent — check your email, or wait a minute" : started.reason === "in_progress_elsewhere" ? "a code for this email is already on its way to another device — use that one, or wait 10 minutes for it to expire" : "no active account for that email — `pantheon signup` first";
|
|
490
512
|
return this.json(res, code, { error: msg, reason: started.reason });
|
|
491
513
|
}
|
|
492
514
|
if (!this.emailBudgetOk())
|
|
@@ -503,12 +525,14 @@ export class Relay {
|
|
|
503
525
|
return this.readJson(req, (b) => {
|
|
504
526
|
if (!b || typeof b.email !== "string" || typeof b.code !== "string")
|
|
505
527
|
return this.json(res, 400, { error: "email and code are required" });
|
|
506
|
-
const r = A.verify(b.email, b.code.trim(), ip);
|
|
528
|
+
const r = A.verify(b.email, b.code.trim(), ip, typeof b.pubkey === "string" ? b.pubkey : undefined);
|
|
507
529
|
if (!r.ok) {
|
|
508
530
|
const msg = r.reason === "expired" ? "that code has expired — request a new one"
|
|
509
|
-
: r.reason === "
|
|
510
|
-
: r.reason === "
|
|
511
|
-
: "
|
|
531
|
+
: r.reason === "other_device" ? "that code was requested from a different device — request a new one from this one"
|
|
532
|
+
: r.reason === "suspended" ? "this account is suspended — contact the relay operator"
|
|
533
|
+
: r.reason === "too_many_attempts" ? "too many wrong tries — request a new code"
|
|
534
|
+
: r.reason === "no_pending" ? "no code is pending for that email — run signup or login first"
|
|
535
|
+
: "that code is not right";
|
|
512
536
|
return this.json(res, 400, { error: msg, reason: r.reason });
|
|
513
537
|
}
|
|
514
538
|
return this.json(res, 200, { ok: true, account: { id: r.account.id, username: r.account.username, email: r.account.email }, session: r.session.id, device: r.device.id });
|
|
@@ -900,6 +924,11 @@ button:hover{background:#1d4ed8}.err{color:var(--bad);margin-top:12px}
|
|
|
900
924
|
}
|
|
901
925
|
setTimeout(() => this.deleteSpaceFiles(name), 600).unref?.();
|
|
902
926
|
const safe = name.replace(/[^a-z0-9._-]/gi, "_");
|
|
927
|
+
try {
|
|
928
|
+
const hash = sp?.allow ? JSON.parse(readFileSync(join(this.dataDir, `${safe}.members.json`), "utf8")).tokenHash ?? null : null;
|
|
929
|
+
writeFileSync(join(this.dataDir, `${safe}.deleted.json`), JSON.stringify({ name, at: Date.now(), tokenHash: hash }));
|
|
930
|
+
}
|
|
931
|
+
catch { }
|
|
903
932
|
const existed = !!sp || [".jsonl", ".state.json", ".members.json"].some((suf) => existsSync(join(this.dataDir, `${safe}${suf}`)));
|
|
904
933
|
this.deleteSpaceFiles(name);
|
|
905
934
|
this.deletedSpaces.set(name, Date.now());
|
|
@@ -1033,6 +1062,42 @@ button:hover{background:#1d4ed8}.err{color:var(--bad);margin-top:12px}
|
|
|
1033
1062
|
default: return this.json(res, 400, { error: `unknown action "${act}"` });
|
|
1034
1063
|
}
|
|
1035
1064
|
}
|
|
1065
|
+
spaceForRead(name, token) {
|
|
1066
|
+
const live = this.spaces.get(name);
|
|
1067
|
+
if (live)
|
|
1068
|
+
return secretEq(live.token, token) ? live : null;
|
|
1069
|
+
if (!name || !token || !this.spaceOnDisk(name))
|
|
1070
|
+
return null;
|
|
1071
|
+
if (new Allowlist(this.dataDir, safeName(name)).tokenMatches(token) !== true)
|
|
1072
|
+
return null;
|
|
1073
|
+
return this.getOrCreateSpace(name, token);
|
|
1074
|
+
}
|
|
1075
|
+
tombstone(name, _token) {
|
|
1076
|
+
try {
|
|
1077
|
+
return JSON.parse(readFileSync(join(this.dataDir, `${safeName(name)}.deleted.json`), "utf8")).at;
|
|
1078
|
+
}
|
|
1079
|
+
catch {
|
|
1080
|
+
return null;
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
tombstoneMatches(name, token) {
|
|
1084
|
+
try {
|
|
1085
|
+
const t = JSON.parse(readFileSync(join(this.dataDir, `${safeName(name)}.deleted.json`), "utf8"));
|
|
1086
|
+
return !!t.tokenHash && !!token && createHash("sha256").update(token).digest("hex") === t.tokenHash;
|
|
1087
|
+
}
|
|
1088
|
+
catch {
|
|
1089
|
+
return false;
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
nameCollides(name) {
|
|
1093
|
+
const stem = safeName(name).toLowerCase();
|
|
1094
|
+
try {
|
|
1095
|
+
return readdirSync(this.dataDir).some((f) => f.endsWith(".members.json") && f.slice(0, -".members.json".length).toLowerCase() === stem);
|
|
1096
|
+
}
|
|
1097
|
+
catch {
|
|
1098
|
+
return false;
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1036
1101
|
spaceOnDisk(name) {
|
|
1037
1102
|
return existsSync(join(this.dataDir, `${name.replace(/[^a-z0-9._-]/gi, "_")}.members.json`));
|
|
1038
1103
|
}
|
|
@@ -1549,7 +1614,7 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
1549
1614
|
const fail = (code, message, cid) => {
|
|
1550
1615
|
if (cid && space && me && !space.deleted) {
|
|
1551
1616
|
noteRefusal(space.store.refusals, { ts: Date.now(), member: me.id, act: lastMsg || "?", code });
|
|
1552
|
-
space.store.
|
|
1617
|
+
space.store.flush(space.goals, space.obligations, space.profiles);
|
|
1553
1618
|
}
|
|
1554
1619
|
this.send(ws, { t: "error", code, message, ...(cid ? { cid } : {}) });
|
|
1555
1620
|
};
|
|
@@ -1575,453 +1640,546 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
1575
1640
|
ws.on("close", clearHelloTimer);
|
|
1576
1641
|
this.send(ws, { t: "challenge", nonce, v: PROTOCOL_VERSION });
|
|
1577
1642
|
ws.on("message", (raw) => {
|
|
1578
|
-
let msg;
|
|
1579
1643
|
try {
|
|
1580
|
-
msg
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
return fail("BAD_JSON", "message was not JSON");
|
|
1584
|
-
}
|
|
1585
|
-
const parsed = Inbound.safeParse(msg);
|
|
1586
|
-
if (!parsed.success) {
|
|
1587
|
-
const cid = typeof msg?.cid === "string"
|
|
1588
|
-
? msg.cid : undefined;
|
|
1589
|
-
return fail("BAD_MESSAGE", parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; "), cid);
|
|
1590
|
-
}
|
|
1591
|
-
const m = parsed.data;
|
|
1592
|
-
lastMsg = m.t === "act" ? m.act : m.t;
|
|
1593
|
-
touch();
|
|
1594
|
-
if (m.t === "ping")
|
|
1595
|
-
return this.send(ws, { t: "pong" });
|
|
1596
|
-
if (m.t === "hello") {
|
|
1597
|
-
if (space)
|
|
1598
|
-
return reject("ALREADY_JOINED", "this socket already said hello");
|
|
1599
|
-
if (m.nonce !== nonce)
|
|
1600
|
-
return reject("BAD_NONCE", "challenge mismatch — reconnect");
|
|
1601
|
-
if (!m.token && !m.enroll)
|
|
1602
|
-
return reject("NO_CREDENTIAL", "present either a space token or an invite");
|
|
1603
|
-
if (!verifyChallenge(m.pubkey, m.space, m.member, m.nonce, m.sig)) {
|
|
1604
|
-
return reject("BAD_SIGNATURE", `signature does not match the presented key for "${m.member}"`);
|
|
1644
|
+
let msg;
|
|
1645
|
+
try {
|
|
1646
|
+
msg = JSON.parse(String(raw));
|
|
1605
1647
|
}
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
let sp;
|
|
1609
|
-
if (m.enroll) {
|
|
1610
|
-
sp = this.spaces.get(m.space) ?? null;
|
|
1611
|
-
if (!sp)
|
|
1612
|
-
return reject("NO_SUCH_SPACE", `no space "${m.space}" on this relay`);
|
|
1648
|
+
catch {
|
|
1649
|
+
return fail("BAD_JSON", "message was not JSON");
|
|
1613
1650
|
}
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1651
|
+
const parsed = Inbound.safeParse(msg);
|
|
1652
|
+
if (!parsed.success) {
|
|
1653
|
+
const cid = typeof msg?.cid === "string"
|
|
1654
|
+
? msg.cid : undefined;
|
|
1655
|
+
return fail("BAD_MESSAGE", parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; "), cid);
|
|
1656
|
+
}
|
|
1657
|
+
const m = parsed.data;
|
|
1658
|
+
lastMsg = m.t === "act" ? m.act : m.t;
|
|
1659
|
+
touch();
|
|
1660
|
+
if (m.t === "ping")
|
|
1661
|
+
return this.send(ws, { t: "pong" });
|
|
1662
|
+
if (m.t === "hello") {
|
|
1663
|
+
if (space)
|
|
1664
|
+
return reject("ALREADY_JOINED", "this socket already said hello");
|
|
1665
|
+
if (m.nonce !== nonce)
|
|
1666
|
+
return reject("BAD_NONCE", "challenge mismatch — reconnect");
|
|
1667
|
+
if (!m.token && !m.enroll)
|
|
1668
|
+
return reject("NO_CREDENTIAL", "present either a space token or an invite");
|
|
1669
|
+
if (!verifyChallenge(m.pubkey, m.space, m.member, m.nonce, m.sig)) {
|
|
1670
|
+
return reject("BAD_SIGNATURE", `signature does not match the presented key for "${m.member}"`);
|
|
1618
1671
|
}
|
|
1619
|
-
const
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1672
|
+
const gated = this.requireAccounts && !!this.accounts && !this.localTrusted(ip);
|
|
1673
|
+
const pre = gated ? this.accounts.authorize(m.pubkey, m.member) : null;
|
|
1674
|
+
let sp;
|
|
1675
|
+
if (m.enroll) {
|
|
1676
|
+
sp = this.spaces.get(m.space) ?? null;
|
|
1677
|
+
if (!sp && this.spaceOnDisk(m.space)) {
|
|
1678
|
+
const inv = new Allowlist(this.dataDir, safeName(m.space)).findInvite(m.enroll);
|
|
1679
|
+
if (inv?.token === m.enroll && inv.space_token)
|
|
1680
|
+
sp = this.getOrCreateSpace(m.space, inv.space_token);
|
|
1681
|
+
}
|
|
1682
|
+
if (!sp)
|
|
1683
|
+
return reject("NO_SUCH_SPACE", `no space "${m.space}" on this relay`);
|
|
1624
1684
|
}
|
|
1625
|
-
|
|
1626
|
-
this.
|
|
1627
|
-
|
|
1685
|
+
else {
|
|
1686
|
+
const tomb = this.deletedSpaces.get(m.space) ?? this.tombstone(m.space, m.token ?? "");
|
|
1687
|
+
if (tomb && Date.now() - tomb < 3_600_000 || (tomb && this.tombstoneMatches(m.space, m.token ?? ""))) {
|
|
1688
|
+
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.`);
|
|
1689
|
+
}
|
|
1690
|
+
const isNew = !this.spaces.has(m.space) && !this.spaceOnDisk(m.space);
|
|
1691
|
+
if (isNew && this.nameCollides(m.space)) {
|
|
1692
|
+
return reject("NAME_TAKEN", `a team with a name too close to "${m.space}" already exists here — pick another name`);
|
|
1693
|
+
}
|
|
1694
|
+
if (!this.spaces.has(m.space)) {
|
|
1695
|
+
const denied = this.mayCreateSpace(ip);
|
|
1696
|
+
if (denied)
|
|
1697
|
+
return reject(denied.code, denied.message);
|
|
1698
|
+
}
|
|
1699
|
+
if (isNew && gated && !pre?.ok) {
|
|
1700
|
+
this.log(`! REFUSED ${m.member}: cannot create "${m.space}" without a signed-in account (${pre?.ok === false ? pre.reason : "no account"})`);
|
|
1701
|
+
return reject("NOT_SIGNED_IN", "sign in first: run `pantheon signup` (new) or `pantheon login` (existing account) on this machine, then try again.");
|
|
1702
|
+
}
|
|
1703
|
+
if (isNew && pre?.ok) {
|
|
1704
|
+
const lim = this.accounts.limitsFor(pre.account);
|
|
1705
|
+
const owned = this.accounts.spacesOwned(pre.account.id);
|
|
1706
|
+
if (owned.length >= lim.spaces) {
|
|
1707
|
+
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`;
|
|
1708
|
+
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 });
|
|
1709
|
+
this.log(`! PLAN_LIMIT ${m.member}: cannot create "${m.space}" (${owned.length}/${fmtLimit(lim.spaces)} spaces on ${pre.account.plan})`);
|
|
1710
|
+
return reject("PLAN_LIMIT", msg);
|
|
1711
|
+
}
|
|
1712
|
+
}
|
|
1713
|
+
sp = this.getOrCreateSpace(m.space, m.token);
|
|
1714
|
+
if (!sp)
|
|
1715
|
+
return reject("BAD_TOKEN", "wrong token for this space");
|
|
1716
|
+
if (isNew) {
|
|
1717
|
+
this.noteSpaceCreate(ip);
|
|
1718
|
+
this.accounts?.recordSpace(sp.name, pre?.ok ? pre.account.id : null);
|
|
1719
|
+
}
|
|
1628
1720
|
}
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
const
|
|
1632
|
-
if (
|
|
1633
|
-
const msg =
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1721
|
+
let acctSession = null;
|
|
1722
|
+
if (this.requireAccounts && this.accounts && !this.localTrusted(ip)) {
|
|
1723
|
+
const auth = this.accounts.authorize(m.pubkey, m.member);
|
|
1724
|
+
if (!auth.ok) {
|
|
1725
|
+
const msg = auth.reason === "username_mismatch"
|
|
1726
|
+
? `this device is signed in as a different username than "${m.member}". Use your own username, or \`pantheon login\` on this machine.`
|
|
1727
|
+
: auth.reason === "suspended"
|
|
1728
|
+
? `this account has been suspended by the relay operator. Contact them to have it reinstated.`
|
|
1729
|
+
: `sign in first: run \`pantheon signup\` (new) or \`pantheon login\` (existing account) on this machine, then try again.`;
|
|
1730
|
+
this.log(`[${sp.name}] ! REFUSED ${m.member}: ${auth.reason}`);
|
|
1731
|
+
return reject("NOT_SIGNED_IN", msg);
|
|
1637
1732
|
}
|
|
1733
|
+
m.member = auth.account.username;
|
|
1734
|
+
acct = auth.account;
|
|
1735
|
+
acctSession = this.accounts.openSession(auth.account.id, auth.device.id, sp.name, ip, m.role ?? "interactive").id;
|
|
1736
|
+
dbSession = acctSession;
|
|
1737
|
+
lastTouch = Date.now();
|
|
1638
1738
|
}
|
|
1639
|
-
|
|
1640
|
-
if (
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1739
|
+
let enrolled = null;
|
|
1740
|
+
if (m.enroll) {
|
|
1741
|
+
const plan = this.spacePlan(sp.name);
|
|
1742
|
+
if (plan && !sp.allow.get(m.member)?.admitted && sp.allow.admittedCount() >= plan.limits.members) {
|
|
1743
|
+
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 });
|
|
1744
|
+
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`);
|
|
1745
|
+
}
|
|
1746
|
+
const r = sp.allow.redeem(m.enroll, m.member, m.pubkey);
|
|
1747
|
+
if (!r.ok) {
|
|
1748
|
+
this.log(`[${sp.name}] ! invite refused for ${m.member}: ${r.why}`);
|
|
1749
|
+
return reject("BAD_INVITE", r.fix ? `${r.why}\n${r.fix}` : r.why);
|
|
1750
|
+
}
|
|
1751
|
+
enrolled = sp.token;
|
|
1752
|
+
this.log(`[${sp.name}] * ENROLLED ${m.member} via invite from ${r.entry.admitted_by}${r.entry.may_wake ? " (may wake)" : ""}`);
|
|
1645
1753
|
}
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
if (!auth.ok) {
|
|
1651
|
-
const msg = auth.reason === "username_mismatch"
|
|
1652
|
-
? `this device is signed in as a different username than "${m.member}". Use your own username, or \`pantheon login\` on this machine.`
|
|
1653
|
-
: auth.reason === "suspended"
|
|
1654
|
-
? `this account has been suspended by the relay operator. Contact them to have it reinstated.`
|
|
1655
|
-
: `sign in first: run \`pantheon signup\` (new) or \`pantheon login\` (existing account) on this machine, then try again.`;
|
|
1656
|
-
this.log(`[${sp.name}] ! REFUSED ${m.member}: ${auth.reason}`);
|
|
1657
|
-
return reject("NOT_SIGNED_IN", msg);
|
|
1754
|
+
const verdict = enrolled ? "ok" : sp.allow.present(m.member, m.pubkey);
|
|
1755
|
+
if (verdict === "key-mismatch") {
|
|
1756
|
+
this.log(`[${sp.name}] ! REFUSED ${m.member}: key mismatch (${fingerprint(m.pubkey)})`);
|
|
1757
|
+
return reject("KEY_MISMATCH", `"${m.member}" is already registered here with a different key — another machine, or this one before its key was replaced. If this is really you, an admitted member runs pantheon admit ${m.member} --forget and sends you a new invite.`);
|
|
1658
1758
|
}
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
lastTouch = Date.now();
|
|
1664
|
-
}
|
|
1665
|
-
let enrolled = null;
|
|
1666
|
-
if (m.enroll) {
|
|
1667
|
-
const plan = this.spacePlan(sp.name);
|
|
1668
|
-
if (plan && !sp.allow.get(m.member)?.admitted && sp.allow.admittedCount() >= plan.limits.members) {
|
|
1669
|
-
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 });
|
|
1670
|
-
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`);
|
|
1759
|
+
if (verdict === "revoked") {
|
|
1760
|
+
const e = sp.allow.get(m.member);
|
|
1761
|
+
this.log(`[${sp.name}] ! REFUSED ${m.member}: removed${e?.revoked_by ? ` by ${e.revoked_by}` : ""}`);
|
|
1762
|
+
return reject("REVOKED", `"${m.member}" was removed from "${sp.name}"${e?.revoked_by ? ` by ${e.revoked_by}` : ""}. Only a member of the team can let you back in (pantheon admit ${m.member}); until then this machine cannot read or send in it.`);
|
|
1671
1763
|
}
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
this.
|
|
1675
|
-
|
|
1764
|
+
if (verdict === "new" || verdict === "pending") {
|
|
1765
|
+
this.log(`[${sp.name}] ? PENDING ${m.member} ${fingerprint(m.pubkey)}`);
|
|
1766
|
+
this.send(ws, {
|
|
1767
|
+
t: "pending", member: m.member, fingerprint: fingerprint(m.pubkey),
|
|
1768
|
+
message: `Not admitted yet. Ask an existing member to run: pantheon admit ${m.member}\nHave them check this fingerprint over a channel you both trust: ${fingerprint(m.pubkey)}`,
|
|
1769
|
+
});
|
|
1770
|
+
setTimeout(() => { try {
|
|
1771
|
+
ws.close(1008, "PENDING");
|
|
1772
|
+
}
|
|
1773
|
+
catch { } }, 20);
|
|
1774
|
+
return;
|
|
1775
|
+
}
|
|
1776
|
+
let newRepo = null;
|
|
1777
|
+
if (m.repo) {
|
|
1778
|
+
if (sp.allow.hasRepo(m.repo)) { }
|
|
1779
|
+
else if (sp.allow.repoCount() === 0 || enrolled || verdict === "founder")
|
|
1780
|
+
newRepo = m.repo;
|
|
1781
|
+
else {
|
|
1782
|
+
const known = sp.allow.repoList().map((r) => r.origin ?? r.fingerprint).join(", ");
|
|
1783
|
+
this.log(`[${sp.name}] ! REFUSED ${m.member}: repo ${m.repo_origin ?? m.repo} not bound to this space`);
|
|
1784
|
+
return reject("REPO_MISMATCH", `this checkout (${m.repo_origin ?? "no origin"}) is not one of the repos "${sp.name}" works from (${known || "none yet"}). ` +
|
|
1785
|
+
`Open your agent in the team's repo, or if this repo really belongs to the team: pantheon repo add`);
|
|
1786
|
+
}
|
|
1787
|
+
}
|
|
1788
|
+
space = sp;
|
|
1789
|
+
clearHelloTimer();
|
|
1790
|
+
sessionId = acctSession ?? this.mintSession();
|
|
1791
|
+
role = m.role;
|
|
1792
|
+
me = this.attach(sp, m.member, m.display ?? m.member, m.agent ?? "unknown", sessionId, m.role, ws, m.pkg);
|
|
1793
|
+
const firstSession = me.sessions.size === 1;
|
|
1794
|
+
const prior = typeof m.session === "string" ? m.session : "";
|
|
1795
|
+
const stale = prior && prior !== sessionId ? me.sessions.get(prior) : undefined;
|
|
1796
|
+
if (stale && stale.role === m.role) {
|
|
1797
|
+
me.sessions.delete(prior);
|
|
1798
|
+
try {
|
|
1799
|
+
stale.ws.terminate();
|
|
1800
|
+
}
|
|
1801
|
+
catch { }
|
|
1802
|
+
}
|
|
1803
|
+
if (prior && prior !== sessionId && !me.sessions.has(prior)) {
|
|
1804
|
+
let moved = 0;
|
|
1805
|
+
for (const o of sp.obligations.values()) {
|
|
1806
|
+
if (o.claimed_by?.session === prior && o.owner === me.id && o.claimed_by.role === m.role) {
|
|
1807
|
+
o.claimed_by = { ...o.claimed_by, session: sessionId, at: Date.now() };
|
|
1808
|
+
moved++;
|
|
1809
|
+
}
|
|
1810
|
+
}
|
|
1811
|
+
for (const v of sp.contLocks.values())
|
|
1812
|
+
if (v.session === prior && v.member === me.id) {
|
|
1813
|
+
v.session = sessionId;
|
|
1814
|
+
v.at = Date.now();
|
|
1815
|
+
}
|
|
1816
|
+
if (moved)
|
|
1817
|
+
this.pushObligations(sp);
|
|
1818
|
+
}
|
|
1819
|
+
sp.lastTouch = Date.now();
|
|
1820
|
+
if (newRepo && sp.allow.addRepo(newRepo, m.repo_origin ?? null, me.id)) {
|
|
1821
|
+
this.log(`[${sp.name}] + repo ${m.repo_origin ?? newRepo} registered by ${me.id}`);
|
|
1822
|
+
this.commit(sp, { from: me.id, act: "NOTE", to: null, goal: null, ref: null, body: `repo registered with this space: ${m.repo_origin ?? newRepo}`, meta: { repo: newRepo, repo_origin: m.repo_origin ?? null } });
|
|
1823
|
+
this.broadcast(sp, { t: "repos", repos: sp.allow.repoList() });
|
|
1676
1824
|
}
|
|
1677
|
-
enrolled = sp.token;
|
|
1678
|
-
this.log(`[${sp.name}] * ENROLLED ${m.member} via invite from ${r.entry.admitted_by}${r.entry.may_wake ? " (may wake)" : ""}`);
|
|
1679
|
-
}
|
|
1680
|
-
const verdict = enrolled ? "ok" : sp.allow.present(m.member, m.pubkey);
|
|
1681
|
-
if (verdict === "key-mismatch") {
|
|
1682
|
-
this.log(`[${sp.name}] ! REFUSED ${m.member}: key mismatch (${fingerprint(m.pubkey)})`);
|
|
1683
|
-
return reject("KEY_MISMATCH", `"${m.member}" is already registered with a different key. If this is really you, an admitted member must revoke and re-admit you.`);
|
|
1684
|
-
}
|
|
1685
|
-
if (verdict === "new" || verdict === "pending") {
|
|
1686
|
-
this.log(`[${sp.name}] ? PENDING ${m.member} ${fingerprint(m.pubkey)}`);
|
|
1687
1825
|
this.send(ws, {
|
|
1688
|
-
t: "
|
|
1689
|
-
|
|
1826
|
+
t: "welcome", member: me.id, session: sessionId, space: sp.name, seq: sp.ledger.head,
|
|
1827
|
+
roster: this.roster(sp), goals: [...sp.goals.values()],
|
|
1828
|
+
obligations: [...sp.obligations.values()], members: this.members(sp), profiles: [...sp.profiles.values()],
|
|
1829
|
+
space_id: sp.allow.spaceId(), repos: sp.allow.repoList(),
|
|
1830
|
+
...(acct ? { plan: this.planView(acct, sp) } : {}),
|
|
1831
|
+
...(enrolled ? { token: enrolled } : {}),
|
|
1690
1832
|
});
|
|
1691
|
-
|
|
1692
|
-
|
|
1833
|
+
let from = m.from_seq && m.from_seq > 0 ? m.from_seq : sp.ledger.head;
|
|
1834
|
+
for (let page = sp.ledger.since(from); page.length; page = sp.ledger.since(from)) {
|
|
1835
|
+
for (const r of page) {
|
|
1836
|
+
this.send(ws, { t: "act", act: r });
|
|
1837
|
+
if (r.seq > me.delivered_seq)
|
|
1838
|
+
me.delivered_seq = r.seq;
|
|
1839
|
+
}
|
|
1840
|
+
from = page[page.length - 1].seq;
|
|
1693
1841
|
}
|
|
1694
|
-
|
|
1842
|
+
if (firstSession && m.role !== "cli") {
|
|
1843
|
+
this.commit(sp, { from: me.id, act: "JOIN", body: `${me.display} online (${me.agent})`, to: null, goal: null, ref: null });
|
|
1844
|
+
}
|
|
1845
|
+
this.broadcast(sp, { t: "roster", roster: this.roster(sp) });
|
|
1846
|
+
this.log(`[${sp.name}] + ${me.id}/${sessionId} (${m.role}, ${me.agent})${verdict === "founder" ? " [founder, auto-admitted]" : ""}`);
|
|
1695
1847
|
return;
|
|
1696
1848
|
}
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
else {
|
|
1703
|
-
const known = sp.allow.repoList().map((r) => r.origin ?? r.fingerprint).join(", ");
|
|
1704
|
-
this.log(`[${sp.name}] ! REFUSED ${m.member}: repo ${m.repo_origin ?? m.repo} not bound to this space`);
|
|
1705
|
-
return reject("REPO_MISMATCH", `this checkout (${m.repo_origin ?? "no origin"}) is not one of the repos "${sp.name}" works from (${known || "none yet"}). ` +
|
|
1706
|
-
`Open your agent in the team's repo, or if this repo really belongs to the team: pantheon repo add`);
|
|
1849
|
+
if (!space || !me)
|
|
1850
|
+
return reject("NOT_JOINED", "say hello first");
|
|
1851
|
+
if (m.t === "publish_profile") {
|
|
1852
|
+
if (m.profile.member !== me.id) {
|
|
1853
|
+
return fail("PROFILE_NOT_YOURS", `you may publish only the profile for ${me.id}`, m.cid);
|
|
1707
1854
|
}
|
|
1855
|
+
if (!this.writeOk(me))
|
|
1856
|
+
return fail("RATE_LIMIT", "too many changes too fast — wait a few seconds", m.cid);
|
|
1857
|
+
const wild = Object.keys(m.profile.authority ?? {}).filter((k) => /[*?]/.test(k) || k.trim() === "");
|
|
1858
|
+
if (wild.length)
|
|
1859
|
+
return fail("BAD_AUTHORITY", `authority is claimed per named domain, not with "${wild[0]}"`, m.cid);
|
|
1860
|
+
const current = space.profiles.get(me.id);
|
|
1861
|
+
if (!current || m.profile.updated_at > current.updated_at) {
|
|
1862
|
+
space.profiles.set(me.id, m.profile);
|
|
1863
|
+
space.store.flush(space.goals, space.obligations, space.profiles);
|
|
1864
|
+
this.broadcast(space, { t: "profiles", profiles: [...space.profiles.values()] });
|
|
1865
|
+
this.commit(space, {
|
|
1866
|
+
from: me.id, session: sessionId, role, act: "NOTE", to: null, goal: null, ref: null,
|
|
1867
|
+
body: `organization profile updated: ${m.profile.display_name} — ${m.profile.title || "no title"}; human authority: ${Object.entries(m.profile.authority).filter(([, v]) => v === "human_required").map(([k]) => k).join(", ") || "none"}`,
|
|
1868
|
+
meta: { organization_profile: m.profile },
|
|
1869
|
+
});
|
|
1870
|
+
}
|
|
1871
|
+
this.send(ws, { t: "ack", ...(m.cid ? { cid: m.cid } : {}), id: me.id, seq: space.ledger.head });
|
|
1872
|
+
return;
|
|
1708
1873
|
}
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1874
|
+
if (m.t === "admit") {
|
|
1875
|
+
const self = space.allow.get(me.id);
|
|
1876
|
+
if (!self?.admitted)
|
|
1877
|
+
return fail("NOT_ADMITTED", "only an admitted member can vouch for another", m.cid);
|
|
1878
|
+
if (m.member === me.id)
|
|
1879
|
+
return fail("NO_SELF_ADMIT", "you cannot admit yourself", m.cid);
|
|
1880
|
+
const target = space.allow.get(m.member);
|
|
1881
|
+
if (!target)
|
|
1882
|
+
return fail("NO_SUCH_MEMBER", `"${m.member}" has never connected, so there is no key to admit`, m.cid);
|
|
1883
|
+
if (!m.revoke && !m.forget) {
|
|
1884
|
+
const plan = this.spacePlan(space.name);
|
|
1885
|
+
if (plan && m.may_wake && !plan.limits.wake)
|
|
1886
|
+
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);
|
|
1887
|
+
if (plan && !target.admitted && space.allow.admittedCount() >= plan.limits.members)
|
|
1888
|
+
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);
|
|
1889
|
+
}
|
|
1890
|
+
if (m.revoke || m.forget) {
|
|
1891
|
+
const founder = space.allow.founder();
|
|
1892
|
+
if (founder && me.id !== founder)
|
|
1893
|
+
return fail("FOUNDER_ONLY", `only ${founder}, who founded "${space.name}", can remove a member`, m.cid);
|
|
1894
|
+
if (m.member === founder)
|
|
1895
|
+
return fail("FOUNDER_STAYS", "the founder cannot be removed from their own team", m.cid);
|
|
1896
|
+
if (m.forget)
|
|
1897
|
+
space.allow.forget(m.member);
|
|
1898
|
+
else
|
|
1899
|
+
space.allow.revoke(m.member, me.id);
|
|
1900
|
+
if (space.profiles.delete(m.member))
|
|
1901
|
+
this.broadcast(space, { t: "profiles", profiles: [...space.profiles.values()] });
|
|
1902
|
+
for (const s of space.members.get(m.member)?.sessions.values() ?? []) {
|
|
1903
|
+
try {
|
|
1904
|
+
s.ws.close(1008, "REVOKED");
|
|
1905
|
+
}
|
|
1906
|
+
catch { }
|
|
1907
|
+
}
|
|
1908
|
+
space.members.delete(m.member);
|
|
1909
|
+
this.log(`[${space.name}] - ${m.forget ? "FORGOT" : "REVOKED"} ${m.member} by ${me.id}`);
|
|
1910
|
+
}
|
|
1911
|
+
else {
|
|
1912
|
+
space.allow.admit(m.member, me.id, m.may_wake);
|
|
1913
|
+
this.log(`[${space.name}] * ADMITTED ${m.member} by ${me.id}${m.may_wake ? " (may wake workers)" : ""}`);
|
|
1914
|
+
}
|
|
1915
|
+
this.broadcast(space, { t: "members", members: this.members(space) });
|
|
1916
|
+
this.broadcast(space, { t: "roster", roster: this.roster(space) });
|
|
1917
|
+
this.send(ws, { t: "ack", ...(m.cid ? { cid: m.cid } : {}), id: m.member, seq: space.ledger.head });
|
|
1918
|
+
return;
|
|
1736
1919
|
}
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
if (m.t === "publish_profile") {
|
|
1744
|
-
if (m.profile.member !== me.id) {
|
|
1745
|
-
return fail("PROFILE_NOT_YOURS", `you may publish only the profile for ${me.id}`, m.cid);
|
|
1920
|
+
if (m.t === "worker_state") {
|
|
1921
|
+
if (role !== "worker")
|
|
1922
|
+
return;
|
|
1923
|
+
me.worker = { state: m.state, ...(m.detail ? { detail: m.detail } : {}), ...(m.until ? { until: m.until } : {}), at: Date.now() };
|
|
1924
|
+
this.broadcast(space, { t: "roster", roster: this.roster(space) });
|
|
1925
|
+
return;
|
|
1746
1926
|
}
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
});
|
|
1927
|
+
if (m.t === "delete_space") {
|
|
1928
|
+
if (m.confirm !== space.name)
|
|
1929
|
+
return fail("CONFIRM_MISMATCH", `type the space name ("${space.name}") to confirm`, m.cid);
|
|
1930
|
+
const owner = this.accounts?.spaceOwner(space.name) ?? null;
|
|
1931
|
+
const isOwner = owner ? owner.id === acct?.id : space.allow.founder() === me.id;
|
|
1932
|
+
if (!isOwner)
|
|
1933
|
+
return fail("NOT_OWNER", `only the founder of "${space.name}" can delete it${owner ? ` (that is ${owner.username})` : ""}`, m.cid);
|
|
1934
|
+
const name = space.name;
|
|
1935
|
+
this.accounts?.audit({ event: "space_deleted", actor_account: acct?.id ?? null, space: name, detail: `by its founder ${me.id}`, ip });
|
|
1936
|
+
this.log(`[${name}] * DELETED by its founder ${me.id}`);
|
|
1937
|
+
this.send(ws, { t: "ack", ...(m.cid ? { cid: m.cid } : {}), id: name, seq: space.ledger.head });
|
|
1938
|
+
setTimeout(() => { this.deleteSpace(name); this.accounts?.markSpaceDeleted(name); }, 50).unref?.();
|
|
1939
|
+
return;
|
|
1757
1940
|
}
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
const self = space.allow.get(me.id);
|
|
1763
|
-
if (!self?.admitted)
|
|
1764
|
-
return fail("NOT_ADMITTED", "only an admitted member can vouch for another", m.cid);
|
|
1765
|
-
if (m.member === me.id)
|
|
1766
|
-
return fail("NO_SELF_ADMIT", "you cannot admit yourself", m.cid);
|
|
1767
|
-
const target = space.allow.get(m.member);
|
|
1768
|
-
if (!target)
|
|
1769
|
-
return fail("NO_SUCH_MEMBER", `"${m.member}" has never connected, so there is no key to admit`, m.cid);
|
|
1770
|
-
if (!m.revoke && !m.forget) {
|
|
1941
|
+
if (m.t === "invite") {
|
|
1942
|
+
const self = space.allow.get(me.id);
|
|
1943
|
+
if (!self?.admitted)
|
|
1944
|
+
return fail("NOT_ADMITTED", "only an admitted member can invite", m.cid);
|
|
1771
1945
|
const plan = this.spacePlan(space.name);
|
|
1772
|
-
if (plan
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
if (m.forget)
|
|
1779
|
-
space.allow.forget(m.member);
|
|
1780
|
-
else
|
|
1781
|
-
space.allow.revoke(m.member);
|
|
1782
|
-
for (const s of space.members.get(m.member)?.sessions.values() ?? []) {
|
|
1783
|
-
try {
|
|
1784
|
-
s.ws.close(1008, "REVOKED");
|
|
1946
|
+
if (plan) {
|
|
1947
|
+
if (m.may_wake && !plan.limits.wake)
|
|
1948
|
+
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);
|
|
1949
|
+
const open = space.allow.openInvites().length;
|
|
1950
|
+
if (space.allow.admittedCount() + open >= plan.limits.members) {
|
|
1951
|
+
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"}` : ""}.${open ? ` An unused invite holds a seat until it expires — free them: pantheon invite --revoke all.` : ""} Upgrade at https://www.joinpantheon.network to invite more.`, m.cid, fail, acct?.id ?? null, ip);
|
|
1785
1952
|
}
|
|
1786
|
-
catch { }
|
|
1787
1953
|
}
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
this.broadcast(space, { t: "roster", roster: this.roster(space) });
|
|
1797
|
-
this.send(ws, { t: "ack", ...(m.cid ? { cid: m.cid } : {}), id: m.member, seq: space.ledger.head });
|
|
1798
|
-
return;
|
|
1799
|
-
}
|
|
1800
|
-
if (m.t === "worker_state") {
|
|
1801
|
-
if (role !== "worker")
|
|
1954
|
+
if (!this.writeOk(me))
|
|
1955
|
+
return fail("RATE_LIMIT", "too many invites too fast — wait a few seconds", m.cid);
|
|
1956
|
+
if (space.allow.openInvites().length >= LIMITS_SPACE.openInvites)
|
|
1957
|
+
return fail("TOO_MANY_INVITES", `this team already has ${LIMITS_SPACE.openInvites} unused invites — revoke some: pantheon invite --revoke all`, m.cid);
|
|
1958
|
+
const token = randomBytes(18).toString("base64url");
|
|
1959
|
+
const inv = space.allow.mintInvite(me.id, m.may_wake, m.ttl_minutes, token, space.token);
|
|
1960
|
+
this.log(`[${space.name}] * invite minted by ${me.id}, valid ${m.ttl_minutes}m, single use`);
|
|
1961
|
+
this.send(ws, { t: "invite", url: token, expires_at: inv.expires_at });
|
|
1802
1962
|
return;
|
|
1803
|
-
me.worker = { state: m.state, ...(m.detail ? { detail: m.detail } : {}), ...(m.until ? { until: m.until } : {}), at: Date.now() };
|
|
1804
|
-
this.broadcast(space, { t: "roster", roster: this.roster(space) });
|
|
1805
|
-
return;
|
|
1806
|
-
}
|
|
1807
|
-
if (m.t === "delete_space") {
|
|
1808
|
-
if (m.confirm !== space.name)
|
|
1809
|
-
return fail("CONFIRM_MISMATCH", `type the space name ("${space.name}") to confirm`, m.cid);
|
|
1810
|
-
const owner = this.accounts?.spaceOwner(space.name) ?? null;
|
|
1811
|
-
const isOwner = owner ? owner.id === acct?.id : space.allow.founder() === me.id;
|
|
1812
|
-
if (!isOwner)
|
|
1813
|
-
return fail("NOT_OWNER", `only the founder of "${space.name}" can delete it${owner ? ` (that is ${owner.username})` : ""}`, m.cid);
|
|
1814
|
-
const name = space.name;
|
|
1815
|
-
this.accounts?.audit({ event: "space_deleted", actor_account: acct?.id ?? null, space: name, detail: `by its founder ${me.id}`, ip });
|
|
1816
|
-
this.log(`[${name}] * DELETED by its founder ${me.id}`);
|
|
1817
|
-
this.send(ws, { t: "ack", ...(m.cid ? { cid: m.cid } : {}), id: name, seq: space.ledger.head });
|
|
1818
|
-
setTimeout(() => { this.deleteSpace(name); this.accounts?.markSpaceDeleted(name); }, 50).unref?.();
|
|
1819
|
-
return;
|
|
1820
|
-
}
|
|
1821
|
-
if (m.t === "invite") {
|
|
1822
|
-
const self = space.allow.get(me.id);
|
|
1823
|
-
if (!self?.admitted)
|
|
1824
|
-
return fail("NOT_ADMITTED", "only an admitted member can invite", m.cid);
|
|
1825
|
-
const plan = this.spacePlan(space.name);
|
|
1826
|
-
if (plan) {
|
|
1827
|
-
if (m.may_wake && !plan.limits.wake)
|
|
1828
|
-
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);
|
|
1829
|
-
const open = space.allow.openInvites().length;
|
|
1830
|
-
if (space.allow.admittedCount() + open >= plan.limits.members) {
|
|
1831
|
-
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);
|
|
1832
|
-
}
|
|
1833
1963
|
}
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
const invites = space.allow.openInvites().map((i) => {
|
|
1847
|
-
const mine = i.created_by === viewer;
|
|
1848
|
-
return { token: mine || viewer === founder ? i.token : `…${i.token.slice(-6)}`, created_by: i.created_by, created_at: i.created_at, expires_at: i.expires_at, may_wake: i.may_wake, mine };
|
|
1849
|
-
});
|
|
1850
|
-
this.send(ws, { t: "invites", invites, ...(m.cid ? { cid: m.cid } : {}) });
|
|
1851
|
-
return;
|
|
1852
|
-
}
|
|
1853
|
-
if (m.t === "revoke_invite") {
|
|
1854
|
-
const self = space.allow.get(me.id);
|
|
1855
|
-
if (!self?.admitted)
|
|
1856
|
-
return fail("NOT_ADMITTED", "only an admitted member can revoke an invite", m.cid);
|
|
1857
|
-
const inv = space.allow.findInvite(m.token.replace(/^…/, ""));
|
|
1858
|
-
if (!inv)
|
|
1859
|
-
return fail("NO_SUCH_INVITE", "no open invite matches that token — it may already be used, expired or revoked (pantheon invite --list)", m.cid);
|
|
1860
|
-
if (inv.created_by !== me.id && me.id !== space.allow.founder())
|
|
1861
|
-
return fail("NOT_YOURS", `that invite was minted by ${inv.created_by}; only they or the founder can revoke it`, m.cid);
|
|
1862
|
-
space.allow.revokeInvite(inv.token);
|
|
1863
|
-
this.log(`[${space.name}] * invite …${inv.token.slice(-6)} (minted by ${inv.created_by}) revoked by ${me.id}`);
|
|
1864
|
-
this.accounts?.audit({ event: "invite_revoked", space: space.name, target: me.id, detail: `minted by ${inv.created_by}, …${inv.token.slice(-6)}`, ip });
|
|
1865
|
-
this.send(ws, { t: "ack", ...(m.cid ? { cid: m.cid } : {}), id: inv.token.slice(-6), seq: space.ledger.head });
|
|
1866
|
-
return;
|
|
1867
|
-
}
|
|
1868
|
-
if (m.t === "bind_repo") {
|
|
1869
|
-
const self = space.allow.get(me.id);
|
|
1870
|
-
if (!self?.admitted)
|
|
1871
|
-
return fail("NOT_ADMITTED", "only an admitted member can register a repo", m.cid);
|
|
1872
|
-
const added = space.allow.addRepo(m.repo, m.origin ?? null, me.id, m.base ?? null);
|
|
1873
|
-
if (!added && m.base && space.allow.setRepoBase(m.repo, m.base)) {
|
|
1874
|
-
this.log(`[${space.name}] ~ repo ${m.origin ?? m.repo} base branch → ${m.base} (by ${me.id})`);
|
|
1875
|
-
this.broadcast(space, { t: "repos", repos: space.allow.repoList() });
|
|
1964
|
+
if (m.t === "invites") {
|
|
1965
|
+
const self = space.allow.get(me.id);
|
|
1966
|
+
if (!self?.admitted)
|
|
1967
|
+
return fail("NOT_ADMITTED", "only an admitted member can see the open invites", m.cid);
|
|
1968
|
+
const founder = space.allow.founder();
|
|
1969
|
+
const viewer = me.id;
|
|
1970
|
+
const invites = space.allow.openInvites().map((i) => {
|
|
1971
|
+
const mine = i.created_by === viewer;
|
|
1972
|
+
return { token: mine || viewer === founder ? i.token : `…${i.token.slice(-6)}`, created_by: i.created_by, created_at: i.created_at, expires_at: i.expires_at, may_wake: i.may_wake, mine };
|
|
1973
|
+
});
|
|
1974
|
+
this.send(ws, { t: "invites", invites, ...(m.cid ? { cid: m.cid } : {}) });
|
|
1975
|
+
return;
|
|
1876
1976
|
}
|
|
1877
|
-
if (
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1977
|
+
if (m.t === "revoke_invite") {
|
|
1978
|
+
const self = space.allow.get(me.id);
|
|
1979
|
+
if (!self?.admitted)
|
|
1980
|
+
return fail("NOT_ADMITTED", "only an admitted member can revoke an invite", m.cid);
|
|
1981
|
+
const inv = space.allow.findInvite(m.token.replace(/^…/, ""));
|
|
1982
|
+
if (!inv)
|
|
1983
|
+
return fail("NO_SUCH_INVITE", "no open invite matches that token — it may already be used, expired or revoked (pantheon invite --list)", m.cid);
|
|
1984
|
+
if (inv.created_by !== me.id && me.id !== space.allow.founder())
|
|
1985
|
+
return fail("NOT_YOURS", `that invite was minted by ${inv.created_by}; only they or the founder can revoke it`, m.cid);
|
|
1986
|
+
space.allow.revokeInvite(inv.token);
|
|
1987
|
+
this.log(`[${space.name}] * invite …${inv.token.slice(-6)} (minted by ${inv.created_by}) revoked by ${me.id}`);
|
|
1988
|
+
this.accounts?.audit({ event: "invite_revoked", space: space.name, target: me.id, detail: `minted by ${inv.created_by}, …${inv.token.slice(-6)}`, ip });
|
|
1989
|
+
this.send(ws, { t: "ack", ...(m.cid ? { cid: m.cid } : {}), id: inv.token.slice(-6), seq: space.ledger.head });
|
|
1990
|
+
return;
|
|
1881
1991
|
}
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
if (
|
|
1889
|
-
return
|
|
1992
|
+
if (m.t === "bind_repo") {
|
|
1993
|
+
const self = space.allow.get(me.id);
|
|
1994
|
+
if (!self?.admitted)
|
|
1995
|
+
return fail("NOT_ADMITTED", "only an admitted member can register a repo", m.cid);
|
|
1996
|
+
if (!this.writeOk(me))
|
|
1997
|
+
return fail("RATE_LIMIT", "too many repo changes too fast — wait a few seconds", m.cid);
|
|
1998
|
+
if (!space.allow.repoList().some((r) => r.fingerprint === m.repo) && space.allow.repoCount() >= LIMITS_SPACE.repos) {
|
|
1999
|
+
return fail("TOO_MANY_REPOS", `this team already works from ${LIMITS_SPACE.repos} repos`, m.cid);
|
|
2000
|
+
}
|
|
2001
|
+
if (m.base !== undefined && !/^[A-Za-z0-9._][A-Za-z0-9._\/-]{0,99}$/.test(m.base)) {
|
|
2002
|
+
return fail("BAD_BASE", `"${m.base}" is not a branch name`, m.cid);
|
|
2003
|
+
}
|
|
2004
|
+
const existing = space.allow.repoList().find((r) => r.fingerprint === m.repo);
|
|
2005
|
+
if (existing && m.base && existing.added_by !== me.id && space.allow.founder() !== me.id) {
|
|
2006
|
+
return fail("NOT_YOURS", `only ${existing.added_by} (who registered this repo) or the team's founder can change which branch its runs start from`, m.cid);
|
|
2007
|
+
}
|
|
2008
|
+
const added = space.allow.addRepo(m.repo, m.origin ?? null, me.id, m.base ?? null);
|
|
2009
|
+
if (!added && m.base && space.allow.setRepoBase(m.repo, m.base)) {
|
|
2010
|
+
this.log(`[${space.name}] ~ repo ${m.origin ?? m.repo} base branch → ${m.base} (by ${me.id})`);
|
|
2011
|
+
this.broadcast(space, { t: "repos", repos: space.allow.repoList() });
|
|
2012
|
+
}
|
|
2013
|
+
if (added) {
|
|
2014
|
+
this.log(`[${space.name}] + repo ${m.origin ?? m.repo} registered by ${me.id}${m.base ? ` (base ${m.base})` : ""}`);
|
|
2015
|
+
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 } });
|
|
2016
|
+
this.broadcast(space, { t: "repos", repos: space.allow.repoList() });
|
|
2017
|
+
}
|
|
2018
|
+
this.send(ws, { t: "ack", cid: m.cid, id: m.repo, seq: space.ledger.head });
|
|
2019
|
+
return;
|
|
1890
2020
|
}
|
|
1891
|
-
if (m.
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
if (
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
2021
|
+
if (m.t === "claim") {
|
|
2022
|
+
if (role === "worker") {
|
|
2023
|
+
const plan = this.spacePlan(space.name);
|
|
2024
|
+
if (plan && !plan.limits.wake)
|
|
2025
|
+
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);
|
|
2026
|
+
}
|
|
2027
|
+
if (m.obligation.startsWith("cont:")) {
|
|
2028
|
+
const act = space.ledger.find(m.obligation.slice(5));
|
|
2029
|
+
if (!act)
|
|
2030
|
+
return fail("NO_SUCH_ACT", `no act ${m.obligation.slice(5)} to continue from`, m.cid);
|
|
2031
|
+
const refFrom = act.ref ? space.ledger.find(act.ref)?.from : undefined;
|
|
2032
|
+
const goal = act.goal ? space.goals.get(act.goal) : undefined;
|
|
2033
|
+
if (!isContinuationFor(act, me.id, {
|
|
2034
|
+
...(refFrom !== undefined ? { refFrom } : {}),
|
|
2035
|
+
...(goal ? { goalMembers: goal.members, goalState: goal.state } : {}),
|
|
2036
|
+
})) {
|
|
2037
|
+
return fail("NOT_YOURS", `that ${act.act} is not ${me.id}'s to continue`, m.cid);
|
|
2038
|
+
}
|
|
2039
|
+
if (role === "worker") {
|
|
2040
|
+
const defer = shouldDeferToHuman([...me.sessions.values()], this.work(me));
|
|
2041
|
+
const age = Date.now() - act.ts;
|
|
2042
|
+
if (defer && age < LIMITS.workerGraceMs) {
|
|
2043
|
+
return fail("DEFER", `you have a live interactive session; waiting ${Math.ceil((LIMITS.workerGraceMs - age) / 1000)}s before a worker may take this`, m.cid);
|
|
2044
|
+
}
|
|
2045
|
+
const sender = space.allow.get(act.from);
|
|
2046
|
+
if (act.from !== me.id && !sender?.may_wake) {
|
|
2047
|
+
return fail("NOT_ALLOWED_TO_WAKE", `"${act.from}" is not permitted to wake a worker on this machine`, m.cid);
|
|
2048
|
+
}
|
|
2049
|
+
}
|
|
2050
|
+
const held = space.contLocks.get(m.obligation);
|
|
2051
|
+
if (held && held.session !== sessionId && Date.now() - held.at < LIMITS.claimTtlMs) {
|
|
2052
|
+
return fail("CLAIMED", `another of your sessions (${held.session}) is handling this`, m.cid);
|
|
2053
|
+
}
|
|
2054
|
+
for (const [k, v] of space.contLocks) {
|
|
2055
|
+
if (Date.now() - v.at > LIMITS.claimTtlMs)
|
|
2056
|
+
space.contLocks.delete(k);
|
|
2057
|
+
}
|
|
2058
|
+
space.contLocks.set(m.obligation, { session: sessionId, member: me.id, at: Date.now() });
|
|
2059
|
+
this.send(ws, { t: "ack", ...(m.cid ? { cid: m.cid } : {}), id: m.obligation, seq: space.ledger.head });
|
|
2060
|
+
return;
|
|
2061
|
+
}
|
|
2062
|
+
const o = space.obligations.get(m.obligation);
|
|
2063
|
+
if (!o)
|
|
2064
|
+
return fail("NO_SUCH_OBLIGATION", `no obligation ${m.obligation} — it may already be discharged`, m.cid);
|
|
2065
|
+
if (o.owner !== me.id)
|
|
2066
|
+
return fail("NOT_YOURS", `${o.owner} owes that, not you`, m.cid);
|
|
2067
|
+
if (o.claimed_by && o.claimed_by.session !== sessionId) {
|
|
2068
|
+
if (Date.now() - o.claimed_by.at < LIMITS.claimTtlMs) {
|
|
2069
|
+
const holder = o.claimed_by;
|
|
2070
|
+
const live = [...me.sessions.keys()].includes(holder.session);
|
|
2071
|
+
const left = Math.ceil((LIMITS.claimTtlMs - (Date.now() - holder.at)) / 60_000);
|
|
2072
|
+
return fail("CLAIMED", live
|
|
2073
|
+
? `another of your sessions (${holder.session}, ${holder.role}) is handling this`
|
|
2074
|
+
: `a session of yours (${holder.role}) was working on this when its connection dropped; it is held for it for up to ${left} more minute${left === 1 ? "" : "s"} in case it comes back`, m.cid);
|
|
2075
|
+
}
|
|
1902
2076
|
}
|
|
1903
2077
|
if (role === "worker") {
|
|
2078
|
+
const age = Date.now() - o.created_at;
|
|
1904
2079
|
const defer = shouldDeferToHuman([...me.sessions.values()], this.work(me));
|
|
1905
|
-
const age = Date.now() - act.ts;
|
|
1906
2080
|
if (defer && age < LIMITS.workerGraceMs) {
|
|
1907
2081
|
return fail("DEFER", `you have a live interactive session; waiting ${Math.ceil((LIMITS.workerGraceMs - age) / 1000)}s before a worker may take this`, m.cid);
|
|
1908
2082
|
}
|
|
1909
|
-
const
|
|
1910
|
-
|
|
1911
|
-
|
|
2083
|
+
const origin = space.ledger.find(o.ref);
|
|
2084
|
+
const asker = origin ? space.allow.get(origin.from) : undefined;
|
|
2085
|
+
if (origin && origin.from !== me.id && !asker?.may_wake) {
|
|
2086
|
+
return fail("NOT_ALLOWED_TO_WAKE", `"${origin.from}" is not permitted to wake a worker on this machine`, m.cid);
|
|
1912
2087
|
}
|
|
1913
2088
|
}
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
2089
|
+
o.claimed_by = { session: sessionId, role, at: Date.now() };
|
|
2090
|
+
this.broadcast(space, { t: "claimed", obligation: o.id, session: sessionId, member: me.id });
|
|
2091
|
+
this.pushObligations(space);
|
|
2092
|
+
this.send(ws, { t: "ack", ...(m.cid ? { cid: m.cid } : {}), id: o.id, seq: space.ledger.head });
|
|
2093
|
+
return;
|
|
2094
|
+
}
|
|
2095
|
+
if (m.t === "open_goal") {
|
|
2096
|
+
if (!this.writeOk(me))
|
|
2097
|
+
return fail("RATE_LIMIT", "too many goals opened too fast — wait a few seconds", m.cid);
|
|
2098
|
+
const openCount = [...space.goals.values()].filter((g) => g.state !== "CLOSED").length;
|
|
2099
|
+
if (openCount >= LIMITS_SPACE.openGoals)
|
|
2100
|
+
return fail("TOO_MANY_GOALS", `this team has ${openCount} goals still open — close some first`, m.cid);
|
|
2101
|
+
for (const id of m.invite ?? []) {
|
|
2102
|
+
if (!space.members.has(id) && !space.allow.get(id)?.admitted)
|
|
2103
|
+
return fail("NO_SUCH_MEMBER", `nobody here is called "${id}"`, m.cid);
|
|
1917
2104
|
}
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
|
|
2105
|
+
const invited = (m.invite ?? [...space.members.keys()]).filter((x) => x !== me.id);
|
|
2106
|
+
if (m.deadline !== undefined && m.deadline <= Date.now()) {
|
|
2107
|
+
return fail("DEADLINE_PAST", `deadline ${new Date(m.deadline).toISOString()} is already in the past`, m.cid);
|
|
1921
2108
|
}
|
|
1922
|
-
|
|
1923
|
-
|
|
2109
|
+
const goal = {
|
|
2110
|
+
id: newId("g"),
|
|
2111
|
+
title: m.title,
|
|
2112
|
+
acceptance: m.acceptance,
|
|
2113
|
+
members: [me.id, ...new Set(invited)],
|
|
2114
|
+
state: "OPEN",
|
|
2115
|
+
acts: 0,
|
|
2116
|
+
budget: m.budget ?? LIMITS.goalBudget,
|
|
2117
|
+
opened_by: me.id,
|
|
2118
|
+
opened_at: Date.now(),
|
|
2119
|
+
...(m.deadline !== undefined ? { deadline: m.deadline } : {}),
|
|
2120
|
+
...(m.kind !== undefined ? { kind: m.kind } : {}),
|
|
2121
|
+
};
|
|
2122
|
+
space.goals.set(goal.id, goal);
|
|
2123
|
+
space.store.flush(space.goals, space.obligations, space.profiles);
|
|
2124
|
+
this.broadcast(space, { t: "goal", goal });
|
|
2125
|
+
const rec = this.commit(space, {
|
|
2126
|
+
from: me.id, act: "NOTE", to: null, goal: goal.id, ref: null,
|
|
2127
|
+
body: `goal opened: ${goal.title}\nacceptance: ${goal.acceptance || "(none stated)"}` +
|
|
2128
|
+
(goal.deadline ? `\ndeadline: ${new Date(goal.deadline).toISOString()}` : "") +
|
|
2129
|
+
(goal.kind === "worklist" ? `\nkind: worklist — ${me.id}'s own list; peers may read it, nobody owes anything in it` : ""),
|
|
2130
|
+
});
|
|
2131
|
+
this.send(ws, { t: "ack", ...(m.cid ? { cid: m.cid } : {}), id: goal.id, seq: rec.seq });
|
|
1924
2132
|
return;
|
|
1925
2133
|
}
|
|
1926
|
-
const
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
return
|
|
1931
|
-
if (o.claimed_by && o.claimed_by.session !== sessionId) {
|
|
1932
|
-
if (Date.now() - o.claimed_by.at < LIMITS.claimTtlMs) {
|
|
1933
|
-
return fail("CLAIMED", `another of your sessions (${o.claimed_by.session}, ${o.claimed_by.role}) is handling this`, m.cid);
|
|
1934
|
-
}
|
|
2134
|
+
const fp = fingerprint_(me.id, m.act, m.body, m.goal ?? null, m.ref ?? null, { to: m.to ?? null, meta: m.meta ?? null, defers_to: m.defers_to ?? null });
|
|
2135
|
+
const prior = space.recent.get(fp);
|
|
2136
|
+
if (prior && Date.now() - prior.at < LIMITS.dedupeWindowMs) {
|
|
2137
|
+
this.send(ws, { t: "ack", ...(m.cid ? { cid: m.cid } : {}), id: prior.id, seq: prior.seq });
|
|
2138
|
+
return;
|
|
1935
2139
|
}
|
|
1936
|
-
if (
|
|
1937
|
-
const
|
|
1938
|
-
const
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
|
|
1943
|
-
|
|
1944
|
-
if (
|
|
1945
|
-
return fail("
|
|
2140
|
+
if (m.after_seq !== undefined) {
|
|
2141
|
+
const mine = me.id;
|
|
2142
|
+
const missed = space.ledger.since(m.after_seq)
|
|
2143
|
+
.filter((r) => r.from !== mine && r.act !== "JOIN" && r.act !== "LEAVE")
|
|
2144
|
+
.filter((r) => (m.goal ? r.goal === m.goal : !r.goal)
|
|
2145
|
+
|| (r.to?.includes(mine) ?? false)
|
|
2146
|
+
|| (m.to?.includes(r.from) ?? false)
|
|
2147
|
+
|| (!!m.ref && r.ref === m.ref));
|
|
2148
|
+
if (missed.length) {
|
|
2149
|
+
return fail("STALE_VIEW", `${missed.length} act(s) arrived while you were composing — read them before you answer:\n` +
|
|
2150
|
+
missed.slice(0, 8).map((r) => ` [${r.seq}] ${r.act} from ${r.from}: ${first(r.body)}`).join("\n") +
|
|
2151
|
+
(missed.length > 8 ? `\n ...and ${missed.length - 8} more` : "") +
|
|
2152
|
+
`\nCall pantheon_wait, then send again.`, m.cid);
|
|
1946
2153
|
}
|
|
1947
2154
|
}
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
|
|
1951
|
-
this.send(ws, { t: "ack", ...(m.cid ? { cid: m.cid } : {}), id: o.id, seq: space.ledger.head });
|
|
1952
|
-
return;
|
|
1953
|
-
}
|
|
1954
|
-
if (m.t === "open_goal") {
|
|
1955
|
-
const invited = (m.invite ?? [...space.members.keys()]).filter((x) => x !== me.id);
|
|
1956
|
-
if (m.deadline !== undefined && m.deadline <= Date.now()) {
|
|
1957
|
-
return fail("DEADLINE_PAST", `deadline ${new Date(m.deadline).toISOString()} is already in the past`, m.cid);
|
|
1958
|
-
}
|
|
1959
|
-
const goal = {
|
|
1960
|
-
id: newId("g"),
|
|
1961
|
-
title: m.title,
|
|
1962
|
-
acceptance: m.acceptance,
|
|
1963
|
-
members: [me.id, ...new Set(invited)],
|
|
1964
|
-
state: "OPEN",
|
|
1965
|
-
acts: 0,
|
|
1966
|
-
budget: m.budget ?? LIMITS.goalBudget,
|
|
1967
|
-
opened_by: me.id,
|
|
1968
|
-
opened_at: Date.now(),
|
|
1969
|
-
...(m.deadline !== undefined ? { deadline: m.deadline } : {}),
|
|
1970
|
-
...(m.kind !== undefined ? { kind: m.kind } : {}),
|
|
1971
|
-
};
|
|
1972
|
-
space.goals.set(goal.id, goal);
|
|
1973
|
-
space.store.save(space.goals, space.obligations, space.profiles);
|
|
1974
|
-
this.broadcast(space, { t: "goal", goal });
|
|
2155
|
+
const err = this.validateAct(space, me, m.act, m.goal ?? null, m.ref ?? null, m.to ?? null, m.meta, m.defers_to, m.body.length, m.summary, m.repo, role);
|
|
2156
|
+
if (err)
|
|
2157
|
+
return fail(err.code, err.message, m.cid);
|
|
1975
2158
|
const rec = this.commit(space, {
|
|
1976
|
-
from: me.id,
|
|
1977
|
-
|
|
1978
|
-
|
|
1979
|
-
|
|
2159
|
+
from: me.id, session: sessionId, role, act: m.act, body: m.body,
|
|
2160
|
+
...(m.summary ? { summary: m.summary } : {}),
|
|
2161
|
+
to: m.to ?? null, goal: m.goal ?? null, ref: m.ref ?? null,
|
|
2162
|
+
...(m.meta || m.defers_to || m.affects
|
|
2163
|
+
? { meta: { ...(m.meta ?? {}),
|
|
2164
|
+
...(m.defers_to ? { defers_to: m.defers_to } : {}),
|
|
2165
|
+
...(m.affects ? { affects: m.affects } : {}) } }
|
|
2166
|
+
: {}),
|
|
1980
2167
|
});
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
|
|
1985
|
-
|
|
1986
|
-
if (prior && Date.now() - prior.at < LIMITS.dedupeWindowMs) {
|
|
1987
|
-
this.send(ws, { t: "ack", ...(m.cid ? { cid: m.cid } : {}), id: prior.id, seq: prior.seq });
|
|
1988
|
-
return;
|
|
2168
|
+
space.recent.set(fp, { id: rec.id, seq: rec.seq, at: Date.now() });
|
|
2169
|
+
for (const [k, v] of space.recent)
|
|
2170
|
+
if (Date.now() - v.at > LIMITS.dedupeWindowMs)
|
|
2171
|
+
space.recent.delete(k);
|
|
2172
|
+
this.send(ws, { t: "ack", ...(m.cid ? { cid: m.cid } : {}), id: rec.id, seq: rec.seq });
|
|
1989
2173
|
}
|
|
1990
|
-
|
|
1991
|
-
|
|
1992
|
-
|
|
1993
|
-
.
|
|
1994
|
-
.filter((r) => (m.goal ? r.goal === m.goal : !r.goal)
|
|
1995
|
-
|| (r.to?.includes(mine) ?? false)
|
|
1996
|
-
|| (m.to?.includes(r.from) ?? false)
|
|
1997
|
-
|| (!!m.ref && r.ref === m.ref));
|
|
1998
|
-
if (missed.length) {
|
|
1999
|
-
return fail("STALE_VIEW", `${missed.length} act(s) arrived while you were composing — read them before you answer:\n` +
|
|
2000
|
-
missed.slice(0, 8).map((r) => ` [${r.seq}] ${r.act} from ${r.from}: ${first(r.body)}`).join("\n") +
|
|
2001
|
-
(missed.length > 8 ? `\n ...and ${missed.length - 8} more` : "") +
|
|
2002
|
-
`\nCall pantheon_wait, then send again.`, m.cid);
|
|
2174
|
+
catch (e) {
|
|
2175
|
+
this.log(`! message handling failed: ${e.message}`);
|
|
2176
|
+
try {
|
|
2177
|
+
ws.send(JSON.stringify({ t: "error", code: "STORAGE", message: "the relay could not save that — try again in a moment" }));
|
|
2003
2178
|
}
|
|
2179
|
+
catch { }
|
|
2004
2180
|
}
|
|
2005
|
-
const err = this.validateAct(space, me, m.act, m.goal ?? null, m.ref ?? null, m.to ?? null, m.meta, m.defers_to, m.body.length, m.summary, m.repo);
|
|
2006
|
-
if (err)
|
|
2007
|
-
return fail(err.code, err.message, m.cid);
|
|
2008
|
-
const rec = this.commit(space, {
|
|
2009
|
-
from: me.id, session: sessionId, role, act: m.act, body: m.body,
|
|
2010
|
-
...(m.summary ? { summary: m.summary } : {}),
|
|
2011
|
-
to: m.to ?? null, goal: m.goal ?? null, ref: m.ref ?? null,
|
|
2012
|
-
...(m.meta || m.defers_to || m.affects
|
|
2013
|
-
? { meta: { ...(m.meta ?? {}),
|
|
2014
|
-
...(m.defers_to ? { defers_to: m.defers_to } : {}),
|
|
2015
|
-
...(m.affects ? { affects: m.affects } : {}) } }
|
|
2016
|
-
: {}),
|
|
2017
|
-
});
|
|
2018
|
-
space.recent.set(fp, { id: rec.id, seq: rec.seq, at: Date.now() });
|
|
2019
|
-
for (const [k, v] of space.recent)
|
|
2020
|
-
if (Date.now() - v.at > LIMITS.dedupeWindowMs)
|
|
2021
|
-
space.recent.delete(k);
|
|
2022
|
-
this.send(ws, { t: "ack", ...(m.cid ? { cid: m.cid } : {}), id: rec.id, seq: rec.seq });
|
|
2023
2181
|
});
|
|
2024
|
-
ws.on("close", () => {
|
|
2182
|
+
ws.on("close", (code) => {
|
|
2025
2183
|
if (dbSession) {
|
|
2026
2184
|
this.accounts?.endSession(dbSession);
|
|
2027
2185
|
dbSession = null;
|
|
@@ -2029,13 +2187,22 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
2029
2187
|
if (!space || !me)
|
|
2030
2188
|
return;
|
|
2031
2189
|
me.sessions.delete(sessionId);
|
|
2190
|
+
const deliberate = code === 1000 || code === 1001 || code === 1005;
|
|
2032
2191
|
for (const o of space.obligations.values()) {
|
|
2033
|
-
if (o.claimed_by?.session
|
|
2192
|
+
if (o.claimed_by?.session !== sessionId)
|
|
2193
|
+
continue;
|
|
2194
|
+
if (deliberate)
|
|
2034
2195
|
delete o.claimed_by;
|
|
2196
|
+
else
|
|
2197
|
+
o.claimed_by = { ...o.claimed_by, at: Date.now() };
|
|
2035
2198
|
}
|
|
2036
2199
|
for (const [k, v] of space.contLocks) {
|
|
2037
|
-
if (v.session
|
|
2200
|
+
if (v.session !== sessionId)
|
|
2201
|
+
continue;
|
|
2202
|
+
if (deliberate)
|
|
2038
2203
|
space.contLocks.delete(k);
|
|
2204
|
+
else
|
|
2205
|
+
v.at = Date.now();
|
|
2039
2206
|
}
|
|
2040
2207
|
if (me.sessions.size === 0 && role !== "cli") {
|
|
2041
2208
|
me.last_seen = Date.now();
|
|
@@ -2064,10 +2231,29 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
2064
2231
|
for (const sp of this.spaces.values()) {
|
|
2065
2232
|
if (now - (this.compactedAt.get(sp.name) ?? 0) > LIMITS.ledgerCompactEveryMs)
|
|
2066
2233
|
this.compactSpace(sp);
|
|
2234
|
+
this.lapseClaims(sp, now);
|
|
2067
2235
|
}
|
|
2068
2236
|
this.evictSpaces();
|
|
2069
2237
|
}
|
|
2070
|
-
|
|
2238
|
+
lapseClaims(sp, now) {
|
|
2239
|
+
const live = new Set();
|
|
2240
|
+
for (const m of sp.members.values())
|
|
2241
|
+
for (const id of m.sessions.keys())
|
|
2242
|
+
live.add(id);
|
|
2243
|
+
let lapsed = 0;
|
|
2244
|
+
for (const o of sp.obligations.values()) {
|
|
2245
|
+
if (o.claimed_by && !live.has(o.claimed_by.session) && now - o.claimed_by.at >= LIMITS.claimTtlMs) {
|
|
2246
|
+
delete o.claimed_by;
|
|
2247
|
+
lapsed++;
|
|
2248
|
+
}
|
|
2249
|
+
}
|
|
2250
|
+
for (const [k, v] of sp.contLocks)
|
|
2251
|
+
if (!live.has(v.session) && now - v.at >= LIMITS.claimTtlMs)
|
|
2252
|
+
sp.contLocks.delete(k);
|
|
2253
|
+
if (lapsed)
|
|
2254
|
+
this.pushObligations(sp);
|
|
2255
|
+
}
|
|
2256
|
+
validateAct(sp, me, act, goalId, ref, to, meta, deferTo, bodyLen = 0, summary, repo, role) {
|
|
2071
2257
|
if (!SENDABLE.includes(act))
|
|
2072
2258
|
return { code: "RESERVED_ACT", message: `${act} is emitted by the relay, not by a delegate` };
|
|
2073
2259
|
const now = Date.now();
|
|
@@ -2083,6 +2269,9 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
2083
2269
|
}
|
|
2084
2270
|
if (ref && !sp.ledger.find(ref))
|
|
2085
2271
|
return { code: "BAD_REF", message: `no act with id ${ref}` };
|
|
2272
|
+
if (meta && JSON.stringify(meta).length > LIMITS_SPACE.metaBytes) {
|
|
2273
|
+
return { code: "META_TOO_LARGE", message: `act metadata is limited to ${LIMITS_SPACE.metaBytes} bytes` };
|
|
2274
|
+
}
|
|
2086
2275
|
if ((act === "ANSWER" || act === "ACCEPT" || act === "OBJECT") && !ref) {
|
|
2087
2276
|
return { code: "REF_REQUIRED", message: `${act} must reference the act it responds to` };
|
|
2088
2277
|
}
|
|
@@ -2219,6 +2408,15 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
2219
2408
|
return { code: "BAD_REMOTE", message: `${why}. Your peer fetches this from another machine.` };
|
|
2220
2409
|
}
|
|
2221
2410
|
}
|
|
2411
|
+
if (act === "ANSWER" && ref) {
|
|
2412
|
+
const asked = sp.ledger.find(ref);
|
|
2413
|
+
if (asked?.act === "ESCALATE" && asked.from !== me.id) {
|
|
2414
|
+
return { code: "NOT_YOUR_DECISION", message: `that ESCALATE is for ${asked.from}'s own human to settle — add to it with a NOTE instead` };
|
|
2415
|
+
}
|
|
2416
|
+
if (asked?.act === "HUMAN_REQUIRED" && asked.to?.[0] && asked.to[0] !== me.id) {
|
|
2417
|
+
return { code: "NOT_YOUR_DECISION", message: `that decision is ${asked.to[0]}'s to make — add to it with a NOTE instead` };
|
|
2418
|
+
}
|
|
2419
|
+
}
|
|
2222
2420
|
if (!goalId) {
|
|
2223
2421
|
const target = ref ? sp.ledger.find(ref) : undefined;
|
|
2224
2422
|
const settlesHuman = act === "ANSWER" && (target?.act === "ESCALATE" || target?.act === "HUMAN_REQUIRED");
|
|
@@ -2230,12 +2428,24 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
2230
2428
|
const goal = sp.goals.get(goalId);
|
|
2231
2429
|
if (!goal)
|
|
2232
2430
|
return { code: "NO_SUCH_GOAL", message: `no goal ${goalId}` };
|
|
2431
|
+
if (ref && act !== "RETRACT") {
|
|
2432
|
+
const target = sp.ledger.find(ref);
|
|
2433
|
+
if (target && target.goal !== goalId) {
|
|
2434
|
+
return { code: "REF_OTHER_GOAL", message: `that act belongs to ${target.goal ? `goal ${target.goal}` : "no goal"}, not this one — reply in its own goal${target.goal ? "" : " (send it without a goal)"}` };
|
|
2435
|
+
}
|
|
2436
|
+
}
|
|
2437
|
+
for (const id of to ?? []) {
|
|
2438
|
+
if (!goal.members.includes(id)) {
|
|
2439
|
+
return { code: "NOT_IN_GOAL", message: `${id} is not in "${goal.title}", so they could never answer it here — open a goal with them, or send it without a goal` };
|
|
2440
|
+
}
|
|
2441
|
+
}
|
|
2233
2442
|
if (goal.state === "CLOSED")
|
|
2234
2443
|
return { code: "GOAL_CLOSED", message: `goal "${goal.title}" is closed` };
|
|
2235
2444
|
if (!goal.members.includes(me.id))
|
|
2236
2445
|
return { code: "NOT_IN_GOAL", message: `you are not a member of "${goal.title}"` };
|
|
2237
|
-
const
|
|
2238
|
-
|
|
2446
|
+
const decision = act === "ANSWER" && ref ? sp.ledger.find(ref) : undefined;
|
|
2447
|
+
const settlesOwnHuman = !!decision && decision.goal === goalId && role === "cli"
|
|
2448
|
+
&& [...sp.obligations.values()].some((o) => o.kind === "HUMAN" && o.ref === ref && o.goal === goalId && o.owner === `human:${me.id}`);
|
|
2239
2449
|
if (goal.acts >= goal.budget && !settlesOwnHuman) {
|
|
2240
2450
|
return { code: "BUDGET_SPENT", message: `goal "${goal.title}" spent its ${goal.budget}-act budget and needs a human` };
|
|
2241
2451
|
}
|
|
@@ -2298,6 +2508,15 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
2298
2508
|
break;
|
|
2299
2509
|
case "ANSWER":
|
|
2300
2510
|
if (rec.ref) {
|
|
2511
|
+
for (const o of sp.obligations.values()) {
|
|
2512
|
+
const g = o.goal ? sp.goals.get(o.goal) : undefined;
|
|
2513
|
+
if (o.kind === "HUMAN" && o.ref === rec.ref && o.owner === `human:${rec.from}` && g?.state === "STALLED"
|
|
2514
|
+
&& !["ESCALATE", "HUMAN_REQUIRED"].includes(sp.ledger.find(o.ref)?.act ?? "")) {
|
|
2515
|
+
g.budget = Math.max(g.budget, g.acts) + Math.ceil(LIMITS.goalBudget / 2);
|
|
2516
|
+
g.state = "OPEN";
|
|
2517
|
+
this.broadcast(sp, { t: "goal", goal: g });
|
|
2518
|
+
}
|
|
2519
|
+
}
|
|
2301
2520
|
closeFor(rec.from, rec.ref);
|
|
2302
2521
|
closeFor(`human:${rec.from}`, rec.ref);
|
|
2303
2522
|
}
|
|
@@ -2320,9 +2539,10 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
2320
2539
|
if (rec.ref)
|
|
2321
2540
|
closeFor(rec.from, rec.ref);
|
|
2322
2541
|
const accepted = rec.ref ? sp.ledger.find(rec.ref) : undefined;
|
|
2323
|
-
if (accepted?.act === "WITHDRAW" && accepted.ref) {
|
|
2542
|
+
if (accepted?.act === "WITHDRAW" && accepted.ref && accepted.from !== rec.from) {
|
|
2543
|
+
const owedTo = sp.ledger.find(accepted.ref)?.from;
|
|
2324
2544
|
for (const [id, o] of sp.obligations) {
|
|
2325
|
-
if (o.ref === accepted.ref && o.owner === accepted.from)
|
|
2545
|
+
if (o.ref === accepted.ref && o.owner === accepted.from && owedTo === rec.from)
|
|
2326
2546
|
sp.obligations.delete(id);
|
|
2327
2547
|
}
|
|
2328
2548
|
}
|
|
@@ -2339,16 +2559,12 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
2339
2559
|
}
|
|
2340
2560
|
break;
|
|
2341
2561
|
case "CONTRACT":
|
|
2342
|
-
if (rec.ref)
|
|
2343
|
-
for (const [id, o] of sp.obligations)
|
|
2344
|
-
if (o.ref === rec.ref)
|
|
2345
|
-
sp.obligations.delete(id);
|
|
2346
|
-
break;
|
|
2347
2562
|
case "DELIVERED":
|
|
2348
2563
|
if (rec.ref)
|
|
2349
|
-
for (const [id, o] of sp.obligations)
|
|
2350
|
-
if (o.ref === rec.ref)
|
|
2564
|
+
for (const [id, o] of sp.obligations) {
|
|
2565
|
+
if (o.ref === rec.ref && o.goal === rec.goal && o.kind !== "HUMAN")
|
|
2351
2566
|
sp.obligations.delete(id);
|
|
2567
|
+
}
|
|
2352
2568
|
break;
|
|
2353
2569
|
case "ESCALATE":
|
|
2354
2570
|
this.oblige(sp, { kind: "HUMAN", owner: `human:${rec.from}`, ref: rec.id, goal: rec.goal,
|
|
@@ -2373,6 +2589,27 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
2373
2589
|
for (const [id, o] of sp.obligations)
|
|
2374
2590
|
if (o.ref === rec.ref)
|
|
2375
2591
|
sp.obligations.delete(id);
|
|
2592
|
+
if (rec.ref)
|
|
2593
|
+
for (const [k, v] of sp.recent)
|
|
2594
|
+
if (v.id === rec.ref)
|
|
2595
|
+
sp.recent.delete(k);
|
|
2596
|
+
if (rec.ref) {
|
|
2597
|
+
const answer = sp.ledger.find(rec.ref);
|
|
2598
|
+
const asked = answer?.act === "ANSWER" && answer.from === rec.from && answer.ref ? sp.ledger.find(answer.ref) : undefined;
|
|
2599
|
+
const decider = asked?.act === "ESCALATE" ? asked.from : asked?.act === "HUMAN_REQUIRED" ? asked.to?.[0] : undefined;
|
|
2600
|
+
const retracted = new Set(sp.ledger.all().filter((r) => r.act === "RETRACT" && r.ref).map((r) => r.ref));
|
|
2601
|
+
const stillAnswered = asked && sp.ledger.all().some((r) => r.act === "ANSWER" && r.ref === asked.id && r.from === rec.from && r.id !== answer.id && !retracted.has(r.id));
|
|
2602
|
+
const alreadyLive = asked && [...sp.obligations.values()].some((o) => o.kind === "HUMAN" && o.ref === asked.id);
|
|
2603
|
+
if (asked && decider === rec.from && !stillAnswered && !alreadyLive && !retracted.has(asked.id)) {
|
|
2604
|
+
this.oblige(sp, { kind: "HUMAN", owner: `human:${decider}`, ref: asked.id, goal: asked.goal,
|
|
2605
|
+
body: asked.act === "ESCALATE" ? `${asked.from} escalated: ${first(asked.body)}` : `${asked.from} needs a decision: ${first(asked.body)}` });
|
|
2606
|
+
const g = asked.goal ? sp.goals.get(asked.goal) : undefined;
|
|
2607
|
+
if (g && g.state === "OPEN") {
|
|
2608
|
+
g.state = "BLOCKED";
|
|
2609
|
+
this.broadcast(sp, { t: "goal", goal: g });
|
|
2610
|
+
}
|
|
2611
|
+
}
|
|
2612
|
+
}
|
|
2376
2613
|
break;
|
|
2377
2614
|
case "CLOSE":
|
|
2378
2615
|
if (goal) {
|
|
@@ -2400,9 +2637,17 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
2400
2637
|
sp.obligations.set(id, { ...o, id, created_at: Date.now() });
|
|
2401
2638
|
}
|
|
2402
2639
|
pushObligations(sp) {
|
|
2403
|
-
sp.store.
|
|
2640
|
+
sp.store.flush(sp.goals, sp.obligations, sp.profiles);
|
|
2404
2641
|
this.broadcast(sp, { t: "obligations", list: [...sp.obligations.values()] });
|
|
2405
2642
|
}
|
|
2643
|
+
writeOk(me) {
|
|
2644
|
+
const now = Date.now();
|
|
2645
|
+
me.stamps = me.stamps.filter((t) => now - t < LIMITS.rateWindowMs);
|
|
2646
|
+
if (me.stamps.length >= LIMITS.rateActs)
|
|
2647
|
+
return false;
|
|
2648
|
+
me.stamps.push(now);
|
|
2649
|
+
return true;
|
|
2650
|
+
}
|
|
2406
2651
|
mayCreateSpace(ip) {
|
|
2407
2652
|
const now = Date.now();
|
|
2408
2653
|
if (!this.localTrusted(ip)) {
|
|
@@ -2490,7 +2735,7 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
2490
2735
|
getOrCreateSpace(name, token) {
|
|
2491
2736
|
const existing = this.spaces.get(name);
|
|
2492
2737
|
if (existing)
|
|
2493
|
-
return existing.token
|
|
2738
|
+
return secretEq(existing.token, token) ? existing : null;
|
|
2494
2739
|
const safe = name.replace(/[^a-z0-9._-]/gi, "_");
|
|
2495
2740
|
const allow = new Allowlist(this.dataDir, safe);
|
|
2496
2741
|
const match = allow.tokenMatches(token);
|
|
@@ -2533,7 +2778,8 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
2533
2778
|
const g = sp.goals.get(goal);
|
|
2534
2779
|
return !!g && g.state !== "CLOSED";
|
|
2535
2780
|
};
|
|
2536
|
-
const
|
|
2781
|
+
const referenced = new Set([...sp.obligations.values()].map((o) => o.ref));
|
|
2782
|
+
const { dropped, kept } = sp.ledger.compact({ keepRecent: LIMITS.ledgerKeepRecent, isLive, keep: (id) => referenced.has(id) });
|
|
2537
2783
|
this.compactedAt.set(sp.name, Date.now());
|
|
2538
2784
|
if (dropped)
|
|
2539
2785
|
this.log(`[${sp.name}] compacted ledger — dropped ${dropped} old act(s) from closed goals, ${kept} kept`);
|