@join-pantheon/cli 0.28.0 → 0.29.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/accounts.js +13 -2
- package/dist/agents.js +377 -9
- package/dist/cli.js +378 -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 +286 -60
- 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 +186 -22
- package/dist/refusal.js +29 -0
- package/dist/relay.js +738 -447
- package/dist/roster-file.js +19 -3
- package/dist/run-failure.js +18 -7
- package/dist/service.js +192 -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";
|
|
@@ -47,8 +47,14 @@ function cookieVal(req, name) {
|
|
|
47
47
|
return "";
|
|
48
48
|
for (const part of raw.split(";")) {
|
|
49
49
|
const eq = part.indexOf("=");
|
|
50
|
-
if (eq > -1 && part.slice(0, eq).trim() === name)
|
|
51
|
-
|
|
50
|
+
if (eq > -1 && part.slice(0, eq).trim() === name) {
|
|
51
|
+
try {
|
|
52
|
+
return decodeURIComponent(part.slice(eq + 1).trim());
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
return "";
|
|
56
|
+
}
|
|
57
|
+
}
|
|
52
58
|
}
|
|
53
59
|
return "";
|
|
54
60
|
}
|
|
@@ -72,8 +78,25 @@ export function isTrustedSource(ip) {
|
|
|
72
78
|
return true;
|
|
73
79
|
return false;
|
|
74
80
|
}
|
|
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);
|
|
81
|
+
function fingerprint_(from, act, body, goal, ref, extra = null) {
|
|
82
|
+
return createHash("sha256").update([from, act, goal ?? "", ref ?? "", body, stableJson(extra)].join("\u0000")).digest("hex").slice(0, 32);
|
|
83
|
+
}
|
|
84
|
+
function stableJson(v) {
|
|
85
|
+
if (v === null || typeof v !== "object")
|
|
86
|
+
return JSON.stringify(v ?? null);
|
|
87
|
+
if (Array.isArray(v))
|
|
88
|
+
return `[${v.map(stableJson).join(",")}]`;
|
|
89
|
+
return `{${Object.keys(v).sort().map((k) => `${JSON.stringify(k)}:${stableJson(v[k])}`).join(",")}}`;
|
|
90
|
+
}
|
|
91
|
+
function clientIp(req) {
|
|
92
|
+
const raw = req.headers["x-forwarded-for"];
|
|
93
|
+
const hops = (Array.isArray(raw) ? raw.join(",") : raw ?? "").split(",").map((x) => x.trim()).filter(Boolean);
|
|
94
|
+
const trusted = Math.max(1, Number(process.env.PANTHEON_TRUSTED_HOPS ?? 1) || 1);
|
|
95
|
+
return hops.length ? hops[Math.max(0, hops.length - trusted)] : req.socket.remoteAddress || "unknown";
|
|
96
|
+
}
|
|
97
|
+
function secretEq(a, b) {
|
|
98
|
+
const x = createHash("sha256").update(a).digest(), y = createHash("sha256").update(b).digest();
|
|
99
|
+
return timingSafeEqualBuf(x, y);
|
|
77
100
|
}
|
|
78
101
|
const ADMIN_CSS = `<style>
|
|
79
102
|
: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,12 +160,38 @@ const fmtAgo = (t) => {
|
|
|
137
160
|
const m = Math.round((Date.now() - t) / 60_000);
|
|
138
161
|
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
162
|
};
|
|
163
|
+
const safeName = (name) => name.replace(/[^a-z0-9._-]/gi, "_");
|
|
164
|
+
const LIMITS_GOAL_READ = 1000;
|
|
165
|
+
const LIMITS_SPACE = { metaBytes: 8192, openGoals: 500, repos: 100, openInvites: 50 };
|
|
140
166
|
export class Relay {
|
|
141
167
|
opts;
|
|
142
168
|
spaces = new Map();
|
|
143
169
|
dataDir;
|
|
144
170
|
log;
|
|
145
|
-
http = createServer((req, res) =>
|
|
171
|
+
http = createServer((req, res) => {
|
|
172
|
+
const fail = (e) => {
|
|
173
|
+
const bad = e instanceof TypeError || e instanceof URIError;
|
|
174
|
+
if (!bad)
|
|
175
|
+
this.log(`! http ${req.method} ${String(req.url ?? "").slice(0, 120)} failed: ${e?.message ?? e}`);
|
|
176
|
+
try {
|
|
177
|
+
if (!res.headersSent)
|
|
178
|
+
this.json(res, bad ? 400 : 500, { error: bad ? "bad request" : "the relay could not handle that request" });
|
|
179
|
+
else
|
|
180
|
+
res.end();
|
|
181
|
+
}
|
|
182
|
+
catch { }
|
|
183
|
+
};
|
|
184
|
+
try {
|
|
185
|
+
if (!URL.canParse(req.url ?? "/", "http://x"))
|
|
186
|
+
return fail(new TypeError("unparseable request target"));
|
|
187
|
+
const r = this.onHttp(req, res);
|
|
188
|
+
if (r && typeof r.catch === "function")
|
|
189
|
+
r.catch(fail);
|
|
190
|
+
}
|
|
191
|
+
catch (e) {
|
|
192
|
+
fail(e);
|
|
193
|
+
}
|
|
194
|
+
});
|
|
146
195
|
wss = new WebSocketServer({ noServer: true, maxPayload: 256 * 1024 });
|
|
147
196
|
heartbeat;
|
|
148
197
|
compactedAt = new Map();
|
|
@@ -197,8 +246,7 @@ export class Relay {
|
|
|
197
246
|
this.busyTtlMs = opts.activityBusyTtlMs ?? LIMITS.activityBusyTtlMs;
|
|
198
247
|
this.pruneMs = opts.activityPruneMs ?? LIMITS.activityPruneMs;
|
|
199
248
|
this.http.on("upgrade", (req, socket, head) => {
|
|
200
|
-
const
|
|
201
|
-
const ip = fwd || req.socket.remoteAddress || "unknown";
|
|
249
|
+
const ip = clientIp(req);
|
|
202
250
|
this.wss.handleUpgrade(req, socket, head, (ws) => this.onSocket(ws, ip));
|
|
203
251
|
});
|
|
204
252
|
}
|
|
@@ -234,8 +282,7 @@ export class Relay {
|
|
|
234
282
|
}
|
|
235
283
|
onHttp(req, res) {
|
|
236
284
|
const url = new URL(req.url ?? "/", "http://x");
|
|
237
|
-
const ip = (req
|
|
238
|
-
|| req.socket.remoteAddress || "unknown";
|
|
285
|
+
const ip = clientIp(req);
|
|
239
286
|
if (url.pathname === "/" || url.pathname.startsWith("/auth/") || url.pathname.startsWith("/account/")
|
|
240
287
|
|| url.pathname === "/audit" || url.pathname.startsWith("/admin")) {
|
|
241
288
|
return this.onAuthHttp(req, res, url, ip);
|
|
@@ -264,6 +311,8 @@ export class Relay {
|
|
|
264
311
|
});
|
|
265
312
|
}
|
|
266
313
|
if (url.pathname === "/join") {
|
|
314
|
+
if (!this.authRateOk(ip))
|
|
315
|
+
return this.json(res, 429, { error: "too many lookups from your network — wait a minute" });
|
|
267
316
|
const token = (url.searchParams.get("token") ?? "").trim();
|
|
268
317
|
if (!/^[A-Za-z0-9_-]{16,64}$/.test(token))
|
|
269
318
|
return this.json(res, 400, { error: "an invite code is required: /join?token=<code>" });
|
|
@@ -273,9 +322,9 @@ export class Relay {
|
|
|
273
322
|
: this.json(res, 404, { error: "no open invite matches that code — it may have been used already or expired" });
|
|
274
323
|
}
|
|
275
324
|
if (url.pathname === "/act") {
|
|
276
|
-
const space = this.
|
|
325
|
+
const space = this.spaceForRead(url.searchParams.get("space") ?? "", url.searchParams.get("token") ?? "");
|
|
277
326
|
const token = url.searchParams.get("token") ?? "";
|
|
278
|
-
if (!space || space.token
|
|
327
|
+
if (!space || !secretEq(space.token, token))
|
|
279
328
|
return this.json(res, 404, { error: "no such space" });
|
|
280
329
|
const act = space.ledger.find(url.searchParams.get("id") ?? "");
|
|
281
330
|
if (!act)
|
|
@@ -284,19 +333,20 @@ export class Relay {
|
|
|
284
333
|
return this.json(res, 200, { act, ...(goal ? { goal: { id: goal.id, title: goal.title, state: goal.state } } : {}) });
|
|
285
334
|
}
|
|
286
335
|
if (url.pathname === "/history") {
|
|
287
|
-
const space = this.
|
|
336
|
+
const space = this.spaceForRead(url.searchParams.get("space") ?? "", url.searchParams.get("token") ?? "");
|
|
288
337
|
const token = url.searchParams.get("token") ?? "";
|
|
289
|
-
if (!space || space.token
|
|
338
|
+
if (!space || !secretEq(space.token, token))
|
|
290
339
|
return this.json(res, 404, { error: "no such space" });
|
|
291
340
|
const goal = url.searchParams.get("goal");
|
|
292
|
-
const
|
|
293
|
-
const acts = goal ? space.ledger.byGoal(goal
|
|
341
|
+
const sinceRaw = url.searchParams.get("since");
|
|
342
|
+
const acts = goal ? space.ledger.byGoal(goal, LIMITS_GOAL_READ)
|
|
343
|
+
: sinceRaw !== null ? space.ledger.since(Number(sinceRaw) || 0) : space.ledger.latest();
|
|
294
344
|
return this.json(res, 200, { acts, head: space.ledger.head });
|
|
295
345
|
}
|
|
296
346
|
if (url.pathname === "/obligations") {
|
|
297
|
-
const space = this.
|
|
347
|
+
const space = this.spaceForRead(url.searchParams.get("space") ?? "", url.searchParams.get("token") ?? "");
|
|
298
348
|
const token = url.searchParams.get("token") ?? "";
|
|
299
|
-
if (!space || space.token
|
|
349
|
+
if (!space || !secretEq(space.token, token))
|
|
300
350
|
return this.json(res, 404, { error: "no such space" });
|
|
301
351
|
const member = url.searchParams.get("member");
|
|
302
352
|
const now = Date.now();
|
|
@@ -312,9 +362,9 @@ export class Relay {
|
|
|
312
362
|
if (url.pathname === "/activity") {
|
|
313
363
|
if (req.method !== "POST")
|
|
314
364
|
return this.json(res, 405, { error: "POST only" });
|
|
315
|
-
const space = this.
|
|
365
|
+
const space = this.spaceForRead(url.searchParams.get("space") ?? "", url.searchParams.get("token") ?? "");
|
|
316
366
|
const token = url.searchParams.get("token") ?? "";
|
|
317
|
-
if (!space || space.token
|
|
367
|
+
if (!space || !secretEq(space.token, token))
|
|
318
368
|
return this.json(res, 404, { error: "no such space" });
|
|
319
369
|
const memberId = url.searchParams.get("member") ?? "";
|
|
320
370
|
const member = space.members.get(memberId);
|
|
@@ -461,9 +511,10 @@ export class Relay {
|
|
|
461
511
|
if (started.reason === "blocked")
|
|
462
512
|
return this.json(res, 403, { error: "this email cannot sign up on this relay", reason: "blocked" });
|
|
463
513
|
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
|
-
: "
|
|
514
|
+
: 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"
|
|
515
|
+
: started.reason === "username_taken" ? "that username is taken"
|
|
516
|
+
: started.reason === "email_taken" ? "that email already has an account — use `pantheon login` from this machine instead"
|
|
517
|
+
: "check the email and username";
|
|
467
518
|
return this.json(res, 409, { error: msg, reason: started.reason });
|
|
468
519
|
}
|
|
469
520
|
A.audit({ event: "signup", target: normalizeUsername(b.username), ip });
|
|
@@ -485,8 +536,8 @@ export class Relay {
|
|
|
485
536
|
if (!started.ok && started.reason === "blocked")
|
|
486
537
|
return this.json(res, 403, { error: "this email cannot sign in on this relay", reason: "blocked" });
|
|
487
538
|
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";
|
|
539
|
+
const code = started.reason === "too_soon" || started.reason === "in_progress_elsewhere" ? 429 : 404;
|
|
540
|
+
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
541
|
return this.json(res, code, { error: msg, reason: started.reason });
|
|
491
542
|
}
|
|
492
543
|
if (!this.emailBudgetOk())
|
|
@@ -503,12 +554,14 @@ export class Relay {
|
|
|
503
554
|
return this.readJson(req, (b) => {
|
|
504
555
|
if (!b || typeof b.email !== "string" || typeof b.code !== "string")
|
|
505
556
|
return this.json(res, 400, { error: "email and code are required" });
|
|
506
|
-
const r = A.verify(b.email, b.code.trim(), ip);
|
|
557
|
+
const r = A.verify(b.email, b.code.trim(), ip, typeof b.pubkey === "string" ? b.pubkey : undefined);
|
|
507
558
|
if (!r.ok) {
|
|
508
559
|
const msg = r.reason === "expired" ? "that code has expired — request a new one"
|
|
509
|
-
: r.reason === "
|
|
510
|
-
: r.reason === "
|
|
511
|
-
: "
|
|
560
|
+
: r.reason === "other_device" ? "that code was requested from a different device — request a new one from this one"
|
|
561
|
+
: r.reason === "suspended" ? "this account is suspended — contact the relay operator"
|
|
562
|
+
: r.reason === "too_many_attempts" ? "too many wrong tries — request a new code"
|
|
563
|
+
: r.reason === "no_pending" ? "no code is pending for that email — run signup or login first"
|
|
564
|
+
: "that code is not right";
|
|
512
565
|
return this.json(res, 400, { error: msg, reason: r.reason });
|
|
513
566
|
}
|
|
514
567
|
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 +953,11 @@ button:hover{background:#1d4ed8}.err{color:var(--bad);margin-top:12px}
|
|
|
900
953
|
}
|
|
901
954
|
setTimeout(() => this.deleteSpaceFiles(name), 600).unref?.();
|
|
902
955
|
const safe = name.replace(/[^a-z0-9._-]/gi, "_");
|
|
956
|
+
try {
|
|
957
|
+
const hash = sp?.allow ? JSON.parse(readFileSync(join(this.dataDir, `${safe}.members.json`), "utf8")).tokenHash ?? null : null;
|
|
958
|
+
writeFileSync(join(this.dataDir, `${safe}.deleted.json`), JSON.stringify({ name, at: Date.now(), tokenHash: hash }));
|
|
959
|
+
}
|
|
960
|
+
catch { }
|
|
903
961
|
const existed = !!sp || [".jsonl", ".state.json", ".members.json"].some((suf) => existsSync(join(this.dataDir, `${safe}${suf}`)));
|
|
904
962
|
this.deleteSpaceFiles(name);
|
|
905
963
|
this.deletedSpaces.set(name, Date.now());
|
|
@@ -1033,6 +1091,42 @@ button:hover{background:#1d4ed8}.err{color:var(--bad);margin-top:12px}
|
|
|
1033
1091
|
default: return this.json(res, 400, { error: `unknown action "${act}"` });
|
|
1034
1092
|
}
|
|
1035
1093
|
}
|
|
1094
|
+
spaceForRead(name, token) {
|
|
1095
|
+
const live = this.spaces.get(name);
|
|
1096
|
+
if (live)
|
|
1097
|
+
return secretEq(live.token, token) ? live : null;
|
|
1098
|
+
if (!name || !token || !this.spaceOnDisk(name))
|
|
1099
|
+
return null;
|
|
1100
|
+
if (new Allowlist(this.dataDir, safeName(name)).tokenMatches(token) !== true)
|
|
1101
|
+
return null;
|
|
1102
|
+
return this.getOrCreateSpace(name, token);
|
|
1103
|
+
}
|
|
1104
|
+
tombstone(name, _token) {
|
|
1105
|
+
try {
|
|
1106
|
+
return JSON.parse(readFileSync(join(this.dataDir, `${safeName(name)}.deleted.json`), "utf8")).at;
|
|
1107
|
+
}
|
|
1108
|
+
catch {
|
|
1109
|
+
return null;
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
tombstoneMatches(name, token) {
|
|
1113
|
+
try {
|
|
1114
|
+
const t = JSON.parse(readFileSync(join(this.dataDir, `${safeName(name)}.deleted.json`), "utf8"));
|
|
1115
|
+
return !!t.tokenHash && !!token && createHash("sha256").update(token).digest("hex") === t.tokenHash;
|
|
1116
|
+
}
|
|
1117
|
+
catch {
|
|
1118
|
+
return false;
|
|
1119
|
+
}
|
|
1120
|
+
}
|
|
1121
|
+
nameCollides(name) {
|
|
1122
|
+
const stem = safeName(name).toLowerCase();
|
|
1123
|
+
try {
|
|
1124
|
+
return readdirSync(this.dataDir).some((f) => f.endsWith(".members.json") && f.slice(0, -".members.json".length).toLowerCase() === stem);
|
|
1125
|
+
}
|
|
1126
|
+
catch {
|
|
1127
|
+
return false;
|
|
1128
|
+
}
|
|
1129
|
+
}
|
|
1036
1130
|
spaceOnDisk(name) {
|
|
1037
1131
|
return existsSync(join(this.dataDir, `${name.replace(/[^a-z0-9._-]/gi, "_")}.members.json`));
|
|
1038
1132
|
}
|
|
@@ -1549,7 +1643,7 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
1549
1643
|
const fail = (code, message, cid) => {
|
|
1550
1644
|
if (cid && space && me && !space.deleted) {
|
|
1551
1645
|
noteRefusal(space.store.refusals, { ts: Date.now(), member: me.id, act: lastMsg || "?", code });
|
|
1552
|
-
space.store.
|
|
1646
|
+
space.store.flush(space.goals, space.obligations, space.profiles);
|
|
1553
1647
|
}
|
|
1554
1648
|
this.send(ws, { t: "error", code, message, ...(cid ? { cid } : {}) });
|
|
1555
1649
|
};
|
|
@@ -1575,453 +1669,546 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
1575
1669
|
ws.on("close", clearHelloTimer);
|
|
1576
1670
|
this.send(ws, { t: "challenge", nonce, v: PROTOCOL_VERSION });
|
|
1577
1671
|
ws.on("message", (raw) => {
|
|
1578
|
-
let msg;
|
|
1579
1672
|
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}"`);
|
|
1673
|
+
let msg;
|
|
1674
|
+
try {
|
|
1675
|
+
msg = JSON.parse(String(raw));
|
|
1605
1676
|
}
|
|
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`);
|
|
1677
|
+
catch {
|
|
1678
|
+
return fail("BAD_JSON", "message was not JSON");
|
|
1613
1679
|
}
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1680
|
+
const parsed = Inbound.safeParse(msg);
|
|
1681
|
+
if (!parsed.success) {
|
|
1682
|
+
const cid = typeof msg?.cid === "string"
|
|
1683
|
+
? msg.cid : undefined;
|
|
1684
|
+
return fail("BAD_MESSAGE", parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; "), cid);
|
|
1685
|
+
}
|
|
1686
|
+
const m = parsed.data;
|
|
1687
|
+
lastMsg = m.t === "act" ? m.act : m.t;
|
|
1688
|
+
touch();
|
|
1689
|
+
if (m.t === "ping")
|
|
1690
|
+
return this.send(ws, { t: "pong" });
|
|
1691
|
+
if (m.t === "hello") {
|
|
1692
|
+
if (space)
|
|
1693
|
+
return reject("ALREADY_JOINED", "this socket already said hello");
|
|
1694
|
+
if (m.nonce !== nonce)
|
|
1695
|
+
return reject("BAD_NONCE", "challenge mismatch — reconnect");
|
|
1696
|
+
if (!m.token && !m.enroll)
|
|
1697
|
+
return reject("NO_CREDENTIAL", "present either a space token or an invite");
|
|
1698
|
+
if (!verifyChallenge(m.pubkey, m.space, m.member, m.nonce, m.sig)) {
|
|
1699
|
+
return reject("BAD_SIGNATURE", `signature does not match the presented key for "${m.member}"`);
|
|
1618
1700
|
}
|
|
1619
|
-
const
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1701
|
+
const gated = this.requireAccounts && !!this.accounts && !this.localTrusted(ip);
|
|
1702
|
+
const pre = gated ? this.accounts.authorize(m.pubkey, m.member) : null;
|
|
1703
|
+
let sp;
|
|
1704
|
+
if (m.enroll) {
|
|
1705
|
+
sp = this.spaces.get(m.space) ?? null;
|
|
1706
|
+
if (!sp && this.spaceOnDisk(m.space)) {
|
|
1707
|
+
const inv = new Allowlist(this.dataDir, safeName(m.space)).findInvite(m.enroll);
|
|
1708
|
+
if (inv?.token === m.enroll && inv.space_token)
|
|
1709
|
+
sp = this.getOrCreateSpace(m.space, inv.space_token);
|
|
1710
|
+
}
|
|
1711
|
+
if (!sp)
|
|
1712
|
+
return reject("NO_SUCH_SPACE", `no space "${m.space}" on this relay`);
|
|
1624
1713
|
}
|
|
1625
|
-
|
|
1626
|
-
this.
|
|
1627
|
-
|
|
1714
|
+
else {
|
|
1715
|
+
const tomb = this.deletedSpaces.get(m.space) ?? this.tombstone(m.space, m.token ?? "");
|
|
1716
|
+
if (tomb && Date.now() - tomb < 3_600_000 || (tomb && this.tombstoneMatches(m.space, m.token ?? ""))) {
|
|
1717
|
+
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.`);
|
|
1718
|
+
}
|
|
1719
|
+
const isNew = !this.spaces.has(m.space) && !this.spaceOnDisk(m.space);
|
|
1720
|
+
if (isNew && this.nameCollides(m.space)) {
|
|
1721
|
+
return reject("NAME_TAKEN", `a team with a name too close to "${m.space}" already exists here — pick another name`);
|
|
1722
|
+
}
|
|
1723
|
+
if (!this.spaces.has(m.space)) {
|
|
1724
|
+
const denied = this.mayCreateSpace(ip);
|
|
1725
|
+
if (denied)
|
|
1726
|
+
return reject(denied.code, denied.message);
|
|
1727
|
+
}
|
|
1728
|
+
if (isNew && gated && !pre?.ok) {
|
|
1729
|
+
this.log(`! REFUSED ${m.member}: cannot create "${m.space}" without a signed-in account (${pre?.ok === false ? pre.reason : "no account"})`);
|
|
1730
|
+
return reject("NOT_SIGNED_IN", "sign in first: run `pantheon signup` (new) or `pantheon login` (existing account) on this machine, then try again.");
|
|
1731
|
+
}
|
|
1732
|
+
if (isNew && pre?.ok) {
|
|
1733
|
+
const lim = this.accounts.limitsFor(pre.account);
|
|
1734
|
+
const owned = this.accounts.spacesOwned(pre.account.id);
|
|
1735
|
+
if (owned.length >= lim.spaces) {
|
|
1736
|
+
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`;
|
|
1737
|
+
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 });
|
|
1738
|
+
this.log(`! PLAN_LIMIT ${m.member}: cannot create "${m.space}" (${owned.length}/${fmtLimit(lim.spaces)} spaces on ${pre.account.plan})`);
|
|
1739
|
+
return reject("PLAN_LIMIT", msg);
|
|
1740
|
+
}
|
|
1741
|
+
}
|
|
1742
|
+
sp = this.getOrCreateSpace(m.space, m.token);
|
|
1743
|
+
if (!sp)
|
|
1744
|
+
return reject("BAD_TOKEN", "wrong token for this space");
|
|
1745
|
+
if (isNew) {
|
|
1746
|
+
this.noteSpaceCreate(ip);
|
|
1747
|
+
this.accounts?.recordSpace(sp.name, pre?.ok ? pre.account.id : null);
|
|
1748
|
+
}
|
|
1628
1749
|
}
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
const
|
|
1632
|
-
if (
|
|
1633
|
-
const msg =
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1750
|
+
let acctSession = null;
|
|
1751
|
+
if (this.requireAccounts && this.accounts && !this.localTrusted(ip)) {
|
|
1752
|
+
const auth = this.accounts.authorize(m.pubkey, m.member);
|
|
1753
|
+
if (!auth.ok) {
|
|
1754
|
+
const msg = auth.reason === "username_mismatch"
|
|
1755
|
+
? `this device is signed in as a different username than "${m.member}". Use your own username, or \`pantheon login\` on this machine.`
|
|
1756
|
+
: auth.reason === "suspended"
|
|
1757
|
+
? `this account has been suspended by the relay operator. Contact them to have it reinstated.`
|
|
1758
|
+
: `sign in first: run \`pantheon signup\` (new) or \`pantheon login\` (existing account) on this machine, then try again.`;
|
|
1759
|
+
this.log(`[${sp.name}] ! REFUSED ${m.member}: ${auth.reason}`);
|
|
1760
|
+
return reject("NOT_SIGNED_IN", msg);
|
|
1637
1761
|
}
|
|
1762
|
+
m.member = auth.account.username;
|
|
1763
|
+
acct = auth.account;
|
|
1764
|
+
acctSession = this.accounts.openSession(auth.account.id, auth.device.id, sp.name, ip, m.role ?? "interactive").id;
|
|
1765
|
+
dbSession = acctSession;
|
|
1766
|
+
lastTouch = Date.now();
|
|
1638
1767
|
}
|
|
1639
|
-
|
|
1640
|
-
if (
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1768
|
+
let enrolled = null;
|
|
1769
|
+
if (m.enroll) {
|
|
1770
|
+
const plan = this.spacePlan(sp.name);
|
|
1771
|
+
if (plan && !sp.allow.get(m.member)?.admitted && sp.allow.admittedCount() >= plan.limits.members) {
|
|
1772
|
+
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 });
|
|
1773
|
+
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`);
|
|
1774
|
+
}
|
|
1775
|
+
const r = sp.allow.redeem(m.enroll, m.member, m.pubkey);
|
|
1776
|
+
if (!r.ok) {
|
|
1777
|
+
this.log(`[${sp.name}] ! invite refused for ${m.member}: ${r.why}`);
|
|
1778
|
+
return reject("BAD_INVITE", r.fix ? `${r.why}\n${r.fix}` : r.why);
|
|
1779
|
+
}
|
|
1780
|
+
enrolled = sp.token;
|
|
1781
|
+
this.log(`[${sp.name}] * ENROLLED ${m.member} via invite from ${r.entry.admitted_by}${r.entry.may_wake ? " (may wake)" : ""}`);
|
|
1645
1782
|
}
|
|
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);
|
|
1783
|
+
const verdict = enrolled ? "ok" : sp.allow.present(m.member, m.pubkey);
|
|
1784
|
+
if (verdict === "key-mismatch") {
|
|
1785
|
+
this.log(`[${sp.name}] ! REFUSED ${m.member}: key mismatch (${fingerprint(m.pubkey)})`);
|
|
1786
|
+
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
1787
|
}
|
|
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`);
|
|
1788
|
+
if (verdict === "revoked") {
|
|
1789
|
+
const e = sp.allow.get(m.member);
|
|
1790
|
+
this.log(`[${sp.name}] ! REFUSED ${m.member}: removed${e?.revoked_by ? ` by ${e.revoked_by}` : ""}`);
|
|
1791
|
+
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
1792
|
}
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
this.
|
|
1675
|
-
|
|
1793
|
+
if (verdict === "new" || verdict === "pending") {
|
|
1794
|
+
this.log(`[${sp.name}] ? PENDING ${m.member} ${fingerprint(m.pubkey)}`);
|
|
1795
|
+
this.send(ws, {
|
|
1796
|
+
t: "pending", member: m.member, fingerprint: fingerprint(m.pubkey),
|
|
1797
|
+
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)}`,
|
|
1798
|
+
});
|
|
1799
|
+
setTimeout(() => { try {
|
|
1800
|
+
ws.close(1008, "PENDING");
|
|
1801
|
+
}
|
|
1802
|
+
catch { } }, 20);
|
|
1803
|
+
return;
|
|
1804
|
+
}
|
|
1805
|
+
let newRepo = null;
|
|
1806
|
+
if (m.repo) {
|
|
1807
|
+
if (sp.allow.hasRepo(m.repo)) { }
|
|
1808
|
+
else if (sp.allow.repoCount() === 0 || enrolled || verdict === "founder")
|
|
1809
|
+
newRepo = m.repo;
|
|
1810
|
+
else {
|
|
1811
|
+
const known = sp.allow.repoList().map((r) => r.origin ?? r.fingerprint).join(", ");
|
|
1812
|
+
this.log(`[${sp.name}] ! REFUSED ${m.member}: repo ${m.repo_origin ?? m.repo} not bound to this space`);
|
|
1813
|
+
return reject("REPO_MISMATCH", `this checkout (${m.repo_origin ?? "no origin"}) is not one of the repos "${sp.name}" works from (${known || "none yet"}). ` +
|
|
1814
|
+
`Open your agent in the team's repo, or if this repo really belongs to the team: pantheon repo add`);
|
|
1815
|
+
}
|
|
1816
|
+
}
|
|
1817
|
+
space = sp;
|
|
1818
|
+
clearHelloTimer();
|
|
1819
|
+
sessionId = acctSession ?? this.mintSession();
|
|
1820
|
+
role = m.role;
|
|
1821
|
+
me = this.attach(sp, m.member, m.display ?? m.member, m.agent ?? "unknown", sessionId, m.role, ws, m.pkg);
|
|
1822
|
+
const firstSession = me.sessions.size === 1;
|
|
1823
|
+
const prior = typeof m.session === "string" ? m.session : "";
|
|
1824
|
+
const stale = prior && prior !== sessionId ? me.sessions.get(prior) : undefined;
|
|
1825
|
+
if (stale && stale.role === m.role) {
|
|
1826
|
+
me.sessions.delete(prior);
|
|
1827
|
+
try {
|
|
1828
|
+
stale.ws.terminate();
|
|
1829
|
+
}
|
|
1830
|
+
catch { }
|
|
1831
|
+
}
|
|
1832
|
+
if (prior && prior !== sessionId && !me.sessions.has(prior)) {
|
|
1833
|
+
let moved = 0;
|
|
1834
|
+
for (const o of sp.obligations.values()) {
|
|
1835
|
+
if (o.claimed_by?.session === prior && o.owner === me.id && o.claimed_by.role === m.role) {
|
|
1836
|
+
o.claimed_by = { ...o.claimed_by, session: sessionId, at: Date.now() };
|
|
1837
|
+
moved++;
|
|
1838
|
+
}
|
|
1839
|
+
}
|
|
1840
|
+
for (const v of sp.contLocks.values())
|
|
1841
|
+
if (v.session === prior && v.member === me.id) {
|
|
1842
|
+
v.session = sessionId;
|
|
1843
|
+
v.at = Date.now();
|
|
1844
|
+
}
|
|
1845
|
+
if (moved)
|
|
1846
|
+
this.pushObligations(sp);
|
|
1847
|
+
}
|
|
1848
|
+
sp.lastTouch = Date.now();
|
|
1849
|
+
if (newRepo && sp.allow.addRepo(newRepo, m.repo_origin ?? null, me.id)) {
|
|
1850
|
+
this.log(`[${sp.name}] + repo ${m.repo_origin ?? newRepo} registered by ${me.id}`);
|
|
1851
|
+
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 } });
|
|
1852
|
+
this.broadcast(sp, { t: "repos", repos: sp.allow.repoList() });
|
|
1676
1853
|
}
|
|
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
1854
|
this.send(ws, {
|
|
1688
|
-
t: "
|
|
1689
|
-
|
|
1855
|
+
t: "welcome", member: me.id, session: sessionId, space: sp.name, seq: sp.ledger.head,
|
|
1856
|
+
roster: this.roster(sp), goals: [...sp.goals.values()],
|
|
1857
|
+
obligations: [...sp.obligations.values()], members: this.members(sp), profiles: [...sp.profiles.values()],
|
|
1858
|
+
space_id: sp.allow.spaceId(), repos: sp.allow.repoList(),
|
|
1859
|
+
...(acct ? { plan: this.planView(acct, sp) } : {}),
|
|
1860
|
+
...(enrolled ? { token: enrolled } : {}),
|
|
1690
1861
|
});
|
|
1691
|
-
|
|
1692
|
-
|
|
1862
|
+
let from = m.from_seq && m.from_seq > 0 ? m.from_seq : sp.ledger.head;
|
|
1863
|
+
for (let page = sp.ledger.since(from); page.length; page = sp.ledger.since(from)) {
|
|
1864
|
+
for (const r of page) {
|
|
1865
|
+
this.send(ws, { t: "act", act: r });
|
|
1866
|
+
if (r.seq > me.delivered_seq)
|
|
1867
|
+
me.delivered_seq = r.seq;
|
|
1868
|
+
}
|
|
1869
|
+
from = page[page.length - 1].seq;
|
|
1870
|
+
}
|
|
1871
|
+
if (firstSession && m.role !== "cli") {
|
|
1872
|
+
this.commit(sp, { from: me.id, act: "JOIN", body: `${me.display} online (${me.agent})`, to: null, goal: null, ref: null });
|
|
1693
1873
|
}
|
|
1694
|
-
|
|
1874
|
+
this.broadcast(sp, { t: "roster", roster: this.roster(sp) });
|
|
1875
|
+
this.log(`[${sp.name}] + ${me.id}/${sessionId} (${m.role}, ${me.agent})${verdict === "founder" ? " [founder, auto-admitted]" : ""}`);
|
|
1695
1876
|
return;
|
|
1696
1877
|
}
|
|
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`);
|
|
1878
|
+
if (!space || !me)
|
|
1879
|
+
return reject("NOT_JOINED", "say hello first");
|
|
1880
|
+
if (m.t === "publish_profile") {
|
|
1881
|
+
if (m.profile.member !== me.id) {
|
|
1882
|
+
return fail("PROFILE_NOT_YOURS", `you may publish only the profile for ${me.id}`, m.cid);
|
|
1707
1883
|
}
|
|
1884
|
+
if (!this.writeOk(me))
|
|
1885
|
+
return fail("RATE_LIMIT", "too many changes too fast — wait a few seconds", m.cid);
|
|
1886
|
+
const wild = Object.keys(m.profile.authority ?? {}).filter((k) => /[*?]/.test(k) || k.trim() === "");
|
|
1887
|
+
if (wild.length)
|
|
1888
|
+
return fail("BAD_AUTHORITY", `authority is claimed per named domain, not with "${wild[0]}"`, m.cid);
|
|
1889
|
+
const current = space.profiles.get(me.id);
|
|
1890
|
+
if (!current || m.profile.updated_at > current.updated_at) {
|
|
1891
|
+
space.profiles.set(me.id, m.profile);
|
|
1892
|
+
space.store.flush(space.goals, space.obligations, space.profiles);
|
|
1893
|
+
this.broadcast(space, { t: "profiles", profiles: [...space.profiles.values()] });
|
|
1894
|
+
this.commit(space, {
|
|
1895
|
+
from: me.id, session: sessionId, role, act: "NOTE", to: null, goal: null, ref: null,
|
|
1896
|
+
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"}`,
|
|
1897
|
+
meta: { organization_profile: m.profile },
|
|
1898
|
+
});
|
|
1899
|
+
}
|
|
1900
|
+
this.send(ws, { t: "ack", ...(m.cid ? { cid: m.cid } : {}), id: me.id, seq: space.ledger.head });
|
|
1901
|
+
return;
|
|
1708
1902
|
}
|
|
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
|
-
|
|
1903
|
+
if (m.t === "admit") {
|
|
1904
|
+
const self = space.allow.get(me.id);
|
|
1905
|
+
if (!self?.admitted)
|
|
1906
|
+
return fail("NOT_ADMITTED", "only an admitted member can vouch for another", m.cid);
|
|
1907
|
+
if (m.member === me.id)
|
|
1908
|
+
return fail("NO_SELF_ADMIT", "you cannot admit yourself", m.cid);
|
|
1909
|
+
const target = space.allow.get(m.member);
|
|
1910
|
+
if (!target)
|
|
1911
|
+
return fail("NO_SUCH_MEMBER", `"${m.member}" has never connected, so there is no key to admit`, m.cid);
|
|
1912
|
+
if (!m.revoke && !m.forget) {
|
|
1913
|
+
const plan = this.spacePlan(space.name);
|
|
1914
|
+
if (plan && m.may_wake && !plan.limits.wake)
|
|
1915
|
+
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);
|
|
1916
|
+
if (plan && !target.admitted && space.allow.admittedCount() >= plan.limits.members)
|
|
1917
|
+
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);
|
|
1918
|
+
}
|
|
1919
|
+
if (m.revoke || m.forget) {
|
|
1920
|
+
const founder = space.allow.founder();
|
|
1921
|
+
if (founder && me.id !== founder)
|
|
1922
|
+
return fail("FOUNDER_ONLY", `only ${founder}, who founded "${space.name}", can remove a member`, m.cid);
|
|
1923
|
+
if (m.member === founder)
|
|
1924
|
+
return fail("FOUNDER_STAYS", "the founder cannot be removed from their own team", m.cid);
|
|
1925
|
+
if (m.forget)
|
|
1926
|
+
space.allow.forget(m.member);
|
|
1927
|
+
else
|
|
1928
|
+
space.allow.revoke(m.member, me.id);
|
|
1929
|
+
if (space.profiles.delete(m.member))
|
|
1930
|
+
this.broadcast(space, { t: "profiles", profiles: [...space.profiles.values()] });
|
|
1931
|
+
for (const s of space.members.get(m.member)?.sessions.values() ?? []) {
|
|
1932
|
+
try {
|
|
1933
|
+
s.ws.close(1008, "REVOKED");
|
|
1934
|
+
}
|
|
1935
|
+
catch { }
|
|
1936
|
+
}
|
|
1937
|
+
space.members.delete(m.member);
|
|
1938
|
+
this.log(`[${space.name}] - ${m.forget ? "FORGOT" : "REVOKED"} ${m.member} by ${me.id}`);
|
|
1939
|
+
}
|
|
1940
|
+
else {
|
|
1941
|
+
space.allow.admit(m.member, me.id, m.may_wake);
|
|
1942
|
+
this.log(`[${space.name}] * ADMITTED ${m.member} by ${me.id}${m.may_wake ? " (may wake workers)" : ""}`);
|
|
1943
|
+
}
|
|
1944
|
+
this.broadcast(space, { t: "members", members: this.members(space) });
|
|
1945
|
+
this.broadcast(space, { t: "roster", roster: this.roster(space) });
|
|
1946
|
+
this.send(ws, { t: "ack", ...(m.cid ? { cid: m.cid } : {}), id: m.member, seq: space.ledger.head });
|
|
1947
|
+
return;
|
|
1736
1948
|
}
|
|
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);
|
|
1949
|
+
if (m.t === "worker_state") {
|
|
1950
|
+
if (role !== "worker")
|
|
1951
|
+
return;
|
|
1952
|
+
me.worker = { state: m.state, ...(m.detail ? { detail: m.detail } : {}), ...(m.until ? { until: m.until } : {}), at: Date.now() };
|
|
1953
|
+
this.broadcast(space, { t: "roster", roster: this.roster(space) });
|
|
1954
|
+
return;
|
|
1746
1955
|
}
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
});
|
|
1956
|
+
if (m.t === "delete_space") {
|
|
1957
|
+
if (m.confirm !== space.name)
|
|
1958
|
+
return fail("CONFIRM_MISMATCH", `type the space name ("${space.name}") to confirm`, m.cid);
|
|
1959
|
+
const owner = this.accounts?.spaceOwner(space.name) ?? null;
|
|
1960
|
+
const isOwner = owner ? owner.id === acct?.id : space.allow.founder() === me.id;
|
|
1961
|
+
if (!isOwner)
|
|
1962
|
+
return fail("NOT_OWNER", `only the founder of "${space.name}" can delete it${owner ? ` (that is ${owner.username})` : ""}`, m.cid);
|
|
1963
|
+
const name = space.name;
|
|
1964
|
+
this.accounts?.audit({ event: "space_deleted", actor_account: acct?.id ?? null, space: name, detail: `by its founder ${me.id}`, ip });
|
|
1965
|
+
this.log(`[${name}] * DELETED by its founder ${me.id}`);
|
|
1966
|
+
this.send(ws, { t: "ack", ...(m.cid ? { cid: m.cid } : {}), id: name, seq: space.ledger.head });
|
|
1967
|
+
setTimeout(() => { this.deleteSpace(name); this.accounts?.markSpaceDeleted(name); }, 50).unref?.();
|
|
1968
|
+
return;
|
|
1757
1969
|
}
|
|
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) {
|
|
1970
|
+
if (m.t === "invite") {
|
|
1971
|
+
const self = space.allow.get(me.id);
|
|
1972
|
+
if (!self?.admitted)
|
|
1973
|
+
return fail("NOT_ADMITTED", "only an admitted member can invite", m.cid);
|
|
1771
1974
|
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");
|
|
1975
|
+
if (plan) {
|
|
1976
|
+
if (m.may_wake && !plan.limits.wake)
|
|
1977
|
+
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);
|
|
1978
|
+
const open = space.allow.openInvites().length;
|
|
1979
|
+
if (space.allow.admittedCount() + open >= plan.limits.members) {
|
|
1980
|
+
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
1981
|
}
|
|
1786
|
-
catch { }
|
|
1787
1982
|
}
|
|
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")
|
|
1983
|
+
if (!this.writeOk(me))
|
|
1984
|
+
return fail("RATE_LIMIT", "too many invites too fast — wait a few seconds", m.cid);
|
|
1985
|
+
if (space.allow.openInvites().length >= LIMITS_SPACE.openInvites)
|
|
1986
|
+
return fail("TOO_MANY_INVITES", `this team already has ${LIMITS_SPACE.openInvites} unused invites — revoke some: pantheon invite --revoke all`, m.cid);
|
|
1987
|
+
const token = randomBytes(18).toString("base64url");
|
|
1988
|
+
const inv = space.allow.mintInvite(me.id, m.may_wake, m.ttl_minutes, token, space.token);
|
|
1989
|
+
this.log(`[${space.name}] * invite minted by ${me.id}, valid ${m.ttl_minutes}m, single use`);
|
|
1990
|
+
this.send(ws, { t: "invite", url: token, expires_at: inv.expires_at });
|
|
1802
1991
|
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
1992
|
}
|
|
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() });
|
|
1993
|
+
if (m.t === "invites") {
|
|
1994
|
+
const self = space.allow.get(me.id);
|
|
1995
|
+
if (!self?.admitted)
|
|
1996
|
+
return fail("NOT_ADMITTED", "only an admitted member can see the open invites", m.cid);
|
|
1997
|
+
const founder = space.allow.founder();
|
|
1998
|
+
const viewer = me.id;
|
|
1999
|
+
const invites = space.allow.openInvites().map((i) => {
|
|
2000
|
+
const mine = i.created_by === viewer;
|
|
2001
|
+
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 };
|
|
2002
|
+
});
|
|
2003
|
+
this.send(ws, { t: "invites", invites, ...(m.cid ? { cid: m.cid } : {}) });
|
|
2004
|
+
return;
|
|
1876
2005
|
}
|
|
1877
|
-
if (
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
2006
|
+
if (m.t === "revoke_invite") {
|
|
2007
|
+
const self = space.allow.get(me.id);
|
|
2008
|
+
if (!self?.admitted)
|
|
2009
|
+
return fail("NOT_ADMITTED", "only an admitted member can revoke an invite", m.cid);
|
|
2010
|
+
const inv = space.allow.findInvite(m.token.replace(/^…/, ""));
|
|
2011
|
+
if (!inv)
|
|
2012
|
+
return fail("NO_SUCH_INVITE", "no open invite matches that token — it may already be used, expired or revoked (pantheon invite --list)", m.cid);
|
|
2013
|
+
if (inv.created_by !== me.id && me.id !== space.allow.founder())
|
|
2014
|
+
return fail("NOT_YOURS", `that invite was minted by ${inv.created_by}; only they or the founder can revoke it`, m.cid);
|
|
2015
|
+
space.allow.revokeInvite(inv.token);
|
|
2016
|
+
this.log(`[${space.name}] * invite …${inv.token.slice(-6)} (minted by ${inv.created_by}) revoked by ${me.id}`);
|
|
2017
|
+
this.accounts?.audit({ event: "invite_revoked", space: space.name, target: me.id, detail: `minted by ${inv.created_by}, …${inv.token.slice(-6)}`, ip });
|
|
2018
|
+
this.send(ws, { t: "ack", ...(m.cid ? { cid: m.cid } : {}), id: inv.token.slice(-6), seq: space.ledger.head });
|
|
2019
|
+
return;
|
|
1881
2020
|
}
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
if (
|
|
1889
|
-
return
|
|
2021
|
+
if (m.t === "bind_repo") {
|
|
2022
|
+
const self = space.allow.get(me.id);
|
|
2023
|
+
if (!self?.admitted)
|
|
2024
|
+
return fail("NOT_ADMITTED", "only an admitted member can register a repo", m.cid);
|
|
2025
|
+
if (!this.writeOk(me))
|
|
2026
|
+
return fail("RATE_LIMIT", "too many repo changes too fast — wait a few seconds", m.cid);
|
|
2027
|
+
if (!space.allow.repoList().some((r) => r.fingerprint === m.repo) && space.allow.repoCount() >= LIMITS_SPACE.repos) {
|
|
2028
|
+
return fail("TOO_MANY_REPOS", `this team already works from ${LIMITS_SPACE.repos} repos`, m.cid);
|
|
2029
|
+
}
|
|
2030
|
+
if (m.base !== undefined && !/^[A-Za-z0-9._][A-Za-z0-9._\/-]{0,99}$/.test(m.base)) {
|
|
2031
|
+
return fail("BAD_BASE", `"${m.base}" is not a branch name`, m.cid);
|
|
2032
|
+
}
|
|
2033
|
+
const existing = space.allow.repoList().find((r) => r.fingerprint === m.repo);
|
|
2034
|
+
if (existing && m.base && existing.added_by !== me.id && space.allow.founder() !== me.id) {
|
|
2035
|
+
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);
|
|
2036
|
+
}
|
|
2037
|
+
const added = space.allow.addRepo(m.repo, m.origin ?? null, me.id, m.base ?? null);
|
|
2038
|
+
if (!added && m.base && space.allow.setRepoBase(m.repo, m.base)) {
|
|
2039
|
+
this.log(`[${space.name}] ~ repo ${m.origin ?? m.repo} base branch → ${m.base} (by ${me.id})`);
|
|
2040
|
+
this.broadcast(space, { t: "repos", repos: space.allow.repoList() });
|
|
2041
|
+
}
|
|
2042
|
+
if (added) {
|
|
2043
|
+
this.log(`[${space.name}] + repo ${m.origin ?? m.repo} registered by ${me.id}${m.base ? ` (base ${m.base})` : ""}`);
|
|
2044
|
+
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 } });
|
|
2045
|
+
this.broadcast(space, { t: "repos", repos: space.allow.repoList() });
|
|
2046
|
+
}
|
|
2047
|
+
this.send(ws, { t: "ack", cid: m.cid, id: m.repo, seq: space.ledger.head });
|
|
2048
|
+
return;
|
|
1890
2049
|
}
|
|
1891
|
-
if (m.
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
if (
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
2050
|
+
if (m.t === "claim") {
|
|
2051
|
+
if (role === "worker") {
|
|
2052
|
+
const plan = this.spacePlan(space.name);
|
|
2053
|
+
if (plan && !plan.limits.wake)
|
|
2054
|
+
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);
|
|
2055
|
+
}
|
|
2056
|
+
if (m.obligation.startsWith("cont:")) {
|
|
2057
|
+
const act = space.ledger.find(m.obligation.slice(5));
|
|
2058
|
+
if (!act)
|
|
2059
|
+
return fail("NO_SUCH_ACT", `no act ${m.obligation.slice(5)} to continue from`, m.cid);
|
|
2060
|
+
const refFrom = act.ref ? space.ledger.find(act.ref)?.from : undefined;
|
|
2061
|
+
const goal = act.goal ? space.goals.get(act.goal) : undefined;
|
|
2062
|
+
if (!isContinuationFor(act, me.id, {
|
|
2063
|
+
...(refFrom !== undefined ? { refFrom } : {}),
|
|
2064
|
+
...(goal ? { goalMembers: goal.members, goalState: goal.state } : {}),
|
|
2065
|
+
})) {
|
|
2066
|
+
return fail("NOT_YOURS", `that ${act.act} is not ${me.id}'s to continue`, m.cid);
|
|
2067
|
+
}
|
|
2068
|
+
if (role === "worker") {
|
|
2069
|
+
const defer = shouldDeferToHuman([...me.sessions.values()], this.work(me));
|
|
2070
|
+
const age = Date.now() - act.ts;
|
|
2071
|
+
if (defer && age < LIMITS.workerGraceMs) {
|
|
2072
|
+
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);
|
|
2073
|
+
}
|
|
2074
|
+
const sender = space.allow.get(act.from);
|
|
2075
|
+
if (act.from !== me.id && !sender?.may_wake) {
|
|
2076
|
+
return fail("NOT_ALLOWED_TO_WAKE", `"${act.from}" is not permitted to wake a worker on this machine`, m.cid);
|
|
2077
|
+
}
|
|
2078
|
+
}
|
|
2079
|
+
const held = space.contLocks.get(m.obligation);
|
|
2080
|
+
if (held && held.session !== sessionId && Date.now() - held.at < LIMITS.claimTtlMs) {
|
|
2081
|
+
return fail("CLAIMED", `another of your sessions (${held.session}) is handling this`, m.cid);
|
|
2082
|
+
}
|
|
2083
|
+
for (const [k, v] of space.contLocks) {
|
|
2084
|
+
if (Date.now() - v.at > LIMITS.claimTtlMs)
|
|
2085
|
+
space.contLocks.delete(k);
|
|
2086
|
+
}
|
|
2087
|
+
space.contLocks.set(m.obligation, { session: sessionId, member: me.id, at: Date.now() });
|
|
2088
|
+
this.send(ws, { t: "ack", ...(m.cid ? { cid: m.cid } : {}), id: m.obligation, seq: space.ledger.head });
|
|
2089
|
+
return;
|
|
2090
|
+
}
|
|
2091
|
+
const o = space.obligations.get(m.obligation);
|
|
2092
|
+
if (!o)
|
|
2093
|
+
return fail("NO_SUCH_OBLIGATION", `no obligation ${m.obligation} — it may already be discharged`, m.cid);
|
|
2094
|
+
if (o.owner !== me.id)
|
|
2095
|
+
return fail("NOT_YOURS", `${o.owner} owes that, not you`, m.cid);
|
|
2096
|
+
if (o.claimed_by && o.claimed_by.session !== sessionId) {
|
|
2097
|
+
if (Date.now() - o.claimed_by.at < LIMITS.claimTtlMs) {
|
|
2098
|
+
const holder = o.claimed_by;
|
|
2099
|
+
const live = [...me.sessions.keys()].includes(holder.session);
|
|
2100
|
+
const left = Math.ceil((LIMITS.claimTtlMs - (Date.now() - holder.at)) / 60_000);
|
|
2101
|
+
return fail("CLAIMED", live
|
|
2102
|
+
? `another of your sessions (${holder.session}, ${holder.role}) is handling this`
|
|
2103
|
+
: `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);
|
|
2104
|
+
}
|
|
1902
2105
|
}
|
|
1903
2106
|
if (role === "worker") {
|
|
2107
|
+
const age = Date.now() - o.created_at;
|
|
1904
2108
|
const defer = shouldDeferToHuman([...me.sessions.values()], this.work(me));
|
|
1905
|
-
const age = Date.now() - act.ts;
|
|
1906
2109
|
if (defer && age < LIMITS.workerGraceMs) {
|
|
1907
2110
|
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
2111
|
}
|
|
1909
|
-
const
|
|
1910
|
-
|
|
1911
|
-
|
|
2112
|
+
const origin = space.ledger.find(o.ref);
|
|
2113
|
+
const asker = origin ? space.allow.get(origin.from) : undefined;
|
|
2114
|
+
if (origin && origin.from !== me.id && !asker?.may_wake) {
|
|
2115
|
+
return fail("NOT_ALLOWED_TO_WAKE", `"${origin.from}" is not permitted to wake a worker on this machine`, m.cid);
|
|
1912
2116
|
}
|
|
1913
2117
|
}
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
2118
|
+
o.claimed_by = { session: sessionId, role, at: Date.now() };
|
|
2119
|
+
this.broadcast(space, { t: "claimed", obligation: o.id, session: sessionId, member: me.id });
|
|
2120
|
+
this.pushObligations(space);
|
|
2121
|
+
this.send(ws, { t: "ack", ...(m.cid ? { cid: m.cid } : {}), id: o.id, seq: space.ledger.head });
|
|
2122
|
+
return;
|
|
2123
|
+
}
|
|
2124
|
+
if (m.t === "open_goal") {
|
|
2125
|
+
if (!this.writeOk(me))
|
|
2126
|
+
return fail("RATE_LIMIT", "too many goals opened too fast — wait a few seconds", m.cid);
|
|
2127
|
+
const openCount = [...space.goals.values()].filter((g) => g.state !== "CLOSED").length;
|
|
2128
|
+
if (openCount >= LIMITS_SPACE.openGoals)
|
|
2129
|
+
return fail("TOO_MANY_GOALS", `this team has ${openCount} goals still open — close some first`, m.cid);
|
|
2130
|
+
for (const id of m.invite ?? []) {
|
|
2131
|
+
if (!space.members.has(id) && !space.allow.get(id)?.admitted)
|
|
2132
|
+
return fail("NO_SUCH_MEMBER", `nobody here is called "${id}"`, m.cid);
|
|
1917
2133
|
}
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
|
|
2134
|
+
const invited = (m.invite ?? [...space.members.keys()]).filter((x) => x !== me.id);
|
|
2135
|
+
if (m.deadline !== undefined && m.deadline <= Date.now()) {
|
|
2136
|
+
return fail("DEADLINE_PAST", `deadline ${new Date(m.deadline).toISOString()} is already in the past`, m.cid);
|
|
1921
2137
|
}
|
|
1922
|
-
|
|
1923
|
-
|
|
2138
|
+
const goal = {
|
|
2139
|
+
id: newId("g"),
|
|
2140
|
+
title: m.title,
|
|
2141
|
+
acceptance: m.acceptance,
|
|
2142
|
+
members: [me.id, ...new Set(invited)],
|
|
2143
|
+
state: "OPEN",
|
|
2144
|
+
acts: 0,
|
|
2145
|
+
budget: m.budget ?? LIMITS.goalBudget,
|
|
2146
|
+
opened_by: me.id,
|
|
2147
|
+
opened_at: Date.now(),
|
|
2148
|
+
...(m.deadline !== undefined ? { deadline: m.deadline } : {}),
|
|
2149
|
+
...(m.kind !== undefined ? { kind: m.kind } : {}),
|
|
2150
|
+
};
|
|
2151
|
+
space.goals.set(goal.id, goal);
|
|
2152
|
+
space.store.flush(space.goals, space.obligations, space.profiles);
|
|
2153
|
+
this.broadcast(space, { t: "goal", goal });
|
|
2154
|
+
const rec = this.commit(space, {
|
|
2155
|
+
from: me.id, act: "NOTE", to: null, goal: goal.id, ref: null,
|
|
2156
|
+
body: `goal opened: ${goal.title}\nacceptance: ${goal.acceptance || "(none stated)"}` +
|
|
2157
|
+
(goal.deadline ? `\ndeadline: ${new Date(goal.deadline).toISOString()}` : "") +
|
|
2158
|
+
(goal.kind === "worklist" ? `\nkind: worklist — ${me.id}'s own list; peers may read it, nobody owes anything in it` : ""),
|
|
2159
|
+
});
|
|
2160
|
+
this.send(ws, { t: "ack", ...(m.cid ? { cid: m.cid } : {}), id: goal.id, seq: rec.seq });
|
|
1924
2161
|
return;
|
|
1925
2162
|
}
|
|
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
|
-
}
|
|
2163
|
+
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 });
|
|
2164
|
+
const prior = space.recent.get(fp);
|
|
2165
|
+
if (prior && Date.now() - prior.at < LIMITS.dedupeWindowMs) {
|
|
2166
|
+
this.send(ws, { t: "ack", ...(m.cid ? { cid: m.cid } : {}), id: prior.id, seq: prior.seq });
|
|
2167
|
+
return;
|
|
1935
2168
|
}
|
|
1936
|
-
if (
|
|
1937
|
-
const
|
|
1938
|
-
const
|
|
1939
|
-
|
|
1940
|
-
|
|
2169
|
+
if (m.after_seq !== undefined) {
|
|
2170
|
+
const mine = me.id;
|
|
2171
|
+
const missed = space.ledger.since(m.after_seq)
|
|
2172
|
+
.filter((r) => r.from !== mine && r.act !== "JOIN" && r.act !== "LEAVE")
|
|
2173
|
+
.filter((r) => (m.goal ? r.goal === m.goal : !r.goal)
|
|
2174
|
+
|| (r.to?.includes(mine) ?? false)
|
|
2175
|
+
|| (m.to?.includes(r.from) ?? false)
|
|
2176
|
+
|| (!!m.ref && r.ref === m.ref));
|
|
2177
|
+
if (missed.length) {
|
|
2178
|
+
return fail("STALE_VIEW", `${missed.length} act(s) arrived while you were composing — read them before you answer:\n` +
|
|
2179
|
+
missed.slice(0, 8).map((r) => ` [${r.seq}] ${r.act} from ${r.from}: ${first(r.body)}`).join("\n") +
|
|
2180
|
+
(missed.length > 8 ? `\n ...and ${missed.length - 8} more` : "") +
|
|
2181
|
+
`\nCall pantheon_wait, then send again.`, m.cid);
|
|
1941
2182
|
}
|
|
1942
|
-
const origin = space.ledger.find(o.ref);
|
|
1943
|
-
const asker = origin ? space.allow.get(origin.from) : undefined;
|
|
1944
|
-
if (origin && origin.from !== me.id && !asker?.may_wake) {
|
|
1945
|
-
return fail("NOT_ALLOWED_TO_WAKE", `"${origin.from}" is not permitted to wake a worker on this machine`, m.cid);
|
|
1946
|
-
}
|
|
1947
|
-
}
|
|
1948
|
-
o.claimed_by = { session: sessionId, role, at: Date.now() };
|
|
1949
|
-
this.broadcast(space, { t: "claimed", obligation: o.id, session: sessionId, member: me.id });
|
|
1950
|
-
this.pushObligations(space);
|
|
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
2183
|
}
|
|
1959
|
-
const
|
|
1960
|
-
|
|
1961
|
-
|
|
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 });
|
|
2184
|
+
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);
|
|
2185
|
+
if (err)
|
|
2186
|
+
return fail(err.code, err.message, m.cid);
|
|
1975
2187
|
const rec = this.commit(space, {
|
|
1976
|
-
from: me.id,
|
|
1977
|
-
|
|
1978
|
-
|
|
1979
|
-
|
|
2188
|
+
from: me.id, session: sessionId, role, act: m.act, body: m.body,
|
|
2189
|
+
...(m.summary ? { summary: m.summary } : {}),
|
|
2190
|
+
to: m.to ?? null, goal: m.goal ?? null, ref: m.ref ?? null,
|
|
2191
|
+
...(m.meta || m.defers_to || m.affects
|
|
2192
|
+
? { meta: { ...(m.meta ?? {}),
|
|
2193
|
+
...(m.defers_to ? { defers_to: m.defers_to } : {}),
|
|
2194
|
+
...(m.affects ? { affects: m.affects } : {}) } }
|
|
2195
|
+
: {}),
|
|
1980
2196
|
});
|
|
1981
|
-
|
|
1982
|
-
|
|
2197
|
+
space.recent.set(fp, { id: rec.id, seq: rec.seq, at: Date.now() });
|
|
2198
|
+
for (const [k, v] of space.recent)
|
|
2199
|
+
if (Date.now() - v.at > LIMITS.dedupeWindowMs)
|
|
2200
|
+
space.recent.delete(k);
|
|
2201
|
+
this.send(ws, { t: "ack", ...(m.cid ? { cid: m.cid } : {}), id: rec.id, seq: rec.seq });
|
|
1983
2202
|
}
|
|
1984
|
-
|
|
1985
|
-
|
|
1986
|
-
|
|
1987
|
-
|
|
1988
|
-
return;
|
|
1989
|
-
}
|
|
1990
|
-
if (m.after_seq !== undefined) {
|
|
1991
|
-
const mine = me.id;
|
|
1992
|
-
const missed = space.ledger.since(m.after_seq)
|
|
1993
|
-
.filter((r) => r.from !== mine && r.act !== "JOIN" && r.act !== "LEAVE")
|
|
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);
|
|
2203
|
+
catch (e) {
|
|
2204
|
+
this.log(`! message handling failed: ${e.message}`);
|
|
2205
|
+
try {
|
|
2206
|
+
ws.send(JSON.stringify({ t: "error", code: "STORAGE", message: "the relay could not save that — try again in a moment" }));
|
|
2003
2207
|
}
|
|
2208
|
+
catch { }
|
|
2004
2209
|
}
|
|
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
2210
|
});
|
|
2024
|
-
|
|
2211
|
+
const closeWork = (code) => {
|
|
2025
2212
|
if (dbSession) {
|
|
2026
2213
|
this.accounts?.endSession(dbSession);
|
|
2027
2214
|
dbSession = null;
|
|
@@ -2029,13 +2216,22 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
2029
2216
|
if (!space || !me)
|
|
2030
2217
|
return;
|
|
2031
2218
|
me.sessions.delete(sessionId);
|
|
2219
|
+
const deliberate = code === 1000 || code === 1001 || code === 1005;
|
|
2032
2220
|
for (const o of space.obligations.values()) {
|
|
2033
|
-
if (o.claimed_by?.session
|
|
2221
|
+
if (o.claimed_by?.session !== sessionId)
|
|
2222
|
+
continue;
|
|
2223
|
+
if (deliberate)
|
|
2034
2224
|
delete o.claimed_by;
|
|
2225
|
+
else
|
|
2226
|
+
o.claimed_by = { ...o.claimed_by, at: Date.now() };
|
|
2035
2227
|
}
|
|
2036
2228
|
for (const [k, v] of space.contLocks) {
|
|
2037
|
-
if (v.session
|
|
2229
|
+
if (v.session !== sessionId)
|
|
2230
|
+
continue;
|
|
2231
|
+
if (deliberate)
|
|
2038
2232
|
space.contLocks.delete(k);
|
|
2233
|
+
else
|
|
2234
|
+
v.at = Date.now();
|
|
2039
2235
|
}
|
|
2040
2236
|
if (me.sessions.size === 0 && role !== "cli") {
|
|
2041
2237
|
me.last_seen = Date.now();
|
|
@@ -2044,7 +2240,13 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
2044
2240
|
this.pushObligations(space);
|
|
2045
2241
|
this.broadcast(space, { t: "roster", roster: this.roster(space) });
|
|
2046
2242
|
this.log(`[${space.name}] - ${me.id}/${sessionId}`);
|
|
2047
|
-
}
|
|
2243
|
+
};
|
|
2244
|
+
ws.on("close", (code) => { try {
|
|
2245
|
+
closeWork(code);
|
|
2246
|
+
}
|
|
2247
|
+
catch (e) {
|
|
2248
|
+
this.log(`! cleanup after a disconnect failed: ${e.message}`);
|
|
2249
|
+
} });
|
|
2048
2250
|
ws.on("error", () => { });
|
|
2049
2251
|
}
|
|
2050
2252
|
sweep() {
|
|
@@ -2062,12 +2264,41 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
2062
2264
|
}
|
|
2063
2265
|
const now = Date.now();
|
|
2064
2266
|
for (const sp of this.spaces.values()) {
|
|
2065
|
-
|
|
2066
|
-
this.
|
|
2267
|
+
try {
|
|
2268
|
+
if (now - (this.compactedAt.get(sp.name) ?? 0) > LIMITS.ledgerCompactEveryMs)
|
|
2269
|
+
this.compactSpace(sp);
|
|
2270
|
+
this.lapseClaims(sp, now);
|
|
2271
|
+
}
|
|
2272
|
+
catch (e) {
|
|
2273
|
+
this.log(`! [${sp.name}] housekeeping failed: ${e.message}`);
|
|
2274
|
+
}
|
|
2067
2275
|
}
|
|
2068
|
-
|
|
2276
|
+
try {
|
|
2277
|
+
this.evictSpaces();
|
|
2278
|
+
}
|
|
2279
|
+
catch (e) {
|
|
2280
|
+
this.log(`! evicting idle spaces failed: ${e.message}`);
|
|
2281
|
+
}
|
|
2282
|
+
}
|
|
2283
|
+
lapseClaims(sp, now) {
|
|
2284
|
+
const live = new Set();
|
|
2285
|
+
for (const m of sp.members.values())
|
|
2286
|
+
for (const id of m.sessions.keys())
|
|
2287
|
+
live.add(id);
|
|
2288
|
+
let lapsed = 0;
|
|
2289
|
+
for (const o of sp.obligations.values()) {
|
|
2290
|
+
if (o.claimed_by && !live.has(o.claimed_by.session) && now - o.claimed_by.at >= LIMITS.claimTtlMs) {
|
|
2291
|
+
delete o.claimed_by;
|
|
2292
|
+
lapsed++;
|
|
2293
|
+
}
|
|
2294
|
+
}
|
|
2295
|
+
for (const [k, v] of sp.contLocks)
|
|
2296
|
+
if (!live.has(v.session) && now - v.at >= LIMITS.claimTtlMs)
|
|
2297
|
+
sp.contLocks.delete(k);
|
|
2298
|
+
if (lapsed)
|
|
2299
|
+
this.pushObligations(sp);
|
|
2069
2300
|
}
|
|
2070
|
-
validateAct(sp, me, act, goalId, ref, to, meta, deferTo, bodyLen = 0, summary, repo) {
|
|
2301
|
+
validateAct(sp, me, act, goalId, ref, to, meta, deferTo, bodyLen = 0, summary, repo, role) {
|
|
2071
2302
|
if (!SENDABLE.includes(act))
|
|
2072
2303
|
return { code: "RESERVED_ACT", message: `${act} is emitted by the relay, not by a delegate` };
|
|
2073
2304
|
const now = Date.now();
|
|
@@ -2083,6 +2314,9 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
2083
2314
|
}
|
|
2084
2315
|
if (ref && !sp.ledger.find(ref))
|
|
2085
2316
|
return { code: "BAD_REF", message: `no act with id ${ref}` };
|
|
2317
|
+
if (meta && JSON.stringify(meta).length > LIMITS_SPACE.metaBytes) {
|
|
2318
|
+
return { code: "META_TOO_LARGE", message: `act metadata is limited to ${LIMITS_SPACE.metaBytes} bytes` };
|
|
2319
|
+
}
|
|
2086
2320
|
if ((act === "ANSWER" || act === "ACCEPT" || act === "OBJECT") && !ref) {
|
|
2087
2321
|
return { code: "REF_REQUIRED", message: `${act} must reference the act it responds to` };
|
|
2088
2322
|
}
|
|
@@ -2219,6 +2453,15 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
2219
2453
|
return { code: "BAD_REMOTE", message: `${why}. Your peer fetches this from another machine.` };
|
|
2220
2454
|
}
|
|
2221
2455
|
}
|
|
2456
|
+
if (act === "ANSWER" && ref) {
|
|
2457
|
+
const asked = sp.ledger.find(ref);
|
|
2458
|
+
if (asked?.act === "ESCALATE" && asked.from !== me.id) {
|
|
2459
|
+
return { code: "NOT_YOUR_DECISION", message: `that ESCALATE is for ${asked.from}'s own human to settle — add to it with a NOTE instead` };
|
|
2460
|
+
}
|
|
2461
|
+
if (asked?.act === "HUMAN_REQUIRED" && asked.to?.[0] && asked.to[0] !== me.id) {
|
|
2462
|
+
return { code: "NOT_YOUR_DECISION", message: `that decision is ${asked.to[0]}'s to make — add to it with a NOTE instead` };
|
|
2463
|
+
}
|
|
2464
|
+
}
|
|
2222
2465
|
if (!goalId) {
|
|
2223
2466
|
const target = ref ? sp.ledger.find(ref) : undefined;
|
|
2224
2467
|
const settlesHuman = act === "ANSWER" && (target?.act === "ESCALATE" || target?.act === "HUMAN_REQUIRED");
|
|
@@ -2230,12 +2473,24 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
2230
2473
|
const goal = sp.goals.get(goalId);
|
|
2231
2474
|
if (!goal)
|
|
2232
2475
|
return { code: "NO_SUCH_GOAL", message: `no goal ${goalId}` };
|
|
2476
|
+
if (ref && act !== "RETRACT") {
|
|
2477
|
+
const target = sp.ledger.find(ref);
|
|
2478
|
+
if (target && target.goal !== goalId) {
|
|
2479
|
+
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)"}` };
|
|
2480
|
+
}
|
|
2481
|
+
}
|
|
2482
|
+
for (const id of to ?? []) {
|
|
2483
|
+
if (!goal.members.includes(id)) {
|
|
2484
|
+
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` };
|
|
2485
|
+
}
|
|
2486
|
+
}
|
|
2233
2487
|
if (goal.state === "CLOSED")
|
|
2234
2488
|
return { code: "GOAL_CLOSED", message: `goal "${goal.title}" is closed` };
|
|
2235
2489
|
if (!goal.members.includes(me.id))
|
|
2236
2490
|
return { code: "NOT_IN_GOAL", message: `you are not a member of "${goal.title}"` };
|
|
2237
|
-
const
|
|
2238
|
-
|
|
2491
|
+
const decision = act === "ANSWER" && ref ? sp.ledger.find(ref) : undefined;
|
|
2492
|
+
const settlesOwnHuman = !!decision && decision.goal === goalId && role === "cli"
|
|
2493
|
+
&& [...sp.obligations.values()].some((o) => o.kind === "HUMAN" && o.ref === ref && o.goal === goalId && o.owner === `human:${me.id}`);
|
|
2239
2494
|
if (goal.acts >= goal.budget && !settlesOwnHuman) {
|
|
2240
2495
|
return { code: "BUDGET_SPENT", message: `goal "${goal.title}" spent its ${goal.budget}-act budget and needs a human` };
|
|
2241
2496
|
}
|
|
@@ -2298,6 +2553,15 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
2298
2553
|
break;
|
|
2299
2554
|
case "ANSWER":
|
|
2300
2555
|
if (rec.ref) {
|
|
2556
|
+
for (const o of sp.obligations.values()) {
|
|
2557
|
+
const g = o.goal ? sp.goals.get(o.goal) : undefined;
|
|
2558
|
+
if (o.kind === "HUMAN" && o.ref === rec.ref && o.owner === `human:${rec.from}` && g?.state === "STALLED"
|
|
2559
|
+
&& !["ESCALATE", "HUMAN_REQUIRED"].includes(sp.ledger.find(o.ref)?.act ?? "")) {
|
|
2560
|
+
g.budget = Math.max(g.budget, g.acts) + Math.ceil(LIMITS.goalBudget / 2);
|
|
2561
|
+
g.state = "OPEN";
|
|
2562
|
+
this.broadcast(sp, { t: "goal", goal: g });
|
|
2563
|
+
}
|
|
2564
|
+
}
|
|
2301
2565
|
closeFor(rec.from, rec.ref);
|
|
2302
2566
|
closeFor(`human:${rec.from}`, rec.ref);
|
|
2303
2567
|
}
|
|
@@ -2320,9 +2584,10 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
2320
2584
|
if (rec.ref)
|
|
2321
2585
|
closeFor(rec.from, rec.ref);
|
|
2322
2586
|
const accepted = rec.ref ? sp.ledger.find(rec.ref) : undefined;
|
|
2323
|
-
if (accepted?.act === "WITHDRAW" && accepted.ref) {
|
|
2587
|
+
if (accepted?.act === "WITHDRAW" && accepted.ref && accepted.from !== rec.from) {
|
|
2588
|
+
const owedTo = sp.ledger.find(accepted.ref)?.from;
|
|
2324
2589
|
for (const [id, o] of sp.obligations) {
|
|
2325
|
-
if (o.ref === accepted.ref && o.owner === accepted.from)
|
|
2590
|
+
if (o.ref === accepted.ref && o.owner === accepted.from && owedTo === rec.from)
|
|
2326
2591
|
sp.obligations.delete(id);
|
|
2327
2592
|
}
|
|
2328
2593
|
}
|
|
@@ -2339,16 +2604,12 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
2339
2604
|
}
|
|
2340
2605
|
break;
|
|
2341
2606
|
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
2607
|
case "DELIVERED":
|
|
2348
2608
|
if (rec.ref)
|
|
2349
|
-
for (const [id, o] of sp.obligations)
|
|
2350
|
-
if (o.ref === rec.ref)
|
|
2609
|
+
for (const [id, o] of sp.obligations) {
|
|
2610
|
+
if (o.ref === rec.ref && o.goal === rec.goal && o.kind !== "HUMAN")
|
|
2351
2611
|
sp.obligations.delete(id);
|
|
2612
|
+
}
|
|
2352
2613
|
break;
|
|
2353
2614
|
case "ESCALATE":
|
|
2354
2615
|
this.oblige(sp, { kind: "HUMAN", owner: `human:${rec.from}`, ref: rec.id, goal: rec.goal,
|
|
@@ -2373,6 +2634,27 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
2373
2634
|
for (const [id, o] of sp.obligations)
|
|
2374
2635
|
if (o.ref === rec.ref)
|
|
2375
2636
|
sp.obligations.delete(id);
|
|
2637
|
+
if (rec.ref)
|
|
2638
|
+
for (const [k, v] of sp.recent)
|
|
2639
|
+
if (v.id === rec.ref)
|
|
2640
|
+
sp.recent.delete(k);
|
|
2641
|
+
if (rec.ref) {
|
|
2642
|
+
const answer = sp.ledger.find(rec.ref);
|
|
2643
|
+
const asked = answer?.act === "ANSWER" && answer.from === rec.from && answer.ref ? sp.ledger.find(answer.ref) : undefined;
|
|
2644
|
+
const decider = asked?.act === "ESCALATE" ? asked.from : asked?.act === "HUMAN_REQUIRED" ? asked.to?.[0] : undefined;
|
|
2645
|
+
const retracted = new Set(sp.ledger.all().filter((r) => r.act === "RETRACT" && r.ref).map((r) => r.ref));
|
|
2646
|
+
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));
|
|
2647
|
+
const alreadyLive = asked && [...sp.obligations.values()].some((o) => o.kind === "HUMAN" && o.ref === asked.id);
|
|
2648
|
+
if (asked && decider === rec.from && !stillAnswered && !alreadyLive && !retracted.has(asked.id)) {
|
|
2649
|
+
this.oblige(sp, { kind: "HUMAN", owner: `human:${decider}`, ref: asked.id, goal: asked.goal,
|
|
2650
|
+
body: asked.act === "ESCALATE" ? `${asked.from} escalated: ${first(asked.body)}` : `${asked.from} needs a decision: ${first(asked.body)}` });
|
|
2651
|
+
const g = asked.goal ? sp.goals.get(asked.goal) : undefined;
|
|
2652
|
+
if (g && g.state === "OPEN") {
|
|
2653
|
+
g.state = "BLOCKED";
|
|
2654
|
+
this.broadcast(sp, { t: "goal", goal: g });
|
|
2655
|
+
}
|
|
2656
|
+
}
|
|
2657
|
+
}
|
|
2376
2658
|
break;
|
|
2377
2659
|
case "CLOSE":
|
|
2378
2660
|
if (goal) {
|
|
@@ -2400,9 +2682,17 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
2400
2682
|
sp.obligations.set(id, { ...o, id, created_at: Date.now() });
|
|
2401
2683
|
}
|
|
2402
2684
|
pushObligations(sp) {
|
|
2403
|
-
sp.store.
|
|
2685
|
+
sp.store.flush(sp.goals, sp.obligations, sp.profiles);
|
|
2404
2686
|
this.broadcast(sp, { t: "obligations", list: [...sp.obligations.values()] });
|
|
2405
2687
|
}
|
|
2688
|
+
writeOk(me) {
|
|
2689
|
+
const now = Date.now();
|
|
2690
|
+
me.stamps = me.stamps.filter((t) => now - t < LIMITS.rateWindowMs);
|
|
2691
|
+
if (me.stamps.length >= LIMITS.rateActs)
|
|
2692
|
+
return false;
|
|
2693
|
+
me.stamps.push(now);
|
|
2694
|
+
return true;
|
|
2695
|
+
}
|
|
2406
2696
|
mayCreateSpace(ip) {
|
|
2407
2697
|
const now = Date.now();
|
|
2408
2698
|
if (!this.localTrusted(ip)) {
|
|
@@ -2490,7 +2780,7 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
2490
2780
|
getOrCreateSpace(name, token) {
|
|
2491
2781
|
const existing = this.spaces.get(name);
|
|
2492
2782
|
if (existing)
|
|
2493
|
-
return existing.token
|
|
2783
|
+
return secretEq(existing.token, token) ? existing : null;
|
|
2494
2784
|
const safe = name.replace(/[^a-z0-9._-]/gi, "_");
|
|
2495
2785
|
const allow = new Allowlist(this.dataDir, safe);
|
|
2496
2786
|
const match = allow.tokenMatches(token);
|
|
@@ -2533,7 +2823,8 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
2533
2823
|
const g = sp.goals.get(goal);
|
|
2534
2824
|
return !!g && g.state !== "CLOSED";
|
|
2535
2825
|
};
|
|
2536
|
-
const
|
|
2826
|
+
const referenced = new Set([...sp.obligations.values()].map((o) => o.ref));
|
|
2827
|
+
const { dropped, kept } = sp.ledger.compact({ keepRecent: LIMITS.ledgerKeepRecent, isLive, keep: (id) => referenced.has(id) });
|
|
2537
2828
|
this.compactedAt.set(sp.name, Date.now());
|
|
2538
2829
|
if (dropped)
|
|
2539
2830
|
this.log(`[${sp.name}] compacted ledger — dropped ${dropped} old act(s) from closed goals, ${kept} kept`);
|