@join-pantheon/cli 0.29.1 → 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 -120
- 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 +494 -32
- 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 +82 -14
- package/dist/reads.js +30 -0
- package/dist/refusal.js +14 -1
- package/dist/relay.js +1143 -180
- 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 +84 -34
- 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;
|
|
@@ -81,6 +81,17 @@ export function isTrustedSource(ip) {
|
|
|
81
81
|
function fingerprint_(from, act, body, goal, ref, extra = null) {
|
|
82
82
|
return createHash("sha256").update([from, act, goal ?? "", ref ?? "", body, stableJson(extra)].join("\u0000")).digest("hex").slice(0, 32);
|
|
83
83
|
}
|
|
84
|
+
function actFingerprint(from, act, body, goal, ref, to, meta) {
|
|
85
|
+
let m = meta ?? null;
|
|
86
|
+
if (m && ("authority" in m || "unverified_domains" in m)) {
|
|
87
|
+
m = { ...m };
|
|
88
|
+
delete m["authority"];
|
|
89
|
+
delete m["unverified_domains"];
|
|
90
|
+
}
|
|
91
|
+
if (m && Object.keys(m).length === 0)
|
|
92
|
+
m = null;
|
|
93
|
+
return fingerprint_(from, act, body, goal, ref, { to: to ?? null, meta: m });
|
|
94
|
+
}
|
|
84
95
|
function stableJson(v) {
|
|
85
96
|
if (v === null || typeof v !== "object")
|
|
86
97
|
return JSON.stringify(v ?? null);
|
|
@@ -89,6 +100,8 @@ function stableJson(v) {
|
|
|
89
100
|
return `{${Object.keys(v).sort().map((k) => `${JSON.stringify(k)}:${stableJson(v[k])}`).join(",")}}`;
|
|
90
101
|
}
|
|
91
102
|
function clientIp(req) {
|
|
103
|
+
if (process.env.PANTHEON_TRUSTED_HOPS?.trim() === "0")
|
|
104
|
+
return req.socket.remoteAddress || "unknown";
|
|
92
105
|
const raw = req.headers["x-forwarded-for"];
|
|
93
106
|
const hops = (Array.isArray(raw) ? raw.join(",") : raw ?? "").split(",").map((x) => x.trim()).filter(Boolean);
|
|
94
107
|
const trusted = Math.max(1, Number(process.env.PANTHEON_TRUSTED_HOPS ?? 1) || 1);
|
|
@@ -161,8 +174,119 @@ const fmtAgo = (t) => {
|
|
|
161
174
|
return m < 1 ? "just now" : m < 60 ? `${m}m ago` : m < 1440 ? `${Math.round(m / 60)}h ago` : `${Math.round(m / 1440)}d ago`;
|
|
162
175
|
};
|
|
163
176
|
const safeName = (name) => name.replace(/[^a-z0-9._-]/gi, "_");
|
|
177
|
+
const spaceKey = (name) => safeName(name).toLowerCase();
|
|
178
|
+
function firstLedgerName(file) {
|
|
179
|
+
let fd = null;
|
|
180
|
+
try {
|
|
181
|
+
fd = openSync(file, "r");
|
|
182
|
+
const chunks = [];
|
|
183
|
+
const buf = Buffer.allocUnsafe(64 * 1024);
|
|
184
|
+
for (let pos = 0, total = 0; total < 1 << 20;) {
|
|
185
|
+
const n = readSync(fd, buf, 0, buf.length, pos);
|
|
186
|
+
if (n <= 0)
|
|
187
|
+
break;
|
|
188
|
+
const nl = buf.subarray(0, n).indexOf(10);
|
|
189
|
+
chunks.push(Buffer.from(buf.subarray(0, nl === -1 ? n : nl)));
|
|
190
|
+
if (nl !== -1)
|
|
191
|
+
break;
|
|
192
|
+
pos += n;
|
|
193
|
+
total += n;
|
|
194
|
+
}
|
|
195
|
+
const r = JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
196
|
+
return typeof r.space === "string" && r.space ? r.space : null;
|
|
197
|
+
}
|
|
198
|
+
catch {
|
|
199
|
+
return null;
|
|
200
|
+
}
|
|
201
|
+
finally {
|
|
202
|
+
if (fd !== null)
|
|
203
|
+
try {
|
|
204
|
+
closeSync(fd);
|
|
205
|
+
}
|
|
206
|
+
catch { }
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
const GOAL_OPENED = /^goal opened: ([^\n]*)\nacceptance: ([\s\S]*?)(?:\ndeadline: (\S+))?(?:\nkind: (\w+)[\s\S]*)?$/;
|
|
164
210
|
const LIMITS_GOAL_READ = 1000;
|
|
165
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
|
+
}
|
|
166
290
|
export class Relay {
|
|
167
291
|
opts;
|
|
168
292
|
spaces = new Map();
|
|
@@ -170,9 +294,22 @@ export class Relay {
|
|
|
170
294
|
log;
|
|
171
295
|
http = createServer((req, res) => {
|
|
172
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
|
+
}
|
|
173
308
|
const bad = e instanceof TypeError || e instanceof URIError;
|
|
174
309
|
if (!bad)
|
|
175
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);
|
|
176
313
|
try {
|
|
177
314
|
if (!res.headersSent)
|
|
178
315
|
this.json(res, bad ? 400 : 500, { error: bad ? "bad request" : "the relay could not handle that request" });
|
|
@@ -202,20 +339,32 @@ export class Relay {
|
|
|
202
339
|
pruneMs;
|
|
203
340
|
accounts;
|
|
204
341
|
requireAccounts;
|
|
342
|
+
readsMode;
|
|
205
343
|
trustLocal;
|
|
206
344
|
email;
|
|
207
345
|
authHits = new Map();
|
|
208
346
|
adminHits = new Map();
|
|
209
347
|
emailSends = [];
|
|
348
|
+
emailSendsTo = new Map();
|
|
349
|
+
emailSendsFrom = new Map();
|
|
350
|
+
storageFailure = null;
|
|
351
|
+
unbound = new Set();
|
|
352
|
+
maxLedgerBytes;
|
|
210
353
|
adminToken;
|
|
211
354
|
adminAllow;
|
|
212
355
|
startedAt = Date.now();
|
|
213
356
|
deletedSpaces = new Map();
|
|
357
|
+
authEpoch = 0;
|
|
358
|
+
rotateOnRemove;
|
|
214
359
|
backupTimer;
|
|
215
360
|
constructor(opts = {}) {
|
|
216
361
|
this.opts = opts;
|
|
217
362
|
this.dataDir = opts.dataDir ?? "./pantheon-data";
|
|
218
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();
|
|
219
368
|
this.requireAccounts = opts.requireAccounts ?? process.env.PANTHEON_REQUIRE_ACCOUNTS === "1";
|
|
220
369
|
this.trustLocal = process.env.PANTHEON_TRUST_LOCAL !== undefined
|
|
221
370
|
? process.env.PANTHEON_TRUST_LOCAL === "1"
|
|
@@ -243,8 +392,14 @@ export class Relay {
|
|
|
243
392
|
spaceJunkDeleteMs: opts.spaceLimits?.spaceJunkDeleteMs ?? LIMITS.spaceJunkDeleteMs,
|
|
244
393
|
spaceMinAgeMs: opts.spaceLimits?.spaceMinAgeMs ?? LIMITS.spaceMinAgeMs,
|
|
245
394
|
};
|
|
395
|
+
this.rotateOnRemove = opts.rotateOnRemove ?? process.env.PANTHEON_ROTATE_ON_REMOVE === "1";
|
|
246
396
|
this.busyTtlMs = opts.activityBusyTtlMs ?? LIMITS.activityBusyTtlMs;
|
|
247
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
|
+
}
|
|
248
403
|
this.http.on("upgrade", (req, socket, head) => {
|
|
249
404
|
const ip = clientIp(req);
|
|
250
405
|
this.wss.handleUpgrade(req, socket, head, (ws) => this.onSocket(ws, ip));
|
|
@@ -267,8 +422,14 @@ export class Relay {
|
|
|
267
422
|
});
|
|
268
423
|
}
|
|
269
424
|
async close() {
|
|
270
|
-
for (const sp of this.spaces.values())
|
|
271
|
-
|
|
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
|
+
}
|
|
272
433
|
if (this.backupTimer)
|
|
273
434
|
clearInterval(this.backupTimer);
|
|
274
435
|
if (this.accounts && !this.opts.accounts)
|
|
@@ -280,6 +441,48 @@ export class Relay {
|
|
|
280
441
|
await new Promise((r) => this.wss.close(() => r()));
|
|
281
442
|
await new Promise((r) => this.http.close(() => r()));
|
|
282
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
|
+
}
|
|
283
486
|
onHttp(req, res) {
|
|
284
487
|
const url = new URL(req.url ?? "/", "http://x");
|
|
285
488
|
const ip = clientIp(req);
|
|
@@ -298,11 +501,16 @@ export class Relay {
|
|
|
298
501
|
seq: s.ledger.head,
|
|
299
502
|
});
|
|
300
503
|
const asked = url.searchParams.get("space");
|
|
301
|
-
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();
|
|
302
507
|
return this.json(res, 200, {
|
|
303
|
-
ok:
|
|
508
|
+
ok: !sf,
|
|
509
|
+
...(sf ? { storage: { error: sf.message, where: sf.where, at: new Date(sf.at).toISOString() } } : {}),
|
|
304
510
|
v: PROTOCOL_VERSION,
|
|
305
511
|
pkg: VERSION,
|
|
512
|
+
...(commit ? { commit } : {}),
|
|
513
|
+
...(adminView && this.unbound.size ? { unbound_spaces: [...this.unbound].sort() } : {}),
|
|
306
514
|
space_count: this.spaces.size,
|
|
307
515
|
space_capacity: this.limits.maxSpaces,
|
|
308
516
|
...(adminView ? { spaces: [...this.spaces.values()].map(detail) }
|
|
@@ -322,10 +530,12 @@ export class Relay {
|
|
|
322
530
|
: this.json(res, 404, { error: "no open invite matches that code — it may have been used already or expired" });
|
|
323
531
|
}
|
|
324
532
|
if (url.pathname === "/act") {
|
|
325
|
-
const
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
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;
|
|
329
539
|
const act = space.ledger.find(url.searchParams.get("id") ?? "");
|
|
330
540
|
if (!act)
|
|
331
541
|
return this.json(res, 404, { error: "no such act" });
|
|
@@ -333,10 +543,12 @@ export class Relay {
|
|
|
333
543
|
return this.json(res, 200, { act, ...(goal ? { goal: { id: goal.id, title: goal.title, state: goal.state } } : {}) });
|
|
334
544
|
}
|
|
335
545
|
if (url.pathname === "/history") {
|
|
336
|
-
const
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
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;
|
|
340
552
|
const goal = url.searchParams.get("goal");
|
|
341
553
|
const sinceRaw = url.searchParams.get("since");
|
|
342
554
|
const acts = goal ? space.ledger.byGoal(goal, LIMITS_GOAL_READ)
|
|
@@ -344,10 +556,12 @@ export class Relay {
|
|
|
344
556
|
return this.json(res, 200, { acts, head: space.ledger.head });
|
|
345
557
|
}
|
|
346
558
|
if (url.pathname === "/obligations") {
|
|
347
|
-
const
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
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;
|
|
351
565
|
const member = url.searchParams.get("member");
|
|
352
566
|
const now = Date.now();
|
|
353
567
|
const owed = [...space.obligations.values()]
|
|
@@ -362,25 +576,38 @@ export class Relay {
|
|
|
362
576
|
if (url.pathname === "/activity") {
|
|
363
577
|
if (req.method !== "POST")
|
|
364
578
|
return this.json(res, 405, { error: "POST only" });
|
|
365
|
-
const
|
|
366
|
-
const
|
|
367
|
-
if (!
|
|
579
|
+
const spaceName = url.searchParams.get("space") ?? "", spaceToken = url.searchParams.get("token") ?? "";
|
|
580
|
+
const target = this.readTarget(spaceName, spaceToken);
|
|
581
|
+
if (!target)
|
|
368
582
|
return this.json(res, 404, { error: "no such space" });
|
|
369
583
|
const memberId = url.searchParams.get("member") ?? "";
|
|
370
|
-
const member = space.members.get(memberId);
|
|
371
584
|
const session = (url.searchParams.get("session") ?? "").slice(0, 64);
|
|
372
585
|
const state = url.searchParams.get("state");
|
|
373
|
-
if (!
|
|
586
|
+
if (!memberId || !session || (state !== "busy" && state !== "idle")) {
|
|
374
587
|
return this.json(res, 400, { error: "member, session and state=busy|idle are required" });
|
|
375
588
|
}
|
|
376
589
|
const ts = Number(url.searchParams.get("ts"));
|
|
377
590
|
const sig = url.searchParams.get("sig") ?? "";
|
|
378
591
|
const seenParam = url.searchParams.get("seen") ?? "";
|
|
379
|
-
const pub =
|
|
592
|
+
const pub = target.allow.pubkeyOf(memberId);
|
|
380
593
|
const fresh = Number.isFinite(ts) && Math.abs(Date.now() - ts) < LIMITS.activityFreshMs;
|
|
381
|
-
if (!pub || !fresh || !verifyActivity(pub,
|
|
594
|
+
if (!pub || !fresh || !verifyActivity(pub, spaceName, memberId, session, state, seenParam, ts, sig)) {
|
|
382
595
|
return this.json(res, 403, { error: "activity report must be signed by the member's key and recent" });
|
|
383
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" });
|
|
384
611
|
if (member.activity.size > 64 && !member.activity.has(session)) {
|
|
385
612
|
const oldest = [...member.activity.entries()].sort((a, b) => a[1].at - b[1].at)[0];
|
|
386
613
|
if (oldest)
|
|
@@ -437,7 +664,7 @@ export class Relay {
|
|
|
437
664
|
res.writeHead(429, { "content-type": "text/html; charset=utf-8", "retry-after": "60" });
|
|
438
665
|
return void res.end("<p>Too many attempts. Wait a minute and try again.</p>");
|
|
439
666
|
}
|
|
440
|
-
return this.readForm(req, (body) => {
|
|
667
|
+
return this.readForm(req, res, (body) => {
|
|
441
668
|
if (!tokenOk(body.token ?? "")) {
|
|
442
669
|
this.noteAdminFail(ip);
|
|
443
670
|
A0.audit({ event: "admin_login_failed", ip });
|
|
@@ -479,7 +706,7 @@ export class Relay {
|
|
|
479
706
|
if (url.pathname === "/admin/action" && req.method === "POST") {
|
|
480
707
|
if (!authed)
|
|
481
708
|
return this.json(res, 403, { error: "sign in at /admin" });
|
|
482
|
-
return this.readForm(req, (body) => this.adminAction(res, A0, body, ip));
|
|
709
|
+
return this.readForm(req, res, (body) => this.adminAction(res, A0, body, ip));
|
|
483
710
|
}
|
|
484
711
|
if (url.pathname === "/admin/space") {
|
|
485
712
|
if (!authed)
|
|
@@ -498,7 +725,7 @@ export class Relay {
|
|
|
498
725
|
if (url.pathname === "/auth/signup" && req.method === "POST") {
|
|
499
726
|
if (!this.authRateOk(ip))
|
|
500
727
|
return this.json(res, 429, { error: "too many attempts from your network — wait a minute and try again" });
|
|
501
|
-
return this.readJson(req, async (b) => {
|
|
728
|
+
return this.readJson(req, res, async (b) => {
|
|
502
729
|
if (!b || typeof b.email !== "string" || typeof b.username !== "string" || typeof b.pubkey !== "string") {
|
|
503
730
|
return this.json(res, 400, { error: "email, username and pubkey are required" });
|
|
504
731
|
}
|
|
@@ -506,63 +733,74 @@ export class Relay {
|
|
|
506
733
|
return this.json(res, 400, { error: "that does not look like an email address" });
|
|
507
734
|
if (!validUsername(normalizeUsername(b.username)))
|
|
508
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" });
|
|
509
739
|
const started = A.startVerification(b.email, "signup", { username: b.username, pubkey: b.pubkey, label: String(b.label ?? "a device").slice(0, 60) });
|
|
510
740
|
if (!started.ok) {
|
|
511
741
|
if (started.reason === "blocked")
|
|
512
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 });
|
|
513
745
|
const msg = started.reason === "too_soon" ? "a code was just sent — check your email, or wait a minute to resend"
|
|
514
|
-
: started.reason === "
|
|
515
|
-
: started.reason === "
|
|
516
|
-
: started.reason === "
|
|
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
|
|
517
749
|
: "check the email and username";
|
|
518
750
|
return this.json(res, 409, { error: msg, reason: started.reason });
|
|
519
751
|
}
|
|
520
752
|
A.audit({ event: "signup", target: normalizeUsername(b.username), ip });
|
|
521
|
-
if (!this.emailBudgetOk())
|
|
522
|
-
return this.json(res, 429, { error: "the relay is sending too many codes right now — try again shortly" });
|
|
523
753
|
const sent = await this.email.sendCode(b.email, started.code, "signup");
|
|
524
754
|
if (sent)
|
|
525
|
-
this.
|
|
755
|
+
this.noteEmailSent("signup", b.email, ip);
|
|
526
756
|
return this.json(res, 200, { ok: true, sent, via: this.email.kind });
|
|
527
757
|
});
|
|
528
758
|
}
|
|
529
759
|
if (url.pathname === "/auth/login" && req.method === "POST") {
|
|
530
760
|
if (!this.authRateOk(ip))
|
|
531
761
|
return this.json(res, 429, { error: "too many attempts from your network — wait a minute and try again" });
|
|
532
|
-
return this.readJson(req, async (b) => {
|
|
762
|
+
return this.readJson(req, res, async (b) => {
|
|
533
763
|
if (!b || typeof b.email !== "string" || typeof b.pubkey !== "string")
|
|
534
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" });
|
|
535
769
|
const started = A.startVerification(b.email, "login", { pubkey: b.pubkey, label: String(b.label ?? "a device").slice(0, 60) });
|
|
536
770
|
if (!started.ok && started.reason === "blocked")
|
|
537
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 });
|
|
538
774
|
if (!started.ok) {
|
|
539
|
-
const code = started.reason === "too_soon" || started.reason === "
|
|
540
|
-
const msg = started.reason === "too_soon" ? "a code was just sent — check your email, or wait a minute" : started.reason === "
|
|
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";
|
|
541
777
|
return this.json(res, code, { error: msg, reason: started.reason });
|
|
542
778
|
}
|
|
543
|
-
if (!this.emailBudgetOk())
|
|
544
|
-
return this.json(res, 429, { error: "the relay is sending too many codes right now — try again shortly" });
|
|
545
779
|
const sent = await this.email.sendCode(b.email, started.code, "login");
|
|
546
780
|
if (sent)
|
|
547
|
-
this.
|
|
781
|
+
this.noteEmailSent("login", b.email, ip, b.pubkey, own);
|
|
548
782
|
return this.json(res, 200, { ok: true, sent, via: this.email.kind });
|
|
549
783
|
});
|
|
550
784
|
}
|
|
551
785
|
if (url.pathname === "/auth/verify" && req.method === "POST") {
|
|
552
786
|
if (!this.authRateOk(ip))
|
|
553
787
|
return this.json(res, 429, { error: "too many attempts from your network — wait a minute and try again" });
|
|
554
|
-
return this.readJson(req, (b) => {
|
|
788
|
+
return this.readJson(req, res, (b) => {
|
|
555
789
|
if (!b || typeof b.email !== "string" || typeof b.code !== "string")
|
|
556
790
|
return this.json(res, 400, { error: "email and code are required" });
|
|
557
|
-
const r = A.verify(b.email, b.code.trim(), ip, typeof b.pubkey === "string" ? b.pubkey : undefined);
|
|
791
|
+
const r = A.verify(b.email, b.code.trim(), ip, typeof b.pubkey === "string" ? b.pubkey : undefined, this.localTrusted(ip) ? null : netOf(ip));
|
|
558
792
|
if (!r.ok) {
|
|
559
793
|
const msg = r.reason === "expired" ? "that code has expired — request a new one"
|
|
560
794
|
: r.reason === "other_device" ? "that code was requested from a different device — request a new one from this one"
|
|
561
795
|
: r.reason === "suspended" ? "this account is suspended — contact the relay operator"
|
|
562
796
|
: r.reason === "too_many_attempts" ? "too many wrong tries — request a new code"
|
|
563
|
-
: r.reason === "
|
|
564
|
-
: "
|
|
565
|
-
|
|
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 } : {}) });
|
|
566
804
|
}
|
|
567
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 });
|
|
568
806
|
});
|
|
@@ -587,8 +825,10 @@ export class Relay {
|
|
|
587
825
|
const hit = A.revoke(who.account.id, kind, id, ip);
|
|
588
826
|
for (const sid of hit.sessionIds)
|
|
589
827
|
this.dropSessionEverywhere(sid);
|
|
590
|
-
if (hit.deviceId)
|
|
828
|
+
if (hit.deviceId) {
|
|
591
829
|
this.dropDeviceSockets(who.account.id, hit.deviceId);
|
|
830
|
+
this.noteAccountRemoval(who.account.username);
|
|
831
|
+
}
|
|
592
832
|
return this.json(res, 200, { ok: true, revoked: hit.sessionIds.length, device_removed: !!hit.deviceId });
|
|
593
833
|
}
|
|
594
834
|
if (url.pathname === "/account/delete" && req.method === "POST") {
|
|
@@ -599,6 +839,7 @@ export class Relay {
|
|
|
599
839
|
A.deleteAccount(who.account.id, ip);
|
|
600
840
|
for (const s of sessions)
|
|
601
841
|
this.dropSessionEverywhere(s.id);
|
|
842
|
+
this.noteAccountRemoval(who.account.username);
|
|
602
843
|
return this.json(res, 200, { ok: true });
|
|
603
844
|
}
|
|
604
845
|
if (url.pathname === "/audit" && req.method === "GET") {
|
|
@@ -607,7 +848,7 @@ export class Relay {
|
|
|
607
848
|
return this.json(res, 403, { error: "this request must be signed by a device on the account" });
|
|
608
849
|
const space = url.searchParams.get("space") ?? undefined;
|
|
609
850
|
if (space) {
|
|
610
|
-
const sp = this.
|
|
851
|
+
const sp = this.live(space);
|
|
611
852
|
const member = sp?.allow.get(who.account.username);
|
|
612
853
|
if (!sp || !member?.admitted)
|
|
613
854
|
return this.json(res, 403, { error: "you are not a member of that space" });
|
|
@@ -675,11 +916,47 @@ export class Relay {
|
|
|
675
916
|
}
|
|
676
917
|
}
|
|
677
918
|
}
|
|
678
|
-
|
|
679
|
-
const now = Date.now();
|
|
680
|
-
|
|
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);
|
|
681
923
|
const cap = Number(process.env.PANTHEON_EMAIL_MAX_PER_HOUR) || 200;
|
|
682
|
-
|
|
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));
|
|
683
960
|
}
|
|
684
961
|
authRateOk(ip) {
|
|
685
962
|
if (this.localTrusted(ip))
|
|
@@ -699,41 +976,134 @@ export class Relay {
|
|
|
699
976
|
}
|
|
700
977
|
return recent.length <= 20;
|
|
701
978
|
}
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
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;
|
|
708
997
|
}
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
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
|
+
});
|
|
713
1060
|
}
|
|
714
1061
|
dropSessionEverywhere(sessionId) {
|
|
1062
|
+
this.authEpoch++;
|
|
715
1063
|
for (const sp of this.spaces.values()) {
|
|
716
1064
|
for (const m of sp.members.values()) {
|
|
717
1065
|
const s = m.sessions.get(sessionId);
|
|
718
1066
|
if (s) {
|
|
719
|
-
|
|
720
|
-
s.ws.close(1008, "SESSION_REVOKED");
|
|
721
|
-
}
|
|
722
|
-
catch { }
|
|
1067
|
+
this.hangUp(s.ws, "SESSION_REVOKED");
|
|
723
1068
|
m.sessions.delete(sessionId);
|
|
724
1069
|
}
|
|
725
1070
|
}
|
|
726
1071
|
}
|
|
727
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
|
+
}
|
|
728
1093
|
dropDeviceSockets(_accountId, _deviceId) {
|
|
729
1094
|
}
|
|
730
|
-
readForm(req, cb) {
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
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
|
+
});
|
|
737
1107
|
}
|
|
738
1108
|
adminLoginPage(res, wrong) {
|
|
739
1109
|
res.writeHead(wrong ? 401 : 200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store", "referrer-policy": "no-referrer" });
|
|
@@ -785,7 +1155,7 @@ button:hover{background:#1d4ed8}.err{color:var(--bad);margin-top:12px}
|
|
|
785
1155
|
if (exact(sp.allow))
|
|
786
1156
|
return sp.name;
|
|
787
1157
|
const safeOf = (name) => name.replace(/[^a-z0-9._-]/gi, "_");
|
|
788
|
-
const live = new Set(
|
|
1158
|
+
const live = new Set(this.spaces.keys());
|
|
789
1159
|
let files = [];
|
|
790
1160
|
try {
|
|
791
1161
|
files = readdirSync(this.dataDir);
|
|
@@ -797,7 +1167,7 @@ button:hover{background:#1d4ed8}.err{color:var(--bad);margin-top:12px}
|
|
|
797
1167
|
if (!f.endsWith(".members.json"))
|
|
798
1168
|
continue;
|
|
799
1169
|
const safe = f.slice(0, -".members.json".length);
|
|
800
|
-
if (live.has(safe))
|
|
1170
|
+
if (live.has(safe.toLowerCase()))
|
|
801
1171
|
continue;
|
|
802
1172
|
try {
|
|
803
1173
|
if (!exact(new Allowlist(this.dataDir, safe)))
|
|
@@ -939,28 +1309,27 @@ button:hover{background:#1d4ed8}.err{color:var(--bad);margin-top:12px}
|
|
|
939
1309
|
return { goals, refusals: st.refusals };
|
|
940
1310
|
}
|
|
941
1311
|
deleteSpace(name) {
|
|
942
|
-
const sp = this.spaces.get(name);
|
|
1312
|
+
const sp = this.spaces.get(spaceKey(name));
|
|
943
1313
|
if (sp) {
|
|
944
1314
|
sp.deleted = true;
|
|
945
1315
|
for (const m of sp.members.values())
|
|
946
|
-
for (const s of m.sessions.values())
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
}
|
|
950
|
-
catch { }
|
|
951
|
-
}
|
|
952
|
-
this.spaces.delete(name);
|
|
1316
|
+
for (const s of m.sessions.values())
|
|
1317
|
+
this.hangUp(s.ws, "SPACE_DELETED");
|
|
1318
|
+
this.spaces.delete(spaceKey(name));
|
|
953
1319
|
}
|
|
954
1320
|
setTimeout(() => this.deleteSpaceFiles(name), 600).unref?.();
|
|
955
1321
|
const safe = name.replace(/[^a-z0-9._-]/gi, "_");
|
|
956
1322
|
try {
|
|
957
1323
|
const hash = sp?.allow ? JSON.parse(readFileSync(join(this.dataDir, `${safe}.members.json`), "utf8")).tokenHash ?? null : null;
|
|
958
|
-
|
|
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);
|
|
959
1328
|
}
|
|
960
1329
|
catch { }
|
|
961
1330
|
const existed = !!sp || [".jsonl", ".state.json", ".members.json"].some((suf) => existsSync(join(this.dataDir, `${safe}${suf}`)));
|
|
962
1331
|
this.deleteSpaceFiles(name);
|
|
963
|
-
this.deletedSpaces.set(name, Date.now());
|
|
1332
|
+
this.deletedSpaces.set(spaceKey(name), Date.now());
|
|
964
1333
|
if (this.deletedSpaces.size > 1000)
|
|
965
1334
|
for (const [k, t] of this.deletedSpaces)
|
|
966
1335
|
if (Date.now() - t > 3_600_000)
|
|
@@ -980,14 +1349,17 @@ button:hover{background:#1d4ed8}.err{color:var(--bad);margin-top:12px}
|
|
|
980
1349
|
return this.json(res, 404, { error: "no such account" });
|
|
981
1350
|
const live = A.sessionsFor(acct.id).filter((s) => !s.revoked_at);
|
|
982
1351
|
A.setStatus(acct.id, act === "suspend" ? "suspended" : "active", ip);
|
|
983
|
-
|
|
1352
|
+
this.authEpoch++;
|
|
1353
|
+
if (act === "suspend") {
|
|
984
1354
|
for (const s of live)
|
|
985
1355
|
this.dropSessionEverywhere(s.id);
|
|
1356
|
+
this.noteAccountRemoval(acct.username);
|
|
1357
|
+
}
|
|
986
1358
|
this.log(`[admin] ${act} ${acct.username} from ${ip}`);
|
|
987
1359
|
return done(`${acct.username} ${act === "suspend" ? "suspended and signed out everywhere" : "reinstated"}`);
|
|
988
1360
|
}
|
|
989
1361
|
case "revoke_invite": {
|
|
990
|
-
const sp = this.spaces.get(id);
|
|
1362
|
+
const sp = this.spaces.get(spaceKey(id));
|
|
991
1363
|
const allow = sp ? sp.allow : new Allowlist(this.dataDir, id.replace(/[^a-z0-9._-]/gi, "_"));
|
|
992
1364
|
const token = (body.token ?? "").trim();
|
|
993
1365
|
const inv = allow.findInvite(token);
|
|
@@ -1004,8 +1376,12 @@ button:hover{background:#1d4ed8}.err{color:var(--bad);margin-top:12px}
|
|
|
1004
1376
|
const hit = A.adminRevoke(act === "revoke_session" ? "session" : "device", id, ip);
|
|
1005
1377
|
for (const sid of hit.sessionIds)
|
|
1006
1378
|
this.dropSessionEverywhere(sid);
|
|
1007
|
-
if (hit.deviceId && hit.accountId)
|
|
1379
|
+
if (hit.deviceId && hit.accountId) {
|
|
1008
1380
|
this.dropDeviceSockets(hit.accountId, hit.deviceId);
|
|
1381
|
+
const owner = A.accountById(hit.accountId);
|
|
1382
|
+
if (owner)
|
|
1383
|
+
this.noteAccountRemoval(owner.username);
|
|
1384
|
+
}
|
|
1009
1385
|
return done(hit.sessionIds.length || hit.deviceId ? `revoked ${act === "revoke_session" ? "session" : "device"} ${id}` : `nothing to revoke for ${id}`);
|
|
1010
1386
|
}
|
|
1011
1387
|
case "block": {
|
|
@@ -1091,28 +1467,213 @@ button:hover{background:#1d4ed8}.err{color:var(--bad);margin-top:12px}
|
|
|
1091
1467
|
default: return this.json(res, 400, { error: `unknown action "${act}"` });
|
|
1092
1468
|
}
|
|
1093
1469
|
}
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
if (live)
|
|
1097
|
-
return secretEq(live.token, token) ? live : null;
|
|
1098
|
-
if (!name || !token || !this.spaceOnDisk(name))
|
|
1470
|
+
readTarget(name, token) {
|
|
1471
|
+
if (!name || !token)
|
|
1099
1472
|
return null;
|
|
1100
|
-
|
|
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))
|
|
1479
|
+
return null;
|
|
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)
|
|
1101
1514
|
return null;
|
|
1102
|
-
|
|
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;
|
|
1103
1655
|
}
|
|
1104
1656
|
tombstone(name, _token) {
|
|
1657
|
+
const f = this.tombstoneFile(name);
|
|
1658
|
+
if (!f)
|
|
1659
|
+
return null;
|
|
1105
1660
|
try {
|
|
1106
|
-
return JSON.parse(readFileSync(
|
|
1661
|
+
return JSON.parse(readFileSync(f, "utf8")).at;
|
|
1107
1662
|
}
|
|
1108
1663
|
catch {
|
|
1109
1664
|
return null;
|
|
1110
1665
|
}
|
|
1111
1666
|
}
|
|
1112
1667
|
tombstoneMatches(name, token) {
|
|
1668
|
+
const f = this.tombstoneFile(name);
|
|
1669
|
+
if (!f)
|
|
1670
|
+
return false;
|
|
1113
1671
|
try {
|
|
1114
|
-
const t = JSON.parse(readFileSync(
|
|
1115
|
-
|
|
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));
|
|
1116
1677
|
}
|
|
1117
1678
|
catch {
|
|
1118
1679
|
return false;
|
|
@@ -1130,20 +1691,50 @@ button:hover{background:#1d4ed8}.err{color:var(--bad);margin-top:12px}
|
|
|
1130
1691
|
spaceOnDisk(name) {
|
|
1131
1692
|
return existsSync(join(this.dataDir, `${name.replace(/[^a-z0-9._-]/gi, "_")}.members.json`));
|
|
1132
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
|
+
}
|
|
1133
1722
|
spacePlan(name) {
|
|
1134
1723
|
if (!this.accounts)
|
|
1135
1724
|
return null;
|
|
1136
1725
|
const owner = this.accounts.spaceOwner(name);
|
|
1137
|
-
if (
|
|
1138
|
-
return
|
|
1139
|
-
|
|
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;
|
|
1140
1731
|
}
|
|
1141
1732
|
planView(acct, sp) {
|
|
1142
1733
|
const fresh = this.accounts?.accountById(acct.id) ?? acct;
|
|
1143
1734
|
return { name: fresh.plan, limits: this.accounts.limitsFor(fresh), usage: { spaces: this.accounts.spacesOwned(fresh.id).length, members: sp.allow.admittedCount() } };
|
|
1144
1735
|
}
|
|
1145
1736
|
planRefusal(space, plan, target, msg, cid, fail, actor, ip) {
|
|
1146
|
-
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 });
|
|
1147
1738
|
this.log(`[${space.name}] ! PLAN_LIMIT (${target}, ${plan.name} plan)`);
|
|
1148
1739
|
fail("PLAN_LIMIT", msg, cid);
|
|
1149
1740
|
}
|
|
@@ -1200,7 +1791,7 @@ button:hover{background:#1d4ed8}.err{color:var(--bad);margin-top:12px}
|
|
|
1200
1791
|
for (const d of devices)
|
|
1201
1792
|
devByAcct.set(d.account_id, (devByAcct.get(d.account_id) ?? 0) + 1);
|
|
1202
1793
|
const emailCap = Number(process.env.PANTHEON_EMAIL_MAX_PER_HOUR) || 200;
|
|
1203
|
-
const emailsHour = this.emailSends.filter((
|
|
1794
|
+
const emailsHour = this.emailSends.filter((s) => Date.now() - s.at < 3_600_000).length;
|
|
1204
1795
|
let snapshots = [];
|
|
1205
1796
|
try {
|
|
1206
1797
|
snapshots = readdirSync(join(this.dataDir, "backups")).filter((f) => f.startsWith("accounts-") && f.endsWith(".db")).sort();
|
|
@@ -1380,8 +1971,8 @@ ${done ? `<div class=done role=status>${esc(done)}</div>` : ""}
|
|
|
1380
1971
|
}
|
|
1381
1972
|
adminSpace(res, url, name) {
|
|
1382
1973
|
const safe = name.replace(/[^a-z0-9._-]/gi, "_");
|
|
1383
|
-
const sp = this.spaces.get(name) ?? null;
|
|
1384
|
-
const tomb = this.deletedSpaces.get(name);
|
|
1974
|
+
const sp = this.spaces.get(spaceKey(name)) ?? null;
|
|
1975
|
+
const tomb = this.deletedSpaces.get(spaceKey(name));
|
|
1385
1976
|
const onDisk = [".jsonl", ".state.json", ".members.json"].some((suf) => existsSync(join(this.dataDir, `${safe}${suf}`)));
|
|
1386
1977
|
if (!name || (!sp && (!onDisk || (tomb && Date.now() - tomb < 3_600_000))))
|
|
1387
1978
|
return this.json(res, 404, { error: `no space "${name}" on this relay` });
|
|
@@ -1563,7 +2154,12 @@ ${url.searchParams.get("done") ? `<div class=done role=status>${esc(url.searchPa
|
|
|
1563
2154
|
return void res.end(JSON.stringify(data, null, 2));
|
|
1564
2155
|
}
|
|
1565
2156
|
const cols = data.length ? Object.keys(data[0]) : [];
|
|
1566
|
-
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
|
+
};
|
|
1567
2163
|
const csv = [cols.join(","), ...data.map((r) => cols.map((k) => cell(r[k])).join(","))].join("\n");
|
|
1568
2164
|
res.writeHead(200, { "content-type": "text/csv", "content-disposition": `attachment; filename="${table}.csv"` });
|
|
1569
2165
|
res.end(csv);
|
|
@@ -1628,22 +2224,59 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
1628
2224
|
const touch = () => {
|
|
1629
2225
|
if (dbSession && Date.now() - lastTouch > 60_000) {
|
|
1630
2226
|
lastTouch = Date.now();
|
|
1631
|
-
|
|
2227
|
+
try {
|
|
2228
|
+
this.accounts?.touchSession(dbSession);
|
|
2229
|
+
}
|
|
2230
|
+
catch (e) {
|
|
2231
|
+
this.noteStorageFailure("session touch", e);
|
|
2232
|
+
}
|
|
1632
2233
|
}
|
|
1633
2234
|
};
|
|
1634
2235
|
let sessionId = "";
|
|
1635
2236
|
let role = "interactive";
|
|
2237
|
+
let myPubkey = "";
|
|
2238
|
+
let authEpoch = -1, authOk = true;
|
|
1636
2239
|
let lastMsg = "";
|
|
1637
2240
|
let alive = true;
|
|
1638
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
|
+
};
|
|
1639
2256
|
ws.on("pong", () => { alive = true; touch(); });
|
|
1640
2257
|
const probe = ws;
|
|
1641
2258
|
probe._alive = () => alive;
|
|
1642
2259
|
probe._clear = () => { alive = false; };
|
|
2260
|
+
let refusalStamps = [];
|
|
2261
|
+
let refusedOut = false;
|
|
1643
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
|
+
}
|
|
1644
2277
|
if (cid && space && me && !space.deleted) {
|
|
1645
|
-
noteRefusal(space.store.refusals, { ts:
|
|
1646
|
-
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);
|
|
1647
2280
|
}
|
|
1648
2281
|
this.send(ws, { t: "error", code, message, ...(cid ? { cid } : {}) });
|
|
1649
2282
|
};
|
|
@@ -1669,6 +2302,9 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
1669
2302
|
ws.on("close", clearHelloTimer);
|
|
1670
2303
|
this.send(ws, { t: "challenge", nonce, v: PROTOCOL_VERSION });
|
|
1671
2304
|
ws.on("message", (raw) => {
|
|
2305
|
+
if (refusedOut)
|
|
2306
|
+
return;
|
|
2307
|
+
let reqCid;
|
|
1672
2308
|
try {
|
|
1673
2309
|
let msg;
|
|
1674
2310
|
try {
|
|
@@ -1677,6 +2313,8 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
1677
2313
|
catch {
|
|
1678
2314
|
return fail("BAD_JSON", "message was not JSON");
|
|
1679
2315
|
}
|
|
2316
|
+
if (typeof msg?.cid === "string")
|
|
2317
|
+
reqCid = (msg.cid).slice(0, 64);
|
|
1680
2318
|
const parsed = Inbound.safeParse(msg);
|
|
1681
2319
|
if (!parsed.success) {
|
|
1682
2320
|
const cid = typeof msg?.cid === "string"
|
|
@@ -1701,26 +2339,32 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
1701
2339
|
const gated = this.requireAccounts && !!this.accounts && !this.localTrusted(ip);
|
|
1702
2340
|
const pre = gated ? this.accounts.authorize(m.pubkey, m.member) : null;
|
|
1703
2341
|
let sp;
|
|
2342
|
+
let staleToken = false;
|
|
1704
2343
|
if (m.enroll) {
|
|
1705
|
-
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`);
|
|
1706
2347
|
if (!sp && this.spaceOnDisk(m.space)) {
|
|
1707
2348
|
const inv = new Allowlist(this.dataDir, safeName(m.space)).findInvite(m.enroll);
|
|
1708
2349
|
if (inv?.token === m.enroll && inv.space_token)
|
|
1709
|
-
sp = this.getOrCreateSpace(m.space, inv.space_token);
|
|
2350
|
+
sp = this.getOrCreateSpace(m.space, inv.space_token, { claim: "invite", retired: true });
|
|
1710
2351
|
}
|
|
1711
2352
|
if (!sp)
|
|
1712
2353
|
return reject("NO_SUCH_SPACE", `no space "${m.space}" on this relay`);
|
|
1713
2354
|
}
|
|
1714
2355
|
else {
|
|
1715
|
-
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 ?? "");
|
|
1716
2357
|
if (tomb && Date.now() - tomb < 3_600_000 || (tomb && this.tombstoneMatches(m.space, m.token ?? ""))) {
|
|
1717
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.`);
|
|
1718
2359
|
}
|
|
1719
|
-
|
|
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);
|
|
1720
2364
|
if (isNew && this.nameCollides(m.space)) {
|
|
1721
2365
|
return reject("NAME_TAKEN", `a team with a name too close to "${m.space}" already exists here — pick another name`);
|
|
1722
2366
|
}
|
|
1723
|
-
if (!this.
|
|
2367
|
+
if (!this.live(m.space)) {
|
|
1724
2368
|
const denied = this.mayCreateSpace(ip);
|
|
1725
2369
|
if (denied)
|
|
1726
2370
|
return reject(denied.code, denied.message);
|
|
@@ -1739,9 +2383,19 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
1739
2383
|
return reject("PLAN_LIMIT", msg);
|
|
1740
2384
|
}
|
|
1741
2385
|
}
|
|
1742
|
-
|
|
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 });
|
|
1743
2396
|
if (!sp)
|
|
1744
2397
|
return reject("BAD_TOKEN", "wrong token for this space");
|
|
2398
|
+
staleToken = this.tokenState(sp, m.token) === "retired";
|
|
1745
2399
|
if (isNew) {
|
|
1746
2400
|
this.noteSpaceCreate(ip);
|
|
1747
2401
|
this.accounts?.recordSpace(sp.name, pre?.ok ? pre.account.id : null);
|
|
@@ -1770,20 +2424,38 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
1770
2424
|
const plan = this.spacePlan(sp.name);
|
|
1771
2425
|
if (plan && !sp.allow.get(m.member)?.admitted && sp.allow.admittedCount() >= plan.limits.members) {
|
|
1772
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 });
|
|
1773
|
-
return reject("PLAN_LIMIT", `"${sp.name}" is full: its owner's ${plan.name} plan allows ${fmtLimit(plan.limits.members)} people. Ask ${plan.owner.username} to upgrade at https://www.joinpantheon.network`);
|
|
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."}`);
|
|
1774
2428
|
}
|
|
1775
2429
|
const r = sp.allow.redeem(m.enroll, m.member, m.pubkey);
|
|
1776
2430
|
if (!r.ok) {
|
|
1777
2431
|
this.log(`[${sp.name}] ! invite refused for ${m.member}: ${r.why}`);
|
|
1778
2432
|
return reject("BAD_INVITE", r.fix ? `${r.why}\n${r.fix}` : r.why);
|
|
1779
2433
|
}
|
|
1780
|
-
enrolled = sp
|
|
2434
|
+
enrolled = this.ensureToken(sp);
|
|
1781
2435
|
this.log(`[${sp.name}] * ENROLLED ${m.member} via invite from ${r.entry.admitted_by}${r.entry.may_wake ? " (may wake)" : ""}`);
|
|
1782
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
|
+
}
|
|
1783
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
|
+
}
|
|
1784
2456
|
if (verdict === "key-mismatch") {
|
|
1785
2457
|
this.log(`[${sp.name}] ! REFUSED ${m.member}: key mismatch (${fingerprint(m.pubkey)})`);
|
|
1786
|
-
return reject("KEY_MISMATCH", `"${m.member}" is already registered here with a different key
|
|
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.`);
|
|
1787
2459
|
}
|
|
1788
2460
|
if (verdict === "revoked") {
|
|
1789
2461
|
const e = sp.allow.get(m.member);
|
|
@@ -1815,6 +2487,7 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
1815
2487
|
}
|
|
1816
2488
|
}
|
|
1817
2489
|
space = sp;
|
|
2490
|
+
myPubkey = m.pubkey;
|
|
1818
2491
|
clearHelloTimer();
|
|
1819
2492
|
sessionId = acctSession ?? this.mintSession();
|
|
1820
2493
|
role = m.role;
|
|
@@ -1857,8 +2530,13 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
1857
2530
|
obligations: [...sp.obligations.values()], members: this.members(sp), profiles: [...sp.profiles.values()],
|
|
1858
2531
|
space_id: sp.allow.spaceId(), repos: sp.allow.repoList(),
|
|
1859
2532
|
...(acct ? { plan: this.planView(acct, sp) } : {}),
|
|
1860
|
-
...(enrolled ? { token: enrolled } : {}),
|
|
2533
|
+
...(enrolled ? { token: enrolled } : staleToken && this.knownToken(sp) ? { token: sp.token } : {}),
|
|
1861
2534
|
});
|
|
2535
|
+
if (staleToken && !enrolled && !sp.token) {
|
|
2536
|
+
const mine = me.sessions.get(sessionId);
|
|
2537
|
+
if (mine)
|
|
2538
|
+
mine.wantsToken = true;
|
|
2539
|
+
}
|
|
1862
2540
|
let from = m.from_seq && m.from_seq > 0 ? m.from_seq : sp.ledger.head;
|
|
1863
2541
|
for (let page = sp.ledger.since(from); page.length; page = sp.ledger.since(from)) {
|
|
1864
2542
|
for (const r of page) {
|
|
@@ -1868,6 +2546,11 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
1868
2546
|
}
|
|
1869
2547
|
from = page[page.length - 1].seq;
|
|
1870
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
|
+
}
|
|
1871
2554
|
if (firstSession && m.role !== "cli") {
|
|
1872
2555
|
this.commit(sp, { from: me.id, act: "JOIN", body: `${me.display} online (${me.agent})`, to: null, goal: null, ref: null });
|
|
1873
2556
|
}
|
|
@@ -1877,6 +2560,11 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
1877
2560
|
}
|
|
1878
2561
|
if (!space || !me)
|
|
1879
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
|
+
}
|
|
1880
2568
|
if (m.t === "publish_profile") {
|
|
1881
2569
|
if (m.profile.member !== me.id) {
|
|
1882
2570
|
return fail("PROFILE_NOT_YOURS", `you may publish only the profile for ${me.id}`, m.cid);
|
|
@@ -1922,19 +2610,16 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
1922
2610
|
return fail("FOUNDER_ONLY", `only ${founder}, who founded "${space.name}", can remove a member`, m.cid);
|
|
1923
2611
|
if (m.member === founder)
|
|
1924
2612
|
return fail("FOUNDER_STAYS", "the founder cannot be removed from their own team", m.cid);
|
|
1925
|
-
|
|
1926
|
-
space.allow.forget(m.member);
|
|
1927
|
-
else
|
|
1928
|
-
space.allow.revoke(m.member, me.id);
|
|
2613
|
+
const removed = m.forget ? space.allow.forget(m.member) : space.allow.revoke(m.member, me.id);
|
|
1929
2614
|
if (space.profiles.delete(m.member))
|
|
1930
2615
|
this.broadcast(space, { t: "profiles", profiles: [...space.profiles.values()] });
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
|
|
1935
|
-
catch { }
|
|
1936
|
-
}
|
|
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();
|
|
1937
2620
|
space.members.delete(m.member);
|
|
2621
|
+
if (removed && this.rotateOnRemove)
|
|
2622
|
+
this.rotateSpaceToken(space, `${m.member} ${m.forget ? "forgotten" : "removed"} by ${me.id}`);
|
|
1938
2623
|
this.log(`[${space.name}] - ${m.forget ? "FORGOT" : "REVOKED"} ${m.member} by ${me.id}`);
|
|
1939
2624
|
}
|
|
1940
2625
|
else {
|
|
@@ -1964,7 +2649,15 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
1964
2649
|
this.accounts?.audit({ event: "space_deleted", actor_account: acct?.id ?? null, space: name, detail: `by its founder ${me.id}`, ip });
|
|
1965
2650
|
this.log(`[${name}] * DELETED by its founder ${me.id}`);
|
|
1966
2651
|
this.send(ws, { t: "ack", ...(m.cid ? { cid: m.cid } : {}), id: name, seq: space.ledger.head });
|
|
1967
|
-
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?.();
|
|
1968
2661
|
return;
|
|
1969
2662
|
}
|
|
1970
2663
|
if (m.t === "invite") {
|
|
@@ -1985,7 +2678,7 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
1985
2678
|
if (space.allow.openInvites().length >= LIMITS_SPACE.openInvites)
|
|
1986
2679
|
return fail("TOO_MANY_INVITES", `this team already has ${LIMITS_SPACE.openInvites} unused invites — revoke some: pantheon invite --revoke all`, m.cid);
|
|
1987
2680
|
const token = randomBytes(18).toString("base64url");
|
|
1988
|
-
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));
|
|
1989
2682
|
this.log(`[${space.name}] * invite minted by ${me.id}, valid ${m.ttl_minutes}m, single use`);
|
|
1990
2683
|
this.send(ws, { t: "invite", url: token, expires_at: inv.expires_at });
|
|
1991
2684
|
return;
|
|
@@ -2124,6 +2817,8 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
2124
2817
|
if (m.t === "open_goal") {
|
|
2125
2818
|
if (!this.writeOk(me))
|
|
2126
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);
|
|
2127
2822
|
const openCount = [...space.goals.values()].filter((g) => g.state !== "CLOSED").length;
|
|
2128
2823
|
if (openCount >= LIMITS_SPACE.openGoals)
|
|
2129
2824
|
return fail("TOO_MANY_GOALS", `this team has ${openCount} goals still open — close some first`, m.cid);
|
|
@@ -2156,11 +2851,15 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
2156
2851
|
body: `goal opened: ${goal.title}\nacceptance: ${goal.acceptance || "(none stated)"}` +
|
|
2157
2852
|
(goal.deadline ? `\ndeadline: ${new Date(goal.deadline).toISOString()}` : "") +
|
|
2158
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 },
|
|
2159
2855
|
});
|
|
2160
2856
|
this.send(ws, { t: "ack", ...(m.cid ? { cid: m.cid } : {}), id: goal.id, seq: rec.seq });
|
|
2161
2857
|
return;
|
|
2162
2858
|
}
|
|
2163
|
-
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);
|
|
2164
2863
|
const prior = space.recent.get(fp);
|
|
2165
2864
|
if (prior && Date.now() - prior.at < LIMITS.dedupeWindowMs) {
|
|
2166
2865
|
this.send(ws, { t: "ack", ...(m.cid ? { cid: m.cid } : {}), id: prior.id, seq: prior.seq });
|
|
@@ -2184,6 +2883,16 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
2184
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);
|
|
2185
2884
|
if (err)
|
|
2186
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);
|
|
2187
2896
|
const rec = this.commit(space, {
|
|
2188
2897
|
from: me.id, session: sessionId, role, act: m.act, body: m.body,
|
|
2189
2898
|
...(m.summary ? { summary: m.summary } : {}),
|
|
@@ -2194,23 +2903,54 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
2194
2903
|
...(m.affects ? { affects: m.affects } : {}) } }
|
|
2195
2904
|
: {}),
|
|
2196
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
|
+
}
|
|
2197
2914
|
space.recent.set(fp, { id: rec.id, seq: rec.seq, at: Date.now() });
|
|
2198
2915
|
for (const [k, v] of space.recent)
|
|
2199
2916
|
if (Date.now() - v.at > LIMITS.dedupeWindowMs)
|
|
2200
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
|
+
}
|
|
2201
2923
|
this.send(ws, { t: "ack", ...(m.cid ? { cid: m.cid } : {}), id: rec.id, seq: rec.seq });
|
|
2202
2924
|
}
|
|
2203
2925
|
catch (e) {
|
|
2204
|
-
|
|
2205
|
-
try {
|
|
2206
|
-
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 }));
|
|
2207
2929
|
}
|
|
2208
|
-
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");
|
|
2209
2944
|
}
|
|
2210
2945
|
});
|
|
2211
2946
|
const closeWork = (code) => {
|
|
2212
2947
|
if (dbSession) {
|
|
2213
|
-
|
|
2948
|
+
try {
|
|
2949
|
+
this.accounts?.endSession(dbSession);
|
|
2950
|
+
}
|
|
2951
|
+
catch (e) {
|
|
2952
|
+
this.noteStorageFailure("session end", e);
|
|
2953
|
+
}
|
|
2214
2954
|
dbSession = null;
|
|
2215
2955
|
}
|
|
2216
2956
|
if (!space || !me)
|
|
@@ -2233,7 +2973,8 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
2233
2973
|
else
|
|
2234
2974
|
v.at = Date.now();
|
|
2235
2975
|
}
|
|
2236
|
-
|
|
2976
|
+
const stillMember = space.members.get(me.id) === me && !!space.allow.get(me.id)?.admitted;
|
|
2977
|
+
if (me.sessions.size === 0 && role !== "cli" && stillMember) {
|
|
2237
2978
|
me.last_seen = Date.now();
|
|
2238
2979
|
this.commit(space, { from: me.id, act: "LEAVE", body: `${me.display} offline`, to: null, goal: null, ref: null });
|
|
2239
2980
|
}
|
|
@@ -2245,7 +2986,7 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
2245
2986
|
closeWork(code);
|
|
2246
2987
|
}
|
|
2247
2988
|
catch (e) {
|
|
2248
|
-
this.
|
|
2989
|
+
this.noteStorageFailure(`[${space?.name ?? "?"}] cleanup after a disconnect`, e);
|
|
2249
2990
|
} });
|
|
2250
2991
|
ws.on("error", () => { });
|
|
2251
2992
|
}
|
|
@@ -2265,19 +3006,20 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
2265
3006
|
const now = Date.now();
|
|
2266
3007
|
for (const sp of this.spaces.values()) {
|
|
2267
3008
|
try {
|
|
2268
|
-
|
|
3009
|
+
const every = this.spaceFull(sp) ? 60_000 : LIMITS.ledgerCompactEveryMs;
|
|
3010
|
+
if (now - (this.compactedAt.get(sp.name) ?? 0) > every)
|
|
2269
3011
|
this.compactSpace(sp);
|
|
2270
3012
|
this.lapseClaims(sp, now);
|
|
2271
3013
|
}
|
|
2272
3014
|
catch (e) {
|
|
2273
|
-
this.
|
|
3015
|
+
this.noteStorageFailure(`[${sp.name}] housekeeping`, e);
|
|
2274
3016
|
}
|
|
2275
3017
|
}
|
|
2276
3018
|
try {
|
|
2277
3019
|
this.evictSpaces();
|
|
2278
3020
|
}
|
|
2279
3021
|
catch (e) {
|
|
2280
|
-
this.
|
|
3022
|
+
this.noteStorageFailure("evicting idle spaces", e);
|
|
2281
3023
|
}
|
|
2282
3024
|
}
|
|
2283
3025
|
lapseClaims(sp, now) {
|
|
@@ -2312,6 +3054,8 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
2312
3054
|
return { code: "NO_SUCH_MEMBER", message: `nobody here is called "${id}" — members: ${[...sp.members.keys()].join(", ")}` };
|
|
2313
3055
|
}
|
|
2314
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.` };
|
|
2315
3059
|
if (ref && !sp.ledger.find(ref))
|
|
2316
3060
|
return { code: "BAD_REF", message: `no act with id ${ref}` };
|
|
2317
3061
|
if (meta && JSON.stringify(meta).length > LIMITS_SPACE.metaBytes) {
|
|
@@ -2506,7 +3250,10 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
2506
3250
|
commit(sp, partial) {
|
|
2507
3251
|
if (sp.deleted)
|
|
2508
3252
|
return { ...partial, id: newId("a"), seq: sp.ledger.head, ts: Date.now(), space: sp.name };
|
|
2509
|
-
|
|
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);
|
|
2510
3257
|
sp.lastTouch = rec.ts;
|
|
2511
3258
|
const m = sp.members.get(rec.from);
|
|
2512
3259
|
if (m) {
|
|
@@ -2514,13 +3261,22 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
2514
3261
|
if (rec.act !== "JOIN" && rec.act !== "LEAVE" && (m.seen_seq ?? 0) < rec.seq - 1)
|
|
2515
3262
|
m.seen_seq = rec.seq - 1;
|
|
2516
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) {
|
|
2517
3273
|
if (rec.goal) {
|
|
2518
3274
|
const goal = sp.goals.get(rec.goal);
|
|
2519
3275
|
if (goal) {
|
|
2520
3276
|
goal.acts++;
|
|
2521
3277
|
const run = sp.lastActor.get(rec.goal);
|
|
2522
3278
|
sp.lastActor.set(rec.goal, run && run.member === rec.from ? { member: rec.from, run: run.run + 1 } : { member: rec.from, run: 1 });
|
|
2523
|
-
this.applyObligations(sp, rec, goal);
|
|
3279
|
+
this.applyObligations(sp, rec, goal, ctx);
|
|
2524
3280
|
this.broadcast(sp, { t: "goal", goal });
|
|
2525
3281
|
if (goal.acts >= goal.budget && goal.state === "OPEN") {
|
|
2526
3282
|
goal.state = "STALLED";
|
|
@@ -2531,14 +3287,10 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
2531
3287
|
}
|
|
2532
3288
|
}
|
|
2533
3289
|
else {
|
|
2534
|
-
this.applyObligations(sp, rec, null);
|
|
3290
|
+
this.applyObligations(sp, rec, null, ctx);
|
|
2535
3291
|
}
|
|
2536
|
-
this.pushObligations(sp);
|
|
2537
|
-
this.broadcast(sp, { t: "act", act: rec });
|
|
2538
|
-
this.broadcast(sp, { t: "roster", roster: this.roster(sp) });
|
|
2539
|
-
return rec;
|
|
2540
3292
|
}
|
|
2541
|
-
applyObligations(sp, rec, goal) {
|
|
3293
|
+
applyObligations(sp, rec, goal, ctx) {
|
|
2542
3294
|
const closeFor = (owner, ref) => {
|
|
2543
3295
|
for (const [id, o] of sp.obligations)
|
|
2544
3296
|
if (o.owner === owner && o.ref === ref)
|
|
@@ -2642,8 +3394,9 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
2642
3394
|
const answer = sp.ledger.find(rec.ref);
|
|
2643
3395
|
const asked = answer?.act === "ANSWER" && answer.from === rec.from && answer.ref ? sp.ledger.find(answer.ref) : undefined;
|
|
2644
3396
|
const decider = asked?.act === "ESCALATE" ? asked.from : asked?.act === "HUMAN_REQUIRED" ? asked.to?.[0] : undefined;
|
|
2645
|
-
const retracted = new Set(sp.ledger.all().filter((r) => r.act === "RETRACT" && r.ref).map((r) => r.ref));
|
|
2646
|
-
const
|
|
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));
|
|
2647
3400
|
const alreadyLive = asked && [...sp.obligations.values()].some((o) => o.kind === "HUMAN" && o.ref === asked.id);
|
|
2648
3401
|
if (asked && decider === rec.from && !stillAnswered && !alreadyLive && !retracted.has(asked.id)) {
|
|
2649
3402
|
this.oblige(sp, { kind: "HUMAN", owner: `human:${decider}`, ref: asked.id, goal: asked.goal,
|
|
@@ -2681,6 +3434,23 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
2681
3434
|
const id = newId("o");
|
|
2682
3435
|
sp.obligations.set(id, { ...o, id, created_at: Date.now() });
|
|
2683
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
|
+
}
|
|
2684
3454
|
pushObligations(sp) {
|
|
2685
3455
|
sp.store.flush(sp.goals, sp.obligations, sp.profiles);
|
|
2686
3456
|
this.broadcast(sp, { t: "obligations", list: [...sp.obligations.values()] });
|
|
@@ -2753,18 +3523,23 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
2753
3523
|
continue;
|
|
2754
3524
|
const idle = now - sp.lastTouch;
|
|
2755
3525
|
const junk = this.isJunkSpace(sp);
|
|
2756
|
-
|
|
2757
|
-
|
|
2758
|
-
|
|
2759
|
-
|
|
2760
|
-
|
|
2761
|
-
|
|
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
|
+
}
|
|
2762
3540
|
}
|
|
2763
|
-
|
|
2764
|
-
|
|
2765
|
-
this.spaces.delete(name);
|
|
2766
|
-
this.compactedAt.delete(name);
|
|
2767
|
-
this.log(`[${name}] evicted from memory (idle ${Math.round(idle / 3_600_000)}h) — reloads from disk on next connect`);
|
|
3541
|
+
catch (e) {
|
|
3542
|
+
this.noteStorageFailure(`[${name}] eviction flush`, e);
|
|
2768
3543
|
}
|
|
2769
3544
|
}
|
|
2770
3545
|
}
|
|
@@ -2777,22 +3552,49 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
2777
3552
|
catch { }
|
|
2778
3553
|
}
|
|
2779
3554
|
}
|
|
2780
|
-
getOrCreateSpace(name, token) {
|
|
2781
|
-
const
|
|
2782
|
-
|
|
2783
|
-
|
|
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;
|
|
2784
3567
|
const safe = name.replace(/[^a-z0-9._-]/gi, "_");
|
|
2785
3568
|
const allow = new Allowlist(this.dataDir, safe);
|
|
2786
3569
|
const match = allow.tokenMatches(token);
|
|
2787
|
-
|
|
2788
|
-
|
|
2789
|
-
|
|
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
|
+
}
|
|
2790
3587
|
allow.bindToken(token);
|
|
3588
|
+
this.unbound.delete(safe);
|
|
3589
|
+
}
|
|
3590
|
+
const ledger = new Ledger(this.dataDir, safe);
|
|
2791
3591
|
const store = new StateStore(this.dataDir, safe);
|
|
2792
|
-
|
|
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 });
|
|
2793
3595
|
const sp = {
|
|
2794
|
-
name, token,
|
|
2795
|
-
ledger
|
|
3596
|
+
name, token: raw,
|
|
3597
|
+
ledger,
|
|
2796
3598
|
allow,
|
|
2797
3599
|
store,
|
|
2798
3600
|
members: new Map(),
|
|
@@ -2805,27 +3607,185 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
2805
3607
|
createdAt: Date.now(),
|
|
2806
3608
|
lastTouch: Date.now(),
|
|
2807
3609
|
};
|
|
2808
|
-
|
|
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
|
+
}
|
|
2809
3662
|
for (const e of allow.all()) {
|
|
2810
3663
|
if (e.admitted && !sp.members.has(e.member)) {
|
|
2811
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: [] });
|
|
2812
3665
|
}
|
|
2813
3666
|
}
|
|
3667
|
+
this.rebuildRecent(sp);
|
|
2814
3668
|
const open = [...sp.goals.values()].filter((g) => g.state !== "CLOSED").length;
|
|
2815
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
|
+
}
|
|
2816
3680
|
this.compactSpace(sp);
|
|
2817
3681
|
return sp;
|
|
2818
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
|
+
}
|
|
2819
3777
|
compactSpace(sp) {
|
|
3778
|
+
this.compactedAt.set(sp.name, Date.now());
|
|
3779
|
+
if (sp.unclean)
|
|
3780
|
+
return;
|
|
2820
3781
|
const isLive = (goal) => {
|
|
2821
3782
|
if (!goal)
|
|
2822
3783
|
return false;
|
|
2823
3784
|
const g = sp.goals.get(goal);
|
|
2824
|
-
return
|
|
3785
|
+
return !g || g.state !== "CLOSED";
|
|
2825
3786
|
};
|
|
2826
3787
|
const referenced = new Set([...sp.obligations.values()].map((o) => o.ref));
|
|
2827
|
-
const { dropped, kept } = sp.ledger.compact({ keepRecent: LIMITS.ledgerKeepRecent, isLive, keep: (id) => referenced.has(id) });
|
|
2828
|
-
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) });
|
|
2829
3789
|
if (dropped)
|
|
2830
3790
|
this.log(`[${sp.name}] compacted ledger — dropped ${dropped} old act(s) from closed goals, ${kept} kept`);
|
|
2831
3791
|
}
|
|
@@ -2862,6 +3822,9 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
2862
3822
|
return sp.allow.all().map((e) => ({
|
|
2863
3823
|
member: e.member, admitted: e.admitted, may_wake: e.may_wake,
|
|
2864
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 } : {}),
|
|
2865
3828
|
}));
|
|
2866
3829
|
}
|
|
2867
3830
|
send(ws, msg) {
|