@join-pantheon/cli 0.29.0 → 0.30.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 +45 -22
- package/bin/pantheon.js +12 -0
- package/dist/accounts.js +151 -26
- package/dist/agents.js +197 -14
- package/dist/claude-confine.js +283 -0
- package/dist/cli.js +725 -119
- package/dist/client.js +37 -2
- package/dist/command-state.js +79 -0
- package/dist/config.js +211 -10
- package/dist/doctor.js +103 -13
- package/dist/grants.js +1 -1
- package/dist/history-view.js +2 -1
- package/dist/home-guard.js +210 -0
- package/dist/hooks-install.js +2 -1
- package/dist/identity.js +24 -0
- package/dist/install.js +76 -26
- package/dist/kiro-files.js +2 -2
- package/dist/ledger.js +111 -28
- package/dist/machine.js +507 -38
- package/dist/mcp-supervisor.js +25 -6
- package/dist/mcp-windows.js +47 -25
- package/dist/mcp.js +25 -10
- package/dist/network.js +39 -8
- package/dist/notify.js +17 -6
- package/dist/pending.js +8 -14
- package/dist/persona.js +21 -0
- package/dist/profile.js +4 -1
- package/dist/quickstart.js +88 -15
- package/dist/reads.js +30 -0
- package/dist/refusal.js +14 -1
- package/dist/relay.js +1193 -185
- package/dist/roster-file.js +169 -23
- package/dist/run-failure.js +83 -3
- package/dist/secret-guard.js +141 -0
- package/dist/service.js +105 -36
- package/dist/setup-guide.js +28 -9
- package/dist/spend.js +6 -2
- package/dist/store.js +94 -10
- package/dist/text-file.js +74 -0
- package/dist/tray.js +11 -2
- package/dist/update-check.js +2 -0
- package/dist/which.js +69 -12
- package/dist/worker-policy.js +137 -3
- package/dist/worker.js +702 -81
- package/helper/macos/menubar.swift +15 -1
- 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
7
|
import { badRemote, Inbound, isContinuationFor, LIMITS, newId, PROTOCOL_VERSION, 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 } 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;
|
|
@@ -47,8 +47,14 @@ function cookieVal(req, name) {
|
|
|
47
47
|
return "";
|
|
48
48
|
for (const part of raw.split(";")) {
|
|
49
49
|
const eq = part.indexOf("=");
|
|
50
|
-
if (eq > -1 && part.slice(0, eq).trim() === name)
|
|
51
|
-
|
|
50
|
+
if (eq > -1 && part.slice(0, eq).trim() === name) {
|
|
51
|
+
try {
|
|
52
|
+
return decodeURIComponent(part.slice(eq + 1).trim());
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
return "";
|
|
56
|
+
}
|
|
57
|
+
}
|
|
52
58
|
}
|
|
53
59
|
return "";
|
|
54
60
|
}
|
|
@@ -75,6 +81,17 @@ export function isTrustedSource(ip) {
|
|
|
75
81
|
function fingerprint_(from, act, body, goal, ref, extra = null) {
|
|
76
82
|
return createHash("sha256").update([from, act, goal ?? "", ref ?? "", body, stableJson(extra)].join("\u0000")).digest("hex").slice(0, 32);
|
|
77
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
|
+
}
|
|
78
95
|
function stableJson(v) {
|
|
79
96
|
if (v === null || typeof v !== "object")
|
|
80
97
|
return JSON.stringify(v ?? null);
|
|
@@ -83,6 +100,8 @@ function stableJson(v) {
|
|
|
83
100
|
return `{${Object.keys(v).sort().map((k) => `${JSON.stringify(k)}:${stableJson(v[k])}`).join(",")}}`;
|
|
84
101
|
}
|
|
85
102
|
function clientIp(req) {
|
|
103
|
+
if (process.env.PANTHEON_TRUSTED_HOPS?.trim() === "0")
|
|
104
|
+
return req.socket.remoteAddress || "unknown";
|
|
86
105
|
const raw = req.headers["x-forwarded-for"];
|
|
87
106
|
const hops = (Array.isArray(raw) ? raw.join(",") : raw ?? "").split(",").map((x) => x.trim()).filter(Boolean);
|
|
88
107
|
const trusted = Math.max(1, Number(process.env.PANTHEON_TRUSTED_HOPS ?? 1) || 1);
|
|
@@ -155,14 +174,161 @@ const fmtAgo = (t) => {
|
|
|
155
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`;
|
|
156
175
|
};
|
|
157
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]*)?$/;
|
|
158
210
|
const LIMITS_GOAL_READ = 1000;
|
|
159
211
|
const LIMITS_SPACE = { metaBytes: 8192, openGoals: 500, repos: 100, openInvites: 50 };
|
|
212
|
+
const SPACE_MAX_BYTES_DEFAULT = 50 * 1024 * 1024;
|
|
213
|
+
const SPACE_HARD_SLACK = 1024 * 1024;
|
|
214
|
+
const REFUSAL_BUDGET = 100;
|
|
215
|
+
const REFUSAL_WINDOW_MS = 10_000;
|
|
216
|
+
const RECENT_MAX = 5_000;
|
|
217
|
+
const DEVICE_OTHER_ACCOUNT = [
|
|
218
|
+
"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.",
|
|
219
|
+
"To sign back in to that account: pantheon login --email <its email>.",
|
|
220
|
+
"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.",
|
|
221
|
+
"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).",
|
|
222
|
+
].join("\n");
|
|
223
|
+
const UPGRADE_NOTE = "This team now needs Pantheon 0.30 or newer to read its history — run: pantheon upgrade";
|
|
224
|
+
const UPGRADE_NOTE_ID = "a_pantheon_upgrade_required";
|
|
225
|
+
const RELAY_MEMBER = "pantheon";
|
|
226
|
+
function ledgerTailSeq(file) {
|
|
227
|
+
let fd = null;
|
|
228
|
+
try {
|
|
229
|
+
fd = openSync(file, "r");
|
|
230
|
+
const size = fstatSync(fd).size;
|
|
231
|
+
const len = Math.min(size, 256 * 1024);
|
|
232
|
+
const buf = Buffer.allocUnsafe(len);
|
|
233
|
+
readSync(fd, buf, 0, len, size - len);
|
|
234
|
+
const lines = buf.toString("utf8").split("\n");
|
|
235
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
236
|
+
try {
|
|
237
|
+
const r = JSON.parse(lines[i]);
|
|
238
|
+
if (typeof r.seq === "number")
|
|
239
|
+
return r.seq;
|
|
240
|
+
}
|
|
241
|
+
catch { }
|
|
242
|
+
}
|
|
243
|
+
return 0;
|
|
244
|
+
}
|
|
245
|
+
catch {
|
|
246
|
+
return 0;
|
|
247
|
+
}
|
|
248
|
+
finally {
|
|
249
|
+
if (fd !== null)
|
|
250
|
+
try {
|
|
251
|
+
closeSync(fd);
|
|
252
|
+
}
|
|
253
|
+
catch { }
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
function versionBelow(a, b) {
|
|
257
|
+
if (!a)
|
|
258
|
+
return true;
|
|
259
|
+
const pa = a.split(/[.-]/).map((x) => Number.parseInt(x, 10) || 0), pb = b.split(".").map(Number);
|
|
260
|
+
for (let i = 0; i < 3; i++) {
|
|
261
|
+
if ((pa[i] ?? 0) !== (pb[i] ?? 0))
|
|
262
|
+
return (pa[i] ?? 0) < (pb[i] ?? 0);
|
|
263
|
+
}
|
|
264
|
+
return false;
|
|
265
|
+
}
|
|
266
|
+
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";
|
|
267
|
+
const STORAGE_UNHEALTHY_MS = 5 * 60_000;
|
|
268
|
+
function isStorageError(e) {
|
|
269
|
+
const code = e?.code;
|
|
270
|
+
return typeof code === "string" && (/^E[A-Z0-9]+$/.test(code) || code.startsWith("ERR_SQLITE"));
|
|
271
|
+
}
|
|
272
|
+
function buildCommit() {
|
|
273
|
+
const c = process.env.PANTHEON_COMMIT ?? process.env.RAILWAY_GIT_COMMIT_SHA ?? process.env.SOURCE_COMMIT ?? process.env.GIT_COMMIT ?? "";
|
|
274
|
+
return /^[0-9a-f]{7,40}$/i.test(c.trim()) ? c.trim() : null;
|
|
275
|
+
}
|
|
276
|
+
export function netOf(ip) {
|
|
277
|
+
const h = ip.replace(/^::ffff:/i, "");
|
|
278
|
+
const v4 = /^(\d+)\.(\d+)\.(\d+)\.\d+$/.exec(h);
|
|
279
|
+
if (v4)
|
|
280
|
+
return `${v4[1]}.${v4[2]}.${v4[3]}.0/24`;
|
|
281
|
+
if (h.includes(":")) {
|
|
282
|
+
const [head, tail] = h.split("::");
|
|
283
|
+
const a = head ? head.split(":") : [];
|
|
284
|
+
const b = tail !== undefined ? (tail ? tail.split(":") : []) : [];
|
|
285
|
+
const groups = tail !== undefined ? [...a, ...Array(Math.max(0, 8 - a.length - b.length)).fill("0"), ...b] : a;
|
|
286
|
+
return `${groups.slice(0, 4).map((g) => (g || "0").toLowerCase().replace(/^0+(?=.)/, "")).join(":")}::/64`;
|
|
287
|
+
}
|
|
288
|
+
return h;
|
|
289
|
+
}
|
|
160
290
|
export class Relay {
|
|
161
291
|
opts;
|
|
162
292
|
spaces = new Map();
|
|
163
293
|
dataDir;
|
|
164
294
|
log;
|
|
165
|
-
http = createServer((req, res) =>
|
|
295
|
+
http = createServer((req, res) => {
|
|
296
|
+
const fail = (e) => {
|
|
297
|
+
if (e instanceof RosterUnreadable) {
|
|
298
|
+
this.log(`!! ${e.message} — reads of this space are refused until it is repaired (it was left in place)`);
|
|
299
|
+
try {
|
|
300
|
+
if (!res.headersSent)
|
|
301
|
+
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." });
|
|
302
|
+
else
|
|
303
|
+
res.end();
|
|
304
|
+
}
|
|
305
|
+
catch { }
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
const bad = e instanceof TypeError || e instanceof URIError;
|
|
309
|
+
if (!bad)
|
|
310
|
+
this.log(`! http ${req.method} ${String(req.url ?? "").slice(0, 120)} failed: ${e?.message ?? e}`);
|
|
311
|
+
if (isStorageError(e))
|
|
312
|
+
this.noteStorageFailure(`http ${String(req.url ?? "").split("?")[0].slice(0, 60)}`, e);
|
|
313
|
+
try {
|
|
314
|
+
if (!res.headersSent)
|
|
315
|
+
this.json(res, bad ? 400 : 500, { error: bad ? "bad request" : "the relay could not handle that request" });
|
|
316
|
+
else
|
|
317
|
+
res.end();
|
|
318
|
+
}
|
|
319
|
+
catch { }
|
|
320
|
+
};
|
|
321
|
+
try {
|
|
322
|
+
if (!URL.canParse(req.url ?? "/", "http://x"))
|
|
323
|
+
return fail(new TypeError("unparseable request target"));
|
|
324
|
+
const r = this.onHttp(req, res);
|
|
325
|
+
if (r && typeof r.catch === "function")
|
|
326
|
+
r.catch(fail);
|
|
327
|
+
}
|
|
328
|
+
catch (e) {
|
|
329
|
+
fail(e);
|
|
330
|
+
}
|
|
331
|
+
});
|
|
166
332
|
wss = new WebSocketServer({ noServer: true, maxPayload: 256 * 1024 });
|
|
167
333
|
heartbeat;
|
|
168
334
|
compactedAt = new Map();
|
|
@@ -173,20 +339,32 @@ export class Relay {
|
|
|
173
339
|
pruneMs;
|
|
174
340
|
accounts;
|
|
175
341
|
requireAccounts;
|
|
342
|
+
readsMode;
|
|
176
343
|
trustLocal;
|
|
177
344
|
email;
|
|
178
345
|
authHits = new Map();
|
|
179
346
|
adminHits = new Map();
|
|
180
347
|
emailSends = [];
|
|
348
|
+
emailSendsTo = new Map();
|
|
349
|
+
emailSendsFrom = new Map();
|
|
350
|
+
storageFailure = null;
|
|
351
|
+
unbound = new Set();
|
|
352
|
+
maxLedgerBytes;
|
|
181
353
|
adminToken;
|
|
182
354
|
adminAllow;
|
|
183
355
|
startedAt = Date.now();
|
|
184
356
|
deletedSpaces = new Map();
|
|
357
|
+
authEpoch = 0;
|
|
358
|
+
rotateOnRemove;
|
|
185
359
|
backupTimer;
|
|
186
360
|
constructor(opts = {}) {
|
|
187
361
|
this.opts = opts;
|
|
188
362
|
this.dataDir = opts.dataDir ?? "./pantheon-data";
|
|
189
363
|
this.log = opts.log ?? ((l) => console.error(l));
|
|
364
|
+
ensurePrivateDir(this.dataDir);
|
|
365
|
+
this.maxLedgerBytes = opts.spaceMaxBytes
|
|
366
|
+
?? (Number(process.env.PANTHEON_SPACE_MAX_BYTES) > 0 ? Number(process.env.PANTHEON_SPACE_MAX_BYTES) : SPACE_MAX_BYTES_DEFAULT);
|
|
367
|
+
this.scanUnbound();
|
|
190
368
|
this.requireAccounts = opts.requireAccounts ?? process.env.PANTHEON_REQUIRE_ACCOUNTS === "1";
|
|
191
369
|
this.trustLocal = process.env.PANTHEON_TRUST_LOCAL !== undefined
|
|
192
370
|
? process.env.PANTHEON_TRUST_LOCAL === "1"
|
|
@@ -214,8 +392,14 @@ export class Relay {
|
|
|
214
392
|
spaceJunkDeleteMs: opts.spaceLimits?.spaceJunkDeleteMs ?? LIMITS.spaceJunkDeleteMs,
|
|
215
393
|
spaceMinAgeMs: opts.spaceLimits?.spaceMinAgeMs ?? LIMITS.spaceMinAgeMs,
|
|
216
394
|
};
|
|
395
|
+
this.rotateOnRemove = opts.rotateOnRemove ?? process.env.PANTHEON_ROTATE_ON_REMOVE === "1";
|
|
217
396
|
this.busyTtlMs = opts.activityBusyTtlMs ?? LIMITS.activityBusyTtlMs;
|
|
218
397
|
this.pruneMs = opts.activityPruneMs ?? LIMITS.activityPruneMs;
|
|
398
|
+
const readsEnv = (process.env.PANTHEON_READS ?? "").trim().toLowerCase();
|
|
399
|
+
this.readsMode = opts.reads ?? (readsEnv === "" || readsEnv === "transition" ? "transition" : "signed");
|
|
400
|
+
if (!opts.reads && readsEnv && readsEnv !== "transition" && readsEnv !== "signed") {
|
|
401
|
+
this.log(`! PANTHEON_READS="${process.env.PANTHEON_READS}" is not "signed" or "transition" — using "signed"`);
|
|
402
|
+
}
|
|
219
403
|
this.http.on("upgrade", (req, socket, head) => {
|
|
220
404
|
const ip = clientIp(req);
|
|
221
405
|
this.wss.handleUpgrade(req, socket, head, (ws) => this.onSocket(ws, ip));
|
|
@@ -238,8 +422,14 @@ export class Relay {
|
|
|
238
422
|
});
|
|
239
423
|
}
|
|
240
424
|
async close() {
|
|
241
|
-
for (const sp of this.spaces.values())
|
|
242
|
-
|
|
425
|
+
for (const sp of this.spaces.values()) {
|
|
426
|
+
try {
|
|
427
|
+
sp.store.close(sp.goals, sp.obligations, sp.profiles);
|
|
428
|
+
}
|
|
429
|
+
catch (e) {
|
|
430
|
+
this.noteStorageFailure(`[${sp.name}] final flush`, e);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
243
433
|
if (this.backupTimer)
|
|
244
434
|
clearInterval(this.backupTimer);
|
|
245
435
|
if (this.accounts && !this.opts.accounts)
|
|
@@ -251,6 +441,48 @@ export class Relay {
|
|
|
251
441
|
await new Promise((r) => this.wss.close(() => r()));
|
|
252
442
|
await new Promise((r) => this.http.close(() => r()));
|
|
253
443
|
}
|
|
444
|
+
noteStorageFailure(where, e) {
|
|
445
|
+
const message = e?.message ?? String(e);
|
|
446
|
+
this.log(`! storage: ${where} failed: ${message}`);
|
|
447
|
+
if (isStorageError(e))
|
|
448
|
+
this.storageFailure = { at: Date.now(), where, message };
|
|
449
|
+
}
|
|
450
|
+
scanUnbound() {
|
|
451
|
+
let files = [];
|
|
452
|
+
try {
|
|
453
|
+
files = readdirSync(this.dataDir);
|
|
454
|
+
}
|
|
455
|
+
catch {
|
|
456
|
+
return;
|
|
457
|
+
}
|
|
458
|
+
for (const f of files) {
|
|
459
|
+
const safe = f.endsWith(".members.json") ? f.slice(0, -".members.json".length) : f.endsWith(".jsonl") ? f.slice(0, -".jsonl".length) : null;
|
|
460
|
+
if (safe === null || this.unbound.has(safe))
|
|
461
|
+
continue;
|
|
462
|
+
try {
|
|
463
|
+
if (this.unboundOnDisk(safe))
|
|
464
|
+
this.unbound.add(safe);
|
|
465
|
+
}
|
|
466
|
+
catch { }
|
|
467
|
+
}
|
|
468
|
+
if (this.unbound.size) {
|
|
469
|
+
this.log(`! ${this.unbound.size} space(s) on disk have no bound token: ${[...this.unbound].sort().join(", ")}. ` +
|
|
470
|
+
`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).`);
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
unboundOnDisk(safe) {
|
|
474
|
+
const size = (suf) => { try {
|
|
475
|
+
return statSync(join(this.dataDir, `${safe}${suf}`)).size;
|
|
476
|
+
}
|
|
477
|
+
catch {
|
|
478
|
+
return -1;
|
|
479
|
+
} };
|
|
480
|
+
const ledger = size(".jsonl");
|
|
481
|
+
if (size(".members.json") < 0)
|
|
482
|
+
return ledger > 0;
|
|
483
|
+
const al = new Allowlist(this.dataDir, safe);
|
|
484
|
+
return !al.hasToken && (!al.empty || ledger > 0);
|
|
485
|
+
}
|
|
254
486
|
onHttp(req, res) {
|
|
255
487
|
const url = new URL(req.url ?? "/", "http://x");
|
|
256
488
|
const ip = clientIp(req);
|
|
@@ -269,11 +501,16 @@ export class Relay {
|
|
|
269
501
|
seq: s.ledger.head,
|
|
270
502
|
});
|
|
271
503
|
const asked = url.searchParams.get("space");
|
|
272
|
-
const one = asked ? this.
|
|
504
|
+
const one = asked ? this.live(asked) : undefined;
|
|
505
|
+
const sf = this.storageFailure && Date.now() - this.storageFailure.at < STORAGE_UNHEALTHY_MS ? this.storageFailure : null;
|
|
506
|
+
const commit = buildCommit();
|
|
273
507
|
return this.json(res, 200, {
|
|
274
|
-
ok:
|
|
508
|
+
ok: !sf,
|
|
509
|
+
...(sf ? { storage: { error: sf.message, where: sf.where, at: new Date(sf.at).toISOString() } } : {}),
|
|
275
510
|
v: PROTOCOL_VERSION,
|
|
276
511
|
pkg: VERSION,
|
|
512
|
+
...(commit ? { commit } : {}),
|
|
513
|
+
...(adminView && this.unbound.size ? { unbound_spaces: [...this.unbound].sort() } : {}),
|
|
277
514
|
space_count: this.spaces.size,
|
|
278
515
|
space_capacity: this.limits.maxSpaces,
|
|
279
516
|
...(adminView ? { spaces: [...this.spaces.values()].map(detail) }
|
|
@@ -293,10 +530,12 @@ export class Relay {
|
|
|
293
530
|
: this.json(res, 404, { error: "no open invite matches that code — it may have been used already or expired" });
|
|
294
531
|
}
|
|
295
532
|
if (url.pathname === "/act") {
|
|
296
|
-
const
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
533
|
+
const gate = this.readGate(req, url, ip);
|
|
534
|
+
if (!gate.ok && gate.notice)
|
|
535
|
+
return this.upgradeAnswer(res, url.pathname, url, gate.notice, gate.error);
|
|
536
|
+
if (!gate.ok)
|
|
537
|
+
return this.json(res, gate.status, { error: gate.error, ...(gate.code ? { code: gate.code } : {}) });
|
|
538
|
+
const space = gate.space;
|
|
300
539
|
const act = space.ledger.find(url.searchParams.get("id") ?? "");
|
|
301
540
|
if (!act)
|
|
302
541
|
return this.json(res, 404, { error: "no such act" });
|
|
@@ -304,10 +543,12 @@ export class Relay {
|
|
|
304
543
|
return this.json(res, 200, { act, ...(goal ? { goal: { id: goal.id, title: goal.title, state: goal.state } } : {}) });
|
|
305
544
|
}
|
|
306
545
|
if (url.pathname === "/history") {
|
|
307
|
-
const
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
546
|
+
const gate = this.readGate(req, url, ip);
|
|
547
|
+
if (!gate.ok && gate.notice)
|
|
548
|
+
return this.upgradeAnswer(res, url.pathname, url, gate.notice, gate.error);
|
|
549
|
+
if (!gate.ok)
|
|
550
|
+
return this.json(res, gate.status, { error: gate.error, ...(gate.code ? { code: gate.code } : {}) });
|
|
551
|
+
const space = gate.space;
|
|
311
552
|
const goal = url.searchParams.get("goal");
|
|
312
553
|
const sinceRaw = url.searchParams.get("since");
|
|
313
554
|
const acts = goal ? space.ledger.byGoal(goal, LIMITS_GOAL_READ)
|
|
@@ -315,10 +556,12 @@ export class Relay {
|
|
|
315
556
|
return this.json(res, 200, { acts, head: space.ledger.head });
|
|
316
557
|
}
|
|
317
558
|
if (url.pathname === "/obligations") {
|
|
318
|
-
const
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
559
|
+
const gate = this.readGate(req, url, ip);
|
|
560
|
+
if (!gate.ok && gate.notice)
|
|
561
|
+
return this.upgradeAnswer(res, url.pathname, url, gate.notice, gate.error);
|
|
562
|
+
if (!gate.ok)
|
|
563
|
+
return this.json(res, gate.status, { error: gate.error, ...(gate.code ? { code: gate.code } : {}) });
|
|
564
|
+
const space = gate.space;
|
|
322
565
|
const member = url.searchParams.get("member");
|
|
323
566
|
const now = Date.now();
|
|
324
567
|
const owed = [...space.obligations.values()]
|
|
@@ -333,25 +576,38 @@ export class Relay {
|
|
|
333
576
|
if (url.pathname === "/activity") {
|
|
334
577
|
if (req.method !== "POST")
|
|
335
578
|
return this.json(res, 405, { error: "POST only" });
|
|
336
|
-
const
|
|
337
|
-
const
|
|
338
|
-
if (!
|
|
579
|
+
const spaceName = url.searchParams.get("space") ?? "", spaceToken = url.searchParams.get("token") ?? "";
|
|
580
|
+
const target = this.readTarget(spaceName, spaceToken);
|
|
581
|
+
if (!target)
|
|
339
582
|
return this.json(res, 404, { error: "no such space" });
|
|
340
583
|
const memberId = url.searchParams.get("member") ?? "";
|
|
341
|
-
const member = space.members.get(memberId);
|
|
342
584
|
const session = (url.searchParams.get("session") ?? "").slice(0, 64);
|
|
343
585
|
const state = url.searchParams.get("state");
|
|
344
|
-
if (!
|
|
586
|
+
if (!memberId || !session || (state !== "busy" && state !== "idle")) {
|
|
345
587
|
return this.json(res, 400, { error: "member, session and state=busy|idle are required" });
|
|
346
588
|
}
|
|
347
589
|
const ts = Number(url.searchParams.get("ts"));
|
|
348
590
|
const sig = url.searchParams.get("sig") ?? "";
|
|
349
591
|
const seenParam = url.searchParams.get("seen") ?? "";
|
|
350
|
-
const pub =
|
|
592
|
+
const pub = target.allow.pubkeyOf(memberId);
|
|
351
593
|
const fresh = Number.isFinite(ts) && Math.abs(Date.now() - ts) < LIMITS.activityFreshMs;
|
|
352
|
-
if (!pub || !fresh || !verifyActivity(pub,
|
|
594
|
+
if (!pub || !fresh || !verifyActivity(pub, spaceName, memberId, session, state, seenParam, ts, sig)) {
|
|
353
595
|
return this.json(res, 403, { error: "activity report must be signed by the member's key and recent" });
|
|
354
596
|
}
|
|
597
|
+
if (this.requireAccounts && this.accounts && !this.localTrusted(ip)) {
|
|
598
|
+
const auth = this.accounts.authorize(pub, memberId);
|
|
599
|
+
if (!auth.ok) {
|
|
600
|
+
return this.json(res, 403, { code: "NOT_SIGNED_IN", error: auth.reason === "suspended"
|
|
601
|
+
? "this account has been suspended by the relay operator"
|
|
602
|
+
: "this device is not signed in to an account that may report here — run pantheon login" });
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
const space = target.live ?? this.getOrCreateSpace(spaceName, spaceToken, { retired: true });
|
|
606
|
+
if (!space)
|
|
607
|
+
return this.json(res, 404, { error: "no such space" });
|
|
608
|
+
const member = space.members.get(memberId);
|
|
609
|
+
if (!member)
|
|
610
|
+
return this.json(res, 400, { error: "member, session and state=busy|idle are required" });
|
|
355
611
|
if (member.activity.size > 64 && !member.activity.has(session)) {
|
|
356
612
|
const oldest = [...member.activity.entries()].sort((a, b) => a[1].at - b[1].at)[0];
|
|
357
613
|
if (oldest)
|
|
@@ -408,7 +664,7 @@ export class Relay {
|
|
|
408
664
|
res.writeHead(429, { "content-type": "text/html; charset=utf-8", "retry-after": "60" });
|
|
409
665
|
return void res.end("<p>Too many attempts. Wait a minute and try again.</p>");
|
|
410
666
|
}
|
|
411
|
-
return this.readForm(req, (body) => {
|
|
667
|
+
return this.readForm(req, res, (body) => {
|
|
412
668
|
if (!tokenOk(body.token ?? "")) {
|
|
413
669
|
this.noteAdminFail(ip);
|
|
414
670
|
A0.audit({ event: "admin_login_failed", ip });
|
|
@@ -450,7 +706,7 @@ export class Relay {
|
|
|
450
706
|
if (url.pathname === "/admin/action" && req.method === "POST") {
|
|
451
707
|
if (!authed)
|
|
452
708
|
return this.json(res, 403, { error: "sign in at /admin" });
|
|
453
|
-
return this.readForm(req, (body) => this.adminAction(res, A0, body, ip));
|
|
709
|
+
return this.readForm(req, res, (body) => this.adminAction(res, A0, body, ip));
|
|
454
710
|
}
|
|
455
711
|
if (url.pathname === "/admin/space") {
|
|
456
712
|
if (!authed)
|
|
@@ -469,7 +725,7 @@ export class Relay {
|
|
|
469
725
|
if (url.pathname === "/auth/signup" && req.method === "POST") {
|
|
470
726
|
if (!this.authRateOk(ip))
|
|
471
727
|
return this.json(res, 429, { error: "too many attempts from your network — wait a minute and try again" });
|
|
472
|
-
return this.readJson(req, async (b) => {
|
|
728
|
+
return this.readJson(req, res, async (b) => {
|
|
473
729
|
if (!b || typeof b.email !== "string" || typeof b.username !== "string" || typeof b.pubkey !== "string") {
|
|
474
730
|
return this.json(res, 400, { error: "email, username and pubkey are required" });
|
|
475
731
|
}
|
|
@@ -477,63 +733,74 @@ export class Relay {
|
|
|
477
733
|
return this.json(res, 400, { error: "that does not look like an email address" });
|
|
478
734
|
if (!validUsername(normalizeUsername(b.username)))
|
|
479
735
|
return this.json(res, 400, { error: "username must be 2–32 chars: letters, digits, . _ - (not at the ends)" });
|
|
736
|
+
const over = this.emailRefusal("signup", b.email, ip);
|
|
737
|
+
if (over)
|
|
738
|
+
return this.json(res, 429, { error: over, reason: "email_budget" });
|
|
480
739
|
const started = A.startVerification(b.email, "signup", { username: b.username, pubkey: b.pubkey, label: String(b.label ?? "a device").slice(0, 60) });
|
|
481
740
|
if (!started.ok) {
|
|
482
741
|
if (started.reason === "blocked")
|
|
483
742
|
return this.json(res, 403, { error: "this email cannot sign up on this relay", reason: "blocked" });
|
|
743
|
+
if (started.reason === "locked")
|
|
744
|
+
return this.json(res, 429, { error: LOCKED_MSG, reason: started.reason });
|
|
484
745
|
const msg = started.reason === "too_soon" ? "a code was just sent — check your email, or wait a minute to resend"
|
|
485
|
-
: started.reason === "
|
|
486
|
-
: started.reason === "
|
|
487
|
-
: started.reason === "
|
|
746
|
+
: started.reason === "username_taken" ? "that username is taken"
|
|
747
|
+
: started.reason === "email_taken" ? "that email already has an account — use `pantheon login` from this machine instead"
|
|
748
|
+
: started.reason === "device_other_account" ? DEVICE_OTHER_ACCOUNT
|
|
488
749
|
: "check the email and username";
|
|
489
750
|
return this.json(res, 409, { error: msg, reason: started.reason });
|
|
490
751
|
}
|
|
491
752
|
A.audit({ event: "signup", target: normalizeUsername(b.username), ip });
|
|
492
|
-
if (!this.emailBudgetOk())
|
|
493
|
-
return this.json(res, 429, { error: "the relay is sending too many codes right now — try again shortly" });
|
|
494
753
|
const sent = await this.email.sendCode(b.email, started.code, "signup");
|
|
495
754
|
if (sent)
|
|
496
|
-
this.
|
|
755
|
+
this.noteEmailSent("signup", b.email, ip);
|
|
497
756
|
return this.json(res, 200, { ok: true, sent, via: this.email.kind });
|
|
498
757
|
});
|
|
499
758
|
}
|
|
500
759
|
if (url.pathname === "/auth/login" && req.method === "POST") {
|
|
501
760
|
if (!this.authRateOk(ip))
|
|
502
761
|
return this.json(res, 429, { error: "too many attempts from your network — wait a minute and try again" });
|
|
503
|
-
return this.readJson(req, async (b) => {
|
|
762
|
+
return this.readJson(req, res, async (b) => {
|
|
504
763
|
if (!b || typeof b.email !== "string" || typeof b.pubkey !== "string")
|
|
505
764
|
return this.json(res, 400, { error: "email and pubkey are required" });
|
|
765
|
+
const own = A.isDeviceOf(b.email, b.pubkey);
|
|
766
|
+
const over = this.emailRefusal("login", b.email, ip, b.pubkey, own);
|
|
767
|
+
if (over)
|
|
768
|
+
return this.json(res, 429, { error: over, reason: "email_budget" });
|
|
506
769
|
const started = A.startVerification(b.email, "login", { pubkey: b.pubkey, label: String(b.label ?? "a device").slice(0, 60) });
|
|
507
770
|
if (!started.ok && started.reason === "blocked")
|
|
508
771
|
return this.json(res, 403, { error: "this email cannot sign in on this relay", reason: "blocked" });
|
|
772
|
+
if (!started.ok && started.reason === "device_other_account")
|
|
773
|
+
return this.json(res, 409, { error: DEVICE_OTHER_ACCOUNT, reason: started.reason });
|
|
509
774
|
if (!started.ok) {
|
|
510
|
-
const code = started.reason === "too_soon" || started.reason === "
|
|
511
|
-
const msg = started.reason === "too_soon" ? "a code was just sent — check your email, or wait a minute" : started.reason === "
|
|
775
|
+
const code = started.reason === "too_soon" || started.reason === "locked" ? 429 : 404;
|
|
776
|
+
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";
|
|
512
777
|
return this.json(res, code, { error: msg, reason: started.reason });
|
|
513
778
|
}
|
|
514
|
-
if (!this.emailBudgetOk())
|
|
515
|
-
return this.json(res, 429, { error: "the relay is sending too many codes right now — try again shortly" });
|
|
516
779
|
const sent = await this.email.sendCode(b.email, started.code, "login");
|
|
517
780
|
if (sent)
|
|
518
|
-
this.
|
|
781
|
+
this.noteEmailSent("login", b.email, ip, b.pubkey, own);
|
|
519
782
|
return this.json(res, 200, { ok: true, sent, via: this.email.kind });
|
|
520
783
|
});
|
|
521
784
|
}
|
|
522
785
|
if (url.pathname === "/auth/verify" && req.method === "POST") {
|
|
523
786
|
if (!this.authRateOk(ip))
|
|
524
787
|
return this.json(res, 429, { error: "too many attempts from your network — wait a minute and try again" });
|
|
525
|
-
return this.readJson(req, (b) => {
|
|
788
|
+
return this.readJson(req, res, (b) => {
|
|
526
789
|
if (!b || typeof b.email !== "string" || typeof b.code !== "string")
|
|
527
790
|
return this.json(res, 400, { error: "email and code are required" });
|
|
528
|
-
const r = A.verify(b.email, b.code.trim(), ip, typeof b.pubkey === "string" ? b.pubkey : undefined);
|
|
791
|
+
const r = A.verify(b.email, b.code.trim(), ip, typeof b.pubkey === "string" ? b.pubkey : undefined, this.localTrusted(ip) ? null : netOf(ip));
|
|
529
792
|
if (!r.ok) {
|
|
530
793
|
const msg = r.reason === "expired" ? "that code has expired — request a new one"
|
|
531
794
|
: r.reason === "other_device" ? "that code was requested from a different device — request a new one from this one"
|
|
532
795
|
: r.reason === "suspended" ? "this account is suspended — contact the relay operator"
|
|
533
796
|
: r.reason === "too_many_attempts" ? "too many wrong tries — request a new code"
|
|
534
|
-
: r.reason === "
|
|
535
|
-
: "
|
|
536
|
-
|
|
797
|
+
: r.reason === "locked" ? LOCKED_MSG
|
|
798
|
+
: 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"
|
|
799
|
+
: r.reason === "device_other_account" ? DEVICE_OTHER_ACCOUNT
|
|
800
|
+
: r.reason === "no_pending" ? "no code is pending for that email — run signup or login first"
|
|
801
|
+
: r.tries_left === 0 ? "that code is not right, and it was the last try — request a new code"
|
|
802
|
+
: `that code is not right — ${r.tries_left ?? "a few"} ${r.tries_left === 1 ? "try" : "tries"} left on it`;
|
|
803
|
+
return this.json(res, 400, { error: msg, reason: r.reason, ...(r.tries_left !== undefined ? { tries_left: r.tries_left } : {}) });
|
|
537
804
|
}
|
|
538
805
|
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 });
|
|
539
806
|
});
|
|
@@ -558,8 +825,10 @@ export class Relay {
|
|
|
558
825
|
const hit = A.revoke(who.account.id, kind, id, ip);
|
|
559
826
|
for (const sid of hit.sessionIds)
|
|
560
827
|
this.dropSessionEverywhere(sid);
|
|
561
|
-
if (hit.deviceId)
|
|
828
|
+
if (hit.deviceId) {
|
|
562
829
|
this.dropDeviceSockets(who.account.id, hit.deviceId);
|
|
830
|
+
this.noteAccountRemoval(who.account.username);
|
|
831
|
+
}
|
|
563
832
|
return this.json(res, 200, { ok: true, revoked: hit.sessionIds.length, device_removed: !!hit.deviceId });
|
|
564
833
|
}
|
|
565
834
|
if (url.pathname === "/account/delete" && req.method === "POST") {
|
|
@@ -570,6 +839,7 @@ export class Relay {
|
|
|
570
839
|
A.deleteAccount(who.account.id, ip);
|
|
571
840
|
for (const s of sessions)
|
|
572
841
|
this.dropSessionEverywhere(s.id);
|
|
842
|
+
this.noteAccountRemoval(who.account.username);
|
|
573
843
|
return this.json(res, 200, { ok: true });
|
|
574
844
|
}
|
|
575
845
|
if (url.pathname === "/audit" && req.method === "GET") {
|
|
@@ -578,7 +848,7 @@ export class Relay {
|
|
|
578
848
|
return this.json(res, 403, { error: "this request must be signed by a device on the account" });
|
|
579
849
|
const space = url.searchParams.get("space") ?? undefined;
|
|
580
850
|
if (space) {
|
|
581
|
-
const sp = this.
|
|
851
|
+
const sp = this.live(space);
|
|
582
852
|
const member = sp?.allow.get(who.account.username);
|
|
583
853
|
if (!sp || !member?.admitted)
|
|
584
854
|
return this.json(res, 403, { error: "you are not a member of that space" });
|
|
@@ -646,11 +916,47 @@ export class Relay {
|
|
|
646
916
|
}
|
|
647
917
|
}
|
|
648
918
|
}
|
|
649
|
-
|
|
650
|
-
const now = Date.now();
|
|
651
|
-
|
|
919
|
+
emailRefusal(kind, email, ip, pubkey = "", own = false) {
|
|
920
|
+
const now = Date.now(), hour = 3_600_000;
|
|
921
|
+
const live = (v) => v.filter((t) => now - t < hour);
|
|
922
|
+
this.emailSends = this.emailSends.filter((s) => now - s.at < hour);
|
|
652
923
|
const cap = Number(process.env.PANTHEON_EMAIL_MAX_PER_HOUR) || 200;
|
|
653
|
-
|
|
924
|
+
const reserved = Math.min(cap - 1, Math.max(0, Number(process.env.PANTHEON_EMAIL_RESERVED_PER_HOUR ?? Math.floor(cap / 4)) || 0));
|
|
925
|
+
const perAddress = Number(process.env.PANTHEON_EMAIL_MAX_PER_ADDRESS) || 5;
|
|
926
|
+
const perNet = Number(process.env.PANTHEON_EMAIL_MAX_PER_NETWORK) || 20;
|
|
927
|
+
if (live(this.emailSendsTo.get(this.emailBucket(email, pubkey, own)) ?? []).length >= perAddress) {
|
|
928
|
+
return own ? "this device has asked for too many codes in the last hour — use the latest one, or try again in an hour"
|
|
929
|
+
: "too many codes were sent to this address in the last hour — use the latest one, or try again in an hour";
|
|
930
|
+
}
|
|
931
|
+
if (!own && !this.localTrusted(ip) && live(this.emailSendsFrom.get(netOf(ip)) ?? []).length >= perNet)
|
|
932
|
+
return "too many codes were requested from your network in the last hour — try again later";
|
|
933
|
+
if (this.emailSends.length >= cap)
|
|
934
|
+
return "the relay is sending too many codes right now — try again shortly";
|
|
935
|
+
if (kind === "signup" && this.emailSends.filter((s) => s.signup).length >= cap - reserved)
|
|
936
|
+
return "the relay is taking too many new sign-ups right now — try again shortly";
|
|
937
|
+
return null;
|
|
938
|
+
}
|
|
939
|
+
emailBucket(email, pubkey, own) {
|
|
940
|
+
const to = email.trim().toLowerCase();
|
|
941
|
+
return own ? `${to}\u0000${pubkey}` : to;
|
|
942
|
+
}
|
|
943
|
+
noteEmailSent(kind, email, ip, pubkey = "", own = false) {
|
|
944
|
+
const now = Date.now(), hour = 3_600_000;
|
|
945
|
+
this.emailSends.push({ at: now, signup: kind === "signup" });
|
|
946
|
+
const bump = (m, k) => {
|
|
947
|
+
m.set(k, [...(m.get(k) ?? []).filter((t) => now - t < hour), now]);
|
|
948
|
+
if (m.size > 10_000)
|
|
949
|
+
for (const [key, v] of m) {
|
|
950
|
+
const keep = v.filter((t) => now - t < hour);
|
|
951
|
+
if (keep.length)
|
|
952
|
+
m.set(key, keep);
|
|
953
|
+
else
|
|
954
|
+
m.delete(key);
|
|
955
|
+
}
|
|
956
|
+
};
|
|
957
|
+
bump(this.emailSendsTo, this.emailBucket(email, pubkey, own));
|
|
958
|
+
if (!own && !this.localTrusted(ip))
|
|
959
|
+
bump(this.emailSendsFrom, netOf(ip));
|
|
654
960
|
}
|
|
655
961
|
authRateOk(ip) {
|
|
656
962
|
if (this.localTrusted(ip))
|
|
@@ -670,41 +976,134 @@ export class Relay {
|
|
|
670
976
|
}
|
|
671
977
|
return recent.length <= 20;
|
|
672
978
|
}
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
979
|
+
readBody(req, res, limit, cb) {
|
|
980
|
+
const HARD = 4 * 1024 * 1024;
|
|
981
|
+
let done = false;
|
|
982
|
+
const tooBig = () => {
|
|
983
|
+
if (done)
|
|
984
|
+
return;
|
|
985
|
+
done = true;
|
|
986
|
+
if (!res.headersSent) {
|
|
987
|
+
res.setHeader("connection", "close");
|
|
988
|
+
this.json(res, 413, { error: `request body too large: the limit here is ${limit} bytes` });
|
|
989
|
+
}
|
|
990
|
+
};
|
|
991
|
+
const declared = Number(req.headers["content-length"]);
|
|
992
|
+
if (Number.isFinite(declared) && declared > HARD) {
|
|
993
|
+
tooBig();
|
|
994
|
+
req.resume();
|
|
995
|
+
res.on("finish", () => req.destroy());
|
|
996
|
+
return;
|
|
679
997
|
}
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
998
|
+
const chunks = [];
|
|
999
|
+
let size = 0, over = false;
|
|
1000
|
+
req.on("data", (c) => {
|
|
1001
|
+
size += c.length;
|
|
1002
|
+
if (size > HARD) {
|
|
1003
|
+
tooBig();
|
|
1004
|
+
res.on("finish", () => req.destroy());
|
|
1005
|
+
return;
|
|
1006
|
+
}
|
|
1007
|
+
if (size > limit) {
|
|
1008
|
+
over = true;
|
|
1009
|
+
chunks.length = 0;
|
|
1010
|
+
return;
|
|
1011
|
+
}
|
|
1012
|
+
if (!over)
|
|
1013
|
+
chunks.push(Buffer.isBuffer(c) ? c : Buffer.from(c));
|
|
1014
|
+
});
|
|
1015
|
+
req.on("end", () => {
|
|
1016
|
+
if (over)
|
|
1017
|
+
return tooBig();
|
|
1018
|
+
if (done)
|
|
1019
|
+
return;
|
|
1020
|
+
done = true;
|
|
1021
|
+
cb(Buffer.concat(chunks).toString("utf8"));
|
|
1022
|
+
});
|
|
1023
|
+
req.on("error", () => { if (done)
|
|
1024
|
+
return; done = true; cb(null); });
|
|
1025
|
+
}
|
|
1026
|
+
settle(res, r) {
|
|
1027
|
+
if (r && typeof r.catch === "function") {
|
|
1028
|
+
r.catch((e) => {
|
|
1029
|
+
this.log(`! http handler failed: ${e?.message ?? e}`);
|
|
1030
|
+
if (isStorageError(e))
|
|
1031
|
+
this.noteStorageFailure("http handler", e);
|
|
1032
|
+
try {
|
|
1033
|
+
if (!res.headersSent)
|
|
1034
|
+
this.json(res, 500, { error: "the relay could not handle that request" });
|
|
1035
|
+
else
|
|
1036
|
+
res.end();
|
|
1037
|
+
}
|
|
1038
|
+
catch { }
|
|
1039
|
+
});
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
readJson(req, res, cb) {
|
|
1043
|
+
this.readBody(req, res, 64_000, (raw) => {
|
|
1044
|
+
let body = null;
|
|
1045
|
+
if (raw !== null) {
|
|
1046
|
+
try {
|
|
1047
|
+
body = raw ? JSON.parse(raw) : {};
|
|
1048
|
+
}
|
|
1049
|
+
catch {
|
|
1050
|
+
body = null;
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
try {
|
|
1054
|
+
this.settle(res, cb(body));
|
|
1055
|
+
}
|
|
1056
|
+
catch (e) {
|
|
1057
|
+
this.settle(res, Promise.reject(e));
|
|
1058
|
+
}
|
|
1059
|
+
});
|
|
684
1060
|
}
|
|
685
1061
|
dropSessionEverywhere(sessionId) {
|
|
1062
|
+
this.authEpoch++;
|
|
686
1063
|
for (const sp of this.spaces.values()) {
|
|
687
1064
|
for (const m of sp.members.values()) {
|
|
688
1065
|
const s = m.sessions.get(sessionId);
|
|
689
1066
|
if (s) {
|
|
690
|
-
|
|
691
|
-
s.ws.close(1008, "SESSION_REVOKED");
|
|
692
|
-
}
|
|
693
|
-
catch { }
|
|
1067
|
+
this.hangUp(s.ws, "SESSION_REVOKED");
|
|
694
1068
|
m.sessions.delete(sessionId);
|
|
695
1069
|
}
|
|
696
1070
|
}
|
|
697
1071
|
}
|
|
698
1072
|
}
|
|
1073
|
+
hangUp(ws, reason) {
|
|
1074
|
+
try {
|
|
1075
|
+
ws.close(1008, reason);
|
|
1076
|
+
}
|
|
1077
|
+
catch { }
|
|
1078
|
+
const t = setTimeout(() => { try {
|
|
1079
|
+
ws.terminate();
|
|
1080
|
+
}
|
|
1081
|
+
catch { } }, 100);
|
|
1082
|
+
t.unref?.();
|
|
1083
|
+
}
|
|
1084
|
+
stillIn(sp, me, sessionId, ws) {
|
|
1085
|
+
if (ws.readyState !== 1)
|
|
1086
|
+
return false;
|
|
1087
|
+
if (sp.deleted || this.spaces.get(spaceKey(sp.name)) !== sp)
|
|
1088
|
+
return false;
|
|
1089
|
+
if (sp.members.get(me.id) !== me || me.sessions.get(sessionId)?.ws !== ws)
|
|
1090
|
+
return false;
|
|
1091
|
+
return !!sp.allow.get(me.id)?.admitted;
|
|
1092
|
+
}
|
|
699
1093
|
dropDeviceSockets(_accountId, _deviceId) {
|
|
700
1094
|
}
|
|
701
|
-
readForm(req, cb) {
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
1095
|
+
readForm(req, res, cb) {
|
|
1096
|
+
this.readBody(req, res, 8_000, (raw) => {
|
|
1097
|
+
const o = {};
|
|
1098
|
+
for (const [k, v] of new URLSearchParams(raw ?? ""))
|
|
1099
|
+
o[k] = v;
|
|
1100
|
+
try {
|
|
1101
|
+
this.settle(res, cb(o));
|
|
1102
|
+
}
|
|
1103
|
+
catch (e) {
|
|
1104
|
+
this.settle(res, Promise.reject(e));
|
|
1105
|
+
}
|
|
1106
|
+
});
|
|
708
1107
|
}
|
|
709
1108
|
adminLoginPage(res, wrong) {
|
|
710
1109
|
res.writeHead(wrong ? 401 : 200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store", "referrer-policy": "no-referrer" });
|
|
@@ -756,7 +1155,7 @@ button:hover{background:#1d4ed8}.err{color:var(--bad);margin-top:12px}
|
|
|
756
1155
|
if (exact(sp.allow))
|
|
757
1156
|
return sp.name;
|
|
758
1157
|
const safeOf = (name) => name.replace(/[^a-z0-9._-]/gi, "_");
|
|
759
|
-
const live = new Set(
|
|
1158
|
+
const live = new Set(this.spaces.keys());
|
|
760
1159
|
let files = [];
|
|
761
1160
|
try {
|
|
762
1161
|
files = readdirSync(this.dataDir);
|
|
@@ -768,7 +1167,7 @@ button:hover{background:#1d4ed8}.err{color:var(--bad);margin-top:12px}
|
|
|
768
1167
|
if (!f.endsWith(".members.json"))
|
|
769
1168
|
continue;
|
|
770
1169
|
const safe = f.slice(0, -".members.json".length);
|
|
771
|
-
if (live.has(safe))
|
|
1170
|
+
if (live.has(safe.toLowerCase()))
|
|
772
1171
|
continue;
|
|
773
1172
|
try {
|
|
774
1173
|
if (!exact(new Allowlist(this.dataDir, safe)))
|
|
@@ -910,28 +1309,27 @@ button:hover{background:#1d4ed8}.err{color:var(--bad);margin-top:12px}
|
|
|
910
1309
|
return { goals, refusals: st.refusals };
|
|
911
1310
|
}
|
|
912
1311
|
deleteSpace(name) {
|
|
913
|
-
const sp = this.spaces.get(name);
|
|
1312
|
+
const sp = this.spaces.get(spaceKey(name));
|
|
914
1313
|
if (sp) {
|
|
915
1314
|
sp.deleted = true;
|
|
916
1315
|
for (const m of sp.members.values())
|
|
917
|
-
for (const s of m.sessions.values())
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
}
|
|
921
|
-
catch { }
|
|
922
|
-
}
|
|
923
|
-
this.spaces.delete(name);
|
|
1316
|
+
for (const s of m.sessions.values())
|
|
1317
|
+
this.hangUp(s.ws, "SPACE_DELETED");
|
|
1318
|
+
this.spaces.delete(spaceKey(name));
|
|
924
1319
|
}
|
|
925
1320
|
setTimeout(() => this.deleteSpaceFiles(name), 600).unref?.();
|
|
926
1321
|
const safe = name.replace(/[^a-z0-9._-]/gi, "_");
|
|
927
1322
|
try {
|
|
928
1323
|
const hash = sp?.allow ? JSON.parse(readFileSync(join(this.dataDir, `${safe}.members.json`), "utf8")).tokenHash ?? null : null;
|
|
929
|
-
|
|
1324
|
+
const earlier = sp?.allow ? sp.allow.tokenHashes().filter((h) => h !== hash) : [];
|
|
1325
|
+
writeFileSync(join(this.dataDir, `${safe}.deleted.json`), JSON.stringify({ name, at: Date.now(), tokenHash: hash, ...(earlier.length ? { retiredTokenHashes: earlier } : {}) }));
|
|
1326
|
+
this.tombstoneFile(name);
|
|
1327
|
+
this.tombstoneStems.set(spaceKey(name), safe);
|
|
930
1328
|
}
|
|
931
1329
|
catch { }
|
|
932
1330
|
const existed = !!sp || [".jsonl", ".state.json", ".members.json"].some((suf) => existsSync(join(this.dataDir, `${safe}${suf}`)));
|
|
933
1331
|
this.deleteSpaceFiles(name);
|
|
934
|
-
this.deletedSpaces.set(name, Date.now());
|
|
1332
|
+
this.deletedSpaces.set(spaceKey(name), Date.now());
|
|
935
1333
|
if (this.deletedSpaces.size > 1000)
|
|
936
1334
|
for (const [k, t] of this.deletedSpaces)
|
|
937
1335
|
if (Date.now() - t > 3_600_000)
|
|
@@ -951,14 +1349,17 @@ button:hover{background:#1d4ed8}.err{color:var(--bad);margin-top:12px}
|
|
|
951
1349
|
return this.json(res, 404, { error: "no such account" });
|
|
952
1350
|
const live = A.sessionsFor(acct.id).filter((s) => !s.revoked_at);
|
|
953
1351
|
A.setStatus(acct.id, act === "suspend" ? "suspended" : "active", ip);
|
|
954
|
-
|
|
1352
|
+
this.authEpoch++;
|
|
1353
|
+
if (act === "suspend") {
|
|
955
1354
|
for (const s of live)
|
|
956
1355
|
this.dropSessionEverywhere(s.id);
|
|
1356
|
+
this.noteAccountRemoval(acct.username);
|
|
1357
|
+
}
|
|
957
1358
|
this.log(`[admin] ${act} ${acct.username} from ${ip}`);
|
|
958
1359
|
return done(`${acct.username} ${act === "suspend" ? "suspended and signed out everywhere" : "reinstated"}`);
|
|
959
1360
|
}
|
|
960
1361
|
case "revoke_invite": {
|
|
961
|
-
const sp = this.spaces.get(id);
|
|
1362
|
+
const sp = this.spaces.get(spaceKey(id));
|
|
962
1363
|
const allow = sp ? sp.allow : new Allowlist(this.dataDir, id.replace(/[^a-z0-9._-]/gi, "_"));
|
|
963
1364
|
const token = (body.token ?? "").trim();
|
|
964
1365
|
const inv = allow.findInvite(token);
|
|
@@ -975,8 +1376,12 @@ button:hover{background:#1d4ed8}.err{color:var(--bad);margin-top:12px}
|
|
|
975
1376
|
const hit = A.adminRevoke(act === "revoke_session" ? "session" : "device", id, ip);
|
|
976
1377
|
for (const sid of hit.sessionIds)
|
|
977
1378
|
this.dropSessionEverywhere(sid);
|
|
978
|
-
if (hit.deviceId && hit.accountId)
|
|
1379
|
+
if (hit.deviceId && hit.accountId) {
|
|
979
1380
|
this.dropDeviceSockets(hit.accountId, hit.deviceId);
|
|
1381
|
+
const owner = A.accountById(hit.accountId);
|
|
1382
|
+
if (owner)
|
|
1383
|
+
this.noteAccountRemoval(owner.username);
|
|
1384
|
+
}
|
|
980
1385
|
return done(hit.sessionIds.length || hit.deviceId ? `revoked ${act === "revoke_session" ? "session" : "device"} ${id}` : `nothing to revoke for ${id}`);
|
|
981
1386
|
}
|
|
982
1387
|
case "block": {
|
|
@@ -1062,28 +1467,213 @@ button:hover{background:#1d4ed8}.err{color:var(--bad);margin-top:12px}
|
|
|
1062
1467
|
default: return this.json(res, 400, { error: `unknown action "${act}"` });
|
|
1063
1468
|
}
|
|
1064
1469
|
}
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
if (live)
|
|
1068
|
-
return secretEq(live.token, token) ? live : null;
|
|
1069
|
-
if (!name || !token || !this.spaceOnDisk(name))
|
|
1470
|
+
readTarget(name, token) {
|
|
1471
|
+
if (!name || !token)
|
|
1070
1472
|
return null;
|
|
1071
|
-
|
|
1473
|
+
const live = this.live(name);
|
|
1474
|
+
if (live) {
|
|
1475
|
+
const st = this.tokenState(live, token);
|
|
1476
|
+
return st ? { live, allow: live.allow, st } : null;
|
|
1477
|
+
}
|
|
1478
|
+
if (this.aliasOf(name) || !this.spaceOnDisk(name))
|
|
1072
1479
|
return null;
|
|
1073
|
-
|
|
1480
|
+
const allow = new Allowlist(this.dataDir, safeName(name));
|
|
1481
|
+
const st = allow.tokenMatches(token) === true ? "current" : allow.tokenRetired(token) ? "retired" : null;
|
|
1482
|
+
return st ? { live: undefined, allow, st } : null;
|
|
1483
|
+
}
|
|
1484
|
+
unsignedRefusal(name, allow, st) {
|
|
1485
|
+
if (this.readsMode === "signed")
|
|
1486
|
+
return "This relay serves only signed reads.";
|
|
1487
|
+
if (allow.removalRecorded())
|
|
1488
|
+
return `"${name}" has removed a member, so it serves only signed reads.`;
|
|
1489
|
+
if (st !== "current")
|
|
1490
|
+
return `"${name}" has a new token since this machine last connected, so it serves this read only signed.`;
|
|
1491
|
+
return null;
|
|
1492
|
+
}
|
|
1493
|
+
upgradeAct(name, seq, goal, why) {
|
|
1494
|
+
return {
|
|
1495
|
+
id: UPGRADE_NOTE_ID, seq, ts: Date.now(), space: name, from: RELAY_MEMBER, act: "NOTE",
|
|
1496
|
+
body: `${UPGRADE_NOTE}\n${why}`, to: null, goal, ref: null, meta: { notice: "upgrade_required" },
|
|
1497
|
+
};
|
|
1498
|
+
}
|
|
1499
|
+
upgradeAnswer(res, path, url, n, error) {
|
|
1500
|
+
const since = url.searchParams.get("since");
|
|
1501
|
+
const seq = since !== null ? Math.max(0, Math.min(n.head, Number(since) || 0)) : n.head;
|
|
1502
|
+
const act = this.upgradeAct(n.name, seq, url.searchParams.get("goal"), n.why);
|
|
1503
|
+
const common = { code: "UPGRADE_REQUIRED", error, note: UPGRADE_NOTE };
|
|
1504
|
+
if (path === "/act")
|
|
1505
|
+
return this.json(res, 200, { act, ...common });
|
|
1506
|
+
if (path === "/obligations")
|
|
1507
|
+
return this.json(res, 200, { obligations: [], closed_goals: [], seq: n.head, worker_online: false, ...common });
|
|
1508
|
+
if (since !== null && Number(since) >= n.head)
|
|
1509
|
+
return this.json(res, 200, { acts: [], head: n.head, ...common });
|
|
1510
|
+
return this.json(res, 200, { acts: [act], head: n.head, ...common });
|
|
1511
|
+
}
|
|
1512
|
+
tokenState(sp, token) {
|
|
1513
|
+
if (!token)
|
|
1514
|
+
return null;
|
|
1515
|
+
if (sp.token && secretEq(sp.token, token))
|
|
1516
|
+
return "current";
|
|
1517
|
+
if (sp.allow.tokenMatches(token) === true) {
|
|
1518
|
+
sp.token = token;
|
|
1519
|
+
this.handTokenToWaiting(sp);
|
|
1520
|
+
return "current";
|
|
1521
|
+
}
|
|
1522
|
+
return sp.allow.tokenRetired(token) ? "retired" : null;
|
|
1523
|
+
}
|
|
1524
|
+
knownToken(sp) {
|
|
1525
|
+
if (!sp.token) {
|
|
1526
|
+
const t = sp.allow.currentTokenFromInvites();
|
|
1527
|
+
if (t)
|
|
1528
|
+
sp.token = t;
|
|
1529
|
+
}
|
|
1530
|
+
return sp.token;
|
|
1531
|
+
}
|
|
1532
|
+
handTokenToWaiting(sp) {
|
|
1533
|
+
for (const m of sp.members.values()) {
|
|
1534
|
+
if (!sp.allow.get(m.id)?.admitted)
|
|
1535
|
+
continue;
|
|
1536
|
+
for (const s of m.sessions.values())
|
|
1537
|
+
if (s.wantsToken) {
|
|
1538
|
+
s.wantsToken = false;
|
|
1539
|
+
this.send(s.ws, { t: "token", token: sp.token });
|
|
1540
|
+
}
|
|
1541
|
+
}
|
|
1542
|
+
}
|
|
1543
|
+
ensureToken(sp) {
|
|
1544
|
+
if (!this.knownToken(sp))
|
|
1545
|
+
this.rotateSpaceToken(sp, "its current token was not in memory");
|
|
1546
|
+
return sp.token;
|
|
1547
|
+
}
|
|
1548
|
+
rotateSpaceToken(sp, why) {
|
|
1549
|
+
const next = randomBytes(16).toString("hex");
|
|
1550
|
+
sp.allow.rotateToken(next);
|
|
1551
|
+
sp.token = next;
|
|
1552
|
+
let told = 0;
|
|
1553
|
+
for (const m of sp.members.values()) {
|
|
1554
|
+
if (!sp.allow.get(m.id)?.admitted)
|
|
1555
|
+
continue;
|
|
1556
|
+
for (const s of m.sessions.values()) {
|
|
1557
|
+
this.send(s.ws, { t: "token", token: next });
|
|
1558
|
+
told++;
|
|
1559
|
+
}
|
|
1560
|
+
}
|
|
1561
|
+
this.log(`[${sp.name}] * space token rotated (${why}); sent to ${told} live session${told === 1 ? "" : "s"}`);
|
|
1562
|
+
}
|
|
1563
|
+
noteAccountRemoval(username) {
|
|
1564
|
+
this.authEpoch++;
|
|
1565
|
+
const u = normalizeUsername(username);
|
|
1566
|
+
const seen = new Set();
|
|
1567
|
+
for (const sp of this.spaces.values()) {
|
|
1568
|
+
seen.add(safeName(sp.name));
|
|
1569
|
+
if (sp.allow.get(u))
|
|
1570
|
+
sp.allow.noteRemoval();
|
|
1571
|
+
}
|
|
1572
|
+
let files = [];
|
|
1573
|
+
try {
|
|
1574
|
+
files = readdirSync(this.dataDir).filter((f) => f.endsWith(".members.json"));
|
|
1575
|
+
}
|
|
1576
|
+
catch { }
|
|
1577
|
+
for (const f of files) {
|
|
1578
|
+
const stem = f.slice(0, -".members.json".length);
|
|
1579
|
+
if (seen.has(stem))
|
|
1580
|
+
continue;
|
|
1581
|
+
try {
|
|
1582
|
+
const al = new Allowlist(this.dataDir, stem);
|
|
1583
|
+
if (al.get(u))
|
|
1584
|
+
al.noteRemoval();
|
|
1585
|
+
}
|
|
1586
|
+
catch { }
|
|
1587
|
+
}
|
|
1588
|
+
}
|
|
1589
|
+
readGate(req, url, ip) {
|
|
1590
|
+
const params = {};
|
|
1591
|
+
for (const [k, v] of url.searchParams) {
|
|
1592
|
+
if (k in params)
|
|
1593
|
+
return { ok: false, status: 400, error: `the query names "${k}" twice` };
|
|
1594
|
+
params[k] = v;
|
|
1595
|
+
}
|
|
1596
|
+
const name = params.space ?? "", token = params.token ?? "";
|
|
1597
|
+
const upgrade = (why) => ({
|
|
1598
|
+
ok: false, status: 401, code: "UPGRADE_REQUIRED",
|
|
1599
|
+
error: `${why} This pantheon is too old to read it: run pantheon upgrade (or npm i -g @join-pantheon/cli), then try again.`,
|
|
1600
|
+
});
|
|
1601
|
+
const target = this.readTarget(name, token);
|
|
1602
|
+
if (params.sig === undefined) {
|
|
1603
|
+
if (!target)
|
|
1604
|
+
return this.readsMode === "signed" ? upgrade("This relay serves only signed reads.") : { ok: false, status: 404, error: "no such space" };
|
|
1605
|
+
const why = this.unsignedRefusal(name, target.allow, target.st);
|
|
1606
|
+
if (why) {
|
|
1607
|
+
const head = target.live ? target.live.ledger.head : ledgerTailSeq(join(this.dataDir, `${safeName(name)}.jsonl`));
|
|
1608
|
+
return { ...upgrade(why), notice: { name, head, why } };
|
|
1609
|
+
}
|
|
1610
|
+
const space = target.live ?? this.getOrCreateSpace(name, token);
|
|
1611
|
+
if (!space)
|
|
1612
|
+
return { ok: false, status: 404, error: "no such space" };
|
|
1613
|
+
return { ok: true, space, reader: null };
|
|
1614
|
+
}
|
|
1615
|
+
if (!target)
|
|
1616
|
+
return { ok: false, status: 404, error: "no such space" };
|
|
1617
|
+
const reader = params.reader ?? "";
|
|
1618
|
+
const ts = Number(params.ts);
|
|
1619
|
+
const refused = { ok: false, status: 403, error: "a read must be signed, recently, by the key of a member admitted to this team" };
|
|
1620
|
+
const pub = reader ? target.allow.pubkeyOf(reader) : null;
|
|
1621
|
+
if (!pub || !Number.isFinite(ts) || Math.abs(Date.now() - ts) >= LIMITS.activityFreshMs)
|
|
1622
|
+
return refused;
|
|
1623
|
+
const { sig, ...signed } = params;
|
|
1624
|
+
if (!verifyRead(pub, req.method ?? "GET", url.pathname, signed, sig))
|
|
1625
|
+
return refused;
|
|
1626
|
+
if (this.requireAccounts && this.accounts && !this.localTrusted(ip)) {
|
|
1627
|
+
const auth = this.accounts.authorize(pub, reader);
|
|
1628
|
+
if (!auth.ok) {
|
|
1629
|
+
return { ok: false, status: 403, code: "NOT_SIGNED_IN", error: auth.reason === "suspended"
|
|
1630
|
+
? "this account has been suspended by the relay operator"
|
|
1631
|
+
: "this device is not signed in to an account that may read here — run pantheon login" };
|
|
1632
|
+
}
|
|
1633
|
+
}
|
|
1634
|
+
const space = target.live ?? this.getOrCreateSpace(name, token, { retired: true });
|
|
1635
|
+
if (!space)
|
|
1636
|
+
return { ok: false, status: 404, error: "no such space" };
|
|
1637
|
+
return { ok: true, space, reader };
|
|
1638
|
+
}
|
|
1639
|
+
tombstoneStems = null;
|
|
1640
|
+
tombstoneFile(name) {
|
|
1641
|
+
if (!this.tombstoneStems) {
|
|
1642
|
+
this.tombstoneStems = new Map();
|
|
1643
|
+
try {
|
|
1644
|
+
for (const f of readdirSync(this.dataDir)) {
|
|
1645
|
+
if (!f.endsWith(".deleted.json"))
|
|
1646
|
+
continue;
|
|
1647
|
+
const stem = f.slice(0, -".deleted.json".length);
|
|
1648
|
+
this.tombstoneStems.set(stem.toLowerCase(), stem);
|
|
1649
|
+
}
|
|
1650
|
+
}
|
|
1651
|
+
catch { }
|
|
1652
|
+
}
|
|
1653
|
+
const stem = this.tombstoneStems.get(spaceKey(name));
|
|
1654
|
+
return stem ? join(this.dataDir, `${stem}.deleted.json`) : null;
|
|
1074
1655
|
}
|
|
1075
1656
|
tombstone(name, _token) {
|
|
1657
|
+
const f = this.tombstoneFile(name);
|
|
1658
|
+
if (!f)
|
|
1659
|
+
return null;
|
|
1076
1660
|
try {
|
|
1077
|
-
return JSON.parse(readFileSync(
|
|
1661
|
+
return JSON.parse(readFileSync(f, "utf8")).at;
|
|
1078
1662
|
}
|
|
1079
1663
|
catch {
|
|
1080
1664
|
return null;
|
|
1081
1665
|
}
|
|
1082
1666
|
}
|
|
1083
1667
|
tombstoneMatches(name, token) {
|
|
1668
|
+
const f = this.tombstoneFile(name);
|
|
1669
|
+
if (!f)
|
|
1670
|
+
return false;
|
|
1084
1671
|
try {
|
|
1085
|
-
const t = JSON.parse(readFileSync(
|
|
1086
|
-
|
|
1672
|
+
const t = JSON.parse(readFileSync(f, "utf8"));
|
|
1673
|
+
if (!token)
|
|
1674
|
+
return false;
|
|
1675
|
+
const h = createHash("sha256").update(token).digest("hex");
|
|
1676
|
+
return (!!t.tokenHash && h === t.tokenHash) || (Array.isArray(t.retiredTokenHashes) && t.retiredTokenHashes.includes(h));
|
|
1087
1677
|
}
|
|
1088
1678
|
catch {
|
|
1089
1679
|
return false;
|
|
@@ -1101,20 +1691,50 @@ button:hover{background:#1d4ed8}.err{color:var(--bad);margin-top:12px}
|
|
|
1101
1691
|
spaceOnDisk(name) {
|
|
1102
1692
|
return existsSync(join(this.dataDir, `${name.replace(/[^a-z0-9._-]/gi, "_")}.members.json`));
|
|
1103
1693
|
}
|
|
1694
|
+
live(name) {
|
|
1695
|
+
const sp = this.spaces.get(spaceKey(name));
|
|
1696
|
+
return sp && sp.name === name ? sp : undefined;
|
|
1697
|
+
}
|
|
1698
|
+
aliasOf(name) {
|
|
1699
|
+
const sp = this.spaces.get(spaceKey(name));
|
|
1700
|
+
if (sp)
|
|
1701
|
+
return sp.name !== name;
|
|
1702
|
+
if (!name || !this.spaceOnDisk(name))
|
|
1703
|
+
return false;
|
|
1704
|
+
const stem = safeName(name);
|
|
1705
|
+
try {
|
|
1706
|
+
if (!readdirSync(this.dataDir).includes(`${stem}.members.json`))
|
|
1707
|
+
return true;
|
|
1708
|
+
}
|
|
1709
|
+
catch { }
|
|
1710
|
+
const recorded = this.recordedName(stem);
|
|
1711
|
+
return recorded !== null && recorded !== name;
|
|
1712
|
+
}
|
|
1713
|
+
recordedName(stem) {
|
|
1714
|
+
try {
|
|
1715
|
+
const n = new Allowlist(this.dataDir, stem).recordedName();
|
|
1716
|
+
if (n)
|
|
1717
|
+
return n;
|
|
1718
|
+
}
|
|
1719
|
+
catch { }
|
|
1720
|
+
return firstLedgerName(join(this.dataDir, `${stem}.jsonl`));
|
|
1721
|
+
}
|
|
1104
1722
|
spacePlan(name) {
|
|
1105
1723
|
if (!this.accounts)
|
|
1106
1724
|
return null;
|
|
1107
1725
|
const owner = this.accounts.spaceOwner(name);
|
|
1108
|
-
if (
|
|
1109
|
-
return
|
|
1110
|
-
|
|
1726
|
+
if (owner)
|
|
1727
|
+
return { name: owner.plan, limits: this.accounts.limitsFor(owner), owner };
|
|
1728
|
+
if (this.accounts.spaceOrphaned(name))
|
|
1729
|
+
return { name: "free", limits: planLimits(this.accounts.plans(), "free"), owner: null };
|
|
1730
|
+
return null;
|
|
1111
1731
|
}
|
|
1112
1732
|
planView(acct, sp) {
|
|
1113
1733
|
const fresh = this.accounts?.accountById(acct.id) ?? acct;
|
|
1114
1734
|
return { name: fresh.plan, limits: this.accounts.limitsFor(fresh), usage: { spaces: this.accounts.spacesOwned(fresh.id).length, members: sp.allow.admittedCount() } };
|
|
1115
1735
|
}
|
|
1116
1736
|
planRefusal(space, plan, target, msg, cid, fail, actor, ip) {
|
|
1117
|
-
this.accounts?.audit({ event: "plan_limit", actor_account: actor ?? plan.owner
|
|
1737
|
+
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 });
|
|
1118
1738
|
this.log(`[${space.name}] ! PLAN_LIMIT (${target}, ${plan.name} plan)`);
|
|
1119
1739
|
fail("PLAN_LIMIT", msg, cid);
|
|
1120
1740
|
}
|
|
@@ -1171,7 +1791,7 @@ button:hover{background:#1d4ed8}.err{color:var(--bad);margin-top:12px}
|
|
|
1171
1791
|
for (const d of devices)
|
|
1172
1792
|
devByAcct.set(d.account_id, (devByAcct.get(d.account_id) ?? 0) + 1);
|
|
1173
1793
|
const emailCap = Number(process.env.PANTHEON_EMAIL_MAX_PER_HOUR) || 200;
|
|
1174
|
-
const emailsHour = this.emailSends.filter((
|
|
1794
|
+
const emailsHour = this.emailSends.filter((s) => Date.now() - s.at < 3_600_000).length;
|
|
1175
1795
|
let snapshots = [];
|
|
1176
1796
|
try {
|
|
1177
1797
|
snapshots = readdirSync(join(this.dataDir, "backups")).filter((f) => f.startsWith("accounts-") && f.endsWith(".db")).sort();
|
|
@@ -1351,8 +1971,8 @@ ${done ? `<div class=done role=status>${esc(done)}</div>` : ""}
|
|
|
1351
1971
|
}
|
|
1352
1972
|
adminSpace(res, url, name) {
|
|
1353
1973
|
const safe = name.replace(/[^a-z0-9._-]/gi, "_");
|
|
1354
|
-
const sp = this.spaces.get(name) ?? null;
|
|
1355
|
-
const tomb = this.deletedSpaces.get(name);
|
|
1974
|
+
const sp = this.spaces.get(spaceKey(name)) ?? null;
|
|
1975
|
+
const tomb = this.deletedSpaces.get(spaceKey(name));
|
|
1356
1976
|
const onDisk = [".jsonl", ".state.json", ".members.json"].some((suf) => existsSync(join(this.dataDir, `${safe}${suf}`)));
|
|
1357
1977
|
if (!name || (!sp && (!onDisk || (tomb && Date.now() - tomb < 3_600_000))))
|
|
1358
1978
|
return this.json(res, 404, { error: `no space "${name}" on this relay` });
|
|
@@ -1534,7 +2154,12 @@ ${url.searchParams.get("done") ? `<div class=done role=status>${esc(url.searchPa
|
|
|
1534
2154
|
return void res.end(JSON.stringify(data, null, 2));
|
|
1535
2155
|
}
|
|
1536
2156
|
const cols = data.length ? Object.keys(data[0]) : [];
|
|
1537
|
-
const cell = (v) => {
|
|
2157
|
+
const cell = (v) => {
|
|
2158
|
+
let s = String(v ?? "");
|
|
2159
|
+
if (/^[=+\-@\t\r]/.test(s))
|
|
2160
|
+
s = `'${s}`;
|
|
2161
|
+
return /[",\n\r]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
|
|
2162
|
+
};
|
|
1538
2163
|
const csv = [cols.join(","), ...data.map((r) => cols.map((k) => cell(r[k])).join(","))].join("\n");
|
|
1539
2164
|
res.writeHead(200, { "content-type": "text/csv", "content-disposition": `attachment; filename="${table}.csv"` });
|
|
1540
2165
|
res.end(csv);
|
|
@@ -1599,22 +2224,59 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
1599
2224
|
const touch = () => {
|
|
1600
2225
|
if (dbSession && Date.now() - lastTouch > 60_000) {
|
|
1601
2226
|
lastTouch = Date.now();
|
|
1602
|
-
|
|
2227
|
+
try {
|
|
2228
|
+
this.accounts?.touchSession(dbSession);
|
|
2229
|
+
}
|
|
2230
|
+
catch (e) {
|
|
2231
|
+
this.noteStorageFailure("session touch", e);
|
|
2232
|
+
}
|
|
1603
2233
|
}
|
|
1604
2234
|
};
|
|
1605
2235
|
let sessionId = "";
|
|
1606
2236
|
let role = "interactive";
|
|
2237
|
+
let myPubkey = "";
|
|
2238
|
+
let authEpoch = -1, authOk = true;
|
|
1607
2239
|
let lastMsg = "";
|
|
1608
2240
|
let alive = true;
|
|
1609
2241
|
const nonce = newNonce();
|
|
2242
|
+
const accountStillOk = () => {
|
|
2243
|
+
if (!acct || !this.accounts)
|
|
2244
|
+
return true;
|
|
2245
|
+
if (authEpoch !== this.authEpoch) {
|
|
2246
|
+
authEpoch = this.authEpoch;
|
|
2247
|
+
try {
|
|
2248
|
+
authOk = this.accounts.authorize(myPubkey, me?.id ?? "").ok;
|
|
2249
|
+
}
|
|
2250
|
+
catch {
|
|
2251
|
+
authOk = false;
|
|
2252
|
+
}
|
|
2253
|
+
}
|
|
2254
|
+
return authOk;
|
|
2255
|
+
};
|
|
1610
2256
|
ws.on("pong", () => { alive = true; touch(); });
|
|
1611
2257
|
const probe = ws;
|
|
1612
2258
|
probe._alive = () => alive;
|
|
1613
2259
|
probe._clear = () => { alive = false; };
|
|
2260
|
+
let refusalStamps = [];
|
|
2261
|
+
let refusedOut = false;
|
|
1614
2262
|
const fail = (code, message, cid) => {
|
|
2263
|
+
if (refusedOut)
|
|
2264
|
+
return;
|
|
2265
|
+
const now = Date.now();
|
|
2266
|
+
refusalStamps.push(now);
|
|
2267
|
+
if (refusalStamps.length > REFUSAL_BUDGET) {
|
|
2268
|
+
refusalStamps = refusalStamps.filter((t) => now - t < REFUSAL_WINDOW_MS);
|
|
2269
|
+
if (refusalStamps.length > REFUSAL_BUDGET) {
|
|
2270
|
+
refusedOut = true;
|
|
2271
|
+
this.log(`[${space?.name ?? "?"}] ! ${me?.id ?? "an unauthenticated socket"} closed: ${refusalStamps.length} refused requests in ${REFUSAL_WINDOW_MS / 1000}s`);
|
|
2272
|
+
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}).` });
|
|
2273
|
+
this.hangUp(ws, "TOO_MANY_REFUSALS");
|
|
2274
|
+
return;
|
|
2275
|
+
}
|
|
2276
|
+
}
|
|
1615
2277
|
if (cid && space && me && !space.deleted) {
|
|
1616
|
-
noteRefusal(space.store.refusals, { ts:
|
|
1617
|
-
space.store.
|
|
2278
|
+
noteRefusal(space.store.refusals, { ts: now, member: me.id, act: lastMsg || "?", code });
|
|
2279
|
+
space.store.save(space.goals, space.obligations, space.profiles);
|
|
1618
2280
|
}
|
|
1619
2281
|
this.send(ws, { t: "error", code, message, ...(cid ? { cid } : {}) });
|
|
1620
2282
|
};
|
|
@@ -1640,6 +2302,9 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
1640
2302
|
ws.on("close", clearHelloTimer);
|
|
1641
2303
|
this.send(ws, { t: "challenge", nonce, v: PROTOCOL_VERSION });
|
|
1642
2304
|
ws.on("message", (raw) => {
|
|
2305
|
+
if (refusedOut)
|
|
2306
|
+
return;
|
|
2307
|
+
let reqCid;
|
|
1643
2308
|
try {
|
|
1644
2309
|
let msg;
|
|
1645
2310
|
try {
|
|
@@ -1648,6 +2313,8 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
1648
2313
|
catch {
|
|
1649
2314
|
return fail("BAD_JSON", "message was not JSON");
|
|
1650
2315
|
}
|
|
2316
|
+
if (typeof msg?.cid === "string")
|
|
2317
|
+
reqCid = (msg.cid).slice(0, 64);
|
|
1651
2318
|
const parsed = Inbound.safeParse(msg);
|
|
1652
2319
|
if (!parsed.success) {
|
|
1653
2320
|
const cid = typeof msg?.cid === "string"
|
|
@@ -1672,26 +2339,32 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
1672
2339
|
const gated = this.requireAccounts && !!this.accounts && !this.localTrusted(ip);
|
|
1673
2340
|
const pre = gated ? this.accounts.authorize(m.pubkey, m.member) : null;
|
|
1674
2341
|
let sp;
|
|
2342
|
+
let staleToken = false;
|
|
1675
2343
|
if (m.enroll) {
|
|
1676
|
-
sp = this.
|
|
2344
|
+
sp = this.live(m.space) ?? null;
|
|
2345
|
+
if (!sp && this.aliasOf(m.space))
|
|
2346
|
+
return reject("NO_SUCH_SPACE", `no space "${m.space}" on this relay`);
|
|
1677
2347
|
if (!sp && this.spaceOnDisk(m.space)) {
|
|
1678
2348
|
const inv = new Allowlist(this.dataDir, safeName(m.space)).findInvite(m.enroll);
|
|
1679
2349
|
if (inv?.token === m.enroll && inv.space_token)
|
|
1680
|
-
sp = this.getOrCreateSpace(m.space, inv.space_token);
|
|
2350
|
+
sp = this.getOrCreateSpace(m.space, inv.space_token, { claim: "invite", retired: true });
|
|
1681
2351
|
}
|
|
1682
2352
|
if (!sp)
|
|
1683
2353
|
return reject("NO_SUCH_SPACE", `no space "${m.space}" on this relay`);
|
|
1684
2354
|
}
|
|
1685
2355
|
else {
|
|
1686
|
-
const tomb = this.deletedSpaces.get(m.space) ?? this.tombstone(m.space, m.token ?? "");
|
|
2356
|
+
const tomb = this.deletedSpaces.get(spaceKey(m.space)) ?? this.tombstone(m.space, m.token ?? "");
|
|
1687
2357
|
if (tomb && Date.now() - tomb < 3_600_000 || (tomb && this.tombstoneMatches(m.space, m.token ?? ""))) {
|
|
1688
2358
|
return reject("SPACE_DELETED", `the space "${m.space}" was removed by the relay operator. Start a new one with a different name, or contact them.`);
|
|
1689
2359
|
}
|
|
1690
|
-
|
|
2360
|
+
if (this.aliasOf(m.space)) {
|
|
2361
|
+
return reject("NAME_TAKEN", `a team with a name too close to "${m.space}" already exists here — use its exact name, or pick another`);
|
|
2362
|
+
}
|
|
2363
|
+
const isNew = !this.live(m.space) && !this.spaceOnDisk(m.space);
|
|
1691
2364
|
if (isNew && this.nameCollides(m.space)) {
|
|
1692
2365
|
return reject("NAME_TAKEN", `a team with a name too close to "${m.space}" already exists here — pick another name`);
|
|
1693
2366
|
}
|
|
1694
|
-
if (!this.
|
|
2367
|
+
if (!this.live(m.space)) {
|
|
1695
2368
|
const denied = this.mayCreateSpace(ip);
|
|
1696
2369
|
if (denied)
|
|
1697
2370
|
return reject(denied.code, denied.message);
|
|
@@ -1710,9 +2383,19 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
1710
2383
|
return reject("PLAN_LIMIT", msg);
|
|
1711
2384
|
}
|
|
1712
2385
|
}
|
|
1713
|
-
|
|
2386
|
+
if (!this.live(m.space) && this.unboundOnDisk(safeName(m.space))) {
|
|
2387
|
+
const e = this.spaceOnDisk(m.space) ? new Allowlist(this.dataDir, safeName(m.space)).get(m.member) : undefined;
|
|
2388
|
+
if (!e?.admitted || e.publicKey !== m.pubkey) {
|
|
2389
|
+
this.unbound.add(safeName(m.space));
|
|
2390
|
+
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`);
|
|
2391
|
+
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. ` +
|
|
2392
|
+
`If you are one, connect with your own identity; otherwise ask a member of "${m.space}" for an invite, or the relay operator.`);
|
|
2393
|
+
}
|
|
2394
|
+
}
|
|
2395
|
+
sp = this.getOrCreateSpace(m.space, m.token, { claim: { member: m.member, pubkey: m.pubkey }, retired: true });
|
|
1714
2396
|
if (!sp)
|
|
1715
2397
|
return reject("BAD_TOKEN", "wrong token for this space");
|
|
2398
|
+
staleToken = this.tokenState(sp, m.token) === "retired";
|
|
1716
2399
|
if (isNew) {
|
|
1717
2400
|
this.noteSpaceCreate(ip);
|
|
1718
2401
|
this.accounts?.recordSpace(sp.name, pre?.ok ? pre.account.id : null);
|
|
@@ -1741,20 +2424,38 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
1741
2424
|
const plan = this.spacePlan(sp.name);
|
|
1742
2425
|
if (plan && !sp.allow.get(m.member)?.admitted && sp.allow.admittedCount() >= plan.limits.members) {
|
|
1743
2426
|
this.accounts?.audit({ event: "plan_limit", actor_account: acct?.id ?? null, space: sp.name, target: "join", detail: `${plan.name}: ${sp.allow.admittedCount()}/${plan.limits.members} people`, ip });
|
|
1744
|
-
return reject("PLAN_LIMIT", `"${sp.name}" is full: its owner's ${plan.name} plan allows ${fmtLimit(plan.limits.members)} people. Ask ${plan.owner.username} to upgrade at https://www.joinpantheon.network`);
|
|
2427
|
+
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."}`);
|
|
1745
2428
|
}
|
|
1746
2429
|
const r = sp.allow.redeem(m.enroll, m.member, m.pubkey);
|
|
1747
2430
|
if (!r.ok) {
|
|
1748
2431
|
this.log(`[${sp.name}] ! invite refused for ${m.member}: ${r.why}`);
|
|
1749
2432
|
return reject("BAD_INVITE", r.fix ? `${r.why}\n${r.fix}` : r.why);
|
|
1750
2433
|
}
|
|
1751
|
-
enrolled = sp
|
|
2434
|
+
enrolled = this.ensureToken(sp);
|
|
1752
2435
|
this.log(`[${sp.name}] * ENROLLED ${m.member} via invite from ${r.entry.admitted_by}${r.entry.may_wake ? " (may wake)" : ""}`);
|
|
1753
2436
|
}
|
|
2437
|
+
if (staleToken && !enrolled) {
|
|
2438
|
+
const e = sp.allow.get(m.member);
|
|
2439
|
+
if (e && e.publicKey === m.pubkey && !e.admitted && e.revoked_at) {
|
|
2440
|
+
this.log(`[${sp.name}] ! REFUSED ${m.member}: removed (earlier token)`);
|
|
2441
|
+
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.`);
|
|
2442
|
+
}
|
|
2443
|
+
if (!e || e.publicKey !== m.pubkey || !e.admitted) {
|
|
2444
|
+
this.log(`[${sp.name}] ! REFUSED ${m.member}: an earlier token, and not an admitted member with this key`);
|
|
2445
|
+
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.");
|
|
2446
|
+
}
|
|
2447
|
+
}
|
|
2448
|
+
if (m.member.toLowerCase() === RELAY_MEMBER && !sp.allow.get(m.member)) {
|
|
2449
|
+
return reject("RESERVED_NAME", `"${m.member}" is the name the relay itself speaks as — choose another name`);
|
|
2450
|
+
}
|
|
1754
2451
|
const verdict = enrolled ? "ok" : sp.allow.present(m.member, m.pubkey);
|
|
2452
|
+
if (verdict === "pending-full") {
|
|
2453
|
+
this.log(`[${sp.name}] ! REFUSED ${m.member}: too many names already waiting to be admitted`);
|
|
2454
|
+
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.`);
|
|
2455
|
+
}
|
|
1755
2456
|
if (verdict === "key-mismatch") {
|
|
1756
2457
|
this.log(`[${sp.name}] ! REFUSED ${m.member}: key mismatch (${fingerprint(m.pubkey)})`);
|
|
1757
|
-
return reject("KEY_MISMATCH", `"${m.member}" is already registered here with a different key
|
|
2458
|
+
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.`);
|
|
1758
2459
|
}
|
|
1759
2460
|
if (verdict === "revoked") {
|
|
1760
2461
|
const e = sp.allow.get(m.member);
|
|
@@ -1786,6 +2487,7 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
1786
2487
|
}
|
|
1787
2488
|
}
|
|
1788
2489
|
space = sp;
|
|
2490
|
+
myPubkey = m.pubkey;
|
|
1789
2491
|
clearHelloTimer();
|
|
1790
2492
|
sessionId = acctSession ?? this.mintSession();
|
|
1791
2493
|
role = m.role;
|
|
@@ -1828,8 +2530,13 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
1828
2530
|
obligations: [...sp.obligations.values()], members: this.members(sp), profiles: [...sp.profiles.values()],
|
|
1829
2531
|
space_id: sp.allow.spaceId(), repos: sp.allow.repoList(),
|
|
1830
2532
|
...(acct ? { plan: this.planView(acct, sp) } : {}),
|
|
1831
|
-
...(enrolled ? { token: enrolled } : {}),
|
|
2533
|
+
...(enrolled ? { token: enrolled } : staleToken && this.knownToken(sp) ? { token: sp.token } : {}),
|
|
1832
2534
|
});
|
|
2535
|
+
if (staleToken && !enrolled && !sp.token) {
|
|
2536
|
+
const mine = me.sessions.get(sessionId);
|
|
2537
|
+
if (mine)
|
|
2538
|
+
mine.wantsToken = true;
|
|
2539
|
+
}
|
|
1833
2540
|
let from = m.from_seq && m.from_seq > 0 ? m.from_seq : sp.ledger.head;
|
|
1834
2541
|
for (let page = sp.ledger.since(from); page.length; page = sp.ledger.since(from)) {
|
|
1835
2542
|
for (const r of page) {
|
|
@@ -1839,6 +2546,11 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
1839
2546
|
}
|
|
1840
2547
|
from = page[page.length - 1].seq;
|
|
1841
2548
|
}
|
|
2549
|
+
if (versionBelow(m.pkg, "0.30.0")) {
|
|
2550
|
+
const why = this.unsignedRefusal(sp.name, sp.allow, "current");
|
|
2551
|
+
if (why)
|
|
2552
|
+
this.send(ws, { t: "act", act: this.upgradeAct(sp.name, sp.ledger.head, null, why) });
|
|
2553
|
+
}
|
|
1842
2554
|
if (firstSession && m.role !== "cli") {
|
|
1843
2555
|
this.commit(sp, { from: me.id, act: "JOIN", body: `${me.display} online (${me.agent})`, to: null, goal: null, ref: null });
|
|
1844
2556
|
}
|
|
@@ -1848,6 +2560,11 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
1848
2560
|
}
|
|
1849
2561
|
if (!space || !me)
|
|
1850
2562
|
return reject("NOT_JOINED", "say hello first");
|
|
2563
|
+
if (!this.stillIn(space, me, sessionId, ws) || (acct && !accountStillOk())) {
|
|
2564
|
+
if (ws.readyState === 1)
|
|
2565
|
+
this.hangUp(ws, "REVOKED");
|
|
2566
|
+
return;
|
|
2567
|
+
}
|
|
1851
2568
|
if (m.t === "publish_profile") {
|
|
1852
2569
|
if (m.profile.member !== me.id) {
|
|
1853
2570
|
return fail("PROFILE_NOT_YOURS", `you may publish only the profile for ${me.id}`, m.cid);
|
|
@@ -1893,19 +2610,16 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
1893
2610
|
return fail("FOUNDER_ONLY", `only ${founder}, who founded "${space.name}", can remove a member`, m.cid);
|
|
1894
2611
|
if (m.member === founder)
|
|
1895
2612
|
return fail("FOUNDER_STAYS", "the founder cannot be removed from their own team", m.cid);
|
|
1896
|
-
|
|
1897
|
-
space.allow.forget(m.member);
|
|
1898
|
-
else
|
|
1899
|
-
space.allow.revoke(m.member, me.id);
|
|
2613
|
+
const removed = m.forget ? space.allow.forget(m.member) : space.allow.revoke(m.member, me.id);
|
|
1900
2614
|
if (space.profiles.delete(m.member))
|
|
1901
2615
|
this.broadcast(space, { t: "profiles", profiles: [...space.profiles.values()] });
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
catch { }
|
|
1907
|
-
}
|
|
2616
|
+
const gone = space.members.get(m.member);
|
|
2617
|
+
for (const s of gone?.sessions.values() ?? [])
|
|
2618
|
+
this.hangUp(s.ws, "REVOKED");
|
|
2619
|
+
gone?.sessions.clear();
|
|
1908
2620
|
space.members.delete(m.member);
|
|
2621
|
+
if (removed && this.rotateOnRemove)
|
|
2622
|
+
this.rotateSpaceToken(space, `${m.member} ${m.forget ? "forgotten" : "removed"} by ${me.id}`);
|
|
1909
2623
|
this.log(`[${space.name}] - ${m.forget ? "FORGOT" : "REVOKED"} ${m.member} by ${me.id}`);
|
|
1910
2624
|
}
|
|
1911
2625
|
else {
|
|
@@ -1935,7 +2649,15 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
1935
2649
|
this.accounts?.audit({ event: "space_deleted", actor_account: acct?.id ?? null, space: name, detail: `by its founder ${me.id}`, ip });
|
|
1936
2650
|
this.log(`[${name}] * DELETED by its founder ${me.id}`);
|
|
1937
2651
|
this.send(ws, { t: "ack", ...(m.cid ? { cid: m.cid } : {}), id: name, seq: space.ledger.head });
|
|
1938
|
-
setTimeout(() => {
|
|
2652
|
+
setTimeout(() => {
|
|
2653
|
+
try {
|
|
2654
|
+
this.deleteSpace(name);
|
|
2655
|
+
this.accounts?.markSpaceDeleted(name);
|
|
2656
|
+
}
|
|
2657
|
+
catch (e) {
|
|
2658
|
+
this.noteStorageFailure(`[${name}] delete`, e);
|
|
2659
|
+
}
|
|
2660
|
+
}, 50).unref?.();
|
|
1939
2661
|
return;
|
|
1940
2662
|
}
|
|
1941
2663
|
if (m.t === "invite") {
|
|
@@ -1956,7 +2678,7 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
1956
2678
|
if (space.allow.openInvites().length >= LIMITS_SPACE.openInvites)
|
|
1957
2679
|
return fail("TOO_MANY_INVITES", `this team already has ${LIMITS_SPACE.openInvites} unused invites — revoke some: pantheon invite --revoke all`, m.cid);
|
|
1958
2680
|
const token = randomBytes(18).toString("base64url");
|
|
1959
|
-
const inv = space.allow.mintInvite(me.id, m.may_wake, m.ttl_minutes, token, space
|
|
2681
|
+
const inv = space.allow.mintInvite(me.id, m.may_wake, m.ttl_minutes, token, this.ensureToken(space));
|
|
1960
2682
|
this.log(`[${space.name}] * invite minted by ${me.id}, valid ${m.ttl_minutes}m, single use`);
|
|
1961
2683
|
this.send(ws, { t: "invite", url: token, expires_at: inv.expires_at });
|
|
1962
2684
|
return;
|
|
@@ -2095,6 +2817,8 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
2095
2817
|
if (m.t === "open_goal") {
|
|
2096
2818
|
if (!this.writeOk(me))
|
|
2097
2819
|
return fail("RATE_LIMIT", "too many goals opened too fast — wait a few seconds", m.cid);
|
|
2820
|
+
if (this.spaceFull(space))
|
|
2821
|
+
return fail("SPACE_FULL", this.spaceFullMessage(space), m.cid);
|
|
2098
2822
|
const openCount = [...space.goals.values()].filter((g) => g.state !== "CLOSED").length;
|
|
2099
2823
|
if (openCount >= LIMITS_SPACE.openGoals)
|
|
2100
2824
|
return fail("TOO_MANY_GOALS", `this team has ${openCount} goals still open — close some first`, m.cid);
|
|
@@ -2127,11 +2851,15 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
2127
2851
|
body: `goal opened: ${goal.title}\nacceptance: ${goal.acceptance || "(none stated)"}` +
|
|
2128
2852
|
(goal.deadline ? `\ndeadline: ${new Date(goal.deadline).toISOString()}` : "") +
|
|
2129
2853
|
(goal.kind === "worklist" ? `\nkind: worklist — ${me.id}'s own list; peers may read it, nobody owes anything in it` : ""),
|
|
2854
|
+
meta: { goal_members: goal.members, goal_budget: goal.budget },
|
|
2130
2855
|
});
|
|
2131
2856
|
this.send(ws, { t: "ack", ...(m.cid ? { cid: m.cid } : {}), id: goal.id, seq: rec.seq });
|
|
2132
2857
|
return;
|
|
2133
2858
|
}
|
|
2134
|
-
const
|
|
2859
|
+
const storedMeta = m.meta || m.defers_to || m.affects
|
|
2860
|
+
? { ...(m.meta ?? {}), ...(m.defers_to ? { defers_to: m.defers_to } : {}), ...(m.affects ? { affects: m.affects } : {}) }
|
|
2861
|
+
: undefined;
|
|
2862
|
+
const fp = actFingerprint(me.id, m.act, m.body, m.goal ?? null, m.ref ?? null, m.to ?? null, storedMeta);
|
|
2135
2863
|
const prior = space.recent.get(fp);
|
|
2136
2864
|
if (prior && Date.now() - prior.at < LIMITS.dedupeWindowMs) {
|
|
2137
2865
|
this.send(ws, { t: "ack", ...(m.cid ? { cid: m.cid } : {}), id: prior.id, seq: prior.seq });
|
|
@@ -2155,6 +2883,16 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
2155
2883
|
const err = this.validateAct(space, me, m.act, m.goal ?? null, m.ref ?? null, m.to ?? null, m.meta, m.defers_to, m.body.length, m.summary, m.repo, role);
|
|
2156
2884
|
if (err)
|
|
2157
2885
|
return fail(err.code, err.message, m.cid);
|
|
2886
|
+
if (m.act === "PROPOSE" && m.affects?.length) {
|
|
2887
|
+
const g = m.goal ? space.goals.get(m.goal) : undefined;
|
|
2888
|
+
const outside = m.affects.filter((x) => !g?.members.includes(x));
|
|
2889
|
+
if (outside.length) {
|
|
2890
|
+
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);
|
|
2891
|
+
}
|
|
2892
|
+
}
|
|
2893
|
+
const wasFull = this.spaceFull(space);
|
|
2894
|
+
if (wasFull && !this.freesRoom(space, me.id, m.act, m.ref ?? null))
|
|
2895
|
+
return fail("SPACE_FULL", this.spaceFullMessage(space), m.cid);
|
|
2158
2896
|
const rec = this.commit(space, {
|
|
2159
2897
|
from: me.id, session: sessionId, role, act: m.act, body: m.body,
|
|
2160
2898
|
...(m.summary ? { summary: m.summary } : {}),
|
|
@@ -2165,23 +2903,54 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
2165
2903
|
...(m.affects ? { affects: m.affects } : {}) } }
|
|
2166
2904
|
: {}),
|
|
2167
2905
|
});
|
|
2906
|
+
if (wasFull && m.act === "CLOSE") {
|
|
2907
|
+
try {
|
|
2908
|
+
this.compactSpace(space);
|
|
2909
|
+
}
|
|
2910
|
+
catch (e) {
|
|
2911
|
+
this.noteStorageFailure(`[${space.name}] compacting after a close`, e);
|
|
2912
|
+
}
|
|
2913
|
+
}
|
|
2168
2914
|
space.recent.set(fp, { id: rec.id, seq: rec.seq, at: Date.now() });
|
|
2169
2915
|
for (const [k, v] of space.recent)
|
|
2170
2916
|
if (Date.now() - v.at > LIMITS.dedupeWindowMs)
|
|
2171
2917
|
space.recent.delete(k);
|
|
2918
|
+
for (const k of space.recent.keys()) {
|
|
2919
|
+
if (space.recent.size <= RECENT_MAX)
|
|
2920
|
+
break;
|
|
2921
|
+
space.recent.delete(k);
|
|
2922
|
+
}
|
|
2172
2923
|
this.send(ws, { t: "ack", ...(m.cid ? { cid: m.cid } : {}), id: rec.id, seq: rec.seq });
|
|
2173
2924
|
}
|
|
2174
2925
|
catch (e) {
|
|
2175
|
-
|
|
2176
|
-
try {
|
|
2177
|
-
ws.send(JSON.stringify({ t: "error", code
|
|
2926
|
+
const cidPart = reqCid ? { cid: reqCid } : {};
|
|
2927
|
+
const send = (code, message) => { try {
|
|
2928
|
+
ws.send(JSON.stringify({ t: "error", code, message, ...cidPart }));
|
|
2178
2929
|
}
|
|
2179
|
-
catch { }
|
|
2930
|
+
catch { } };
|
|
2931
|
+
if (e instanceof SpaceFull) {
|
|
2932
|
+
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.`);
|
|
2933
|
+
return;
|
|
2934
|
+
}
|
|
2935
|
+
if (e instanceof RosterUnreadable) {
|
|
2936
|
+
this.log(`!! ${e.message} — every member of this space is refused until it is repaired (it was left in place)`);
|
|
2937
|
+
if (!space)
|
|
2938
|
+
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.");
|
|
2939
|
+
send("SPACE_UNREADABLE", "the relay cannot read this team's member list; tell the relay operator.");
|
|
2940
|
+
return;
|
|
2941
|
+
}
|
|
2942
|
+
this.noteStorageFailure(`[${space?.name ?? "?"}] ${lastMsg || "message"}`, e);
|
|
2943
|
+
send("STORAGE", "the relay could not save that — try again in a moment");
|
|
2180
2944
|
}
|
|
2181
2945
|
});
|
|
2182
|
-
|
|
2946
|
+
const closeWork = (code) => {
|
|
2183
2947
|
if (dbSession) {
|
|
2184
|
-
|
|
2948
|
+
try {
|
|
2949
|
+
this.accounts?.endSession(dbSession);
|
|
2950
|
+
}
|
|
2951
|
+
catch (e) {
|
|
2952
|
+
this.noteStorageFailure("session end", e);
|
|
2953
|
+
}
|
|
2185
2954
|
dbSession = null;
|
|
2186
2955
|
}
|
|
2187
2956
|
if (!space || !me)
|
|
@@ -2204,14 +2973,21 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
2204
2973
|
else
|
|
2205
2974
|
v.at = Date.now();
|
|
2206
2975
|
}
|
|
2207
|
-
|
|
2976
|
+
const stillMember = space.members.get(me.id) === me && !!space.allow.get(me.id)?.admitted;
|
|
2977
|
+
if (me.sessions.size === 0 && role !== "cli" && stillMember) {
|
|
2208
2978
|
me.last_seen = Date.now();
|
|
2209
2979
|
this.commit(space, { from: me.id, act: "LEAVE", body: `${me.display} offline`, to: null, goal: null, ref: null });
|
|
2210
2980
|
}
|
|
2211
2981
|
this.pushObligations(space);
|
|
2212
2982
|
this.broadcast(space, { t: "roster", roster: this.roster(space) });
|
|
2213
2983
|
this.log(`[${space.name}] - ${me.id}/${sessionId}`);
|
|
2214
|
-
}
|
|
2984
|
+
};
|
|
2985
|
+
ws.on("close", (code) => { try {
|
|
2986
|
+
closeWork(code);
|
|
2987
|
+
}
|
|
2988
|
+
catch (e) {
|
|
2989
|
+
this.noteStorageFailure(`[${space?.name ?? "?"}] cleanup after a disconnect`, e);
|
|
2990
|
+
} });
|
|
2215
2991
|
ws.on("error", () => { });
|
|
2216
2992
|
}
|
|
2217
2993
|
sweep() {
|
|
@@ -2229,11 +3005,22 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
2229
3005
|
}
|
|
2230
3006
|
const now = Date.now();
|
|
2231
3007
|
for (const sp of this.spaces.values()) {
|
|
2232
|
-
|
|
2233
|
-
this.
|
|
2234
|
-
|
|
3008
|
+
try {
|
|
3009
|
+
const every = this.spaceFull(sp) ? 60_000 : LIMITS.ledgerCompactEveryMs;
|
|
3010
|
+
if (now - (this.compactedAt.get(sp.name) ?? 0) > every)
|
|
3011
|
+
this.compactSpace(sp);
|
|
3012
|
+
this.lapseClaims(sp, now);
|
|
3013
|
+
}
|
|
3014
|
+
catch (e) {
|
|
3015
|
+
this.noteStorageFailure(`[${sp.name}] housekeeping`, e);
|
|
3016
|
+
}
|
|
3017
|
+
}
|
|
3018
|
+
try {
|
|
3019
|
+
this.evictSpaces();
|
|
3020
|
+
}
|
|
3021
|
+
catch (e) {
|
|
3022
|
+
this.noteStorageFailure("evicting idle spaces", e);
|
|
2235
3023
|
}
|
|
2236
|
-
this.evictSpaces();
|
|
2237
3024
|
}
|
|
2238
3025
|
lapseClaims(sp, now) {
|
|
2239
3026
|
const live = new Set();
|
|
@@ -2267,6 +3054,8 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
2267
3054
|
return { code: "NO_SUCH_MEMBER", message: `nobody here is called "${id}" — members: ${[...sp.members.keys()].join(", ")}` };
|
|
2268
3055
|
}
|
|
2269
3056
|
}
|
|
3057
|
+
if (ref === UPGRADE_NOTE_ID)
|
|
3058
|
+
return { code: "UPGRADE_REQUIRED", message: `${UPGRADE_NOTE}. That notice is from the relay, not a teammate, so there is nothing to reply to.` };
|
|
2270
3059
|
if (ref && !sp.ledger.find(ref))
|
|
2271
3060
|
return { code: "BAD_REF", message: `no act with id ${ref}` };
|
|
2272
3061
|
if (meta && JSON.stringify(meta).length > LIMITS_SPACE.metaBytes) {
|
|
@@ -2461,7 +3250,10 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
2461
3250
|
commit(sp, partial) {
|
|
2462
3251
|
if (sp.deleted)
|
|
2463
3252
|
return { ...partial, id: newId("a"), seq: sp.ledger.head, ts: Date.now(), space: sp.name };
|
|
2464
|
-
|
|
3253
|
+
if ((partial.act === "JOIN" || partial.act === "LEAVE") && this.spaceFull(sp)) {
|
|
3254
|
+
return { ...partial, id: newId("a"), seq: sp.ledger.head, ts: Date.now(), space: sp.name };
|
|
3255
|
+
}
|
|
3256
|
+
const rec = sp.ledger.append({ ...partial, id: newId("a"), ts: Date.now(), space: sp.name }, this.maxLedgerBytes + SPACE_HARD_SLACK);
|
|
2465
3257
|
sp.lastTouch = rec.ts;
|
|
2466
3258
|
const m = sp.members.get(rec.from);
|
|
2467
3259
|
if (m) {
|
|
@@ -2469,13 +3261,22 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
2469
3261
|
if (rec.act !== "JOIN" && rec.act !== "LEAVE" && (m.seen_seq ?? 0) < rec.seq - 1)
|
|
2470
3262
|
m.seen_seq = rec.seq - 1;
|
|
2471
3263
|
}
|
|
3264
|
+
this.effects(sp, rec);
|
|
3265
|
+
if (sp.store.rebuilt && rec.act !== "JOIN" && rec.act !== "LEAVE")
|
|
3266
|
+
sp.store.rebuilt = null;
|
|
3267
|
+
this.pushObligations(sp);
|
|
3268
|
+
this.broadcast(sp, { t: "act", act: rec });
|
|
3269
|
+
this.broadcast(sp, { t: "roster", roster: this.roster(sp) });
|
|
3270
|
+
return rec;
|
|
3271
|
+
}
|
|
3272
|
+
effects(sp, rec, ctx) {
|
|
2472
3273
|
if (rec.goal) {
|
|
2473
3274
|
const goal = sp.goals.get(rec.goal);
|
|
2474
3275
|
if (goal) {
|
|
2475
3276
|
goal.acts++;
|
|
2476
3277
|
const run = sp.lastActor.get(rec.goal);
|
|
2477
3278
|
sp.lastActor.set(rec.goal, run && run.member === rec.from ? { member: rec.from, run: run.run + 1 } : { member: rec.from, run: 1 });
|
|
2478
|
-
this.applyObligations(sp, rec, goal);
|
|
3279
|
+
this.applyObligations(sp, rec, goal, ctx);
|
|
2479
3280
|
this.broadcast(sp, { t: "goal", goal });
|
|
2480
3281
|
if (goal.acts >= goal.budget && goal.state === "OPEN") {
|
|
2481
3282
|
goal.state = "STALLED";
|
|
@@ -2486,14 +3287,10 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
2486
3287
|
}
|
|
2487
3288
|
}
|
|
2488
3289
|
else {
|
|
2489
|
-
this.applyObligations(sp, rec, null);
|
|
3290
|
+
this.applyObligations(sp, rec, null, ctx);
|
|
2490
3291
|
}
|
|
2491
|
-
this.pushObligations(sp);
|
|
2492
|
-
this.broadcast(sp, { t: "act", act: rec });
|
|
2493
|
-
this.broadcast(sp, { t: "roster", roster: this.roster(sp) });
|
|
2494
|
-
return rec;
|
|
2495
3292
|
}
|
|
2496
|
-
applyObligations(sp, rec, goal) {
|
|
3293
|
+
applyObligations(sp, rec, goal, ctx) {
|
|
2497
3294
|
const closeFor = (owner, ref) => {
|
|
2498
3295
|
for (const [id, o] of sp.obligations)
|
|
2499
3296
|
if (o.owner === owner && o.ref === ref)
|
|
@@ -2597,8 +3394,9 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
2597
3394
|
const answer = sp.ledger.find(rec.ref);
|
|
2598
3395
|
const asked = answer?.act === "ANSWER" && answer.from === rec.from && answer.ref ? sp.ledger.find(answer.ref) : undefined;
|
|
2599
3396
|
const decider = asked?.act === "ESCALATE" ? asked.from : asked?.act === "HUMAN_REQUIRED" ? asked.to?.[0] : undefined;
|
|
2600
|
-
const retracted = new Set(sp.ledger.all().filter((r) => r.act === "RETRACT" && r.ref).map((r) => r.ref));
|
|
2601
|
-
const
|
|
3397
|
+
const retracted = ctx?.retracted ?? new Set(sp.ledger.all().filter((r) => r.act === "RETRACT" && r.ref).map((r) => r.ref));
|
|
3398
|
+
const answersTo = asked ? (ctx ? ctx.answers.get(asked.id) ?? [] : sp.ledger.all().filter((r) => r.act === "ANSWER" && r.ref === asked.id)) : [];
|
|
3399
|
+
const stillAnswered = answersTo.some((r) => r.from === rec.from && r.id !== answer.id && !retracted.has(r.id));
|
|
2602
3400
|
const alreadyLive = asked && [...sp.obligations.values()].some((o) => o.kind === "HUMAN" && o.ref === asked.id);
|
|
2603
3401
|
if (asked && decider === rec.from && !stillAnswered && !alreadyLive && !retracted.has(asked.id)) {
|
|
2604
3402
|
this.oblige(sp, { kind: "HUMAN", owner: `human:${decider}`, ref: asked.id, goal: asked.goal,
|
|
@@ -2636,6 +3434,23 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
2636
3434
|
const id = newId("o");
|
|
2637
3435
|
sp.obligations.set(id, { ...o, id, created_at: Date.now() });
|
|
2638
3436
|
}
|
|
3437
|
+
freesRoom(sp, member, act, ref) {
|
|
3438
|
+
if (act === "CLOSE" || act === "RETRACT" || act === "WITHDRAW")
|
|
3439
|
+
return true;
|
|
3440
|
+
if (act === "ANSWER" && ref) {
|
|
3441
|
+
for (const o of sp.obligations.values())
|
|
3442
|
+
if (o.kind === "HUMAN" && o.ref === ref && o.owner === `human:${member}`)
|
|
3443
|
+
return true;
|
|
3444
|
+
}
|
|
3445
|
+
return false;
|
|
3446
|
+
}
|
|
3447
|
+
spaceFull(sp) {
|
|
3448
|
+
return sp.ledger.bytes >= this.maxLedgerBytes;
|
|
3449
|
+
}
|
|
3450
|
+
spaceFullMessage(sp) {
|
|
3451
|
+
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. ` +
|
|
3452
|
+
`Close finished goals: the relay compacts their old acts away, which frees room. Or ask the relay operator to raise PANTHEON_SPACE_MAX_BYTES.`;
|
|
3453
|
+
}
|
|
2639
3454
|
pushObligations(sp) {
|
|
2640
3455
|
sp.store.flush(sp.goals, sp.obligations, sp.profiles);
|
|
2641
3456
|
this.broadcast(sp, { t: "obligations", list: [...sp.obligations.values()] });
|
|
@@ -2708,18 +3523,23 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
2708
3523
|
continue;
|
|
2709
3524
|
const idle = now - sp.lastTouch;
|
|
2710
3525
|
const junk = this.isJunkSpace(sp);
|
|
2711
|
-
|
|
2712
|
-
|
|
2713
|
-
|
|
2714
|
-
|
|
2715
|
-
|
|
2716
|
-
|
|
3526
|
+
try {
|
|
3527
|
+
if (junk && idle > this.limits.spaceJunkDeleteMs) {
|
|
3528
|
+
sp.store.close(sp.goals, sp.obligations, sp.profiles);
|
|
3529
|
+
this.spaces.delete(name);
|
|
3530
|
+
this.compactedAt.delete(name);
|
|
3531
|
+
this.deleteSpaceFiles(sp.name);
|
|
3532
|
+
this.log(`[${name}] evicted junk space (idle ${Math.round(idle / 3_600_000)}h, no work, no members) — files removed`);
|
|
3533
|
+
}
|
|
3534
|
+
else if (idle > this.limits.spaceIdleEvictMs) {
|
|
3535
|
+
sp.store.close(sp.goals, sp.obligations, sp.profiles);
|
|
3536
|
+
this.spaces.delete(name);
|
|
3537
|
+
this.compactedAt.delete(name);
|
|
3538
|
+
this.log(`[${name}] evicted from memory (idle ${Math.round(idle / 3_600_000)}h) — reloads from disk on next connect`);
|
|
3539
|
+
}
|
|
2717
3540
|
}
|
|
2718
|
-
|
|
2719
|
-
|
|
2720
|
-
this.spaces.delete(name);
|
|
2721
|
-
this.compactedAt.delete(name);
|
|
2722
|
-
this.log(`[${name}] evicted from memory (idle ${Math.round(idle / 3_600_000)}h) — reloads from disk on next connect`);
|
|
3541
|
+
catch (e) {
|
|
3542
|
+
this.noteStorageFailure(`[${name}] eviction flush`, e);
|
|
2723
3543
|
}
|
|
2724
3544
|
}
|
|
2725
3545
|
}
|
|
@@ -2732,22 +3552,49 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
2732
3552
|
catch { }
|
|
2733
3553
|
}
|
|
2734
3554
|
}
|
|
2735
|
-
getOrCreateSpace(name, token) {
|
|
2736
|
-
const
|
|
2737
|
-
|
|
2738
|
-
|
|
3555
|
+
getOrCreateSpace(name, token, opts = {}) {
|
|
3556
|
+
const claim = opts.claim;
|
|
3557
|
+
const retired = !!opts.retired;
|
|
3558
|
+
const existing = this.spaces.get(spaceKey(name));
|
|
3559
|
+
if (existing) {
|
|
3560
|
+
if (existing.name !== name)
|
|
3561
|
+
return null;
|
|
3562
|
+
const st = this.tokenState(existing, token);
|
|
3563
|
+
return st === "current" || (retired && st === "retired") ? existing : null;
|
|
3564
|
+
}
|
|
3565
|
+
if (this.aliasOf(name))
|
|
3566
|
+
return null;
|
|
2739
3567
|
const safe = name.replace(/[^a-z0-9._-]/gi, "_");
|
|
2740
3568
|
const allow = new Allowlist(this.dataDir, safe);
|
|
2741
3569
|
const match = allow.tokenMatches(token);
|
|
2742
|
-
|
|
2743
|
-
|
|
2744
|
-
|
|
3570
|
+
let raw = token;
|
|
3571
|
+
if (match === false) {
|
|
3572
|
+
if (!(retired && allow.tokenRetired(token)))
|
|
3573
|
+
return null;
|
|
3574
|
+
raw = allow.currentTokenFromInvites() ?? "";
|
|
3575
|
+
}
|
|
3576
|
+
if (match === null) {
|
|
3577
|
+
if (this.unboundOnDisk(safe)) {
|
|
3578
|
+
const who = claim && claim !== "invite" ? claim : null;
|
|
3579
|
+
const e = who ? allow.get(who.member) : undefined;
|
|
3580
|
+
const ok = claim === "invite" || (!!who && !!e?.admitted && e.publicKey === who.pubkey);
|
|
3581
|
+
if (!ok) {
|
|
3582
|
+
this.unbound.add(safe);
|
|
3583
|
+
return null;
|
|
3584
|
+
}
|
|
3585
|
+
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"}`);
|
|
3586
|
+
}
|
|
2745
3587
|
allow.bindToken(token);
|
|
3588
|
+
this.unbound.delete(safe);
|
|
3589
|
+
}
|
|
3590
|
+
const ledger = new Ledger(this.dataDir, safe);
|
|
2746
3591
|
const store = new StateStore(this.dataDir, safe);
|
|
2747
|
-
|
|
3592
|
+
store.headOf = () => ledger.head;
|
|
3593
|
+
store.onError = (e) => this.noteStorageFailure(`[${name}] snapshot save`, e);
|
|
3594
|
+
const restored = store.load({ quarantine: true, expectHistory: ledger.size > 0 });
|
|
2748
3595
|
const sp = {
|
|
2749
|
-
name, token,
|
|
2750
|
-
ledger
|
|
3596
|
+
name, token: raw,
|
|
3597
|
+
ledger,
|
|
2751
3598
|
allow,
|
|
2752
3599
|
store,
|
|
2753
3600
|
members: new Map(),
|
|
@@ -2760,27 +3607,185 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
2760
3607
|
createdAt: Date.now(),
|
|
2761
3608
|
lastTouch: Date.now(),
|
|
2762
3609
|
};
|
|
2763
|
-
|
|
3610
|
+
if (restored.unclean) {
|
|
3611
|
+
sp.unclean = restored.unclean;
|
|
3612
|
+
const rebuilt = this.rebuildFromLedger(sp);
|
|
3613
|
+
this.log(`!! [${name}] UNCLEAN LOAD: ${restored.unclean}${restored.quarantined ? ` — moved aside to ${restored.quarantined}` : ""}. ` +
|
|
3614
|
+
`Rebuilt ${rebuilt.goals} goal(s) (${rebuilt.open} open) and ${sp.obligations.size} obligation(s) from ${ledger.size} act(s) in the ledger; ` +
|
|
3615
|
+
`ledger compaction is OFF for this space until a snapshot written from live state (after a real act) is loaded clean.`);
|
|
3616
|
+
store.rebuilt = restored.unclean;
|
|
3617
|
+
try {
|
|
3618
|
+
store.flush(sp.goals, sp.obligations, sp.profiles);
|
|
3619
|
+
}
|
|
3620
|
+
catch (e) {
|
|
3621
|
+
this.noteStorageFailure(`[${name}] rebuilt snapshot`, e);
|
|
3622
|
+
}
|
|
3623
|
+
}
|
|
3624
|
+
else if (restored.rebuilt) {
|
|
3625
|
+
sp.unclean = `its snapshot is still the one rebuilt from the ledger (${restored.rebuilt}), and no act has been built on it yet`;
|
|
3626
|
+
store.rebuilt = restored.rebuilt;
|
|
3627
|
+
this.log(`! [${name}] loaded a snapshot rebuilt from the ledger that no act has built on yet; ledger compaction stays OFF for this run.`);
|
|
3628
|
+
}
|
|
3629
|
+
else if (restored.ledgerHead !== undefined && ledger.head > restored.ledgerHead) {
|
|
3630
|
+
const after = restored.ledgerHead;
|
|
3631
|
+
const missed = ledger.all().filter((r) => r.seq > after);
|
|
3632
|
+
const had = new Set(sp.obligations.keys());
|
|
3633
|
+
let opened = 0;
|
|
3634
|
+
for (const r of missed) {
|
|
3635
|
+
if (r.goal && !sp.goals.has(r.goal)) {
|
|
3636
|
+
const g = this.goalFromOpening(r);
|
|
3637
|
+
if (g) {
|
|
3638
|
+
sp.goals.set(g.id, g);
|
|
3639
|
+
opened++;
|
|
3640
|
+
}
|
|
3641
|
+
}
|
|
3642
|
+
this.effects(sp, r);
|
|
3643
|
+
}
|
|
3644
|
+
for (const o of sp.obligations.values())
|
|
3645
|
+
if (!had.has(o.id))
|
|
3646
|
+
o.created_at = ledger.find(o.ref)?.ts ?? o.created_at;
|
|
3647
|
+
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` : ""}`);
|
|
3648
|
+
try {
|
|
3649
|
+
store.flush(sp.goals, sp.obligations, sp.profiles);
|
|
3650
|
+
}
|
|
3651
|
+
catch (e) {
|
|
3652
|
+
this.noteStorageFailure(`[${name}] replayed snapshot`, e);
|
|
3653
|
+
}
|
|
3654
|
+
}
|
|
3655
|
+
this.spaces.set(spaceKey(name), sp);
|
|
3656
|
+
try {
|
|
3657
|
+
allow.recordName(name);
|
|
3658
|
+
}
|
|
3659
|
+
catch (e) {
|
|
3660
|
+
this.noteStorageFailure(`[${name}] recording its name`, e);
|
|
3661
|
+
}
|
|
2764
3662
|
for (const e of allow.all()) {
|
|
2765
3663
|
if (e.admitted && !sp.members.has(e.member)) {
|
|
2766
3664
|
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: [] });
|
|
2767
3665
|
}
|
|
2768
3666
|
}
|
|
3667
|
+
this.rebuildRecent(sp);
|
|
2769
3668
|
const open = [...sp.goals.values()].filter((g) => g.state !== "CLOSED").length;
|
|
2770
3669
|
this.log(`[${name}] space ready (seq ${sp.ledger.head}, ${sp.goals.size} goals — ${open} still open, ${sp.obligations.size} obligations restored)`);
|
|
3670
|
+
const founder = allow.founder();
|
|
3671
|
+
const guess = allow.founderGuess();
|
|
3672
|
+
if (founder && (guess || !allow.get(founder)?.admitted)) {
|
|
3673
|
+
this.log(`! [${name}] founder is "${founder}"${guess ? ` — a guess: ${guess} (an older relay rewrote who admitted whom)` : ""}` +
|
|
3674
|
+
`${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)`}`);
|
|
3675
|
+
}
|
|
3676
|
+
if (ledger.outOfOrder) {
|
|
3677
|
+
this.log(`! [${name}] ledger seq is not strictly increasing: ${ledger.outOfOrder} act(s) at or below the one before (first at seq ${ledger.firstOutOfOrder}). ` +
|
|
3678
|
+
`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.`);
|
|
3679
|
+
}
|
|
2771
3680
|
this.compactSpace(sp);
|
|
2772
3681
|
return sp;
|
|
2773
3682
|
}
|
|
3683
|
+
rebuildRecent(sp) {
|
|
3684
|
+
const now = Date.now();
|
|
3685
|
+
const recs = sp.ledger.all();
|
|
3686
|
+
const retracted = new Set();
|
|
3687
|
+
const window = [];
|
|
3688
|
+
for (let i = recs.length - 1; i >= 0; i--) {
|
|
3689
|
+
const r = recs[i];
|
|
3690
|
+
if (now - r.ts > LIMITS.dedupeWindowMs)
|
|
3691
|
+
break;
|
|
3692
|
+
if (r.act === "RETRACT" && r.ref)
|
|
3693
|
+
retracted.add(r.ref);
|
|
3694
|
+
window.push(r);
|
|
3695
|
+
}
|
|
3696
|
+
for (const r of window.reverse()) {
|
|
3697
|
+
if (!r.session || !SENDABLE.includes(r.act) || retracted.has(r.id))
|
|
3698
|
+
continue;
|
|
3699
|
+
const fp = actFingerprint(r.from, r.act, r.body, r.goal ?? null, r.ref ?? null, r.to ?? null, r.meta);
|
|
3700
|
+
sp.recent.set(fp, { id: r.id, seq: r.seq, at: r.ts });
|
|
3701
|
+
}
|
|
3702
|
+
for (const k of sp.recent.keys()) {
|
|
3703
|
+
if (sp.recent.size <= RECENT_MAX)
|
|
3704
|
+
break;
|
|
3705
|
+
sp.recent.delete(k);
|
|
3706
|
+
}
|
|
3707
|
+
}
|
|
3708
|
+
rebuildFromLedger(sp) {
|
|
3709
|
+
sp.goals.clear();
|
|
3710
|
+
sp.obligations.clear();
|
|
3711
|
+
const ctx = { retracted: new Set(), answers: new Map() };
|
|
3712
|
+
for (const r of sp.ledger.all()) {
|
|
3713
|
+
if (r.act === "RETRACT" && r.ref)
|
|
3714
|
+
ctx.retracted.add(r.ref);
|
|
3715
|
+
if (r.act === "ANSWER" && r.ref) {
|
|
3716
|
+
const a = ctx.answers.get(r.ref);
|
|
3717
|
+
if (a)
|
|
3718
|
+
a.push(r);
|
|
3719
|
+
else
|
|
3720
|
+
ctx.answers.set(r.ref, [r]);
|
|
3721
|
+
}
|
|
3722
|
+
let goal = null;
|
|
3723
|
+
if (r.goal) {
|
|
3724
|
+
goal = sp.goals.get(r.goal) ?? null;
|
|
3725
|
+
if (!goal) {
|
|
3726
|
+
goal = this.goalFromOpening(r) ?? {
|
|
3727
|
+
id: r.goal, title: `(recovered goal ${r.goal})`, acceptance: "", members: [r.from],
|
|
3728
|
+
state: "OPEN", acts: 0, budget: LIMITS.goalBudget, opened_by: r.from, opened_at: r.ts,
|
|
3729
|
+
};
|
|
3730
|
+
sp.goals.set(goal.id, goal);
|
|
3731
|
+
}
|
|
3732
|
+
const affects = r.meta?.affects;
|
|
3733
|
+
for (const who of [r.from, ...(r.to ?? []), ...(Array.isArray(affects) ? affects.filter((x) => typeof x === "string") : [])]) {
|
|
3734
|
+
if (!who.startsWith("human:") && !goal.members.includes(who))
|
|
3735
|
+
goal.members.push(who);
|
|
3736
|
+
}
|
|
3737
|
+
goal.acts++;
|
|
3738
|
+
}
|
|
3739
|
+
this.applyObligations(sp, r, goal, ctx);
|
|
3740
|
+
}
|
|
3741
|
+
for (const o of sp.obligations.values())
|
|
3742
|
+
o.created_at = sp.ledger.find(o.ref)?.ts ?? o.created_at;
|
|
3743
|
+
let open = 0;
|
|
3744
|
+
for (const g of sp.goals.values()) {
|
|
3745
|
+
if (g.state === "CLOSED")
|
|
3746
|
+
continue;
|
|
3747
|
+
open++;
|
|
3748
|
+
g.budget = Math.max(g.budget, g.acts + Math.ceil(LIMITS.goalBudget / 2));
|
|
3749
|
+
}
|
|
3750
|
+
return { goals: sp.goals.size, open };
|
|
3751
|
+
}
|
|
3752
|
+
goalFromOpening(r) {
|
|
3753
|
+
if (!r.goal || r.act !== "NOTE" || r.session)
|
|
3754
|
+
return null;
|
|
3755
|
+
const mm = GOAL_OPENED.exec(r.body);
|
|
3756
|
+
if (!mm)
|
|
3757
|
+
return null;
|
|
3758
|
+
const meta = r.meta;
|
|
3759
|
+
const deadline = mm[3] ? Date.parse(mm[3]) : NaN;
|
|
3760
|
+
const listed = meta?.goal_members;
|
|
3761
|
+
const members = Array.isArray(listed) ? [...new Set([r.from, ...listed.filter((x) => typeof x === "string" && !x.startsWith("human:"))])] : [r.from];
|
|
3762
|
+
const budget = typeof meta?.goal_budget === "number" && Number.isInteger(meta.goal_budget) && meta.goal_budget > 0 ? meta.goal_budget : LIMITS.goalBudget;
|
|
3763
|
+
return {
|
|
3764
|
+
id: r.goal,
|
|
3765
|
+
title: mm[1],
|
|
3766
|
+
acceptance: mm[2] !== "(none stated)" ? mm[2] : "",
|
|
3767
|
+
members,
|
|
3768
|
+
state: "OPEN",
|
|
3769
|
+
acts: 0,
|
|
3770
|
+
budget,
|
|
3771
|
+
opened_by: r.from,
|
|
3772
|
+
opened_at: r.ts,
|
|
3773
|
+
...(Number.isFinite(deadline) ? { deadline } : {}),
|
|
3774
|
+
...(mm[4] === "worklist" ? { kind: "worklist" } : {}),
|
|
3775
|
+
};
|
|
3776
|
+
}
|
|
2774
3777
|
compactSpace(sp) {
|
|
3778
|
+
this.compactedAt.set(sp.name, Date.now());
|
|
3779
|
+
if (sp.unclean)
|
|
3780
|
+
return;
|
|
2775
3781
|
const isLive = (goal) => {
|
|
2776
3782
|
if (!goal)
|
|
2777
3783
|
return false;
|
|
2778
3784
|
const g = sp.goals.get(goal);
|
|
2779
|
-
return
|
|
3785
|
+
return !g || g.state !== "CLOSED";
|
|
2780
3786
|
};
|
|
2781
3787
|
const referenced = new Set([...sp.obligations.values()].map((o) => o.ref));
|
|
2782
|
-
const { dropped, kept } = sp.ledger.compact({ keepRecent: LIMITS.ledgerKeepRecent, isLive, keep: (id) => referenced.has(id) });
|
|
2783
|
-
this.compactedAt.set(sp.name, Date.now());
|
|
3788
|
+
const { dropped, kept } = sp.ledger.compact({ keepRecent: this.opts.ledgerKeepRecent ?? LIMITS.ledgerKeepRecent, isLive, keep: (id) => referenced.has(id) });
|
|
2784
3789
|
if (dropped)
|
|
2785
3790
|
this.log(`[${sp.name}] compacted ledger — dropped ${dropped} old act(s) from closed goals, ${kept} kept`);
|
|
2786
3791
|
}
|
|
@@ -2817,6 +3822,9 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
2817
3822
|
return sp.allow.all().map((e) => ({
|
|
2818
3823
|
member: e.member, admitted: e.admitted, may_wake: e.may_wake,
|
|
2819
3824
|
fingerprint: fingerprint(e.publicKey),
|
|
3825
|
+
...(e.admitted_by ? { admitted_by: e.admitted_by } : {}),
|
|
3826
|
+
...(e.revoked_at ? { revoked_at: e.revoked_at, ...(e.revoked_by ? { revoked_by: e.revoked_by } : {}) } : {}),
|
|
3827
|
+
...(sp.allow.founder() === e.member ? { founder: true } : {}),
|
|
2820
3828
|
}));
|
|
2821
3829
|
}
|
|
2822
3830
|
send(ws, msg) {
|