@join-pantheon/cli 0.29.1 → 0.31.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +142 -40
- package/bin/pantheon.js +12 -0
- package/dist/accounts.js +151 -26
- package/dist/agents.js +224 -17
- package/dist/claude-confine.js +299 -0
- package/dist/cli.js +1116 -137
- package/dist/client.js +294 -10
- package/dist/command-state.js +102 -0
- package/dist/config.js +287 -14
- package/dist/continued.js +42 -10
- package/dist/doctor.js +191 -23
- package/dist/file-lock.js +197 -0
- package/dist/grants.js +1 -1
- package/dist/history-view.js +2 -1
- package/dist/home-guard.js +565 -0
- package/dist/hooks-install.js +2 -1
- package/dist/identity.js +101 -1
- package/dist/install.js +76 -26
- package/dist/kiro-files.js +2 -2
- package/dist/ledger.js +111 -28
- package/dist/machine.js +494 -32
- package/dist/mcp-supervisor.js +29 -7
- package/dist/mcp-windows.js +47 -25
- package/dist/mcp.js +99 -36
- package/dist/network.js +39 -8
- package/dist/nonces.js +130 -0
- package/dist/notify.js +26 -9
- package/dist/pending.js +121 -22
- package/dist/persona.js +21 -0
- package/dist/pins.js +161 -0
- package/dist/profile.js +22 -1
- package/dist/protocol.js +40 -1
- package/dist/quickstart.js +173 -24
- package/dist/reads.js +37 -0
- package/dist/refusal.js +14 -1
- package/dist/relay.js +1341 -207
- package/dist/repo-find.js +127 -0
- package/dist/repo-route.js +70 -0
- package/dist/roster-file.js +190 -25
- package/dist/run-failure.js +99 -3
- package/dist/secret-guard.js +141 -0
- package/dist/service.js +91 -35
- package/dist/setup-guide.js +91 -31
- package/dist/spend.js +6 -2
- package/dist/store.js +94 -10
- package/dist/text-file.js +74 -0
- package/dist/tray.js +24 -3
- package/dist/untrusted.js +24 -5
- package/dist/update-check.js +2 -0
- package/dist/upgrade.js +9 -5
- package/dist/verify-act.js +102 -0
- package/dist/which.js +69 -12
- package/dist/worker-fleet.js +30 -9
- package/dist/worker-policy.js +231 -7
- package/dist/worker.js +1717 -130
- package/helper/macos/menubar.swift +63 -2
- package/package.json +2 -2
package/dist/relay.js
CHANGED
|
@@ -1,19 +1,19 @@
|
|
|
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, writeFileSync } from "node:fs";
|
|
3
|
+
import { rmSync, readdirSync, readFileSync, statSync, existsSync, writeFileSync, openSync, readSync, closeSync, fstatSync } from "node:fs";
|
|
4
4
|
import { spawn } from "node:child_process";
|
|
5
5
|
import { join } from "node:path";
|
|
6
6
|
import { WebSocketServer } from "ws";
|
|
7
|
-
import { badRemote, Inbound, isContinuationFor, LIMITS, newId, PROTOCOL_VERSION, SENDABLE, shouldDeferToHuman, } from "./protocol.js";
|
|
7
|
+
import { badRemote, Inbound, isContinuationFor, LIMITS, newId, PROTOCOL_VERSION, RELAY_FEATURES, SENDABLE, shouldDeferToHuman, } from "./protocol.js";
|
|
8
8
|
import { VERSION } from "./version.js";
|
|
9
|
-
import { Ledger } from "./ledger.js";
|
|
10
|
-
import { fingerprint, newNonce, verifyChallenge, verifyActivity, verifyAccount } from "./identity.js";
|
|
9
|
+
import { Ledger, SpaceFull } from "./ledger.js";
|
|
10
|
+
import { fingerprint, newNonce, verifyChallenge, verifyActivity, verifyAccount, verifyRead, actPayload, goalPayload, verifyBytes, verifyDelegate, canonicalJson } from "./identity.js";
|
|
11
11
|
import { Accounts, normalizeUsername, validEmail, validUsername } from "./accounts.js";
|
|
12
12
|
import { emailSenderFromEnv } from "./email.js";
|
|
13
|
-
import { Allowlist } from "./roster-file.js";
|
|
14
|
-
import { StateStore } from "./store.js";
|
|
13
|
+
import { Allowlist, RosterUnreadable } from "./roster-file.js";
|
|
14
|
+
import { StateStore, ensurePrivateDir } from "./store.js";
|
|
15
15
|
import { activity, mergeActivity, feed, noteRefusal, refusalsSince, countActs, spaceHealth, fleetReport, ESCALATIONS, MESSAGE_ACTS, ACT_TONE, DAY } from "./admin-stats.js";
|
|
16
|
-
import { fmtLimit, parseLimits, PLAN_NAME } from "./plans.js";
|
|
16
|
+
import { fmtLimit, parseLimits, planLimits, PLAN_NAME } from "./plans.js";
|
|
17
17
|
import { routeDecision } from "./profile.js";
|
|
18
18
|
function workState(m, now = Date.now(), busyTtlMs = LIMITS.activityBusyTtlMs, pruneMs = LIMITS.activityPruneMs) {
|
|
19
19
|
let known = false;
|
|
@@ -81,6 +81,17 @@ export function isTrustedSource(ip) {
|
|
|
81
81
|
function fingerprint_(from, act, body, goal, ref, extra = null) {
|
|
82
82
|
return createHash("sha256").update([from, act, goal ?? "", ref ?? "", body, stableJson(extra)].join("\u0000")).digest("hex").slice(0, 32);
|
|
83
83
|
}
|
|
84
|
+
function actFingerprint(from, act, body, goal, ref, to, meta) {
|
|
85
|
+
let m = meta ?? null;
|
|
86
|
+
if (m && ("authority" in m || "unverified_domains" in m)) {
|
|
87
|
+
m = { ...m };
|
|
88
|
+
delete m["authority"];
|
|
89
|
+
delete m["unverified_domains"];
|
|
90
|
+
}
|
|
91
|
+
if (m && Object.keys(m).length === 0)
|
|
92
|
+
m = null;
|
|
93
|
+
return fingerprint_(from, act, body, goal, ref, { to: to ?? null, meta: m });
|
|
94
|
+
}
|
|
84
95
|
function stableJson(v) {
|
|
85
96
|
if (v === null || typeof v !== "object")
|
|
86
97
|
return JSON.stringify(v ?? null);
|
|
@@ -89,6 +100,8 @@ function stableJson(v) {
|
|
|
89
100
|
return `{${Object.keys(v).sort().map((k) => `${JSON.stringify(k)}:${stableJson(v[k])}`).join(",")}}`;
|
|
90
101
|
}
|
|
91
102
|
function clientIp(req) {
|
|
103
|
+
if (process.env.PANTHEON_TRUSTED_HOPS?.trim() === "0")
|
|
104
|
+
return req.socket.remoteAddress || "unknown";
|
|
92
105
|
const raw = req.headers["x-forwarded-for"];
|
|
93
106
|
const hops = (Array.isArray(raw) ? raw.join(",") : raw ?? "").split(",").map((x) => x.trim()).filter(Boolean);
|
|
94
107
|
const trusted = Math.max(1, Number(process.env.PANTHEON_TRUSTED_HOPS ?? 1) || 1);
|
|
@@ -161,8 +174,195 @@ const fmtAgo = (t) => {
|
|
|
161
174
|
return m < 1 ? "just now" : m < 60 ? `${m}m ago` : m < 1440 ? `${Math.round(m / 60)}h ago` : `${Math.round(m / 1440)}d ago`;
|
|
162
175
|
};
|
|
163
176
|
const safeName = (name) => name.replace(/[^a-z0-9._-]/gi, "_");
|
|
177
|
+
const spaceKey = (name) => safeName(name).toLowerCase();
|
|
178
|
+
function firstLedgerName(file) {
|
|
179
|
+
let fd = null;
|
|
180
|
+
try {
|
|
181
|
+
fd = openSync(file, "r");
|
|
182
|
+
const chunks = [];
|
|
183
|
+
const buf = Buffer.allocUnsafe(64 * 1024);
|
|
184
|
+
for (let pos = 0, total = 0; total < 1 << 20;) {
|
|
185
|
+
const n = readSync(fd, buf, 0, buf.length, pos);
|
|
186
|
+
if (n <= 0)
|
|
187
|
+
break;
|
|
188
|
+
const nl = buf.subarray(0, n).indexOf(10);
|
|
189
|
+
chunks.push(Buffer.from(buf.subarray(0, nl === -1 ? n : nl)));
|
|
190
|
+
if (nl !== -1)
|
|
191
|
+
break;
|
|
192
|
+
pos += n;
|
|
193
|
+
total += n;
|
|
194
|
+
}
|
|
195
|
+
const r = JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
196
|
+
return typeof r.space === "string" && r.space ? r.space : null;
|
|
197
|
+
}
|
|
198
|
+
catch {
|
|
199
|
+
return null;
|
|
200
|
+
}
|
|
201
|
+
finally {
|
|
202
|
+
if (fd !== null)
|
|
203
|
+
try {
|
|
204
|
+
closeSync(fd);
|
|
205
|
+
}
|
|
206
|
+
catch { }
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
const GOAL_OPENED = /^goal opened: ([^\n]*)\nacceptance: ([\s\S]*?)(?:\ndeadline: (\S+))?(?:\nkind: (\w+)[\s\S]*)?$/;
|
|
164
210
|
const LIMITS_GOAL_READ = 1000;
|
|
211
|
+
function repoLabelSafe(v) {
|
|
212
|
+
if (!v)
|
|
213
|
+
return null;
|
|
214
|
+
const s = v.normalize("NFKC").replace(/[^A-Za-z0-9._@:/+~-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 100);
|
|
215
|
+
return s || null;
|
|
216
|
+
}
|
|
165
217
|
const LIMITS_SPACE = { metaBytes: 8192, openGoals: 500, repos: 100, openInvites: 50 };
|
|
218
|
+
const SPACE_MAX_BYTES_DEFAULT = 50 * 1024 * 1024;
|
|
219
|
+
const SPACE_HARD_SLACK = 1024 * 1024;
|
|
220
|
+
const REFUSAL_BUDGET = 100;
|
|
221
|
+
const REFUSAL_WINDOW_MS = 10_000;
|
|
222
|
+
const RECENT_MAX = 5_000;
|
|
223
|
+
const DEVICE_OTHER_ACCOUNT = [
|
|
224
|
+
"this machine's key is already signed in to a different account, and one key belongs to one account — nothing was changed and no code was sent.",
|
|
225
|
+
"To sign back in to that account: pantheon login --email <its email>.",
|
|
226
|
+
"To switch this machine to the other account, sign it out of the first one (pantheon sessions revoke <this device's id> --device, while signed in to it), then run this again.",
|
|
227
|
+
"To keep both, give the second account its own identity folder: PANTHEON_HOME=~/.pantheon-<name> pantheon signup … (and use the same PANTHEON_HOME for every command as that account).",
|
|
228
|
+
].join("\n");
|
|
229
|
+
const UPGRADE_NOTE = "This team now needs Pantheon 0.30 or newer to read its history — run: pantheon upgrade";
|
|
230
|
+
const UPGRADE_NOTE_ID = "a_pantheon_upgrade_required";
|
|
231
|
+
const RELAY_MEMBER = "pantheon";
|
|
232
|
+
function ledgerTailSeq(file) {
|
|
233
|
+
let fd = null;
|
|
234
|
+
try {
|
|
235
|
+
fd = openSync(file, "r");
|
|
236
|
+
const size = fstatSync(fd).size;
|
|
237
|
+
const len = Math.min(size, 256 * 1024);
|
|
238
|
+
const buf = Buffer.allocUnsafe(len);
|
|
239
|
+
readSync(fd, buf, 0, len, size - len);
|
|
240
|
+
const lines = buf.toString("utf8").split("\n");
|
|
241
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
242
|
+
try {
|
|
243
|
+
const r = JSON.parse(lines[i]);
|
|
244
|
+
if (typeof r.seq === "number")
|
|
245
|
+
return r.seq;
|
|
246
|
+
}
|
|
247
|
+
catch { }
|
|
248
|
+
}
|
|
249
|
+
return 0;
|
|
250
|
+
}
|
|
251
|
+
catch {
|
|
252
|
+
return 0;
|
|
253
|
+
}
|
|
254
|
+
finally {
|
|
255
|
+
if (fd !== null)
|
|
256
|
+
try {
|
|
257
|
+
closeSync(fd);
|
|
258
|
+
}
|
|
259
|
+
catch { }
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
function versionBelow(a, b) {
|
|
263
|
+
if (!a)
|
|
264
|
+
return true;
|
|
265
|
+
const pa = a.split(/[.-]/).map((x) => Number.parseInt(x, 10) || 0), pb = b.split(".").map(Number);
|
|
266
|
+
for (let i = 0; i < 3; i++) {
|
|
267
|
+
if ((pa[i] ?? 0) !== (pb[i] ?? 0))
|
|
268
|
+
return (pa[i] ?? 0) < (pb[i] ?? 0);
|
|
269
|
+
}
|
|
270
|
+
return false;
|
|
271
|
+
}
|
|
272
|
+
const LOCKED_MSG = "too many wrong codes from this device or your network in the last hour — wait an hour, then request a new code";
|
|
273
|
+
const STORAGE_UNHEALTHY_MS = 5 * 60_000;
|
|
274
|
+
function isStorageError(e) {
|
|
275
|
+
const code = e?.code;
|
|
276
|
+
return typeof code === "string" && (/^E[A-Z0-9]+$/.test(code) || code.startsWith("ERR_SQLITE"));
|
|
277
|
+
}
|
|
278
|
+
function buildCommit() {
|
|
279
|
+
const c = process.env.PANTHEON_COMMIT ?? process.env.RAILWAY_GIT_COMMIT_SHA ?? process.env.SOURCE_COMMIT ?? process.env.GIT_COMMIT ?? "";
|
|
280
|
+
return /^[0-9a-f]{7,40}$/i.test(c.trim()) ? c.trim() : null;
|
|
281
|
+
}
|
|
282
|
+
export function netOf(ip) {
|
|
283
|
+
const h = ip.replace(/^::ffff:/i, "");
|
|
284
|
+
const v4 = /^(\d+)\.(\d+)\.(\d+)\.\d+$/.exec(h);
|
|
285
|
+
if (v4)
|
|
286
|
+
return `${v4[1]}.${v4[2]}.${v4[3]}.0/24`;
|
|
287
|
+
if (h.includes(":")) {
|
|
288
|
+
const [head, tail] = h.split("::");
|
|
289
|
+
const a = head ? head.split(":") : [];
|
|
290
|
+
const b = tail !== undefined ? (tail ? tail.split(":") : []) : [];
|
|
291
|
+
const groups = tail !== undefined ? [...a, ...Array(Math.max(0, 8 - a.length - b.length)).fill("0"), ...b] : a;
|
|
292
|
+
return `${groups.slice(0, 4).map((g) => (g || "0").toLowerCase().replace(/^0+(?=.)/, "")).join(":")}::/64`;
|
|
293
|
+
}
|
|
294
|
+
return h;
|
|
295
|
+
}
|
|
296
|
+
function idIsFresh(id) {
|
|
297
|
+
const body = /^a_([a-z0-9]+)[0-9a-f]{18}$/.exec(id)?.[1];
|
|
298
|
+
const t = body ? parseInt(body, 36) : NaN;
|
|
299
|
+
return Number.isFinite(t) && Math.abs(Date.now() - t) < DAY;
|
|
300
|
+
}
|
|
301
|
+
function actSigProblem(m, member, memberPub, teams, delegated) {
|
|
302
|
+
const sig = m.sig;
|
|
303
|
+
if (!sig)
|
|
304
|
+
return "a woken run's session must sign every act";
|
|
305
|
+
if (canonicalJson(sig.meta ?? {}) !== canonicalJson(m.meta ?? {}))
|
|
306
|
+
return "the signed meta is not the meta sent";
|
|
307
|
+
if ((sig.repo ?? null) !== (m.repo ?? null))
|
|
308
|
+
return "the signed repo is not the repo sent";
|
|
309
|
+
const keyOk = sigKeyProblem(sig, member, memberPub, teams, delegated);
|
|
310
|
+
if (keyOk)
|
|
311
|
+
return keyOk;
|
|
312
|
+
if (!m.id)
|
|
313
|
+
return "a signed act names its own id";
|
|
314
|
+
if (Math.abs(Date.now() - sig.ts) > DAY)
|
|
315
|
+
return "signed at a time more than a day from now — check this machine's clock";
|
|
316
|
+
const ok = teams.filter((t) => !!t).some((team) => verifyBytes(sig.pub, actPayload({
|
|
317
|
+
id: m.id, team, member, keyRole: sig.role, nonce: sig.nonce, ts: sig.ts,
|
|
318
|
+
act: m.act, goal: m.goal ?? null, ref: m.ref ?? null, to: m.to ?? null,
|
|
319
|
+
defers_to: m.defers_to ?? null, affects: m.affects ?? null, repo: m.repo ?? null,
|
|
320
|
+
summary: m.summary ?? null, body: m.body, meta: m.meta ?? {},
|
|
321
|
+
}), sig.s));
|
|
322
|
+
return ok ? null : "the signature does not match this act";
|
|
323
|
+
}
|
|
324
|
+
function goalSigProblem(m, member, memberPub, teams, delegated) {
|
|
325
|
+
const sig = m.sig;
|
|
326
|
+
if (!sig)
|
|
327
|
+
return "a woken run's session must sign the goals it opens";
|
|
328
|
+
const inviteSent = m.invite ? [...new Set(m.invite)].sort().join(",") : null;
|
|
329
|
+
const inviteSigned = sig.invite === null ? null : [...new Set(sig.invite)].sort().join(",");
|
|
330
|
+
if (inviteSent !== inviteSigned)
|
|
331
|
+
return "the signed invitations are not the ones sent";
|
|
332
|
+
if ((sig.budget ?? null) !== (m.budget ?? null) || (sig.deadline ?? null) !== (m.deadline ?? null) || (sig.kind ?? null) !== (m.kind ?? null))
|
|
333
|
+
return "the signed budget, deadline or kind is not what was sent";
|
|
334
|
+
const keyOk = sigKeyProblem(sig, member, memberPub, teams, delegated);
|
|
335
|
+
if (keyOk)
|
|
336
|
+
return keyOk;
|
|
337
|
+
if (!m.id)
|
|
338
|
+
return "a signed goal names its own id";
|
|
339
|
+
if (Math.abs(Date.now() - sig.ts) > DAY)
|
|
340
|
+
return "signed at a time more than a day from now — check this machine's clock";
|
|
341
|
+
const ok = teams.filter((t) => !!t).some((team) => verifyBytes(sig.pub, goalPayload({
|
|
342
|
+
id: m.id, team, member, keyRole: sig.role, nonce: sig.nonce, ts: sig.ts,
|
|
343
|
+
title: m.title, acceptance: m.acceptance ?? "", invite: sig.invite,
|
|
344
|
+
budget: sig.budget ?? null, deadline: sig.deadline ?? null, kind: sig.kind ?? null,
|
|
345
|
+
}), sig.s));
|
|
346
|
+
return ok ? null : "the signature does not match this goal";
|
|
347
|
+
}
|
|
348
|
+
function sigKeyProblem(sig, member, memberPub, teams, delegated) {
|
|
349
|
+
if (delegated) {
|
|
350
|
+
if (sig.role !== "run" || sig.pub !== delegated.pub || sig.cert?.pub !== delegated.pub)
|
|
351
|
+
return "a woken run signs with the key its session was opened with";
|
|
352
|
+
}
|
|
353
|
+
if (sig.role === "member")
|
|
354
|
+
return sig.pub === memberPub ? null : `signed with a key that is not ${member}'s`;
|
|
355
|
+
if (!sig.cert || sig.cert.pub !== sig.pub)
|
|
356
|
+
return "a run's signature needs its certificate";
|
|
357
|
+
const c = sig.cert;
|
|
358
|
+
return teams.filter((t) => !!t).some((t) => verifyDelegate(memberPub, t, member, c)) ? null : `that run key is not vouched for by ${member}, or it has expired`;
|
|
359
|
+
}
|
|
360
|
+
function withoutFromRepo(meta) {
|
|
361
|
+
if (!meta || typeof meta !== "object")
|
|
362
|
+
return {};
|
|
363
|
+
const { from_repo: _f, ...rest } = meta;
|
|
364
|
+
return rest;
|
|
365
|
+
}
|
|
166
366
|
export class Relay {
|
|
167
367
|
opts;
|
|
168
368
|
spaces = new Map();
|
|
@@ -170,9 +370,22 @@ export class Relay {
|
|
|
170
370
|
log;
|
|
171
371
|
http = createServer((req, res) => {
|
|
172
372
|
const fail = (e) => {
|
|
373
|
+
if (e instanceof RosterUnreadable) {
|
|
374
|
+
this.log(`!! ${e.message} — reads of this space are refused until it is repaired (it was left in place)`);
|
|
375
|
+
try {
|
|
376
|
+
if (!res.headersSent)
|
|
377
|
+
this.json(res, 503, { code: "SPACE_UNREADABLE", error: "the relay cannot read this team's member list, so it cannot say who may read it. Nothing was changed; tell the relay operator." });
|
|
378
|
+
else
|
|
379
|
+
res.end();
|
|
380
|
+
}
|
|
381
|
+
catch { }
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
173
384
|
const bad = e instanceof TypeError || e instanceof URIError;
|
|
174
385
|
if (!bad)
|
|
175
386
|
this.log(`! http ${req.method} ${String(req.url ?? "").slice(0, 120)} failed: ${e?.message ?? e}`);
|
|
387
|
+
if (isStorageError(e))
|
|
388
|
+
this.noteStorageFailure(`http ${String(req.url ?? "").split("?")[0].slice(0, 60)}`, e);
|
|
176
389
|
try {
|
|
177
390
|
if (!res.headersSent)
|
|
178
391
|
this.json(res, bad ? 400 : 500, { error: bad ? "bad request" : "the relay could not handle that request" });
|
|
@@ -202,20 +415,33 @@ export class Relay {
|
|
|
202
415
|
pruneMs;
|
|
203
416
|
accounts;
|
|
204
417
|
requireAccounts;
|
|
418
|
+
readsMode;
|
|
205
419
|
trustLocal;
|
|
206
420
|
email;
|
|
207
421
|
authHits = new Map();
|
|
208
422
|
adminHits = new Map();
|
|
209
423
|
emailSends = [];
|
|
424
|
+
emailSendsTo = new Map();
|
|
425
|
+
emailSendsFrom = new Map();
|
|
426
|
+
storageFailure = null;
|
|
427
|
+
unbound = new Set();
|
|
428
|
+
maxLedgerBytes;
|
|
210
429
|
adminToken;
|
|
211
430
|
adminAllow;
|
|
212
431
|
startedAt = Date.now();
|
|
213
432
|
deletedSpaces = new Map();
|
|
433
|
+
authEpoch = 0;
|
|
434
|
+
rotateOnRemove;
|
|
214
435
|
backupTimer;
|
|
436
|
+
get features() { return this.opts.features ?? RELAY_FEATURES; }
|
|
215
437
|
constructor(opts = {}) {
|
|
216
438
|
this.opts = opts;
|
|
217
439
|
this.dataDir = opts.dataDir ?? "./pantheon-data";
|
|
218
440
|
this.log = opts.log ?? ((l) => console.error(l));
|
|
441
|
+
ensurePrivateDir(this.dataDir);
|
|
442
|
+
this.maxLedgerBytes = opts.spaceMaxBytes
|
|
443
|
+
?? (Number(process.env.PANTHEON_SPACE_MAX_BYTES) > 0 ? Number(process.env.PANTHEON_SPACE_MAX_BYTES) : SPACE_MAX_BYTES_DEFAULT);
|
|
444
|
+
this.scanUnbound();
|
|
219
445
|
this.requireAccounts = opts.requireAccounts ?? process.env.PANTHEON_REQUIRE_ACCOUNTS === "1";
|
|
220
446
|
this.trustLocal = process.env.PANTHEON_TRUST_LOCAL !== undefined
|
|
221
447
|
? process.env.PANTHEON_TRUST_LOCAL === "1"
|
|
@@ -243,8 +469,14 @@ export class Relay {
|
|
|
243
469
|
spaceJunkDeleteMs: opts.spaceLimits?.spaceJunkDeleteMs ?? LIMITS.spaceJunkDeleteMs,
|
|
244
470
|
spaceMinAgeMs: opts.spaceLimits?.spaceMinAgeMs ?? LIMITS.spaceMinAgeMs,
|
|
245
471
|
};
|
|
472
|
+
this.rotateOnRemove = opts.rotateOnRemove ?? process.env.PANTHEON_ROTATE_ON_REMOVE === "1";
|
|
246
473
|
this.busyTtlMs = opts.activityBusyTtlMs ?? LIMITS.activityBusyTtlMs;
|
|
247
474
|
this.pruneMs = opts.activityPruneMs ?? LIMITS.activityPruneMs;
|
|
475
|
+
const readsEnv = (process.env.PANTHEON_READS ?? "").trim().toLowerCase();
|
|
476
|
+
this.readsMode = opts.reads ?? (readsEnv === "" || readsEnv === "transition" ? "transition" : "signed");
|
|
477
|
+
if (!opts.reads && readsEnv && readsEnv !== "transition" && readsEnv !== "signed") {
|
|
478
|
+
this.log(`! PANTHEON_READS="${process.env.PANTHEON_READS}" is not "signed" or "transition" — using "signed"`);
|
|
479
|
+
}
|
|
248
480
|
this.http.on("upgrade", (req, socket, head) => {
|
|
249
481
|
const ip = clientIp(req);
|
|
250
482
|
this.wss.handleUpgrade(req, socket, head, (ws) => this.onSocket(ws, ip));
|
|
@@ -267,8 +499,14 @@ export class Relay {
|
|
|
267
499
|
});
|
|
268
500
|
}
|
|
269
501
|
async close() {
|
|
270
|
-
for (const sp of this.spaces.values())
|
|
271
|
-
|
|
502
|
+
for (const sp of this.spaces.values()) {
|
|
503
|
+
try {
|
|
504
|
+
sp.store.close(sp.goals, sp.obligations, sp.profiles);
|
|
505
|
+
}
|
|
506
|
+
catch (e) {
|
|
507
|
+
this.noteStorageFailure(`[${sp.name}] final flush`, e);
|
|
508
|
+
}
|
|
509
|
+
}
|
|
272
510
|
if (this.backupTimer)
|
|
273
511
|
clearInterval(this.backupTimer);
|
|
274
512
|
if (this.accounts && !this.opts.accounts)
|
|
@@ -280,6 +518,48 @@ export class Relay {
|
|
|
280
518
|
await new Promise((r) => this.wss.close(() => r()));
|
|
281
519
|
await new Promise((r) => this.http.close(() => r()));
|
|
282
520
|
}
|
|
521
|
+
noteStorageFailure(where, e) {
|
|
522
|
+
const message = e?.message ?? String(e);
|
|
523
|
+
this.log(`! storage: ${where} failed: ${message}`);
|
|
524
|
+
if (isStorageError(e))
|
|
525
|
+
this.storageFailure = { at: Date.now(), where, message };
|
|
526
|
+
}
|
|
527
|
+
scanUnbound() {
|
|
528
|
+
let files = [];
|
|
529
|
+
try {
|
|
530
|
+
files = readdirSync(this.dataDir);
|
|
531
|
+
}
|
|
532
|
+
catch {
|
|
533
|
+
return;
|
|
534
|
+
}
|
|
535
|
+
for (const f of files) {
|
|
536
|
+
const safe = f.endsWith(".members.json") ? f.slice(0, -".members.json".length) : f.endsWith(".jsonl") ? f.slice(0, -".jsonl".length) : null;
|
|
537
|
+
if (safe === null || this.unbound.has(safe))
|
|
538
|
+
continue;
|
|
539
|
+
try {
|
|
540
|
+
if (this.unboundOnDisk(safe))
|
|
541
|
+
this.unbound.add(safe);
|
|
542
|
+
}
|
|
543
|
+
catch { }
|
|
544
|
+
}
|
|
545
|
+
if (this.unbound.size) {
|
|
546
|
+
this.log(`! ${this.unbound.size} space(s) on disk have no bound token: ${[...this.unbound].sort().join(", ")}. ` +
|
|
547
|
+
`Each is re-bound only by a reconnect from a member already admitted in it, with that member's key; anyone else is refused (UNBOUND_SPACE).`);
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
unboundOnDisk(safe) {
|
|
551
|
+
const size = (suf) => { try {
|
|
552
|
+
return statSync(join(this.dataDir, `${safe}${suf}`)).size;
|
|
553
|
+
}
|
|
554
|
+
catch {
|
|
555
|
+
return -1;
|
|
556
|
+
} };
|
|
557
|
+
const ledger = size(".jsonl");
|
|
558
|
+
if (size(".members.json") < 0)
|
|
559
|
+
return ledger > 0;
|
|
560
|
+
const al = new Allowlist(this.dataDir, safe);
|
|
561
|
+
return !al.hasToken && (!al.empty || ledger > 0);
|
|
562
|
+
}
|
|
283
563
|
onHttp(req, res) {
|
|
284
564
|
const url = new URL(req.url ?? "/", "http://x");
|
|
285
565
|
const ip = clientIp(req);
|
|
@@ -298,11 +578,16 @@ export class Relay {
|
|
|
298
578
|
seq: s.ledger.head,
|
|
299
579
|
});
|
|
300
580
|
const asked = url.searchParams.get("space");
|
|
301
|
-
const one = asked ? this.
|
|
581
|
+
const one = asked ? this.live(asked) : undefined;
|
|
582
|
+
const sf = this.storageFailure && Date.now() - this.storageFailure.at < STORAGE_UNHEALTHY_MS ? this.storageFailure : null;
|
|
583
|
+
const commit = buildCommit();
|
|
302
584
|
return this.json(res, 200, {
|
|
303
|
-
ok:
|
|
585
|
+
ok: !sf,
|
|
586
|
+
...(sf ? { storage: { error: sf.message, where: sf.where, at: new Date(sf.at).toISOString() } } : {}),
|
|
304
587
|
v: PROTOCOL_VERSION,
|
|
305
588
|
pkg: VERSION,
|
|
589
|
+
...(commit ? { commit } : {}),
|
|
590
|
+
...(adminView && this.unbound.size ? { unbound_spaces: [...this.unbound].sort() } : {}),
|
|
306
591
|
space_count: this.spaces.size,
|
|
307
592
|
space_capacity: this.limits.maxSpaces,
|
|
308
593
|
...(adminView ? { spaces: [...this.spaces.values()].map(detail) }
|
|
@@ -322,10 +607,12 @@ export class Relay {
|
|
|
322
607
|
: this.json(res, 404, { error: "no open invite matches that code — it may have been used already or expired" });
|
|
323
608
|
}
|
|
324
609
|
if (url.pathname === "/act") {
|
|
325
|
-
const
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
610
|
+
const gate = this.readGate(req, url, ip);
|
|
611
|
+
if (!gate.ok && gate.notice)
|
|
612
|
+
return this.upgradeAnswer(res, url.pathname, url, gate.notice, gate.error);
|
|
613
|
+
if (!gate.ok)
|
|
614
|
+
return this.json(res, gate.status, { error: gate.error, ...(gate.code ? { code: gate.code } : {}) });
|
|
615
|
+
const space = gate.space;
|
|
329
616
|
const act = space.ledger.find(url.searchParams.get("id") ?? "");
|
|
330
617
|
if (!act)
|
|
331
618
|
return this.json(res, 404, { error: "no such act" });
|
|
@@ -333,10 +620,12 @@ export class Relay {
|
|
|
333
620
|
return this.json(res, 200, { act, ...(goal ? { goal: { id: goal.id, title: goal.title, state: goal.state } } : {}) });
|
|
334
621
|
}
|
|
335
622
|
if (url.pathname === "/history") {
|
|
336
|
-
const
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
623
|
+
const gate = this.readGate(req, url, ip);
|
|
624
|
+
if (!gate.ok && gate.notice)
|
|
625
|
+
return this.upgradeAnswer(res, url.pathname, url, gate.notice, gate.error);
|
|
626
|
+
if (!gate.ok)
|
|
627
|
+
return this.json(res, gate.status, { error: gate.error, ...(gate.code ? { code: gate.code } : {}) });
|
|
628
|
+
const space = gate.space;
|
|
340
629
|
const goal = url.searchParams.get("goal");
|
|
341
630
|
const sinceRaw = url.searchParams.get("since");
|
|
342
631
|
const acts = goal ? space.ledger.byGoal(goal, LIMITS_GOAL_READ)
|
|
@@ -344,49 +633,67 @@ export class Relay {
|
|
|
344
633
|
return this.json(res, 200, { acts, head: space.ledger.head });
|
|
345
634
|
}
|
|
346
635
|
if (url.pathname === "/obligations") {
|
|
347
|
-
const
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
636
|
+
const gate = this.readGate(req, url, ip);
|
|
637
|
+
if (!gate.ok && gate.notice)
|
|
638
|
+
return this.upgradeAnswer(res, url.pathname, url, gate.notice, gate.error);
|
|
639
|
+
if (!gate.ok)
|
|
640
|
+
return this.json(res, gate.status, { error: gate.error, ...(gate.code ? { code: gate.code } : {}) });
|
|
641
|
+
const space = gate.space;
|
|
351
642
|
const member = url.searchParams.get("member");
|
|
352
643
|
const now = Date.now();
|
|
353
644
|
const owed = [...space.obligations.values()]
|
|
354
645
|
.filter((o) => (member ? o.owner === member : true))
|
|
355
|
-
.map((o) => ({ ...o, claimed: !!o.claimed_by &&
|
|
646
|
+
.map((o) => ({ ...o, claimed: !!o.claimed_by && this.claimHolds(space, o.claimed_by, now) }));
|
|
356
647
|
const closed = [...space.goals.values()].filter((g) => g.state === "CLOSED").map((g) => g.id);
|
|
357
648
|
const worker_online = member
|
|
358
649
|
? [...(space.members.get(member)?.sessions.values() ?? [])].some((s) => s.role === "worker")
|
|
359
650
|
: false;
|
|
360
|
-
return this.json(res, 200, { obligations: owed, closed_goals: closed, seq: space.ledger.head, worker_online });
|
|
651
|
+
return this.json(res, 200, { obligations: owed, closed_goals: closed, seq: space.ledger.head, worker_online, space_id: space.allow.spaceId(), members: this.members(space) });
|
|
361
652
|
}
|
|
362
653
|
if (url.pathname === "/activity") {
|
|
363
654
|
if (req.method !== "POST")
|
|
364
655
|
return this.json(res, 405, { error: "POST only" });
|
|
365
|
-
const
|
|
366
|
-
const
|
|
367
|
-
if (!
|
|
656
|
+
const spaceName = url.searchParams.get("space") ?? "", spaceToken = url.searchParams.get("token") ?? "";
|
|
657
|
+
const target = this.readTarget(spaceName, spaceToken);
|
|
658
|
+
if (!target)
|
|
368
659
|
return this.json(res, 404, { error: "no such space" });
|
|
369
660
|
const memberId = url.searchParams.get("member") ?? "";
|
|
370
|
-
const member = space.members.get(memberId);
|
|
371
661
|
const session = (url.searchParams.get("session") ?? "").slice(0, 64);
|
|
372
662
|
const state = url.searchParams.get("state");
|
|
373
|
-
if (!
|
|
663
|
+
if (!memberId || !session || (state !== "busy" && state !== "idle")) {
|
|
374
664
|
return this.json(res, 400, { error: "member, session and state=busy|idle are required" });
|
|
375
665
|
}
|
|
376
666
|
const ts = Number(url.searchParams.get("ts"));
|
|
377
667
|
const sig = url.searchParams.get("sig") ?? "";
|
|
378
668
|
const seenParam = url.searchParams.get("seen") ?? "";
|
|
379
|
-
const pub =
|
|
669
|
+
const pub = target.allow.pubkeyOf(memberId);
|
|
380
670
|
const fresh = Number.isFinite(ts) && Math.abs(Date.now() - ts) < LIMITS.activityFreshMs;
|
|
381
|
-
if (!pub || !fresh || !verifyActivity(pub,
|
|
671
|
+
if (!pub || !fresh || !verifyActivity(pub, spaceName, memberId, session, state, seenParam, ts, sig)) {
|
|
382
672
|
return this.json(res, 403, { error: "activity report must be signed by the member's key and recent" });
|
|
383
673
|
}
|
|
674
|
+
if (this.requireAccounts && this.accounts && !this.localTrusted(ip)) {
|
|
675
|
+
const auth = this.accounts.authorize(pub, memberId);
|
|
676
|
+
if (!auth.ok) {
|
|
677
|
+
return this.json(res, 403, { code: "NOT_SIGNED_IN", error: auth.reason === "suspended"
|
|
678
|
+
? "this account has been suspended by the relay operator"
|
|
679
|
+
: "this device is not signed in to an account that may report here — run pantheon login" });
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
const space = target.live ?? this.getOrCreateSpace(spaceName, spaceToken, { retired: true });
|
|
683
|
+
if (!space)
|
|
684
|
+
return this.json(res, 404, { error: "no such space" });
|
|
685
|
+
const member = space.members.get(memberId);
|
|
686
|
+
if (!member)
|
|
687
|
+
return this.json(res, 400, { error: "member, session and state=busy|idle are required" });
|
|
384
688
|
if (member.activity.size > 64 && !member.activity.has(session)) {
|
|
385
689
|
const oldest = [...member.activity.entries()].sort((a, b) => a[1].at - b[1].at)[0];
|
|
386
690
|
if (oldest)
|
|
387
691
|
member.activity.delete(oldest[0]);
|
|
388
692
|
}
|
|
389
|
-
member.activity.
|
|
693
|
+
const prev = member.activity.get(session);
|
|
694
|
+
if (prev?.ts !== undefined && ts < prev.ts)
|
|
695
|
+
return this.json(res, 409, { error: "an activity report older than the last one from this session — ignored" });
|
|
696
|
+
member.activity.set(session, { busy: state === "busy", at: Date.now(), ts });
|
|
390
697
|
const seen = Number(url.searchParams.get("seen"));
|
|
391
698
|
if (Number.isFinite(seen) && seen > (member.seen_seq ?? 0))
|
|
392
699
|
member.seen_seq = seen;
|
|
@@ -437,7 +744,7 @@ export class Relay {
|
|
|
437
744
|
res.writeHead(429, { "content-type": "text/html; charset=utf-8", "retry-after": "60" });
|
|
438
745
|
return void res.end("<p>Too many attempts. Wait a minute and try again.</p>");
|
|
439
746
|
}
|
|
440
|
-
return this.readForm(req, (body) => {
|
|
747
|
+
return this.readForm(req, res, (body) => {
|
|
441
748
|
if (!tokenOk(body.token ?? "")) {
|
|
442
749
|
this.noteAdminFail(ip);
|
|
443
750
|
A0.audit({ event: "admin_login_failed", ip });
|
|
@@ -479,7 +786,7 @@ export class Relay {
|
|
|
479
786
|
if (url.pathname === "/admin/action" && req.method === "POST") {
|
|
480
787
|
if (!authed)
|
|
481
788
|
return this.json(res, 403, { error: "sign in at /admin" });
|
|
482
|
-
return this.readForm(req, (body) => this.adminAction(res, A0, body, ip));
|
|
789
|
+
return this.readForm(req, res, (body) => this.adminAction(res, A0, body, ip));
|
|
483
790
|
}
|
|
484
791
|
if (url.pathname === "/admin/space") {
|
|
485
792
|
if (!authed)
|
|
@@ -498,7 +805,7 @@ export class Relay {
|
|
|
498
805
|
if (url.pathname === "/auth/signup" && req.method === "POST") {
|
|
499
806
|
if (!this.authRateOk(ip))
|
|
500
807
|
return this.json(res, 429, { error: "too many attempts from your network — wait a minute and try again" });
|
|
501
|
-
return this.readJson(req, async (b) => {
|
|
808
|
+
return this.readJson(req, res, async (b) => {
|
|
502
809
|
if (!b || typeof b.email !== "string" || typeof b.username !== "string" || typeof b.pubkey !== "string") {
|
|
503
810
|
return this.json(res, 400, { error: "email, username and pubkey are required" });
|
|
504
811
|
}
|
|
@@ -506,63 +813,74 @@ export class Relay {
|
|
|
506
813
|
return this.json(res, 400, { error: "that does not look like an email address" });
|
|
507
814
|
if (!validUsername(normalizeUsername(b.username)))
|
|
508
815
|
return this.json(res, 400, { error: "username must be 2–32 chars: letters, digits, . _ - (not at the ends)" });
|
|
816
|
+
const over = this.emailRefusal("signup", b.email, ip);
|
|
817
|
+
if (over)
|
|
818
|
+
return this.json(res, 429, { error: over, reason: "email_budget" });
|
|
509
819
|
const started = A.startVerification(b.email, "signup", { username: b.username, pubkey: b.pubkey, label: String(b.label ?? "a device").slice(0, 60) });
|
|
510
820
|
if (!started.ok) {
|
|
511
821
|
if (started.reason === "blocked")
|
|
512
822
|
return this.json(res, 403, { error: "this email cannot sign up on this relay", reason: "blocked" });
|
|
823
|
+
if (started.reason === "locked")
|
|
824
|
+
return this.json(res, 429, { error: LOCKED_MSG, reason: started.reason });
|
|
513
825
|
const msg = started.reason === "too_soon" ? "a code was just sent — check your email, or wait a minute to resend"
|
|
514
|
-
: started.reason === "
|
|
515
|
-
: started.reason === "
|
|
516
|
-
: started.reason === "
|
|
826
|
+
: started.reason === "username_taken" ? "that username is taken"
|
|
827
|
+
: started.reason === "email_taken" ? "that email already has an account — use `pantheon login` from this machine instead"
|
|
828
|
+
: started.reason === "device_other_account" ? DEVICE_OTHER_ACCOUNT
|
|
517
829
|
: "check the email and username";
|
|
518
830
|
return this.json(res, 409, { error: msg, reason: started.reason });
|
|
519
831
|
}
|
|
520
832
|
A.audit({ event: "signup", target: normalizeUsername(b.username), ip });
|
|
521
|
-
if (!this.emailBudgetOk())
|
|
522
|
-
return this.json(res, 429, { error: "the relay is sending too many codes right now — try again shortly" });
|
|
523
833
|
const sent = await this.email.sendCode(b.email, started.code, "signup");
|
|
524
834
|
if (sent)
|
|
525
|
-
this.
|
|
835
|
+
this.noteEmailSent("signup", b.email, ip);
|
|
526
836
|
return this.json(res, 200, { ok: true, sent, via: this.email.kind });
|
|
527
837
|
});
|
|
528
838
|
}
|
|
529
839
|
if (url.pathname === "/auth/login" && req.method === "POST") {
|
|
530
840
|
if (!this.authRateOk(ip))
|
|
531
841
|
return this.json(res, 429, { error: "too many attempts from your network — wait a minute and try again" });
|
|
532
|
-
return this.readJson(req, async (b) => {
|
|
842
|
+
return this.readJson(req, res, async (b) => {
|
|
533
843
|
if (!b || typeof b.email !== "string" || typeof b.pubkey !== "string")
|
|
534
844
|
return this.json(res, 400, { error: "email and pubkey are required" });
|
|
845
|
+
const own = A.isDeviceOf(b.email, b.pubkey);
|
|
846
|
+
const over = this.emailRefusal("login", b.email, ip, b.pubkey, own);
|
|
847
|
+
if (over)
|
|
848
|
+
return this.json(res, 429, { error: over, reason: "email_budget" });
|
|
535
849
|
const started = A.startVerification(b.email, "login", { pubkey: b.pubkey, label: String(b.label ?? "a device").slice(0, 60) });
|
|
536
850
|
if (!started.ok && started.reason === "blocked")
|
|
537
851
|
return this.json(res, 403, { error: "this email cannot sign in on this relay", reason: "blocked" });
|
|
852
|
+
if (!started.ok && started.reason === "device_other_account")
|
|
853
|
+
return this.json(res, 409, { error: DEVICE_OTHER_ACCOUNT, reason: started.reason });
|
|
538
854
|
if (!started.ok) {
|
|
539
|
-
const code = started.reason === "too_soon" || started.reason === "
|
|
540
|
-
const msg = started.reason === "too_soon" ? "a code was just sent — check your email, or wait a minute" : started.reason === "
|
|
855
|
+
const code = started.reason === "too_soon" || started.reason === "locked" ? 429 : 404;
|
|
856
|
+
const msg = started.reason === "too_soon" ? "a code was just sent — check your email, or wait a minute" : started.reason === "locked" ? LOCKED_MSG : "no active account for that email — `pantheon signup` first";
|
|
541
857
|
return this.json(res, code, { error: msg, reason: started.reason });
|
|
542
858
|
}
|
|
543
|
-
if (!this.emailBudgetOk())
|
|
544
|
-
return this.json(res, 429, { error: "the relay is sending too many codes right now — try again shortly" });
|
|
545
859
|
const sent = await this.email.sendCode(b.email, started.code, "login");
|
|
546
860
|
if (sent)
|
|
547
|
-
this.
|
|
861
|
+
this.noteEmailSent("login", b.email, ip, b.pubkey, own);
|
|
548
862
|
return this.json(res, 200, { ok: true, sent, via: this.email.kind });
|
|
549
863
|
});
|
|
550
864
|
}
|
|
551
865
|
if (url.pathname === "/auth/verify" && req.method === "POST") {
|
|
552
866
|
if (!this.authRateOk(ip))
|
|
553
867
|
return this.json(res, 429, { error: "too many attempts from your network — wait a minute and try again" });
|
|
554
|
-
return this.readJson(req, (b) => {
|
|
868
|
+
return this.readJson(req, res, (b) => {
|
|
555
869
|
if (!b || typeof b.email !== "string" || typeof b.code !== "string")
|
|
556
870
|
return this.json(res, 400, { error: "email and code are required" });
|
|
557
|
-
const r = A.verify(b.email, b.code.trim(), ip, typeof b.pubkey === "string" ? b.pubkey : undefined);
|
|
871
|
+
const r = A.verify(b.email, b.code.trim(), ip, typeof b.pubkey === "string" ? b.pubkey : undefined, this.localTrusted(ip) ? null : netOf(ip));
|
|
558
872
|
if (!r.ok) {
|
|
559
873
|
const msg = r.reason === "expired" ? "that code has expired — request a new one"
|
|
560
874
|
: r.reason === "other_device" ? "that code was requested from a different device — request a new one from this one"
|
|
561
875
|
: r.reason === "suspended" ? "this account is suspended — contact the relay operator"
|
|
562
876
|
: r.reason === "too_many_attempts" ? "too many wrong tries — request a new code"
|
|
563
|
-
: r.reason === "
|
|
564
|
-
: "
|
|
565
|
-
|
|
877
|
+
: r.reason === "locked" ? LOCKED_MSG
|
|
878
|
+
: r.reason === "several_pending" ? "codes for this email were asked for from more than one device, and this pantheon is too old to say which device it is — run pantheon upgrade , then verify again"
|
|
879
|
+
: r.reason === "device_other_account" ? DEVICE_OTHER_ACCOUNT
|
|
880
|
+
: r.reason === "no_pending" ? "no code is pending for that email — run signup or login first"
|
|
881
|
+
: r.tries_left === 0 ? "that code is not right, and it was the last try — request a new code"
|
|
882
|
+
: `that code is not right — ${r.tries_left ?? "a few"} ${r.tries_left === 1 ? "try" : "tries"} left on it`;
|
|
883
|
+
return this.json(res, 400, { error: msg, reason: r.reason, ...(r.tries_left !== undefined ? { tries_left: r.tries_left } : {}) });
|
|
566
884
|
}
|
|
567
885
|
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 });
|
|
568
886
|
});
|
|
@@ -587,8 +905,10 @@ export class Relay {
|
|
|
587
905
|
const hit = A.revoke(who.account.id, kind, id, ip);
|
|
588
906
|
for (const sid of hit.sessionIds)
|
|
589
907
|
this.dropSessionEverywhere(sid);
|
|
590
|
-
if (hit.deviceId)
|
|
908
|
+
if (hit.deviceId) {
|
|
591
909
|
this.dropDeviceSockets(who.account.id, hit.deviceId);
|
|
910
|
+
this.noteAccountRemoval(who.account.username);
|
|
911
|
+
}
|
|
592
912
|
return this.json(res, 200, { ok: true, revoked: hit.sessionIds.length, device_removed: !!hit.deviceId });
|
|
593
913
|
}
|
|
594
914
|
if (url.pathname === "/account/delete" && req.method === "POST") {
|
|
@@ -599,6 +919,7 @@ export class Relay {
|
|
|
599
919
|
A.deleteAccount(who.account.id, ip);
|
|
600
920
|
for (const s of sessions)
|
|
601
921
|
this.dropSessionEverywhere(s.id);
|
|
922
|
+
this.noteAccountRemoval(who.account.username);
|
|
602
923
|
return this.json(res, 200, { ok: true });
|
|
603
924
|
}
|
|
604
925
|
if (url.pathname === "/audit" && req.method === "GET") {
|
|
@@ -607,7 +928,7 @@ export class Relay {
|
|
|
607
928
|
return this.json(res, 403, { error: "this request must be signed by a device on the account" });
|
|
608
929
|
const space = url.searchParams.get("space") ?? undefined;
|
|
609
930
|
if (space) {
|
|
610
|
-
const sp = this.
|
|
931
|
+
const sp = this.live(space);
|
|
611
932
|
const member = sp?.allow.get(who.account.username);
|
|
612
933
|
if (!sp || !member?.admitted)
|
|
613
934
|
return this.json(res, 403, { error: "you are not a member of that space" });
|
|
@@ -675,11 +996,47 @@ export class Relay {
|
|
|
675
996
|
}
|
|
676
997
|
}
|
|
677
998
|
}
|
|
678
|
-
|
|
679
|
-
const now = Date.now();
|
|
680
|
-
|
|
999
|
+
emailRefusal(kind, email, ip, pubkey = "", own = false) {
|
|
1000
|
+
const now = Date.now(), hour = 3_600_000;
|
|
1001
|
+
const live = (v) => v.filter((t) => now - t < hour);
|
|
1002
|
+
this.emailSends = this.emailSends.filter((s) => now - s.at < hour);
|
|
681
1003
|
const cap = Number(process.env.PANTHEON_EMAIL_MAX_PER_HOUR) || 200;
|
|
682
|
-
|
|
1004
|
+
const reserved = Math.min(cap - 1, Math.max(0, Number(process.env.PANTHEON_EMAIL_RESERVED_PER_HOUR ?? Math.floor(cap / 4)) || 0));
|
|
1005
|
+
const perAddress = Number(process.env.PANTHEON_EMAIL_MAX_PER_ADDRESS) || 5;
|
|
1006
|
+
const perNet = Number(process.env.PANTHEON_EMAIL_MAX_PER_NETWORK) || 20;
|
|
1007
|
+
if (live(this.emailSendsTo.get(this.emailBucket(email, pubkey, own)) ?? []).length >= perAddress) {
|
|
1008
|
+
return own ? "this device has asked for too many codes in the last hour — use the latest one, or try again in an hour"
|
|
1009
|
+
: "too many codes were sent to this address in the last hour — use the latest one, or try again in an hour";
|
|
1010
|
+
}
|
|
1011
|
+
if (!own && !this.localTrusted(ip) && live(this.emailSendsFrom.get(netOf(ip)) ?? []).length >= perNet)
|
|
1012
|
+
return "too many codes were requested from your network in the last hour — try again later";
|
|
1013
|
+
if (this.emailSends.length >= cap)
|
|
1014
|
+
return "the relay is sending too many codes right now — try again shortly";
|
|
1015
|
+
if (kind === "signup" && this.emailSends.filter((s) => s.signup).length >= cap - reserved)
|
|
1016
|
+
return "the relay is taking too many new sign-ups right now — try again shortly";
|
|
1017
|
+
return null;
|
|
1018
|
+
}
|
|
1019
|
+
emailBucket(email, pubkey, own) {
|
|
1020
|
+
const to = email.trim().toLowerCase();
|
|
1021
|
+
return own ? `${to}\u0000${pubkey}` : to;
|
|
1022
|
+
}
|
|
1023
|
+
noteEmailSent(kind, email, ip, pubkey = "", own = false) {
|
|
1024
|
+
const now = Date.now(), hour = 3_600_000;
|
|
1025
|
+
this.emailSends.push({ at: now, signup: kind === "signup" });
|
|
1026
|
+
const bump = (m, k) => {
|
|
1027
|
+
m.set(k, [...(m.get(k) ?? []).filter((t) => now - t < hour), now]);
|
|
1028
|
+
if (m.size > 10_000)
|
|
1029
|
+
for (const [key, v] of m) {
|
|
1030
|
+
const keep = v.filter((t) => now - t < hour);
|
|
1031
|
+
if (keep.length)
|
|
1032
|
+
m.set(key, keep);
|
|
1033
|
+
else
|
|
1034
|
+
m.delete(key);
|
|
1035
|
+
}
|
|
1036
|
+
};
|
|
1037
|
+
bump(this.emailSendsTo, this.emailBucket(email, pubkey, own));
|
|
1038
|
+
if (!own && !this.localTrusted(ip))
|
|
1039
|
+
bump(this.emailSendsFrom, netOf(ip));
|
|
683
1040
|
}
|
|
684
1041
|
authRateOk(ip) {
|
|
685
1042
|
if (this.localTrusted(ip))
|
|
@@ -699,41 +1056,134 @@ export class Relay {
|
|
|
699
1056
|
}
|
|
700
1057
|
return recent.length <= 20;
|
|
701
1058
|
}
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
1059
|
+
readBody(req, res, limit, cb) {
|
|
1060
|
+
const HARD = 4 * 1024 * 1024;
|
|
1061
|
+
let done = false;
|
|
1062
|
+
const tooBig = () => {
|
|
1063
|
+
if (done)
|
|
1064
|
+
return;
|
|
1065
|
+
done = true;
|
|
1066
|
+
if (!res.headersSent) {
|
|
1067
|
+
res.setHeader("connection", "close");
|
|
1068
|
+
this.json(res, 413, { error: `request body too large: the limit here is ${limit} bytes` });
|
|
1069
|
+
}
|
|
1070
|
+
};
|
|
1071
|
+
const declared = Number(req.headers["content-length"]);
|
|
1072
|
+
if (Number.isFinite(declared) && declared > HARD) {
|
|
1073
|
+
tooBig();
|
|
1074
|
+
req.resume();
|
|
1075
|
+
res.on("finish", () => req.destroy());
|
|
1076
|
+
return;
|
|
1077
|
+
}
|
|
1078
|
+
const chunks = [];
|
|
1079
|
+
let size = 0, over = false;
|
|
1080
|
+
req.on("data", (c) => {
|
|
1081
|
+
size += c.length;
|
|
1082
|
+
if (size > HARD) {
|
|
1083
|
+
tooBig();
|
|
1084
|
+
res.on("finish", () => req.destroy());
|
|
1085
|
+
return;
|
|
1086
|
+
}
|
|
1087
|
+
if (size > limit) {
|
|
1088
|
+
over = true;
|
|
1089
|
+
chunks.length = 0;
|
|
1090
|
+
return;
|
|
1091
|
+
}
|
|
1092
|
+
if (!over)
|
|
1093
|
+
chunks.push(Buffer.isBuffer(c) ? c : Buffer.from(c));
|
|
1094
|
+
});
|
|
1095
|
+
req.on("end", () => {
|
|
1096
|
+
if (over)
|
|
1097
|
+
return tooBig();
|
|
1098
|
+
if (done)
|
|
1099
|
+
return;
|
|
1100
|
+
done = true;
|
|
1101
|
+
cb(Buffer.concat(chunks).toString("utf8"));
|
|
1102
|
+
});
|
|
1103
|
+
req.on("error", () => { if (done)
|
|
1104
|
+
return; done = true; cb(null); });
|
|
1105
|
+
}
|
|
1106
|
+
settle(res, r) {
|
|
1107
|
+
if (r && typeof r.catch === "function") {
|
|
1108
|
+
r.catch((e) => {
|
|
1109
|
+
this.log(`! http handler failed: ${e?.message ?? e}`);
|
|
1110
|
+
if (isStorageError(e))
|
|
1111
|
+
this.noteStorageFailure("http handler", e);
|
|
1112
|
+
try {
|
|
1113
|
+
if (!res.headersSent)
|
|
1114
|
+
this.json(res, 500, { error: "the relay could not handle that request" });
|
|
1115
|
+
else
|
|
1116
|
+
res.end();
|
|
1117
|
+
}
|
|
1118
|
+
catch { }
|
|
1119
|
+
});
|
|
708
1120
|
}
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
1121
|
+
}
|
|
1122
|
+
readJson(req, res, cb) {
|
|
1123
|
+
this.readBody(req, res, 64_000, (raw) => {
|
|
1124
|
+
let body = null;
|
|
1125
|
+
if (raw !== null) {
|
|
1126
|
+
try {
|
|
1127
|
+
body = raw ? JSON.parse(raw) : {};
|
|
1128
|
+
}
|
|
1129
|
+
catch {
|
|
1130
|
+
body = null;
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
1133
|
+
try {
|
|
1134
|
+
this.settle(res, cb(body));
|
|
1135
|
+
}
|
|
1136
|
+
catch (e) {
|
|
1137
|
+
this.settle(res, Promise.reject(e));
|
|
1138
|
+
}
|
|
1139
|
+
});
|
|
713
1140
|
}
|
|
714
1141
|
dropSessionEverywhere(sessionId) {
|
|
1142
|
+
this.authEpoch++;
|
|
715
1143
|
for (const sp of this.spaces.values()) {
|
|
716
1144
|
for (const m of sp.members.values()) {
|
|
717
1145
|
const s = m.sessions.get(sessionId);
|
|
718
1146
|
if (s) {
|
|
719
|
-
|
|
720
|
-
s.ws.close(1008, "SESSION_REVOKED");
|
|
721
|
-
}
|
|
722
|
-
catch { }
|
|
1147
|
+
this.hangUp(s.ws, "SESSION_REVOKED");
|
|
723
1148
|
m.sessions.delete(sessionId);
|
|
724
1149
|
}
|
|
725
1150
|
}
|
|
726
1151
|
}
|
|
727
1152
|
}
|
|
1153
|
+
hangUp(ws, reason) {
|
|
1154
|
+
try {
|
|
1155
|
+
ws.close(1008, reason);
|
|
1156
|
+
}
|
|
1157
|
+
catch { }
|
|
1158
|
+
const t = setTimeout(() => { try {
|
|
1159
|
+
ws.terminate();
|
|
1160
|
+
}
|
|
1161
|
+
catch { } }, 100);
|
|
1162
|
+
t.unref?.();
|
|
1163
|
+
}
|
|
1164
|
+
stillIn(sp, me, sessionId, ws) {
|
|
1165
|
+
if (ws.readyState !== 1)
|
|
1166
|
+
return false;
|
|
1167
|
+
if (sp.deleted || this.spaces.get(spaceKey(sp.name)) !== sp)
|
|
1168
|
+
return false;
|
|
1169
|
+
if (sp.members.get(me.id) !== me || me.sessions.get(sessionId)?.ws !== ws)
|
|
1170
|
+
return false;
|
|
1171
|
+
return !!sp.allow.get(me.id)?.admitted;
|
|
1172
|
+
}
|
|
728
1173
|
dropDeviceSockets(_accountId, _deviceId) {
|
|
729
1174
|
}
|
|
730
|
-
readForm(req, cb) {
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
1175
|
+
readForm(req, res, cb) {
|
|
1176
|
+
this.readBody(req, res, 8_000, (raw) => {
|
|
1177
|
+
const o = {};
|
|
1178
|
+
for (const [k, v] of new URLSearchParams(raw ?? ""))
|
|
1179
|
+
o[k] = v;
|
|
1180
|
+
try {
|
|
1181
|
+
this.settle(res, cb(o));
|
|
1182
|
+
}
|
|
1183
|
+
catch (e) {
|
|
1184
|
+
this.settle(res, Promise.reject(e));
|
|
1185
|
+
}
|
|
1186
|
+
});
|
|
737
1187
|
}
|
|
738
1188
|
adminLoginPage(res, wrong) {
|
|
739
1189
|
res.writeHead(wrong ? 401 : 200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store", "referrer-policy": "no-referrer" });
|
|
@@ -785,7 +1235,7 @@ button:hover{background:#1d4ed8}.err{color:var(--bad);margin-top:12px}
|
|
|
785
1235
|
if (exact(sp.allow))
|
|
786
1236
|
return sp.name;
|
|
787
1237
|
const safeOf = (name) => name.replace(/[^a-z0-9._-]/gi, "_");
|
|
788
|
-
const live = new Set(
|
|
1238
|
+
const live = new Set(this.spaces.keys());
|
|
789
1239
|
let files = [];
|
|
790
1240
|
try {
|
|
791
1241
|
files = readdirSync(this.dataDir);
|
|
@@ -797,7 +1247,7 @@ button:hover{background:#1d4ed8}.err{color:var(--bad);margin-top:12px}
|
|
|
797
1247
|
if (!f.endsWith(".members.json"))
|
|
798
1248
|
continue;
|
|
799
1249
|
const safe = f.slice(0, -".members.json".length);
|
|
800
|
-
if (live.has(safe))
|
|
1250
|
+
if (live.has(safe.toLowerCase()))
|
|
801
1251
|
continue;
|
|
802
1252
|
try {
|
|
803
1253
|
if (!exact(new Allowlist(this.dataDir, safe)))
|
|
@@ -939,28 +1389,27 @@ button:hover{background:#1d4ed8}.err{color:var(--bad);margin-top:12px}
|
|
|
939
1389
|
return { goals, refusals: st.refusals };
|
|
940
1390
|
}
|
|
941
1391
|
deleteSpace(name) {
|
|
942
|
-
const sp = this.spaces.get(name);
|
|
1392
|
+
const sp = this.spaces.get(spaceKey(name));
|
|
943
1393
|
if (sp) {
|
|
944
1394
|
sp.deleted = true;
|
|
945
1395
|
for (const m of sp.members.values())
|
|
946
|
-
for (const s of m.sessions.values())
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
}
|
|
950
|
-
catch { }
|
|
951
|
-
}
|
|
952
|
-
this.spaces.delete(name);
|
|
1396
|
+
for (const s of m.sessions.values())
|
|
1397
|
+
this.hangUp(s.ws, "SPACE_DELETED");
|
|
1398
|
+
this.spaces.delete(spaceKey(name));
|
|
953
1399
|
}
|
|
954
1400
|
setTimeout(() => this.deleteSpaceFiles(name), 600).unref?.();
|
|
955
1401
|
const safe = name.replace(/[^a-z0-9._-]/gi, "_");
|
|
956
1402
|
try {
|
|
957
1403
|
const hash = sp?.allow ? JSON.parse(readFileSync(join(this.dataDir, `${safe}.members.json`), "utf8")).tokenHash ?? null : null;
|
|
958
|
-
|
|
1404
|
+
const earlier = sp?.allow ? sp.allow.tokenHashes().filter((h) => h !== hash) : [];
|
|
1405
|
+
writeFileSync(join(this.dataDir, `${safe}.deleted.json`), JSON.stringify({ name, at: Date.now(), tokenHash: hash, ...(earlier.length ? { retiredTokenHashes: earlier } : {}) }));
|
|
1406
|
+
this.tombstoneFile(name);
|
|
1407
|
+
this.tombstoneStems.set(spaceKey(name), safe);
|
|
959
1408
|
}
|
|
960
1409
|
catch { }
|
|
961
1410
|
const existed = !!sp || [".jsonl", ".state.json", ".members.json"].some((suf) => existsSync(join(this.dataDir, `${safe}${suf}`)));
|
|
962
1411
|
this.deleteSpaceFiles(name);
|
|
963
|
-
this.deletedSpaces.set(name, Date.now());
|
|
1412
|
+
this.deletedSpaces.set(spaceKey(name), Date.now());
|
|
964
1413
|
if (this.deletedSpaces.size > 1000)
|
|
965
1414
|
for (const [k, t] of this.deletedSpaces)
|
|
966
1415
|
if (Date.now() - t > 3_600_000)
|
|
@@ -980,14 +1429,17 @@ button:hover{background:#1d4ed8}.err{color:var(--bad);margin-top:12px}
|
|
|
980
1429
|
return this.json(res, 404, { error: "no such account" });
|
|
981
1430
|
const live = A.sessionsFor(acct.id).filter((s) => !s.revoked_at);
|
|
982
1431
|
A.setStatus(acct.id, act === "suspend" ? "suspended" : "active", ip);
|
|
983
|
-
|
|
1432
|
+
this.authEpoch++;
|
|
1433
|
+
if (act === "suspend") {
|
|
984
1434
|
for (const s of live)
|
|
985
1435
|
this.dropSessionEverywhere(s.id);
|
|
1436
|
+
this.noteAccountRemoval(acct.username);
|
|
1437
|
+
}
|
|
986
1438
|
this.log(`[admin] ${act} ${acct.username} from ${ip}`);
|
|
987
1439
|
return done(`${acct.username} ${act === "suspend" ? "suspended and signed out everywhere" : "reinstated"}`);
|
|
988
1440
|
}
|
|
989
1441
|
case "revoke_invite": {
|
|
990
|
-
const sp = this.spaces.get(id);
|
|
1442
|
+
const sp = this.spaces.get(spaceKey(id));
|
|
991
1443
|
const allow = sp ? sp.allow : new Allowlist(this.dataDir, id.replace(/[^a-z0-9._-]/gi, "_"));
|
|
992
1444
|
const token = (body.token ?? "").trim();
|
|
993
1445
|
const inv = allow.findInvite(token);
|
|
@@ -1004,8 +1456,12 @@ button:hover{background:#1d4ed8}.err{color:var(--bad);margin-top:12px}
|
|
|
1004
1456
|
const hit = A.adminRevoke(act === "revoke_session" ? "session" : "device", id, ip);
|
|
1005
1457
|
for (const sid of hit.sessionIds)
|
|
1006
1458
|
this.dropSessionEverywhere(sid);
|
|
1007
|
-
if (hit.deviceId && hit.accountId)
|
|
1459
|
+
if (hit.deviceId && hit.accountId) {
|
|
1008
1460
|
this.dropDeviceSockets(hit.accountId, hit.deviceId);
|
|
1461
|
+
const owner = A.accountById(hit.accountId);
|
|
1462
|
+
if (owner)
|
|
1463
|
+
this.noteAccountRemoval(owner.username);
|
|
1464
|
+
}
|
|
1009
1465
|
return done(hit.sessionIds.length || hit.deviceId ? `revoked ${act === "revoke_session" ? "session" : "device"} ${id}` : `nothing to revoke for ${id}`);
|
|
1010
1466
|
}
|
|
1011
1467
|
case "block": {
|
|
@@ -1091,28 +1547,220 @@ button:hover{background:#1d4ed8}.err{color:var(--bad);margin-top:12px}
|
|
|
1091
1547
|
default: return this.json(res, 400, { error: `unknown action "${act}"` });
|
|
1092
1548
|
}
|
|
1093
1549
|
}
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
if (live)
|
|
1097
|
-
return secretEq(live.token, token) ? live : null;
|
|
1098
|
-
if (!name || !token || !this.spaceOnDisk(name))
|
|
1550
|
+
readTarget(name, token) {
|
|
1551
|
+
if (!name || !token)
|
|
1099
1552
|
return null;
|
|
1100
|
-
|
|
1553
|
+
const live = this.live(name);
|
|
1554
|
+
if (live) {
|
|
1555
|
+
const st = this.tokenState(live, token);
|
|
1556
|
+
return st ? { live, allow: live.allow, st } : null;
|
|
1557
|
+
}
|
|
1558
|
+
if (this.aliasOf(name) || !this.spaceOnDisk(name))
|
|
1101
1559
|
return null;
|
|
1102
|
-
|
|
1560
|
+
const allow = new Allowlist(this.dataDir, safeName(name));
|
|
1561
|
+
const st = allow.tokenMatches(token) === true ? "current" : allow.tokenRetired(token) ? "retired" : null;
|
|
1562
|
+
return st ? { live: undefined, allow, st } : null;
|
|
1563
|
+
}
|
|
1564
|
+
unsignedRefusal(name, allow, st) {
|
|
1565
|
+
if (this.readsMode === "signed")
|
|
1566
|
+
return "This relay serves only signed reads.";
|
|
1567
|
+
if (allow.removalRecorded())
|
|
1568
|
+
return `"${name}" has removed a member, so it serves only signed reads.`;
|
|
1569
|
+
if (st !== "current")
|
|
1570
|
+
return `"${name}" has a new token since this machine last connected, so it serves this read only signed.`;
|
|
1571
|
+
return null;
|
|
1572
|
+
}
|
|
1573
|
+
upgradeAct(name, seq, goal, why) {
|
|
1574
|
+
return {
|
|
1575
|
+
id: UPGRADE_NOTE_ID, seq, ts: Date.now(), space: name, from: RELAY_MEMBER, act: "NOTE",
|
|
1576
|
+
body: `${UPGRADE_NOTE}\n${why}`, to: null, goal, ref: null, meta: { notice: "upgrade_required" },
|
|
1577
|
+
};
|
|
1578
|
+
}
|
|
1579
|
+
upgradeAnswer(res, path, url, n, error) {
|
|
1580
|
+
const since = url.searchParams.get("since");
|
|
1581
|
+
const seq = since !== null ? Math.max(0, Math.min(n.head, Number(since) || 0)) : n.head;
|
|
1582
|
+
const act = this.upgradeAct(n.name, seq, url.searchParams.get("goal"), n.why);
|
|
1583
|
+
const common = { code: "UPGRADE_REQUIRED", error, note: UPGRADE_NOTE };
|
|
1584
|
+
if (path === "/act")
|
|
1585
|
+
return this.json(res, 200, { act, ...common });
|
|
1586
|
+
if (path === "/obligations")
|
|
1587
|
+
return this.json(res, 200, { obligations: [], closed_goals: [], seq: n.head, worker_online: false, ...common });
|
|
1588
|
+
if (since !== null && Number(since) >= n.head)
|
|
1589
|
+
return this.json(res, 200, { acts: [], head: n.head, ...common });
|
|
1590
|
+
return this.json(res, 200, { acts: [act], head: n.head, ...common });
|
|
1591
|
+
}
|
|
1592
|
+
tokenState(sp, token) {
|
|
1593
|
+
if (!token)
|
|
1594
|
+
return null;
|
|
1595
|
+
if (sp.token && secretEq(sp.token, token))
|
|
1596
|
+
return "current";
|
|
1597
|
+
if (sp.allow.tokenMatches(token) === true) {
|
|
1598
|
+
sp.token = token;
|
|
1599
|
+
this.handTokenToWaiting(sp);
|
|
1600
|
+
return "current";
|
|
1601
|
+
}
|
|
1602
|
+
return sp.allow.tokenRetired(token) ? "retired" : null;
|
|
1603
|
+
}
|
|
1604
|
+
knownToken(sp) {
|
|
1605
|
+
if (!sp.token) {
|
|
1606
|
+
const t = sp.allow.currentTokenFromInvites();
|
|
1607
|
+
if (t)
|
|
1608
|
+
sp.token = t;
|
|
1609
|
+
}
|
|
1610
|
+
return sp.token;
|
|
1611
|
+
}
|
|
1612
|
+
handTokenToWaiting(sp) {
|
|
1613
|
+
for (const m of sp.members.values()) {
|
|
1614
|
+
if (!sp.allow.get(m.id)?.admitted)
|
|
1615
|
+
continue;
|
|
1616
|
+
for (const s of m.sessions.values())
|
|
1617
|
+
if (s.wantsToken) {
|
|
1618
|
+
s.wantsToken = false;
|
|
1619
|
+
this.send(s.ws, { t: "token", token: sp.token });
|
|
1620
|
+
}
|
|
1621
|
+
}
|
|
1622
|
+
}
|
|
1623
|
+
ensureToken(sp) {
|
|
1624
|
+
if (!this.knownToken(sp))
|
|
1625
|
+
this.rotateSpaceToken(sp, "its current token was not in memory");
|
|
1626
|
+
return sp.token;
|
|
1627
|
+
}
|
|
1628
|
+
rotateSpaceToken(sp, why) {
|
|
1629
|
+
const next = randomBytes(16).toString("hex");
|
|
1630
|
+
sp.allow.rotateToken(next);
|
|
1631
|
+
sp.token = next;
|
|
1632
|
+
let told = 0;
|
|
1633
|
+
for (const m of sp.members.values()) {
|
|
1634
|
+
if (!sp.allow.get(m.id)?.admitted)
|
|
1635
|
+
continue;
|
|
1636
|
+
for (const s of m.sessions.values()) {
|
|
1637
|
+
this.send(s.ws, { t: "token", token: next });
|
|
1638
|
+
told++;
|
|
1639
|
+
}
|
|
1640
|
+
}
|
|
1641
|
+
this.log(`[${sp.name}] * space token rotated (${why}); sent to ${told} live session${told === 1 ? "" : "s"}`);
|
|
1642
|
+
}
|
|
1643
|
+
noteAccountRemoval(username) {
|
|
1644
|
+
this.authEpoch++;
|
|
1645
|
+
const u = normalizeUsername(username);
|
|
1646
|
+
const seen = new Set();
|
|
1647
|
+
for (const sp of this.spaces.values()) {
|
|
1648
|
+
seen.add(safeName(sp.name));
|
|
1649
|
+
if (sp.allow.get(u))
|
|
1650
|
+
sp.allow.noteRemoval();
|
|
1651
|
+
}
|
|
1652
|
+
let files = [];
|
|
1653
|
+
try {
|
|
1654
|
+
files = readdirSync(this.dataDir).filter((f) => f.endsWith(".members.json"));
|
|
1655
|
+
}
|
|
1656
|
+
catch { }
|
|
1657
|
+
for (const f of files) {
|
|
1658
|
+
const stem = f.slice(0, -".members.json".length);
|
|
1659
|
+
if (seen.has(stem))
|
|
1660
|
+
continue;
|
|
1661
|
+
try {
|
|
1662
|
+
const al = new Allowlist(this.dataDir, stem);
|
|
1663
|
+
if (al.get(u))
|
|
1664
|
+
al.noteRemoval();
|
|
1665
|
+
}
|
|
1666
|
+
catch { }
|
|
1667
|
+
}
|
|
1668
|
+
}
|
|
1669
|
+
readGate(req, url, ip) {
|
|
1670
|
+
const params = {};
|
|
1671
|
+
for (const [k, v] of url.searchParams) {
|
|
1672
|
+
if (k in params)
|
|
1673
|
+
return { ok: false, status: 400, error: `the query names "${k}" twice` };
|
|
1674
|
+
params[k] = v;
|
|
1675
|
+
}
|
|
1676
|
+
const name = params.space ?? "", token = params.token ?? "";
|
|
1677
|
+
const upgrade = (why) => ({
|
|
1678
|
+
ok: false, status: 401, code: "UPGRADE_REQUIRED",
|
|
1679
|
+
error: `${why} This pantheon is too old to read it: run pantheon upgrade (or npm i -g @join-pantheon/cli), then try again.`,
|
|
1680
|
+
});
|
|
1681
|
+
const target = this.readTarget(name, token);
|
|
1682
|
+
if (params.sig === undefined) {
|
|
1683
|
+
if (!target)
|
|
1684
|
+
return this.readsMode === "signed" ? upgrade("This relay serves only signed reads.") : { ok: false, status: 404, error: "no such space" };
|
|
1685
|
+
const why = this.unsignedRefusal(name, target.allow, target.st);
|
|
1686
|
+
if (why) {
|
|
1687
|
+
const head = target.live ? target.live.ledger.head : ledgerTailSeq(join(this.dataDir, `${safeName(name)}.jsonl`));
|
|
1688
|
+
return { ...upgrade(why), notice: { name, head, why } };
|
|
1689
|
+
}
|
|
1690
|
+
const space = target.live ?? this.getOrCreateSpace(name, token);
|
|
1691
|
+
if (!space)
|
|
1692
|
+
return { ok: false, status: 404, error: "no such space" };
|
|
1693
|
+
return { ok: true, space, reader: null };
|
|
1694
|
+
}
|
|
1695
|
+
if (!target)
|
|
1696
|
+
return { ok: false, status: 404, error: "no such space" };
|
|
1697
|
+
const reader = params.reader ?? "";
|
|
1698
|
+
const ts = Number(params.ts);
|
|
1699
|
+
const refused = { ok: false, status: 403, error: "a read must be signed, recently, by the key of a member admitted to this team" };
|
|
1700
|
+
const pub = reader ? target.allow.pubkeyOf(reader) : null;
|
|
1701
|
+
if (!pub || !Number.isFinite(ts) || Math.abs(Date.now() - ts) >= LIMITS.activityFreshMs)
|
|
1702
|
+
return refused;
|
|
1703
|
+
const { sig, ...signed } = params;
|
|
1704
|
+
let signer = pub;
|
|
1705
|
+
if (signed.dpub) {
|
|
1706
|
+
const cert = { pub: signed.dpub, run: signed.drun ?? "", expires: Number(signed.dexp), sig: signed.dcert ?? "" };
|
|
1707
|
+
if (![target.allow.spaceId(), name].some((t) => t && verifyDelegate(pub, t, reader, cert)))
|
|
1708
|
+
return refused;
|
|
1709
|
+
signer = signed.dpub;
|
|
1710
|
+
}
|
|
1711
|
+
if (!verifyRead(signer, req.method ?? "GET", url.pathname, signed, sig))
|
|
1712
|
+
return refused;
|
|
1713
|
+
if (this.requireAccounts && this.accounts && !this.localTrusted(ip)) {
|
|
1714
|
+
const auth = this.accounts.authorize(pub, reader);
|
|
1715
|
+
if (!auth.ok) {
|
|
1716
|
+
return { ok: false, status: 403, code: "NOT_SIGNED_IN", error: auth.reason === "suspended"
|
|
1717
|
+
? "this account has been suspended by the relay operator"
|
|
1718
|
+
: "this device is not signed in to an account that may read here — run pantheon login" };
|
|
1719
|
+
}
|
|
1720
|
+
}
|
|
1721
|
+
const space = target.live ?? this.getOrCreateSpace(name, token, { retired: true });
|
|
1722
|
+
if (!space)
|
|
1723
|
+
return { ok: false, status: 404, error: "no such space" };
|
|
1724
|
+
return { ok: true, space, reader };
|
|
1725
|
+
}
|
|
1726
|
+
tombstoneStems = null;
|
|
1727
|
+
tombstoneFile(name) {
|
|
1728
|
+
if (!this.tombstoneStems) {
|
|
1729
|
+
this.tombstoneStems = new Map();
|
|
1730
|
+
try {
|
|
1731
|
+
for (const f of readdirSync(this.dataDir)) {
|
|
1732
|
+
if (!f.endsWith(".deleted.json"))
|
|
1733
|
+
continue;
|
|
1734
|
+
const stem = f.slice(0, -".deleted.json".length);
|
|
1735
|
+
this.tombstoneStems.set(stem.toLowerCase(), stem);
|
|
1736
|
+
}
|
|
1737
|
+
}
|
|
1738
|
+
catch { }
|
|
1739
|
+
}
|
|
1740
|
+
const stem = this.tombstoneStems.get(spaceKey(name));
|
|
1741
|
+
return stem ? join(this.dataDir, `${stem}.deleted.json`) : null;
|
|
1103
1742
|
}
|
|
1104
1743
|
tombstone(name, _token) {
|
|
1744
|
+
const f = this.tombstoneFile(name);
|
|
1745
|
+
if (!f)
|
|
1746
|
+
return null;
|
|
1105
1747
|
try {
|
|
1106
|
-
return JSON.parse(readFileSync(
|
|
1748
|
+
return JSON.parse(readFileSync(f, "utf8")).at;
|
|
1107
1749
|
}
|
|
1108
1750
|
catch {
|
|
1109
1751
|
return null;
|
|
1110
1752
|
}
|
|
1111
1753
|
}
|
|
1112
1754
|
tombstoneMatches(name, token) {
|
|
1755
|
+
const f = this.tombstoneFile(name);
|
|
1756
|
+
if (!f)
|
|
1757
|
+
return false;
|
|
1113
1758
|
try {
|
|
1114
|
-
const t = JSON.parse(readFileSync(
|
|
1115
|
-
|
|
1759
|
+
const t = JSON.parse(readFileSync(f, "utf8"));
|
|
1760
|
+
if (!token)
|
|
1761
|
+
return false;
|
|
1762
|
+
const h = createHash("sha256").update(token).digest("hex");
|
|
1763
|
+
return (!!t.tokenHash && h === t.tokenHash) || (Array.isArray(t.retiredTokenHashes) && t.retiredTokenHashes.includes(h));
|
|
1116
1764
|
}
|
|
1117
1765
|
catch {
|
|
1118
1766
|
return false;
|
|
@@ -1130,20 +1778,50 @@ button:hover{background:#1d4ed8}.err{color:var(--bad);margin-top:12px}
|
|
|
1130
1778
|
spaceOnDisk(name) {
|
|
1131
1779
|
return existsSync(join(this.dataDir, `${name.replace(/[^a-z0-9._-]/gi, "_")}.members.json`));
|
|
1132
1780
|
}
|
|
1781
|
+
live(name) {
|
|
1782
|
+
const sp = this.spaces.get(spaceKey(name));
|
|
1783
|
+
return sp && sp.name === name ? sp : undefined;
|
|
1784
|
+
}
|
|
1785
|
+
aliasOf(name) {
|
|
1786
|
+
const sp = this.spaces.get(spaceKey(name));
|
|
1787
|
+
if (sp)
|
|
1788
|
+
return sp.name !== name;
|
|
1789
|
+
if (!name || !this.spaceOnDisk(name))
|
|
1790
|
+
return false;
|
|
1791
|
+
const stem = safeName(name);
|
|
1792
|
+
try {
|
|
1793
|
+
if (!readdirSync(this.dataDir).includes(`${stem}.members.json`))
|
|
1794
|
+
return true;
|
|
1795
|
+
}
|
|
1796
|
+
catch { }
|
|
1797
|
+
const recorded = this.recordedName(stem);
|
|
1798
|
+
return recorded !== null && recorded !== name;
|
|
1799
|
+
}
|
|
1800
|
+
recordedName(stem) {
|
|
1801
|
+
try {
|
|
1802
|
+
const n = new Allowlist(this.dataDir, stem).recordedName();
|
|
1803
|
+
if (n)
|
|
1804
|
+
return n;
|
|
1805
|
+
}
|
|
1806
|
+
catch { }
|
|
1807
|
+
return firstLedgerName(join(this.dataDir, `${stem}.jsonl`));
|
|
1808
|
+
}
|
|
1133
1809
|
spacePlan(name) {
|
|
1134
1810
|
if (!this.accounts)
|
|
1135
1811
|
return null;
|
|
1136
1812
|
const owner = this.accounts.spaceOwner(name);
|
|
1137
|
-
if (
|
|
1138
|
-
return
|
|
1139
|
-
|
|
1813
|
+
if (owner)
|
|
1814
|
+
return { name: owner.plan, limits: this.accounts.limitsFor(owner), owner };
|
|
1815
|
+
if (this.accounts.spaceOrphaned(name))
|
|
1816
|
+
return { name: "free", limits: planLimits(this.accounts.plans(), "free"), owner: null };
|
|
1817
|
+
return null;
|
|
1140
1818
|
}
|
|
1141
1819
|
planView(acct, sp) {
|
|
1142
1820
|
const fresh = this.accounts?.accountById(acct.id) ?? acct;
|
|
1143
1821
|
return { name: fresh.plan, limits: this.accounts.limitsFor(fresh), usage: { spaces: this.accounts.spacesOwned(fresh.id).length, members: sp.allow.admittedCount() } };
|
|
1144
1822
|
}
|
|
1145
1823
|
planRefusal(space, plan, target, msg, cid, fail, actor, ip) {
|
|
1146
|
-
this.accounts?.audit({ event: "plan_limit", actor_account: actor ?? plan.owner
|
|
1824
|
+
this.accounts?.audit({ event: "plan_limit", actor_account: actor ?? plan.owner?.id ?? null, space: space.name, target, detail: `${plan.name}: ${msg.slice(0, 160)}`, ip });
|
|
1147
1825
|
this.log(`[${space.name}] ! PLAN_LIMIT (${target}, ${plan.name} plan)`);
|
|
1148
1826
|
fail("PLAN_LIMIT", msg, cid);
|
|
1149
1827
|
}
|
|
@@ -1200,7 +1878,7 @@ button:hover{background:#1d4ed8}.err{color:var(--bad);margin-top:12px}
|
|
|
1200
1878
|
for (const d of devices)
|
|
1201
1879
|
devByAcct.set(d.account_id, (devByAcct.get(d.account_id) ?? 0) + 1);
|
|
1202
1880
|
const emailCap = Number(process.env.PANTHEON_EMAIL_MAX_PER_HOUR) || 200;
|
|
1203
|
-
const emailsHour = this.emailSends.filter((
|
|
1881
|
+
const emailsHour = this.emailSends.filter((s) => Date.now() - s.at < 3_600_000).length;
|
|
1204
1882
|
let snapshots = [];
|
|
1205
1883
|
try {
|
|
1206
1884
|
snapshots = readdirSync(join(this.dataDir, "backups")).filter((f) => f.startsWith("accounts-") && f.endsWith(".db")).sort();
|
|
@@ -1380,8 +2058,8 @@ ${done ? `<div class=done role=status>${esc(done)}</div>` : ""}
|
|
|
1380
2058
|
}
|
|
1381
2059
|
adminSpace(res, url, name) {
|
|
1382
2060
|
const safe = name.replace(/[^a-z0-9._-]/gi, "_");
|
|
1383
|
-
const sp = this.spaces.get(name) ?? null;
|
|
1384
|
-
const tomb = this.deletedSpaces.get(name);
|
|
2061
|
+
const sp = this.spaces.get(spaceKey(name)) ?? null;
|
|
2062
|
+
const tomb = this.deletedSpaces.get(spaceKey(name));
|
|
1385
2063
|
const onDisk = [".jsonl", ".state.json", ".members.json"].some((suf) => existsSync(join(this.dataDir, `${safe}${suf}`)));
|
|
1386
2064
|
if (!name || (!sp && (!onDisk || (tomb && Date.now() - tomb < 3_600_000))))
|
|
1387
2065
|
return this.json(res, 404, { error: `no space "${name}" on this relay` });
|
|
@@ -1563,7 +2241,12 @@ ${url.searchParams.get("done") ? `<div class=done role=status>${esc(url.searchPa
|
|
|
1563
2241
|
return void res.end(JSON.stringify(data, null, 2));
|
|
1564
2242
|
}
|
|
1565
2243
|
const cols = data.length ? Object.keys(data[0]) : [];
|
|
1566
|
-
const cell = (v) => {
|
|
2244
|
+
const cell = (v) => {
|
|
2245
|
+
let s = String(v ?? "");
|
|
2246
|
+
if (/^[=+\-@\t\r]/.test(s))
|
|
2247
|
+
s = `'${s}`;
|
|
2248
|
+
return /[",\n\r]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
|
|
2249
|
+
};
|
|
1567
2250
|
const csv = [cols.join(","), ...data.map((r) => cols.map((k) => cell(r[k])).join(","))].join("\n");
|
|
1568
2251
|
res.writeHead(200, { "content-type": "text/csv", "content-disposition": `attachment; filename="${table}.csv"` });
|
|
1569
2252
|
res.end(csv);
|
|
@@ -1628,22 +2311,60 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
1628
2311
|
const touch = () => {
|
|
1629
2312
|
if (dbSession && Date.now() - lastTouch > 60_000) {
|
|
1630
2313
|
lastTouch = Date.now();
|
|
1631
|
-
|
|
2314
|
+
try {
|
|
2315
|
+
this.accounts?.touchSession(dbSession);
|
|
2316
|
+
}
|
|
2317
|
+
catch (e) {
|
|
2318
|
+
this.noteStorageFailure("session touch", e);
|
|
2319
|
+
}
|
|
1632
2320
|
}
|
|
1633
2321
|
};
|
|
1634
2322
|
let sessionId = "";
|
|
1635
2323
|
let role = "interactive";
|
|
2324
|
+
let myPubkey = "";
|
|
2325
|
+
let delegated = null;
|
|
2326
|
+
let authEpoch = -1, authOk = true;
|
|
1636
2327
|
let lastMsg = "";
|
|
1637
2328
|
let alive = true;
|
|
1638
2329
|
const nonce = newNonce();
|
|
2330
|
+
const accountStillOk = () => {
|
|
2331
|
+
if (!acct || !this.accounts)
|
|
2332
|
+
return true;
|
|
2333
|
+
if (authEpoch !== this.authEpoch) {
|
|
2334
|
+
authEpoch = this.authEpoch;
|
|
2335
|
+
try {
|
|
2336
|
+
authOk = this.accounts.authorize(myPubkey, me?.id ?? "").ok;
|
|
2337
|
+
}
|
|
2338
|
+
catch {
|
|
2339
|
+
authOk = false;
|
|
2340
|
+
}
|
|
2341
|
+
}
|
|
2342
|
+
return authOk;
|
|
2343
|
+
};
|
|
1639
2344
|
ws.on("pong", () => { alive = true; touch(); });
|
|
1640
2345
|
const probe = ws;
|
|
1641
2346
|
probe._alive = () => alive;
|
|
1642
2347
|
probe._clear = () => { alive = false; };
|
|
2348
|
+
let refusalStamps = [];
|
|
2349
|
+
let refusedOut = false;
|
|
1643
2350
|
const fail = (code, message, cid) => {
|
|
2351
|
+
if (refusedOut)
|
|
2352
|
+
return;
|
|
2353
|
+
const now = Date.now();
|
|
2354
|
+
refusalStamps.push(now);
|
|
2355
|
+
if (refusalStamps.length > REFUSAL_BUDGET) {
|
|
2356
|
+
refusalStamps = refusalStamps.filter((t) => now - t < REFUSAL_WINDOW_MS);
|
|
2357
|
+
if (refusalStamps.length > REFUSAL_BUDGET) {
|
|
2358
|
+
refusedOut = true;
|
|
2359
|
+
this.log(`[${space?.name ?? "?"}] ! ${me?.id ?? "an unauthenticated socket"} closed: ${refusalStamps.length} refused requests in ${REFUSAL_WINDOW_MS / 1000}s`);
|
|
2360
|
+
this.send(ws, { t: "error", code: "TOO_MANY_REFUSALS", message: `this connection had ${REFUSAL_BUDGET} requests refused within ${REFUSAL_WINDOW_MS / 1000}s, so the relay closed it. Reconnect, and fix the request that keeps being refused (the last one: ${code}).` });
|
|
2361
|
+
this.hangUp(ws, "TOO_MANY_REFUSALS");
|
|
2362
|
+
return;
|
|
2363
|
+
}
|
|
2364
|
+
}
|
|
1644
2365
|
if (cid && space && me && !space.deleted) {
|
|
1645
|
-
noteRefusal(space.store.refusals, { ts:
|
|
1646
|
-
space.store.
|
|
2366
|
+
noteRefusal(space.store.refusals, { ts: now, member: me.id, act: lastMsg || "?", code });
|
|
2367
|
+
space.store.save(space.goals, space.obligations, space.profiles);
|
|
1647
2368
|
}
|
|
1648
2369
|
this.send(ws, { t: "error", code, message, ...(cid ? { cid } : {}) });
|
|
1649
2370
|
};
|
|
@@ -1667,8 +2388,11 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
1667
2388
|
helloTimer = null;
|
|
1668
2389
|
} };
|
|
1669
2390
|
ws.on("close", clearHelloTimer);
|
|
1670
|
-
this.send(ws, { t: "challenge", nonce, v: PROTOCOL_VERSION });
|
|
2391
|
+
this.send(ws, { t: "challenge", nonce, v: PROTOCOL_VERSION, features: [...this.features] });
|
|
1671
2392
|
ws.on("message", (raw) => {
|
|
2393
|
+
if (refusedOut)
|
|
2394
|
+
return;
|
|
2395
|
+
let reqCid;
|
|
1672
2396
|
try {
|
|
1673
2397
|
let msg;
|
|
1674
2398
|
try {
|
|
@@ -1677,6 +2401,8 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
1677
2401
|
catch {
|
|
1678
2402
|
return fail("BAD_JSON", "message was not JSON");
|
|
1679
2403
|
}
|
|
2404
|
+
if (typeof msg?.cid === "string")
|
|
2405
|
+
reqCid = (msg.cid).slice(0, 64);
|
|
1680
2406
|
const parsed = Inbound.safeParse(msg);
|
|
1681
2407
|
if (!parsed.success) {
|
|
1682
2408
|
const cid = typeof msg?.cid === "string"
|
|
@@ -1695,32 +2421,42 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
1695
2421
|
return reject("BAD_NONCE", "challenge mismatch — reconnect");
|
|
1696
2422
|
if (!m.token && !m.enroll)
|
|
1697
2423
|
return reject("NO_CREDENTIAL", "present either a space token or an invite");
|
|
1698
|
-
if (
|
|
2424
|
+
if (m.delegate && !this.features.includes("delegate"))
|
|
2425
|
+
return reject("BAD_SIGNATURE", `signature does not match the presented key for "${m.member}"`);
|
|
2426
|
+
if (m.delegate && m.enroll)
|
|
2427
|
+
return reject("BAD_DELEGATE", "a woken run cannot redeem an invite");
|
|
2428
|
+
if (!verifyChallenge(m.delegate ? m.delegate.pub : m.pubkey, m.space, m.member, m.nonce, m.sig)) {
|
|
1699
2429
|
return reject("BAD_SIGNATURE", `signature does not match the presented key for "${m.member}"`);
|
|
1700
2430
|
}
|
|
1701
2431
|
const gated = this.requireAccounts && !!this.accounts && !this.localTrusted(ip);
|
|
1702
2432
|
const pre = gated ? this.accounts.authorize(m.pubkey, m.member) : null;
|
|
1703
2433
|
let sp;
|
|
2434
|
+
let staleToken = false;
|
|
1704
2435
|
if (m.enroll) {
|
|
1705
|
-
sp = this.
|
|
2436
|
+
sp = this.live(m.space) ?? null;
|
|
2437
|
+
if (!sp && this.aliasOf(m.space))
|
|
2438
|
+
return reject("NO_SUCH_SPACE", `no space "${m.space}" on this relay`);
|
|
1706
2439
|
if (!sp && this.spaceOnDisk(m.space)) {
|
|
1707
2440
|
const inv = new Allowlist(this.dataDir, safeName(m.space)).findInvite(m.enroll);
|
|
1708
2441
|
if (inv?.token === m.enroll && inv.space_token)
|
|
1709
|
-
sp = this.getOrCreateSpace(m.space, inv.space_token);
|
|
2442
|
+
sp = this.getOrCreateSpace(m.space, inv.space_token, { claim: "invite", retired: true });
|
|
1710
2443
|
}
|
|
1711
2444
|
if (!sp)
|
|
1712
2445
|
return reject("NO_SUCH_SPACE", `no space "${m.space}" on this relay`);
|
|
1713
2446
|
}
|
|
1714
2447
|
else {
|
|
1715
|
-
const tomb = this.deletedSpaces.get(m.space) ?? this.tombstone(m.space, m.token ?? "");
|
|
2448
|
+
const tomb = this.deletedSpaces.get(spaceKey(m.space)) ?? this.tombstone(m.space, m.token ?? "");
|
|
1716
2449
|
if (tomb && Date.now() - tomb < 3_600_000 || (tomb && this.tombstoneMatches(m.space, m.token ?? ""))) {
|
|
1717
2450
|
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
2451
|
}
|
|
1719
|
-
|
|
2452
|
+
if (this.aliasOf(m.space)) {
|
|
2453
|
+
return reject("NAME_TAKEN", `a team with a name too close to "${m.space}" already exists here — use its exact name, or pick another`);
|
|
2454
|
+
}
|
|
2455
|
+
const isNew = !this.live(m.space) && !this.spaceOnDisk(m.space);
|
|
1720
2456
|
if (isNew && this.nameCollides(m.space)) {
|
|
1721
2457
|
return reject("NAME_TAKEN", `a team with a name too close to "${m.space}" already exists here — pick another name`);
|
|
1722
2458
|
}
|
|
1723
|
-
if (!this.
|
|
2459
|
+
if (!this.live(m.space)) {
|
|
1724
2460
|
const denied = this.mayCreateSpace(ip);
|
|
1725
2461
|
if (denied)
|
|
1726
2462
|
return reject(denied.code, denied.message);
|
|
@@ -1739,9 +2475,19 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
1739
2475
|
return reject("PLAN_LIMIT", msg);
|
|
1740
2476
|
}
|
|
1741
2477
|
}
|
|
1742
|
-
|
|
2478
|
+
if (!this.live(m.space) && this.unboundOnDisk(safeName(m.space))) {
|
|
2479
|
+
const e = this.spaceOnDisk(m.space) ? new Allowlist(this.dataDir, safeName(m.space)).get(m.member) : undefined;
|
|
2480
|
+
if (!e?.admitted || e.publicKey !== m.pubkey) {
|
|
2481
|
+
this.unbound.add(safeName(m.space));
|
|
2482
|
+
this.log(`[${m.space}] ! REFUSED ${m.member}: the space has no bound token, and only an admitted member of it (with their key) may bind one`);
|
|
2483
|
+
return reject("UNBOUND_SPACE", `"${m.space}" was created before this relay kept a space's token, so it can only be reopened by one of its admitted members, from their own machine. ` +
|
|
2484
|
+
`If you are one, connect with your own identity; otherwise ask a member of "${m.space}" for an invite, or the relay operator.`);
|
|
2485
|
+
}
|
|
2486
|
+
}
|
|
2487
|
+
sp = this.getOrCreateSpace(m.space, m.token, { claim: { member: m.member, pubkey: m.pubkey }, retired: true });
|
|
1743
2488
|
if (!sp)
|
|
1744
2489
|
return reject("BAD_TOKEN", "wrong token for this space");
|
|
2490
|
+
staleToken = this.tokenState(sp, m.token) === "retired";
|
|
1745
2491
|
if (isNew) {
|
|
1746
2492
|
this.noteSpaceCreate(ip);
|
|
1747
2493
|
this.accounts?.recordSpace(sp.name, pre?.ok ? pre.account.id : null);
|
|
@@ -1770,20 +2516,38 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
1770
2516
|
const plan = this.spacePlan(sp.name);
|
|
1771
2517
|
if (plan && !sp.allow.get(m.member)?.admitted && sp.allow.admittedCount() >= plan.limits.members) {
|
|
1772
2518
|
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`);
|
|
2519
|
+
return reject("PLAN_LIMIT", `"${sp.name}" is full: its owner's ${plan.name} plan allows ${fmtLimit(plan.limits.members)} people. ${plan.owner ? `Ask ${plan.owner.username} to upgrade at https://www.joinpantheon.network` : "Its owner's account was deleted; the relay operator can move it to another plan."}`);
|
|
1774
2520
|
}
|
|
1775
2521
|
const r = sp.allow.redeem(m.enroll, m.member, m.pubkey);
|
|
1776
2522
|
if (!r.ok) {
|
|
1777
2523
|
this.log(`[${sp.name}] ! invite refused for ${m.member}: ${r.why}`);
|
|
1778
2524
|
return reject("BAD_INVITE", r.fix ? `${r.why}\n${r.fix}` : r.why);
|
|
1779
2525
|
}
|
|
1780
|
-
enrolled = sp
|
|
2526
|
+
enrolled = this.ensureToken(sp);
|
|
1781
2527
|
this.log(`[${sp.name}] * ENROLLED ${m.member} via invite from ${r.entry.admitted_by}${r.entry.may_wake ? " (may wake)" : ""}`);
|
|
1782
2528
|
}
|
|
2529
|
+
if (staleToken && !enrolled) {
|
|
2530
|
+
const e = sp.allow.get(m.member);
|
|
2531
|
+
if (e && e.publicKey === m.pubkey && !e.admitted && e.revoked_at) {
|
|
2532
|
+
this.log(`[${sp.name}] ! REFUSED ${m.member}: removed (earlier token)`);
|
|
2533
|
+
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.`);
|
|
2534
|
+
}
|
|
2535
|
+
if (!e || e.publicKey !== m.pubkey || !e.admitted) {
|
|
2536
|
+
this.log(`[${sp.name}] ! REFUSED ${m.member}: an earlier token, and not an admitted member with this key`);
|
|
2537
|
+
return reject("BAD_TOKEN", "wrong token for this space — this team's token has changed since, and only its admitted members are handed the new one. Ask a member for a fresh invite.");
|
|
2538
|
+
}
|
|
2539
|
+
}
|
|
2540
|
+
if (m.member.toLowerCase() === RELAY_MEMBER && !sp.allow.get(m.member)) {
|
|
2541
|
+
return reject("RESERVED_NAME", `"${m.member}" is the name the relay itself speaks as — choose another name`);
|
|
2542
|
+
}
|
|
1783
2543
|
const verdict = enrolled ? "ok" : sp.allow.present(m.member, m.pubkey);
|
|
2544
|
+
if (verdict === "pending-full") {
|
|
2545
|
+
this.log(`[${sp.name}] ! REFUSED ${m.member}: too many names already waiting to be admitted`);
|
|
2546
|
+
return reject("PENDING_FULL", `"${sp.name}" already has too many names waiting to be admitted. Ask a member to admit you by invite instead (pantheon invite), or to clear the waiting list.`);
|
|
2547
|
+
}
|
|
1784
2548
|
if (verdict === "key-mismatch") {
|
|
1785
2549
|
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
|
|
2550
|
+
return reject("KEY_MISMATCH", `"${m.member}" is already registered here with a different key. If you are someone else, join under your own name (--me <your-name>). If this is really you on another machine, an admitted member who has checked it is you runs pantheon admit ${m.member} --forget (it removes whoever holds the name now) and sends you a new invite.`);
|
|
1787
2551
|
}
|
|
1788
2552
|
if (verdict === "revoked") {
|
|
1789
2553
|
const e = sp.allow.get(m.member);
|
|
@@ -1809,16 +2573,24 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
1809
2573
|
newRepo = m.repo;
|
|
1810
2574
|
else {
|
|
1811
2575
|
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`);
|
|
2576
|
+
this.log(`[${sp.name}] ! REFUSED ${m.member}: repo ${repoLabelSafe(m.repo_origin) ?? m.repo} not bound to this space`);
|
|
1813
2577
|
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
2578
|
`Open your agent in the team's repo, or if this repo really belongs to the team: pantheon repo add`);
|
|
1815
2579
|
}
|
|
1816
2580
|
}
|
|
2581
|
+
if (m.delegate) {
|
|
2582
|
+
const teamIds = [sp.allow.spaceId(), sp.name];
|
|
2583
|
+
if (!teamIds.some((t) => verifyDelegate(m.pubkey, t, m.member, m.delegate))) {
|
|
2584
|
+
return reject("BAD_DELEGATE", `that run key is not vouched for by ${m.member}'s key for "${sp.name}", or its certificate has expired`);
|
|
2585
|
+
}
|
|
2586
|
+
delegated = { pub: m.delegate.pub, run: m.delegate.run, expires: m.delegate.expires };
|
|
2587
|
+
}
|
|
1817
2588
|
space = sp;
|
|
2589
|
+
myPubkey = m.pubkey;
|
|
1818
2590
|
clearHelloTimer();
|
|
1819
2591
|
sessionId = acctSession ?? this.mintSession();
|
|
1820
|
-
role = m.role;
|
|
1821
|
-
me = this.attach(sp, m.member, m.display ?? m.member, m.agent ?? "unknown", sessionId,
|
|
2592
|
+
role = delegated ? "worker" : m.role;
|
|
2593
|
+
me = this.attach(sp, m.member, m.display ?? m.member, m.agent ?? "unknown", sessionId, role, ws, m.pkg);
|
|
1822
2594
|
const firstSession = me.sessions.size === 1;
|
|
1823
2595
|
const prior = typeof m.session === "string" ? m.session : "";
|
|
1824
2596
|
const stale = prior && prior !== sessionId ? me.sessions.get(prior) : undefined;
|
|
@@ -1846,19 +2618,25 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
1846
2618
|
this.pushObligations(sp);
|
|
1847
2619
|
}
|
|
1848
2620
|
sp.lastTouch = Date.now();
|
|
1849
|
-
if (newRepo && sp.allow.addRepo(newRepo, m.repo_origin
|
|
1850
|
-
|
|
1851
|
-
this.
|
|
2621
|
+
if (newRepo && sp.allow.addRepo(newRepo, repoLabelSafe(m.repo_origin), me.id)) {
|
|
2622
|
+
const label = repoLabelSafe(m.repo_origin);
|
|
2623
|
+
this.log(`[${sp.name}] + repo ${label ?? newRepo} registered by ${me.id}`);
|
|
2624
|
+
this.commit(sp, { from: me.id, act: "NOTE", to: null, goal: null, ref: null, body: `repo registered with this space: ${label ?? newRepo}`, meta: { repo: newRepo, repo_origin: label ?? null } });
|
|
1852
2625
|
this.broadcast(sp, { t: "repos", repos: sp.allow.repoList() });
|
|
1853
2626
|
}
|
|
1854
2627
|
this.send(ws, {
|
|
1855
2628
|
t: "welcome", member: me.id, session: sessionId, space: sp.name, seq: sp.ledger.head,
|
|
1856
2629
|
roster: this.roster(sp), goals: [...sp.goals.values()],
|
|
1857
2630
|
obligations: [...sp.obligations.values()], members: this.members(sp), profiles: [...sp.profiles.values()],
|
|
1858
|
-
space_id: sp.allow.spaceId(), repos: sp.allow.repoList(),
|
|
2631
|
+
space_id: sp.allow.spaceId(), repos: sp.allow.repoList(), features: [...this.features],
|
|
1859
2632
|
...(acct ? { plan: this.planView(acct, sp) } : {}),
|
|
1860
|
-
...(enrolled ? { token: enrolled } : {}),
|
|
2633
|
+
...(enrolled ? { token: enrolled } : staleToken && this.knownToken(sp) ? { token: sp.token } : {}),
|
|
1861
2634
|
});
|
|
2635
|
+
if (staleToken && !enrolled && !sp.token) {
|
|
2636
|
+
const mine = me.sessions.get(sessionId);
|
|
2637
|
+
if (mine)
|
|
2638
|
+
mine.wantsToken = true;
|
|
2639
|
+
}
|
|
1862
2640
|
let from = m.from_seq && m.from_seq > 0 ? m.from_seq : sp.ledger.head;
|
|
1863
2641
|
for (let page = sp.ledger.since(from); page.length; page = sp.ledger.since(from)) {
|
|
1864
2642
|
for (const r of page) {
|
|
@@ -1868,6 +2646,11 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
1868
2646
|
}
|
|
1869
2647
|
from = page[page.length - 1].seq;
|
|
1870
2648
|
}
|
|
2649
|
+
if (versionBelow(m.pkg, "0.30.0")) {
|
|
2650
|
+
const why = this.unsignedRefusal(sp.name, sp.allow, "current");
|
|
2651
|
+
if (why)
|
|
2652
|
+
this.send(ws, { t: "act", act: this.upgradeAct(sp.name, sp.ledger.head, null, why) });
|
|
2653
|
+
}
|
|
1871
2654
|
if (firstSession && m.role !== "cli") {
|
|
1872
2655
|
this.commit(sp, { from: me.id, act: "JOIN", body: `${me.display} online (${me.agent})`, to: null, goal: null, ref: null });
|
|
1873
2656
|
}
|
|
@@ -1877,6 +2660,19 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
1877
2660
|
}
|
|
1878
2661
|
if (!space || !me)
|
|
1879
2662
|
return reject("NOT_JOINED", "say hello first");
|
|
2663
|
+
if (!this.stillIn(space, me, sessionId, ws) || (acct && !accountStillOk())) {
|
|
2664
|
+
if (ws.readyState === 1)
|
|
2665
|
+
this.hangUp(ws, "REVOKED");
|
|
2666
|
+
return;
|
|
2667
|
+
}
|
|
2668
|
+
if (delegated && Date.now() > delegated.expires + 10 * 60_000) {
|
|
2669
|
+
if (ws.readyState === 1)
|
|
2670
|
+
this.hangUp(ws, "REVOKED");
|
|
2671
|
+
return;
|
|
2672
|
+
}
|
|
2673
|
+
if (delegated && m.t !== "act" && m.t !== "open_goal" && m.t !== "claim" && m.t !== "worker_state") {
|
|
2674
|
+
return fail("NOT_FOR_A_RUN", `an unattended run cannot ${m.t.replace(/_/g, " ")} — its member does that from their own session`, m.cid);
|
|
2675
|
+
}
|
|
1880
2676
|
if (m.t === "publish_profile") {
|
|
1881
2677
|
if (m.profile.member !== me.id) {
|
|
1882
2678
|
return fail("PROFILE_NOT_YOURS", `you may publish only the profile for ${me.id}`, m.cid);
|
|
@@ -1909,6 +2705,13 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
1909
2705
|
const target = space.allow.get(m.member);
|
|
1910
2706
|
if (!target)
|
|
1911
2707
|
return fail("NO_SUCH_MEMBER", `"${m.member}" has never connected, so there is no key to admit`, m.cid);
|
|
2708
|
+
if (!m.revoke && !m.forget && !m.wake_only && !target.admitted && space.allow.removedWhen(m.member) !== null) {
|
|
2709
|
+
const founder = space.allow.founder();
|
|
2710
|
+
if (founder && me.id !== founder)
|
|
2711
|
+
return fail("FOUNDER_ONLY", `${m.member} was removed from "${space.name}"; only ${founder}, who founded it, can bring them back`, m.cid);
|
|
2712
|
+
}
|
|
2713
|
+
if (m.wake_only && (m.revoke || m.forget || !target.admitted))
|
|
2714
|
+
return fail("NOT_ADMITTED", `"${m.member}" is not in "${space.name}" now, so there is no wake permission to change`, m.cid);
|
|
1912
2715
|
if (!m.revoke && !m.forget) {
|
|
1913
2716
|
const plan = this.spacePlan(space.name);
|
|
1914
2717
|
if (plan && m.may_wake && !plan.limits.wake)
|
|
@@ -1922,19 +2725,16 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
1922
2725
|
return fail("FOUNDER_ONLY", `only ${founder}, who founded "${space.name}", can remove a member`, m.cid);
|
|
1923
2726
|
if (m.member === founder)
|
|
1924
2727
|
return fail("FOUNDER_STAYS", "the founder cannot be removed from their own team", m.cid);
|
|
1925
|
-
|
|
1926
|
-
space.allow.forget(m.member);
|
|
1927
|
-
else
|
|
1928
|
-
space.allow.revoke(m.member, me.id);
|
|
2728
|
+
const removed = m.forget ? space.allow.forget(m.member) : space.allow.revoke(m.member, me.id);
|
|
1929
2729
|
if (space.profiles.delete(m.member))
|
|
1930
2730
|
this.broadcast(space, { t: "profiles", profiles: [...space.profiles.values()] });
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
|
|
1935
|
-
catch { }
|
|
1936
|
-
}
|
|
2731
|
+
const gone = space.members.get(m.member);
|
|
2732
|
+
for (const s of gone?.sessions.values() ?? [])
|
|
2733
|
+
this.hangUp(s.ws, "REVOKED");
|
|
2734
|
+
gone?.sessions.clear();
|
|
1937
2735
|
space.members.delete(m.member);
|
|
2736
|
+
if (removed && this.rotateOnRemove)
|
|
2737
|
+
this.rotateSpaceToken(space, `${m.member} ${m.forget ? "forgotten" : "removed"} by ${me.id}`);
|
|
1938
2738
|
this.log(`[${space.name}] - ${m.forget ? "FORGOT" : "REVOKED"} ${m.member} by ${me.id}`);
|
|
1939
2739
|
}
|
|
1940
2740
|
else {
|
|
@@ -1964,7 +2764,15 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
1964
2764
|
this.accounts?.audit({ event: "space_deleted", actor_account: acct?.id ?? null, space: name, detail: `by its founder ${me.id}`, ip });
|
|
1965
2765
|
this.log(`[${name}] * DELETED by its founder ${me.id}`);
|
|
1966
2766
|
this.send(ws, { t: "ack", ...(m.cid ? { cid: m.cid } : {}), id: name, seq: space.ledger.head });
|
|
1967
|
-
setTimeout(() => {
|
|
2767
|
+
setTimeout(() => {
|
|
2768
|
+
try {
|
|
2769
|
+
this.deleteSpace(name);
|
|
2770
|
+
this.accounts?.markSpaceDeleted(name);
|
|
2771
|
+
}
|
|
2772
|
+
catch (e) {
|
|
2773
|
+
this.noteStorageFailure(`[${name}] delete`, e);
|
|
2774
|
+
}
|
|
2775
|
+
}, 50).unref?.();
|
|
1968
2776
|
return;
|
|
1969
2777
|
}
|
|
1970
2778
|
if (m.t === "invite") {
|
|
@@ -1985,7 +2793,7 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
1985
2793
|
if (space.allow.openInvites().length >= LIMITS_SPACE.openInvites)
|
|
1986
2794
|
return fail("TOO_MANY_INVITES", `this team already has ${LIMITS_SPACE.openInvites} unused invites — revoke some: pantheon invite --revoke all`, m.cid);
|
|
1987
2795
|
const token = randomBytes(18).toString("base64url");
|
|
1988
|
-
const inv = space.allow.mintInvite(me.id, m.may_wake, m.ttl_minutes, token, space
|
|
2796
|
+
const inv = space.allow.mintInvite(me.id, m.may_wake, m.ttl_minutes, token, this.ensureToken(space));
|
|
1989
2797
|
this.log(`[${space.name}] * invite minted by ${me.id}, valid ${m.ttl_minutes}m, single use`);
|
|
1990
2798
|
this.send(ws, { t: "invite", url: token, expires_at: inv.expires_at });
|
|
1991
2799
|
return;
|
|
@@ -2034,14 +2842,14 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
2034
2842
|
if (existing && m.base && existing.added_by !== me.id && space.allow.founder() !== me.id) {
|
|
2035
2843
|
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
2844
|
}
|
|
2037
|
-
const added = space.allow.addRepo(m.repo, m.origin
|
|
2845
|
+
const added = space.allow.addRepo(m.repo, repoLabelSafe(m.origin), me.id, m.base ?? null);
|
|
2038
2846
|
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})`);
|
|
2847
|
+
this.log(`[${space.name}] ~ repo ${repoLabelSafe(m.origin) ?? m.repo} base branch → ${m.base} (by ${me.id})`);
|
|
2040
2848
|
this.broadcast(space, { t: "repos", repos: space.allow.repoList() });
|
|
2041
2849
|
}
|
|
2042
2850
|
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 } });
|
|
2851
|
+
this.log(`[${space.name}] + repo ${repoLabelSafe(m.origin) ?? m.repo} registered by ${me.id}${m.base ? ` (base ${m.base})` : ""}`);
|
|
2852
|
+
this.commit(space, { from: me.id, session: sessionId, role, act: "NOTE", to: null, goal: null, ref: null, body: `repo registered with this space: ${repoLabelSafe(m.origin) ?? m.repo}`, meta: { repo: m.repo, repo_origin: repoLabelSafe(m.origin) ?? null } });
|
|
2045
2853
|
this.broadcast(space, { t: "repos", repos: space.allow.repoList() });
|
|
2046
2854
|
}
|
|
2047
2855
|
this.send(ws, { t: "ack", cid: m.cid, id: m.repo, seq: space.ledger.head });
|
|
@@ -2077,11 +2885,11 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
2077
2885
|
}
|
|
2078
2886
|
}
|
|
2079
2887
|
const held = space.contLocks.get(m.obligation);
|
|
2080
|
-
if (held && held.session !== sessionId &&
|
|
2888
|
+
if (held && held.session !== sessionId && this.claimHolds(space, held)) {
|
|
2081
2889
|
return fail("CLAIMED", `another of your sessions (${held.session}) is handling this`, m.cid);
|
|
2082
2890
|
}
|
|
2083
2891
|
for (const [k, v] of space.contLocks) {
|
|
2084
|
-
if (
|
|
2892
|
+
if (!this.claimHolds(space, v))
|
|
2085
2893
|
space.contLocks.delete(k);
|
|
2086
2894
|
}
|
|
2087
2895
|
space.contLocks.set(m.obligation, { session: sessionId, member: me.id, at: Date.now() });
|
|
@@ -2094,7 +2902,7 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
2094
2902
|
if (o.owner !== me.id)
|
|
2095
2903
|
return fail("NOT_YOURS", `${o.owner} owes that, not you`, m.cid);
|
|
2096
2904
|
if (o.claimed_by && o.claimed_by.session !== sessionId) {
|
|
2097
|
-
if (
|
|
2905
|
+
if (this.claimHolds(space, o.claimed_by)) {
|
|
2098
2906
|
const holder = o.claimed_by;
|
|
2099
2907
|
const live = [...me.sessions.keys()].includes(holder.session);
|
|
2100
2908
|
const left = Math.ceil((LIMITS.claimTtlMs - (Date.now() - holder.at)) / 60_000);
|
|
@@ -2124,6 +2932,8 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
2124
2932
|
if (m.t === "open_goal") {
|
|
2125
2933
|
if (!this.writeOk(me))
|
|
2126
2934
|
return fail("RATE_LIMIT", "too many goals opened too fast — wait a few seconds", m.cid);
|
|
2935
|
+
if (this.spaceFull(space))
|
|
2936
|
+
return fail("SPACE_FULL", this.spaceFullMessage(space), m.cid);
|
|
2127
2937
|
const openCount = [...space.goals.values()].filter((g) => g.state !== "CLOSED").length;
|
|
2128
2938
|
if (openCount >= LIMITS_SPACE.openGoals)
|
|
2129
2939
|
return fail("TOO_MANY_GOALS", `this team has ${openCount} goals still open — close some first`, m.cid);
|
|
@@ -2131,12 +2941,19 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
2131
2941
|
if (!space.members.has(id) && !space.allow.get(id)?.admitted)
|
|
2132
2942
|
return fail("NO_SUCH_MEMBER", `nobody here is called "${id}"`, m.cid);
|
|
2133
2943
|
}
|
|
2944
|
+
if (!this.features.includes("act-sig"))
|
|
2945
|
+
delete m.sig;
|
|
2946
|
+
if (m.sig || delegated) {
|
|
2947
|
+
const why = goalSigProblem(m, me.id, myPubkey, [space.allow.spaceId(), space.name], delegated);
|
|
2948
|
+
if (why)
|
|
2949
|
+
return fail("BAD_GOAL_SIGNATURE", why, m.cid);
|
|
2950
|
+
}
|
|
2134
2951
|
const invited = (m.invite ?? [...space.members.keys()]).filter((x) => x !== me.id);
|
|
2135
2952
|
if (m.deadline !== undefined && m.deadline <= Date.now()) {
|
|
2136
2953
|
return fail("DEADLINE_PAST", `deadline ${new Date(m.deadline).toISOString()} is already in the past`, m.cid);
|
|
2137
2954
|
}
|
|
2138
2955
|
const goal = {
|
|
2139
|
-
id: newId("g"),
|
|
2956
|
+
id: m.id && !space.goals.has(m.id) ? m.id : newId("g"),
|
|
2140
2957
|
title: m.title,
|
|
2141
2958
|
acceptance: m.acceptance,
|
|
2142
2959
|
members: [me.id, ...new Set(invited)],
|
|
@@ -2147,6 +2964,7 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
2147
2964
|
opened_at: Date.now(),
|
|
2148
2965
|
...(m.deadline !== undefined ? { deadline: m.deadline } : {}),
|
|
2149
2966
|
...(m.kind !== undefined ? { kind: m.kind } : {}),
|
|
2967
|
+
...(m.sig ? { sig: m.sig } : {}),
|
|
2150
2968
|
};
|
|
2151
2969
|
space.goals.set(goal.id, goal);
|
|
2152
2970
|
space.store.flush(space.goals, space.obligations, space.profiles);
|
|
@@ -2156,16 +2974,38 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
2156
2974
|
body: `goal opened: ${goal.title}\nacceptance: ${goal.acceptance || "(none stated)"}` +
|
|
2157
2975
|
(goal.deadline ? `\ndeadline: ${new Date(goal.deadline).toISOString()}` : "") +
|
|
2158
2976
|
(goal.kind === "worklist" ? `\nkind: worklist — ${me.id}'s own list; peers may read it, nobody owes anything in it` : ""),
|
|
2977
|
+
meta: { goal_members: goal.members, goal_budget: goal.budget, ...(m.sig ? { goal_sig: m.sig, goal_text: { title: goal.title, acceptance: goal.acceptance } } : {}) },
|
|
2159
2978
|
});
|
|
2160
2979
|
this.send(ws, { t: "ack", ...(m.cid ? { cid: m.cid } : {}), id: goal.id, seq: rec.seq });
|
|
2161
2980
|
return;
|
|
2162
2981
|
}
|
|
2163
|
-
|
|
2982
|
+
if (!this.features.includes("act-sig"))
|
|
2983
|
+
delete m.sig;
|
|
2984
|
+
if (m.sig || delegated) {
|
|
2985
|
+
const why = actSigProblem(m, me.id, myPubkey, [space.allow.spaceId(), space.name], delegated);
|
|
2986
|
+
if (why)
|
|
2987
|
+
return fail("BAD_ACT_SIGNATURE", why, m.cid);
|
|
2988
|
+
}
|
|
2989
|
+
if (m.act === "NOTE" && m.meta && ("goal_members" in m.meta || "organization_profile" in m.meta || "notice" in m.meta))
|
|
2990
|
+
return fail("BAD_META", "goal_members, organization_profile and notice are written by the relay, not sent", m.cid);
|
|
2991
|
+
if (m.act === "NOTE" && m.body.startsWith("repo registered with this space: "))
|
|
2992
|
+
return fail("BAD_META", "that line is written by the relay, not sent", m.cid);
|
|
2993
|
+
const fromRepo = m.repo && space.allow.hasRepo(m.repo) ? m.repo : null;
|
|
2994
|
+
const { from_repo: _forged, ...senderMeta } = (m.meta ?? {});
|
|
2995
|
+
const hasMeta = !!m.meta && (Object.keys(senderMeta).length > 0 || _forged === undefined);
|
|
2996
|
+
const storedMeta = hasMeta || m.defers_to || m.affects || fromRepo
|
|
2997
|
+
? { ...senderMeta, ...(m.defers_to ? { defers_to: m.defers_to } : {}), ...(m.affects ? { affects: m.affects } : {}), ...(fromRepo ? { from_repo: fromRepo } : {}) }
|
|
2998
|
+
: undefined;
|
|
2999
|
+
const fp = actFingerprint(me.id, m.act, m.body, m.goal ?? null, m.ref ?? null, m.to ?? null, storedMeta);
|
|
2164
3000
|
const prior = space.recent.get(fp);
|
|
2165
3001
|
if (prior && Date.now() - prior.at < LIMITS.dedupeWindowMs) {
|
|
2166
3002
|
this.send(ws, { t: "ack", ...(m.cid ? { cid: m.cid } : {}), id: prior.id, seq: prior.seq });
|
|
2167
3003
|
return;
|
|
2168
3004
|
}
|
|
3005
|
+
if (m.sig && m.id && (space.ledger.find(m.id) || space.droppedIds?.has(m.id)))
|
|
3006
|
+
return fail("ID_TAKEN", `an act with id ${m.id} already exists`, m.cid);
|
|
3007
|
+
if (m.sig && m.id && !idIsFresh(m.id))
|
|
3008
|
+
return fail("BAD_ACT_ID", `act id ${m.id} was not made in the last day — check this machine's clock`, m.cid);
|
|
2169
3009
|
if (m.after_seq !== undefined) {
|
|
2170
3010
|
const mine = me.id;
|
|
2171
3011
|
const missed = space.ledger.since(m.after_seq)
|
|
@@ -2184,33 +3024,77 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
2184
3024
|
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
3025
|
if (err)
|
|
2186
3026
|
return fail(err.code, err.message, m.cid);
|
|
3027
|
+
if (m.act === "PROPOSE" && m.affects?.length) {
|
|
3028
|
+
const g = m.goal ? space.goals.get(m.goal) : undefined;
|
|
3029
|
+
const outside = m.affects.filter((x) => !g?.members.includes(x));
|
|
3030
|
+
if (outside.length) {
|
|
3031
|
+
return fail("NOT_IN_GOAL", `${outside.join(", ")} ${outside.length === 1 ? "is" : "are"} not in ${g ? `"${g.title}"` : "this goal"}, so could never ACCEPT this proposal there. Name only members of the goal in affects.`, m.cid);
|
|
3032
|
+
}
|
|
3033
|
+
}
|
|
3034
|
+
const wasFull = this.spaceFull(space);
|
|
3035
|
+
if (wasFull && !this.freesRoom(space, me.id, m.act, m.ref ?? null))
|
|
3036
|
+
return fail("SPACE_FULL", this.spaceFullMessage(space), m.cid);
|
|
2187
3037
|
const rec = this.commit(space, {
|
|
3038
|
+
...(m.sig && m.id ? { id: m.id } : {}),
|
|
2188
3039
|
from: me.id, session: sessionId, role, act: m.act, body: m.body,
|
|
2189
3040
|
...(m.summary ? { summary: m.summary } : {}),
|
|
3041
|
+
...(m.sig ? { sig: m.sig } : {}),
|
|
2190
3042
|
to: m.to ?? null, goal: m.goal ?? null, ref: m.ref ?? null,
|
|
2191
|
-
...(m.meta || m.defers_to || m.affects
|
|
2192
|
-
? { meta: { ...(m.meta
|
|
3043
|
+
...((m.meta && (Object.keys(withoutFromRepo(m.meta)).length > 0 || !("from_repo" in m.meta))) || m.defers_to || m.affects || fromRepo
|
|
3044
|
+
? { meta: { ...withoutFromRepo(m.meta),
|
|
2193
3045
|
...(m.defers_to ? { defers_to: m.defers_to } : {}),
|
|
2194
|
-
...(m.affects ? { affects: m.affects } : {})
|
|
3046
|
+
...(m.affects ? { affects: m.affects } : {}),
|
|
3047
|
+
...(fromRepo ? { from_repo: fromRepo } : {}) } }
|
|
2195
3048
|
: {}),
|
|
2196
3049
|
});
|
|
3050
|
+
if (wasFull && m.act === "CLOSE") {
|
|
3051
|
+
try {
|
|
3052
|
+
this.compactSpace(space);
|
|
3053
|
+
}
|
|
3054
|
+
catch (e) {
|
|
3055
|
+
this.noteStorageFailure(`[${space.name}] compacting after a close`, e);
|
|
3056
|
+
}
|
|
3057
|
+
}
|
|
2197
3058
|
space.recent.set(fp, { id: rec.id, seq: rec.seq, at: Date.now() });
|
|
2198
3059
|
for (const [k, v] of space.recent)
|
|
2199
3060
|
if (Date.now() - v.at > LIMITS.dedupeWindowMs)
|
|
2200
3061
|
space.recent.delete(k);
|
|
3062
|
+
for (const k of space.recent.keys()) {
|
|
3063
|
+
if (space.recent.size <= RECENT_MAX)
|
|
3064
|
+
break;
|
|
3065
|
+
space.recent.delete(k);
|
|
3066
|
+
}
|
|
2201
3067
|
this.send(ws, { t: "ack", ...(m.cid ? { cid: m.cid } : {}), id: rec.id, seq: rec.seq });
|
|
2202
3068
|
}
|
|
2203
3069
|
catch (e) {
|
|
2204
|
-
|
|
2205
|
-
try {
|
|
2206
|
-
ws.send(JSON.stringify({ t: "error", code
|
|
3070
|
+
const cidPart = reqCid ? { cid: reqCid } : {};
|
|
3071
|
+
const send = (code, message) => { try {
|
|
3072
|
+
ws.send(JSON.stringify({ t: "error", code, message, ...cidPart }));
|
|
2207
3073
|
}
|
|
2208
|
-
catch { }
|
|
3074
|
+
catch { } };
|
|
3075
|
+
if (e instanceof SpaceFull) {
|
|
3076
|
+
send("SPACE_FULL", `${e.message}. Nothing new can be recorded until finished goals are closed (the relay then compacts their old acts), or the relay operator raises PANTHEON_SPACE_MAX_BYTES.`);
|
|
3077
|
+
return;
|
|
3078
|
+
}
|
|
3079
|
+
if (e instanceof RosterUnreadable) {
|
|
3080
|
+
this.log(`!! ${e.message} — every member of this space is refused until it is repaired (it was left in place)`);
|
|
3081
|
+
if (!space)
|
|
3082
|
+
return reject("SPACE_UNREADABLE", "the relay cannot read this team's member list, so nobody can be let in until its operator repairs it. Nothing was changed; tell the relay operator.");
|
|
3083
|
+
send("SPACE_UNREADABLE", "the relay cannot read this team's member list; tell the relay operator.");
|
|
3084
|
+
return;
|
|
3085
|
+
}
|
|
3086
|
+
this.noteStorageFailure(`[${space?.name ?? "?"}] ${lastMsg || "message"}`, e);
|
|
3087
|
+
send("STORAGE", "the relay could not save that — try again in a moment");
|
|
2209
3088
|
}
|
|
2210
3089
|
});
|
|
2211
3090
|
const closeWork = (code) => {
|
|
2212
3091
|
if (dbSession) {
|
|
2213
|
-
|
|
3092
|
+
try {
|
|
3093
|
+
this.accounts?.endSession(dbSession);
|
|
3094
|
+
}
|
|
3095
|
+
catch (e) {
|
|
3096
|
+
this.noteStorageFailure("session end", e);
|
|
3097
|
+
}
|
|
2214
3098
|
dbSession = null;
|
|
2215
3099
|
}
|
|
2216
3100
|
if (!space || !me)
|
|
@@ -2233,7 +3117,8 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
2233
3117
|
else
|
|
2234
3118
|
v.at = Date.now();
|
|
2235
3119
|
}
|
|
2236
|
-
|
|
3120
|
+
const stillMember = space.members.get(me.id) === me && !!space.allow.get(me.id)?.admitted;
|
|
3121
|
+
if (me.sessions.size === 0 && role !== "cli" && stillMember) {
|
|
2237
3122
|
me.last_seen = Date.now();
|
|
2238
3123
|
this.commit(space, { from: me.id, act: "LEAVE", body: `${me.display} offline`, to: null, goal: null, ref: null });
|
|
2239
3124
|
}
|
|
@@ -2245,7 +3130,7 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
2245
3130
|
closeWork(code);
|
|
2246
3131
|
}
|
|
2247
3132
|
catch (e) {
|
|
2248
|
-
this.
|
|
3133
|
+
this.noteStorageFailure(`[${space?.name ?? "?"}] cleanup after a disconnect`, e);
|
|
2249
3134
|
} });
|
|
2250
3135
|
ws.on("error", () => { });
|
|
2251
3136
|
}
|
|
@@ -2265,21 +3150,26 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
2265
3150
|
const now = Date.now();
|
|
2266
3151
|
for (const sp of this.spaces.values()) {
|
|
2267
3152
|
try {
|
|
2268
|
-
|
|
3153
|
+
const every = this.spaceFull(sp) ? 60_000 : LIMITS.ledgerCompactEveryMs;
|
|
3154
|
+
if (now - (this.compactedAt.get(sp.name) ?? 0) > every)
|
|
2269
3155
|
this.compactSpace(sp);
|
|
2270
3156
|
this.lapseClaims(sp, now);
|
|
2271
3157
|
}
|
|
2272
3158
|
catch (e) {
|
|
2273
|
-
this.
|
|
3159
|
+
this.noteStorageFailure(`[${sp.name}] housekeeping`, e);
|
|
2274
3160
|
}
|
|
2275
3161
|
}
|
|
2276
3162
|
try {
|
|
2277
3163
|
this.evictSpaces();
|
|
2278
3164
|
}
|
|
2279
3165
|
catch (e) {
|
|
2280
|
-
this.
|
|
3166
|
+
this.noteStorageFailure("evicting idle spaces", e);
|
|
2281
3167
|
}
|
|
2282
3168
|
}
|
|
3169
|
+
claimHolds(sp, holder, now = Date.now()) {
|
|
3170
|
+
const live = [...sp.members.values()].some((m) => m.sessions.has(holder.session));
|
|
3171
|
+
return now - holder.at < (live ? LIMITS.claimLiveTtlMs : LIMITS.claimTtlMs);
|
|
3172
|
+
}
|
|
2283
3173
|
lapseClaims(sp, now) {
|
|
2284
3174
|
const live = new Set();
|
|
2285
3175
|
for (const m of sp.members.values())
|
|
@@ -2287,14 +3177,15 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
2287
3177
|
live.add(id);
|
|
2288
3178
|
let lapsed = 0;
|
|
2289
3179
|
for (const o of sp.obligations.values()) {
|
|
2290
|
-
if (o.claimed_by && !
|
|
3180
|
+
if (o.claimed_by && !this.claimHolds(sp, o.claimed_by, now)) {
|
|
2291
3181
|
delete o.claimed_by;
|
|
2292
3182
|
lapsed++;
|
|
2293
3183
|
}
|
|
2294
3184
|
}
|
|
2295
3185
|
for (const [k, v] of sp.contLocks)
|
|
2296
|
-
if (!
|
|
3186
|
+
if (!this.claimHolds(sp, v, now))
|
|
2297
3187
|
sp.contLocks.delete(k);
|
|
3188
|
+
void live;
|
|
2298
3189
|
if (lapsed)
|
|
2299
3190
|
this.pushObligations(sp);
|
|
2300
3191
|
}
|
|
@@ -2312,6 +3203,8 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
2312
3203
|
return { code: "NO_SUCH_MEMBER", message: `nobody here is called "${id}" — members: ${[...sp.members.keys()].join(", ")}` };
|
|
2313
3204
|
}
|
|
2314
3205
|
}
|
|
3206
|
+
if (ref === UPGRADE_NOTE_ID)
|
|
3207
|
+
return { code: "UPGRADE_REQUIRED", message: `${UPGRADE_NOTE}. That notice is from the relay, not a teammate, so there is nothing to reply to.` };
|
|
2315
3208
|
if (ref && !sp.ledger.find(ref))
|
|
2316
3209
|
return { code: "BAD_REF", message: `no act with id ${ref}` };
|
|
2317
3210
|
if (meta && JSON.stringify(meta).length > LIMITS_SPACE.metaBytes) {
|
|
@@ -2327,6 +3220,10 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
2327
3220
|
return { code: "ESCALATE_IS_LOCAL",
|
|
2328
3221
|
message: "ESCALATE is for your own human. Use HUMAN_REQUIRED to notify one named peer's human." };
|
|
2329
3222
|
}
|
|
3223
|
+
if (meta && ("authority" in meta || "unverified_domains" in meta)) {
|
|
3224
|
+
delete meta["authority"];
|
|
3225
|
+
delete meta["unverified_domains"];
|
|
3226
|
+
}
|
|
2330
3227
|
if (act === "HUMAN_REQUIRED" && (!to || to.length !== 1)) {
|
|
2331
3228
|
return { code: "ONE_HUMAN_REQUIRED",
|
|
2332
3229
|
message: "HUMAN_REQUIRED must name exactly one human owner. Send separate requests when separate people must decide." };
|
|
@@ -2335,6 +3232,9 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
2335
3232
|
const domains = Array.isArray(meta?.decision_domains)
|
|
2336
3233
|
? [...new Set(meta.decision_domains.filter((x) => typeof x === "string" && x.trim().length > 0))]
|
|
2337
3234
|
: [];
|
|
3235
|
+
if (domains.some((d) => d.length > 64 || /[\u0000-\u001f\u007f-\u009f\u2028\u2029]/.test(d))) {
|
|
3236
|
+
return { code: "BAD_DECISION_DOMAIN", message: "decision_domains are single-line names of up to 64 characters, as profiles publish them" };
|
|
3237
|
+
}
|
|
2338
3238
|
if (!domains.length) {
|
|
2339
3239
|
return { code: "DECISION_DOMAINS_REQUIRED",
|
|
2340
3240
|
message: "HUMAN_REQUIRED must name decision_domains so the relay can verify the human owner. Call pantheon_route_decision first." };
|
|
@@ -2506,7 +3406,13 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
2506
3406
|
commit(sp, partial) {
|
|
2507
3407
|
if (sp.deleted)
|
|
2508
3408
|
return { ...partial, id: newId("a"), seq: sp.ledger.head, ts: Date.now(), space: sp.name };
|
|
2509
|
-
|
|
3409
|
+
if ((partial.act === "JOIN" || partial.act === "LEAVE") && this.spaceFull(sp)) {
|
|
3410
|
+
return { ...partial, id: newId("a"), seq: sp.ledger.head, ts: Date.now(), space: sp.name };
|
|
3411
|
+
}
|
|
3412
|
+
let id = partial.id ?? newId("a");
|
|
3413
|
+
while (!partial.id && sp.ledger.find(id))
|
|
3414
|
+
id = newId("a");
|
|
3415
|
+
const rec = sp.ledger.append({ ...partial, id, ts: Date.now(), space: sp.name }, this.maxLedgerBytes + SPACE_HARD_SLACK);
|
|
2510
3416
|
sp.lastTouch = rec.ts;
|
|
2511
3417
|
const m = sp.members.get(rec.from);
|
|
2512
3418
|
if (m) {
|
|
@@ -2514,13 +3420,22 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
2514
3420
|
if (rec.act !== "JOIN" && rec.act !== "LEAVE" && (m.seen_seq ?? 0) < rec.seq - 1)
|
|
2515
3421
|
m.seen_seq = rec.seq - 1;
|
|
2516
3422
|
}
|
|
3423
|
+
this.effects(sp, rec);
|
|
3424
|
+
if (sp.store.rebuilt && rec.act !== "JOIN" && rec.act !== "LEAVE")
|
|
3425
|
+
sp.store.rebuilt = null;
|
|
3426
|
+
this.pushObligations(sp);
|
|
3427
|
+
this.broadcast(sp, { t: "act", act: rec });
|
|
3428
|
+
this.broadcast(sp, { t: "roster", roster: this.roster(sp) });
|
|
3429
|
+
return rec;
|
|
3430
|
+
}
|
|
3431
|
+
effects(sp, rec, ctx) {
|
|
2517
3432
|
if (rec.goal) {
|
|
2518
3433
|
const goal = sp.goals.get(rec.goal);
|
|
2519
3434
|
if (goal) {
|
|
2520
3435
|
goal.acts++;
|
|
2521
3436
|
const run = sp.lastActor.get(rec.goal);
|
|
2522
3437
|
sp.lastActor.set(rec.goal, run && run.member === rec.from ? { member: rec.from, run: run.run + 1 } : { member: rec.from, run: 1 });
|
|
2523
|
-
this.applyObligations(sp, rec, goal);
|
|
3438
|
+
this.applyObligations(sp, rec, goal, ctx);
|
|
2524
3439
|
this.broadcast(sp, { t: "goal", goal });
|
|
2525
3440
|
if (goal.acts >= goal.budget && goal.state === "OPEN") {
|
|
2526
3441
|
goal.state = "STALLED";
|
|
@@ -2531,14 +3446,10 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
2531
3446
|
}
|
|
2532
3447
|
}
|
|
2533
3448
|
else {
|
|
2534
|
-
this.applyObligations(sp, rec, null);
|
|
3449
|
+
this.applyObligations(sp, rec, null, ctx);
|
|
2535
3450
|
}
|
|
2536
|
-
this.pushObligations(sp);
|
|
2537
|
-
this.broadcast(sp, { t: "act", act: rec });
|
|
2538
|
-
this.broadcast(sp, { t: "roster", roster: this.roster(sp) });
|
|
2539
|
-
return rec;
|
|
2540
3451
|
}
|
|
2541
|
-
applyObligations(sp, rec, goal) {
|
|
3452
|
+
applyObligations(sp, rec, goal, ctx) {
|
|
2542
3453
|
const closeFor = (owner, ref) => {
|
|
2543
3454
|
for (const [id, o] of sp.obligations)
|
|
2544
3455
|
if (o.owner === owner && o.ref === ref)
|
|
@@ -2642,8 +3553,9 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
2642
3553
|
const answer = sp.ledger.find(rec.ref);
|
|
2643
3554
|
const asked = answer?.act === "ANSWER" && answer.from === rec.from && answer.ref ? sp.ledger.find(answer.ref) : undefined;
|
|
2644
3555
|
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
|
|
3556
|
+
const retracted = ctx?.retracted ?? new Set(sp.ledger.all().filter((r) => r.act === "RETRACT" && r.ref).map((r) => r.ref));
|
|
3557
|
+
const answersTo = asked ? (ctx ? ctx.answers.get(asked.id) ?? [] : sp.ledger.all().filter((r) => r.act === "ANSWER" && r.ref === asked.id)) : [];
|
|
3558
|
+
const stillAnswered = answersTo.some((r) => r.from === rec.from && r.id !== answer.id && !retracted.has(r.id));
|
|
2647
3559
|
const alreadyLive = asked && [...sp.obligations.values()].some((o) => o.kind === "HUMAN" && o.ref === asked.id);
|
|
2648
3560
|
if (asked && decider === rec.from && !stillAnswered && !alreadyLive && !retracted.has(asked.id)) {
|
|
2649
3561
|
this.oblige(sp, { kind: "HUMAN", owner: `human:${decider}`, ref: asked.id, goal: asked.goal,
|
|
@@ -2681,6 +3593,23 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
2681
3593
|
const id = newId("o");
|
|
2682
3594
|
sp.obligations.set(id, { ...o, id, created_at: Date.now() });
|
|
2683
3595
|
}
|
|
3596
|
+
freesRoom(sp, member, act, ref) {
|
|
3597
|
+
if (act === "CLOSE" || act === "RETRACT" || act === "WITHDRAW")
|
|
3598
|
+
return true;
|
|
3599
|
+
if (act === "ANSWER" && ref) {
|
|
3600
|
+
for (const o of sp.obligations.values())
|
|
3601
|
+
if (o.kind === "HUMAN" && o.ref === ref && o.owner === `human:${member}`)
|
|
3602
|
+
return true;
|
|
3603
|
+
}
|
|
3604
|
+
return false;
|
|
3605
|
+
}
|
|
3606
|
+
spaceFull(sp) {
|
|
3607
|
+
return sp.ledger.bytes >= this.maxLedgerBytes;
|
|
3608
|
+
}
|
|
3609
|
+
spaceFullMessage(sp) {
|
|
3610
|
+
return `"${sp.name}" has reached the relay's limit for one team's history (${(sp.ledger.bytes / 1e6).toFixed(1)} of ${(this.maxLedgerBytes / 1e6).toFixed(1)} MB), so nothing new can be recorded. ` +
|
|
3611
|
+
`Close finished goals: the relay compacts their old acts away, which frees room. Or ask the relay operator to raise PANTHEON_SPACE_MAX_BYTES.`;
|
|
3612
|
+
}
|
|
2684
3613
|
pushObligations(sp) {
|
|
2685
3614
|
sp.store.flush(sp.goals, sp.obligations, sp.profiles);
|
|
2686
3615
|
this.broadcast(sp, { t: "obligations", list: [...sp.obligations.values()] });
|
|
@@ -2753,18 +3682,23 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
2753
3682
|
continue;
|
|
2754
3683
|
const idle = now - sp.lastTouch;
|
|
2755
3684
|
const junk = this.isJunkSpace(sp);
|
|
2756
|
-
|
|
2757
|
-
|
|
2758
|
-
|
|
2759
|
-
|
|
2760
|
-
|
|
2761
|
-
|
|
3685
|
+
try {
|
|
3686
|
+
if (junk && idle > this.limits.spaceJunkDeleteMs) {
|
|
3687
|
+
sp.store.close(sp.goals, sp.obligations, sp.profiles);
|
|
3688
|
+
this.spaces.delete(name);
|
|
3689
|
+
this.compactedAt.delete(name);
|
|
3690
|
+
this.deleteSpaceFiles(sp.name);
|
|
3691
|
+
this.log(`[${name}] evicted junk space (idle ${Math.round(idle / 3_600_000)}h, no work, no members) — files removed`);
|
|
3692
|
+
}
|
|
3693
|
+
else if (idle > this.limits.spaceIdleEvictMs) {
|
|
3694
|
+
sp.store.close(sp.goals, sp.obligations, sp.profiles);
|
|
3695
|
+
this.spaces.delete(name);
|
|
3696
|
+
this.compactedAt.delete(name);
|
|
3697
|
+
this.log(`[${name}] evicted from memory (idle ${Math.round(idle / 3_600_000)}h) — reloads from disk on next connect`);
|
|
3698
|
+
}
|
|
2762
3699
|
}
|
|
2763
|
-
|
|
2764
|
-
|
|
2765
|
-
this.spaces.delete(name);
|
|
2766
|
-
this.compactedAt.delete(name);
|
|
2767
|
-
this.log(`[${name}] evicted from memory (idle ${Math.round(idle / 3_600_000)}h) — reloads from disk on next connect`);
|
|
3700
|
+
catch (e) {
|
|
3701
|
+
this.noteStorageFailure(`[${name}] eviction flush`, e);
|
|
2768
3702
|
}
|
|
2769
3703
|
}
|
|
2770
3704
|
}
|
|
@@ -2777,22 +3711,49 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
2777
3711
|
catch { }
|
|
2778
3712
|
}
|
|
2779
3713
|
}
|
|
2780
|
-
getOrCreateSpace(name, token) {
|
|
2781
|
-
const
|
|
2782
|
-
|
|
2783
|
-
|
|
3714
|
+
getOrCreateSpace(name, token, opts = {}) {
|
|
3715
|
+
const claim = opts.claim;
|
|
3716
|
+
const retired = !!opts.retired;
|
|
3717
|
+
const existing = this.spaces.get(spaceKey(name));
|
|
3718
|
+
if (existing) {
|
|
3719
|
+
if (existing.name !== name)
|
|
3720
|
+
return null;
|
|
3721
|
+
const st = this.tokenState(existing, token);
|
|
3722
|
+
return st === "current" || (retired && st === "retired") ? existing : null;
|
|
3723
|
+
}
|
|
3724
|
+
if (this.aliasOf(name))
|
|
3725
|
+
return null;
|
|
2784
3726
|
const safe = name.replace(/[^a-z0-9._-]/gi, "_");
|
|
2785
3727
|
const allow = new Allowlist(this.dataDir, safe);
|
|
2786
3728
|
const match = allow.tokenMatches(token);
|
|
2787
|
-
|
|
2788
|
-
|
|
2789
|
-
|
|
3729
|
+
let raw = token;
|
|
3730
|
+
if (match === false) {
|
|
3731
|
+
if (!(retired && allow.tokenRetired(token)))
|
|
3732
|
+
return null;
|
|
3733
|
+
raw = allow.currentTokenFromInvites() ?? "";
|
|
3734
|
+
}
|
|
3735
|
+
if (match === null) {
|
|
3736
|
+
if (this.unboundOnDisk(safe)) {
|
|
3737
|
+
const who = claim && claim !== "invite" ? claim : null;
|
|
3738
|
+
const e = who ? allow.get(who.member) : undefined;
|
|
3739
|
+
const ok = claim === "invite" || (!!who && !!e?.admitted && e.publicKey === who.pubkey);
|
|
3740
|
+
if (!ok) {
|
|
3741
|
+
this.unbound.add(safe);
|
|
3742
|
+
return null;
|
|
3743
|
+
}
|
|
3744
|
+
this.log(`[${name}] bound a token to a space that had none, on the reconnect of ${who ? `its admitted member ${who.member}` : "an invite it issued"}`);
|
|
3745
|
+
}
|
|
2790
3746
|
allow.bindToken(token);
|
|
3747
|
+
this.unbound.delete(safe);
|
|
3748
|
+
}
|
|
3749
|
+
const ledger = new Ledger(this.dataDir, safe);
|
|
2791
3750
|
const store = new StateStore(this.dataDir, safe);
|
|
2792
|
-
|
|
3751
|
+
store.headOf = () => ledger.head;
|
|
3752
|
+
store.onError = (e) => this.noteStorageFailure(`[${name}] snapshot save`, e);
|
|
3753
|
+
const restored = store.load({ quarantine: true, expectHistory: ledger.size > 0 });
|
|
2793
3754
|
const sp = {
|
|
2794
|
-
name, token,
|
|
2795
|
-
ledger
|
|
3755
|
+
name, token: raw,
|
|
3756
|
+
ledger,
|
|
2796
3757
|
allow,
|
|
2797
3758
|
store,
|
|
2798
3759
|
members: new Map(),
|
|
@@ -2805,27 +3766,197 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
2805
3766
|
createdAt: Date.now(),
|
|
2806
3767
|
lastTouch: Date.now(),
|
|
2807
3768
|
};
|
|
2808
|
-
|
|
3769
|
+
if (restored.unclean) {
|
|
3770
|
+
sp.unclean = restored.unclean;
|
|
3771
|
+
const rebuilt = this.rebuildFromLedger(sp);
|
|
3772
|
+
this.log(`!! [${name}] UNCLEAN LOAD: ${restored.unclean}${restored.quarantined ? ` — moved aside to ${restored.quarantined}` : ""}. ` +
|
|
3773
|
+
`Rebuilt ${rebuilt.goals} goal(s) (${rebuilt.open} open) and ${sp.obligations.size} obligation(s) from ${ledger.size} act(s) in the ledger; ` +
|
|
3774
|
+
`ledger compaction is OFF for this space until a snapshot written from live state (after a real act) is loaded clean.`);
|
|
3775
|
+
store.rebuilt = restored.unclean;
|
|
3776
|
+
try {
|
|
3777
|
+
store.flush(sp.goals, sp.obligations, sp.profiles);
|
|
3778
|
+
}
|
|
3779
|
+
catch (e) {
|
|
3780
|
+
this.noteStorageFailure(`[${name}] rebuilt snapshot`, e);
|
|
3781
|
+
}
|
|
3782
|
+
}
|
|
3783
|
+
else if (restored.rebuilt) {
|
|
3784
|
+
sp.unclean = `its snapshot is still the one rebuilt from the ledger (${restored.rebuilt}), and no act has been built on it yet`;
|
|
3785
|
+
store.rebuilt = restored.rebuilt;
|
|
3786
|
+
this.log(`! [${name}] loaded a snapshot rebuilt from the ledger that no act has built on yet; ledger compaction stays OFF for this run.`);
|
|
3787
|
+
}
|
|
3788
|
+
else if (restored.ledgerHead !== undefined && ledger.head > restored.ledgerHead) {
|
|
3789
|
+
const after = restored.ledgerHead;
|
|
3790
|
+
const missed = ledger.all().filter((r) => r.seq > after);
|
|
3791
|
+
const had = new Set(sp.obligations.keys());
|
|
3792
|
+
let opened = 0;
|
|
3793
|
+
for (const r of missed) {
|
|
3794
|
+
if (r.goal && !sp.goals.has(r.goal)) {
|
|
3795
|
+
const g = this.goalFromOpening(r);
|
|
3796
|
+
if (g) {
|
|
3797
|
+
sp.goals.set(g.id, g);
|
|
3798
|
+
opened++;
|
|
3799
|
+
}
|
|
3800
|
+
}
|
|
3801
|
+
this.effects(sp, r);
|
|
3802
|
+
}
|
|
3803
|
+
for (const o of sp.obligations.values())
|
|
3804
|
+
if (!had.has(o.id))
|
|
3805
|
+
o.created_at = ledger.find(o.ref)?.ts ?? o.created_at;
|
|
3806
|
+
this.log(`! [${name}] the snapshot was ${missed.length} act(s) behind the ledger (seq ${after} < ${ledger.head}) — replayed them${opened ? `, and made again ${opened} goal(s) opened among them` : ""}`);
|
|
3807
|
+
try {
|
|
3808
|
+
store.flush(sp.goals, sp.obligations, sp.profiles);
|
|
3809
|
+
}
|
|
3810
|
+
catch (e) {
|
|
3811
|
+
this.noteStorageFailure(`[${name}] replayed snapshot`, e);
|
|
3812
|
+
}
|
|
3813
|
+
}
|
|
3814
|
+
this.spaces.set(spaceKey(name), sp);
|
|
3815
|
+
try {
|
|
3816
|
+
allow.recordName(name);
|
|
3817
|
+
}
|
|
3818
|
+
catch (e) {
|
|
3819
|
+
this.noteStorageFailure(`[${name}] recording its name`, e);
|
|
3820
|
+
}
|
|
2809
3821
|
for (const e of allow.all()) {
|
|
2810
3822
|
if (e.admitted && !sp.members.has(e.member)) {
|
|
2811
3823
|
sp.members.set(e.member, { id: e.member, display: e.member, agent: "unknown", last_seen: e.first_seen, sessions: new Map(), activity: new Map(), delivered_seq: 0, stamps: [] });
|
|
2812
3824
|
}
|
|
2813
3825
|
}
|
|
3826
|
+
this.rebuildRecent(sp);
|
|
2814
3827
|
const open = [...sp.goals.values()].filter((g) => g.state !== "CLOSED").length;
|
|
2815
3828
|
this.log(`[${name}] space ready (seq ${sp.ledger.head}, ${sp.goals.size} goals — ${open} still open, ${sp.obligations.size} obligations restored)`);
|
|
3829
|
+
const founder = allow.founder();
|
|
3830
|
+
const guess = allow.founderGuess();
|
|
3831
|
+
if (founder && (guess || !allow.get(founder)?.admitted)) {
|
|
3832
|
+
this.log(`! [${name}] founder is "${founder}"${guess ? ` — a guess: ${guess} (an older relay rewrote who admitted whom)` : ""}` +
|
|
3833
|
+
`${allow.get(founder)?.admitted ? "" : `, and "${founder}" is not admitted here, so nobody can remove a member or delete the team until they are re-admitted (or the operator steps in)`}`);
|
|
3834
|
+
}
|
|
3835
|
+
if (ledger.outOfOrder) {
|
|
3836
|
+
this.log(`! [${name}] ledger seq is not strictly increasing: ${ledger.outOfOrder} act(s) at or below the one before (first at seq ${ledger.firstOutOfOrder}). ` +
|
|
3837
|
+
`Two relays or two names wrote this file (0.29.1 let "a b" and "a_b" share one); readers paging by seq may skip or repeat those acts. Nothing was renumbered.`);
|
|
3838
|
+
}
|
|
2816
3839
|
this.compactSpace(sp);
|
|
2817
3840
|
return sp;
|
|
2818
3841
|
}
|
|
3842
|
+
rebuildRecent(sp) {
|
|
3843
|
+
const now = Date.now();
|
|
3844
|
+
const recs = sp.ledger.all();
|
|
3845
|
+
const retracted = new Set();
|
|
3846
|
+
const window = [];
|
|
3847
|
+
for (let i = recs.length - 1; i >= 0; i--) {
|
|
3848
|
+
const r = recs[i];
|
|
3849
|
+
if (now - r.ts > LIMITS.dedupeWindowMs)
|
|
3850
|
+
break;
|
|
3851
|
+
if (r.act === "RETRACT" && r.ref)
|
|
3852
|
+
retracted.add(r.ref);
|
|
3853
|
+
window.push(r);
|
|
3854
|
+
}
|
|
3855
|
+
for (const r of window.reverse()) {
|
|
3856
|
+
if (!r.session || !SENDABLE.includes(r.act) || retracted.has(r.id))
|
|
3857
|
+
continue;
|
|
3858
|
+
const fp = actFingerprint(r.from, r.act, r.body, r.goal ?? null, r.ref ?? null, r.to ?? null, r.meta);
|
|
3859
|
+
sp.recent.set(fp, { id: r.id, seq: r.seq, at: r.ts });
|
|
3860
|
+
}
|
|
3861
|
+
for (const k of sp.recent.keys()) {
|
|
3862
|
+
if (sp.recent.size <= RECENT_MAX)
|
|
3863
|
+
break;
|
|
3864
|
+
sp.recent.delete(k);
|
|
3865
|
+
}
|
|
3866
|
+
}
|
|
3867
|
+
rebuildFromLedger(sp) {
|
|
3868
|
+
sp.goals.clear();
|
|
3869
|
+
sp.obligations.clear();
|
|
3870
|
+
const ctx = { retracted: new Set(), answers: new Map() };
|
|
3871
|
+
for (const r of sp.ledger.all()) {
|
|
3872
|
+
if (r.act === "RETRACT" && r.ref)
|
|
3873
|
+
ctx.retracted.add(r.ref);
|
|
3874
|
+
if (r.act === "ANSWER" && r.ref) {
|
|
3875
|
+
const a = ctx.answers.get(r.ref);
|
|
3876
|
+
if (a)
|
|
3877
|
+
a.push(r);
|
|
3878
|
+
else
|
|
3879
|
+
ctx.answers.set(r.ref, [r]);
|
|
3880
|
+
}
|
|
3881
|
+
let goal = null;
|
|
3882
|
+
if (r.goal) {
|
|
3883
|
+
goal = sp.goals.get(r.goal) ?? null;
|
|
3884
|
+
if (!goal) {
|
|
3885
|
+
goal = this.goalFromOpening(r) ?? {
|
|
3886
|
+
id: r.goal, title: `(recovered goal ${r.goal})`, acceptance: "", members: [r.from],
|
|
3887
|
+
state: "OPEN", acts: 0, budget: LIMITS.goalBudget, opened_by: r.from, opened_at: r.ts,
|
|
3888
|
+
};
|
|
3889
|
+
sp.goals.set(goal.id, goal);
|
|
3890
|
+
}
|
|
3891
|
+
const affects = r.meta?.affects;
|
|
3892
|
+
for (const who of [r.from, ...(r.to ?? []), ...(Array.isArray(affects) ? affects.filter((x) => typeof x === "string") : [])]) {
|
|
3893
|
+
if (!who.startsWith("human:") && !goal.members.includes(who))
|
|
3894
|
+
goal.members.push(who);
|
|
3895
|
+
}
|
|
3896
|
+
goal.acts++;
|
|
3897
|
+
}
|
|
3898
|
+
this.applyObligations(sp, r, goal, ctx);
|
|
3899
|
+
}
|
|
3900
|
+
for (const o of sp.obligations.values())
|
|
3901
|
+
o.created_at = sp.ledger.find(o.ref)?.ts ?? o.created_at;
|
|
3902
|
+
let open = 0;
|
|
3903
|
+
for (const g of sp.goals.values()) {
|
|
3904
|
+
if (g.state === "CLOSED")
|
|
3905
|
+
continue;
|
|
3906
|
+
open++;
|
|
3907
|
+
g.budget = Math.max(g.budget, g.acts + Math.ceil(LIMITS.goalBudget / 2));
|
|
3908
|
+
}
|
|
3909
|
+
return { goals: sp.goals.size, open };
|
|
3910
|
+
}
|
|
3911
|
+
goalFromOpening(r) {
|
|
3912
|
+
if (!r.goal || r.act !== "NOTE" || r.session)
|
|
3913
|
+
return null;
|
|
3914
|
+
const mm = GOAL_OPENED.exec(r.body);
|
|
3915
|
+
if (!mm)
|
|
3916
|
+
return null;
|
|
3917
|
+
const meta = r.meta;
|
|
3918
|
+
const deadline = mm[3] ? Date.parse(mm[3]) : NaN;
|
|
3919
|
+
const listed = meta?.goal_members;
|
|
3920
|
+
const members = Array.isArray(listed) ? [...new Set([r.from, ...listed.filter((x) => typeof x === "string" && !x.startsWith("human:"))])] : [r.from];
|
|
3921
|
+
const budget = typeof meta?.goal_budget === "number" && Number.isInteger(meta.goal_budget) && meta.goal_budget > 0 ? meta.goal_budget : LIMITS.goalBudget;
|
|
3922
|
+
return {
|
|
3923
|
+
id: r.goal,
|
|
3924
|
+
title: typeof meta?.goal_text?.title === "string" ? meta.goal_text.title : mm[1],
|
|
3925
|
+
acceptance: typeof meta?.goal_text?.acceptance === "string" ? meta.goal_text.acceptance : (mm[2] !== "(none stated)" ? mm[2] : ""),
|
|
3926
|
+
...(meta?.goal_sig && typeof meta.goal_sig === "object" ? { sig: meta.goal_sig } : {}),
|
|
3927
|
+
members,
|
|
3928
|
+
state: "OPEN",
|
|
3929
|
+
acts: 0,
|
|
3930
|
+
budget,
|
|
3931
|
+
opened_by: r.from,
|
|
3932
|
+
opened_at: r.ts,
|
|
3933
|
+
...(Number.isFinite(deadline) ? { deadline } : {}),
|
|
3934
|
+
...(mm[4] === "worklist" ? { kind: "worklist" } : {}),
|
|
3935
|
+
};
|
|
3936
|
+
}
|
|
2819
3937
|
compactSpace(sp) {
|
|
3938
|
+
this.compactedAt.set(sp.name, Date.now());
|
|
3939
|
+
if (sp.unclean)
|
|
3940
|
+
return;
|
|
2820
3941
|
const isLive = (goal) => {
|
|
2821
3942
|
if (!goal)
|
|
2822
3943
|
return false;
|
|
2823
3944
|
const g = sp.goals.get(goal);
|
|
2824
|
-
return
|
|
3945
|
+
return !g || g.state !== "CLOSED";
|
|
2825
3946
|
};
|
|
2826
3947
|
const referenced = new Set([...sp.obligations.values()].map((o) => o.ref));
|
|
2827
|
-
const
|
|
2828
|
-
|
|
3948
|
+
const before = sp.ledger.all().map((r) => r.id);
|
|
3949
|
+
const { dropped, kept } = sp.ledger.compact({ keepRecent: this.opts.ledgerKeepRecent ?? LIMITS.ledgerKeepRecent, isLive, keep: (id) => referenced.has(id) });
|
|
3950
|
+
if (dropped) {
|
|
3951
|
+
const now = Date.now();
|
|
3952
|
+
const d = (sp.droppedIds ??= new Map());
|
|
3953
|
+
for (const [id, at] of d)
|
|
3954
|
+
if (now - at > DAY)
|
|
3955
|
+
d.delete(id);
|
|
3956
|
+
for (const id of before)
|
|
3957
|
+
if (!sp.ledger.find(id))
|
|
3958
|
+
d.set(id, now);
|
|
3959
|
+
}
|
|
2829
3960
|
if (dropped)
|
|
2830
3961
|
this.log(`[${sp.name}] compacted ledger — dropped ${dropped} old act(s) from closed goals, ${kept} kept`);
|
|
2831
3962
|
}
|
|
@@ -2861,7 +3992,10 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
2861
3992
|
members(sp) {
|
|
2862
3993
|
return sp.allow.all().map((e) => ({
|
|
2863
3994
|
member: e.member, admitted: e.admitted, may_wake: e.may_wake,
|
|
2864
|
-
fingerprint: fingerprint(e.publicKey),
|
|
3995
|
+
fingerprint: fingerprint(e.publicKey), pubkey: e.publicKey,
|
|
3996
|
+
...(e.admitted_by ? { admitted_by: e.admitted_by } : {}),
|
|
3997
|
+
...(e.revoked_at ? { revoked_at: e.revoked_at, ...(e.revoked_by ? { revoked_by: e.revoked_by } : {}) } : {}),
|
|
3998
|
+
...(sp.allow.founder() === e.member ? { founder: true } : {}),
|
|
2865
3999
|
}));
|
|
2866
4000
|
}
|
|
2867
4001
|
send(ws, msg) {
|