@join-pantheon/cli 0.29.1 → 0.31.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +142 -40
- package/bin/pantheon.js +12 -0
- package/dist/accounts.js +151 -26
- package/dist/agents.js +224 -17
- package/dist/claude-confine.js +299 -0
- package/dist/cli.js +1116 -137
- package/dist/client.js +294 -10
- package/dist/command-state.js +102 -0
- package/dist/config.js +287 -14
- package/dist/continued.js +42 -10
- package/dist/doctor.js +191 -23
- package/dist/file-lock.js +197 -0
- package/dist/grants.js +1 -1
- package/dist/history-view.js +2 -1
- package/dist/home-guard.js +565 -0
- package/dist/hooks-install.js +2 -1
- package/dist/identity.js +101 -1
- package/dist/install.js +76 -26
- package/dist/kiro-files.js +2 -2
- package/dist/ledger.js +111 -28
- package/dist/machine.js +494 -32
- package/dist/mcp-supervisor.js +29 -7
- package/dist/mcp-windows.js +47 -25
- package/dist/mcp.js +99 -36
- package/dist/network.js +39 -8
- package/dist/nonces.js +130 -0
- package/dist/notify.js +26 -9
- package/dist/pending.js +121 -22
- package/dist/persona.js +21 -0
- package/dist/pins.js +161 -0
- package/dist/profile.js +22 -1
- package/dist/protocol.js +40 -1
- package/dist/quickstart.js +173 -24
- package/dist/reads.js +37 -0
- package/dist/refusal.js +14 -1
- package/dist/relay.js +1341 -207
- package/dist/repo-find.js +127 -0
- package/dist/repo-route.js +70 -0
- package/dist/roster-file.js +190 -25
- package/dist/run-failure.js +99 -3
- package/dist/secret-guard.js +141 -0
- package/dist/service.js +91 -35
- package/dist/setup-guide.js +91 -31
- package/dist/spend.js +6 -2
- package/dist/store.js +94 -10
- package/dist/text-file.js +74 -0
- package/dist/tray.js +24 -3
- package/dist/untrusted.js +24 -5
- package/dist/update-check.js +2 -0
- package/dist/upgrade.js +9 -5
- package/dist/verify-act.js +102 -0
- package/dist/which.js +69 -12
- package/dist/worker-fleet.js +30 -9
- package/dist/worker-policy.js +231 -7
- package/dist/worker.js +1717 -130
- package/helper/macos/menubar.swift +63 -2
- package/package.json +2 -2
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
2
|
+
import { homedir, platform } from "node:os";
|
|
3
|
+
import { join, resolve } from "node:path";
|
|
4
|
+
import { repoInfo } from "./config.js";
|
|
5
|
+
const SKIP = new Set(["node_modules", "vendor", "dist", "build", "target", "out", ".next", "Pods", "DerivedData", "venv", ".venv", "__pycache__", "Library", "Applications"]);
|
|
6
|
+
export function reposUnder(dir, depth = 3, budget = { left: 4000 }) {
|
|
7
|
+
const out = [];
|
|
8
|
+
const seen = new Set();
|
|
9
|
+
const walk = (d, level) => {
|
|
10
|
+
if (budget.left-- <= 0)
|
|
11
|
+
return;
|
|
12
|
+
if (existsSync(join(d, ".git"))) {
|
|
13
|
+
const r = repoInfo(d);
|
|
14
|
+
if (r && !seen.has(r.root)) {
|
|
15
|
+
seen.add(r.root);
|
|
16
|
+
out.push(r);
|
|
17
|
+
}
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
if (level >= depth)
|
|
21
|
+
return;
|
|
22
|
+
let entries = [];
|
|
23
|
+
try {
|
|
24
|
+
entries = readdirSync(d);
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
for (const e of entries) {
|
|
30
|
+
if (e.startsWith(".") || SKIP.has(e))
|
|
31
|
+
continue;
|
|
32
|
+
const p = join(d, e);
|
|
33
|
+
try {
|
|
34
|
+
if (!statSync(p).isDirectory())
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
walk(p, level + 1);
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
walk(resolve(dir), 0);
|
|
44
|
+
return out;
|
|
45
|
+
}
|
|
46
|
+
export function holdsRepo(dir, budget = 20_000) {
|
|
47
|
+
let left = budget;
|
|
48
|
+
const stack = [resolve(dir)];
|
|
49
|
+
while (stack.length) {
|
|
50
|
+
const d = stack.pop();
|
|
51
|
+
if (left-- <= 0)
|
|
52
|
+
return true;
|
|
53
|
+
let entries = [];
|
|
54
|
+
try {
|
|
55
|
+
entries = readdirSync(d, { withFileTypes: true });
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
for (const e of entries) {
|
|
61
|
+
if (e.name === ".git")
|
|
62
|
+
return true;
|
|
63
|
+
if (e.isDirectory())
|
|
64
|
+
stack.push(join(d, e.name));
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
export function commonCodeRoots(home = homedir()) {
|
|
70
|
+
return ["code", "Code", "src", "dev", "Developer", "projects", "Projects", "workspace", "work", "repos", "git", "github", "GitHub", "Documents/GitHub", "Documents/code", "Sites"]
|
|
71
|
+
.map((d) => join(home, d)).filter((d) => { try {
|
|
72
|
+
return statSync(d).isDirectory();
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
return false;
|
|
76
|
+
} });
|
|
77
|
+
}
|
|
78
|
+
export function openedFolders(home = homedir(), os = platform()) {
|
|
79
|
+
const out = new Set();
|
|
80
|
+
const add = (p) => { if (typeof p === "string" && (p.startsWith("/") || /^[A-Za-z]:[\\/]/.test(p)))
|
|
81
|
+
out.add(p); };
|
|
82
|
+
try {
|
|
83
|
+
for (const k of Object.keys(JSON.parse(readFileSync(join(home, ".claude.json"), "utf8")).projects ?? {}))
|
|
84
|
+
add(k);
|
|
85
|
+
}
|
|
86
|
+
catch { }
|
|
87
|
+
const appData = os === "darwin" ? join(home, "Library", "Application Support") : os === "win32" ? (process.env["APPDATA"] ?? join(home, "AppData", "Roaming")) : join(home, ".config");
|
|
88
|
+
for (const app of ["Code", "Cursor", "Code - Insiders", "Windsurf"]) {
|
|
89
|
+
try {
|
|
90
|
+
const text = readFileSync(join(appData, app, "User", "globalStorage", "storage.json"), "utf8");
|
|
91
|
+
for (const m of text.matchAll(/"file:\/\/([^"]+)"/g)) {
|
|
92
|
+
try {
|
|
93
|
+
add(decodeURIComponent(m[1]).replace(/^\/([A-Za-z]:)/, "$1"));
|
|
94
|
+
}
|
|
95
|
+
catch { }
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
catch { }
|
|
99
|
+
}
|
|
100
|
+
return [...out];
|
|
101
|
+
}
|
|
102
|
+
export function findTeamClones(teamFps, opts = {}) {
|
|
103
|
+
const want = new Set(teamFps);
|
|
104
|
+
const found = new Map();
|
|
105
|
+
const consider = (r, how) => { if (r && want.has(r.fingerprint) && !found.has(r.fingerprint))
|
|
106
|
+
found.set(r.fingerprint, { repo: r, how }); };
|
|
107
|
+
const home = opts.home ?? homedir();
|
|
108
|
+
if (opts.recents)
|
|
109
|
+
for (const d of openedFolders(home, opts.os)) {
|
|
110
|
+
if (found.size === want.size)
|
|
111
|
+
break;
|
|
112
|
+
try {
|
|
113
|
+
if (existsSync(d))
|
|
114
|
+
consider(repoInfo(d), "opened in your editor or agent");
|
|
115
|
+
}
|
|
116
|
+
catch { }
|
|
117
|
+
}
|
|
118
|
+
const budget = { left: 6000 };
|
|
119
|
+
for (const root of [...(opts.extra ?? []), ...commonCodeRoots(home)]) {
|
|
120
|
+
if (found.size === want.size)
|
|
121
|
+
break;
|
|
122
|
+
for (const r of reposUnder(root, 3, budget))
|
|
123
|
+
consider(r, `found in ${root.startsWith(home) ? `~${root.slice(home.length)}` : root}`);
|
|
124
|
+
}
|
|
125
|
+
return [...found.values()];
|
|
126
|
+
}
|
|
127
|
+
//# sourceMappingURL=repo-find.js.map
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { basename } from "node:path";
|
|
2
|
+
export function repoName(originOrPath) {
|
|
3
|
+
const s = originOrPath.trim().replace(/[\\/]+$/, "").replace(/\.git$/i, "");
|
|
4
|
+
return safeName(basename(s.replace(/^.*[:/]([^:/]+)$/, "$1")) || s);
|
|
5
|
+
}
|
|
6
|
+
export function safeName(n) {
|
|
7
|
+
return n.normalize("NFKC").replace(/[^A-Za-z0-9._-]+/g, "-").replace(/^[-.]+|-+$/g, "").slice(0, 64) || "repo";
|
|
8
|
+
}
|
|
9
|
+
const esc = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
10
|
+
export function names(text, name, origin, strict = false) {
|
|
11
|
+
if (!name)
|
|
12
|
+
return false;
|
|
13
|
+
const t = text.toLowerCase();
|
|
14
|
+
const n = name.toLowerCase();
|
|
15
|
+
if (origin) {
|
|
16
|
+
const slug = origin.toLowerCase().replace(/\.git$/, "").replace(/^.*?[:/]([^:/]+\/[^:/]+)$/, "$1");
|
|
17
|
+
if (slug.includes("/") && t.includes(slug))
|
|
18
|
+
return true;
|
|
19
|
+
}
|
|
20
|
+
const word = `(?<![\\w./-])${esc(n)}(?![\\w-])`;
|
|
21
|
+
if (n.length >= 5 && !strict && new RegExp(word, "i").test(t))
|
|
22
|
+
return true;
|
|
23
|
+
return new RegExp(`\`${esc(n)}\`|${word}\\s+(?:repo|repository|codebase|app|service|project)\\b|\\b(?:repo|repository|codebase)\\s+${esc(n)}(?![\\w-])|${word}/[\\w.-]`, "i").test(t);
|
|
24
|
+
}
|
|
25
|
+
export function explicitlyNames(text, name) {
|
|
26
|
+
if (!name)
|
|
27
|
+
return false;
|
|
28
|
+
const n = esc(name.toLowerCase());
|
|
29
|
+
const word = `(?<![\\w./-])${n}(?![\\w-])`;
|
|
30
|
+
return new RegExp(`${word}\\s+(?:repo|repository)\\b|\\b(?:repo|repository)\\s+${n}(?![\\w-])`, "i").test(text.toLowerCase());
|
|
31
|
+
}
|
|
32
|
+
export function routeRequest(i) {
|
|
33
|
+
const ws = i.workspaces;
|
|
34
|
+
if (!ws.length)
|
|
35
|
+
return { kind: "none", why: "no folder on this machine is bound to this team" };
|
|
36
|
+
const byFp = (fp) => (fp ? ws.find((w) => w.fp === fp) : undefined);
|
|
37
|
+
const teamName = (fp) => i.teamRepos.find((r) => r.fp === fp)?.name ?? fp;
|
|
38
|
+
const here = ws.map((w) => w.name);
|
|
39
|
+
const localFps = new Set(ws.map((w) => w.fp).filter(Boolean));
|
|
40
|
+
const notHere = i.teamRepos.filter((r) => !localFps.has(r.fp)).map((r) => r.name);
|
|
41
|
+
const max = i.maxContext ?? 2;
|
|
42
|
+
const activity = i.activity ?? (() => 0);
|
|
43
|
+
const withContext = (primary, why, prefer = []) => {
|
|
44
|
+
const others = ws.filter((w) => w !== primary && w.kind === "repo");
|
|
45
|
+
const ordered = [...prefer.filter((w) => w !== primary), ...others.filter((w) => !prefer.includes(w)).sort((a, b) => activity(b) - activity(a))];
|
|
46
|
+
return { kind: "run", primary, context: ordered.filter((w) => w.kind === "repo").slice(0, max), why, notHere };
|
|
47
|
+
};
|
|
48
|
+
const strict = (n) => !!i.teamName && n.toLowerCase() === i.teamName.toLowerCase();
|
|
49
|
+
const namedLocal = ws.filter((w) => names(i.text, w.name, w.origin, strict(w.name))).sort((a, b) => b.name.length - a.name.length);
|
|
50
|
+
const namedTeam = i.teamRepos.filter((r) => !localFps.has(r.fp) && explicitlyNames(i.ask ?? i.text, r.name)).sort((a, b) => b.name.length - a.name.length);
|
|
51
|
+
if (namedLocal.length) {
|
|
52
|
+
return withContext(namedLocal[0], `the request names ${namedLocal.map((w) => w.name).join(" and ")}`, namedLocal.slice(1));
|
|
53
|
+
}
|
|
54
|
+
if (namedTeam.length) {
|
|
55
|
+
return { kind: "missing", name: namedTeam[0].name, why: `the request is about ${namedTeam[0].name}, which is not on this machine`, here };
|
|
56
|
+
}
|
|
57
|
+
const delivered = byFp(i.deliveredRepo);
|
|
58
|
+
if (delivered)
|
|
59
|
+
return withContext(delivered, `this goal's work was delivered in ${delivered.name}`);
|
|
60
|
+
const sender = byFp(i.senderRepo);
|
|
61
|
+
if (sender)
|
|
62
|
+
return withContext(sender, `the request was sent from ${sender.name}`);
|
|
63
|
+
if (ws.length === 1) {
|
|
64
|
+
const note = i.senderRepo && !sender ? ` (it was sent from ${teamName(i.senderRepo)}, which is not on this machine)` : "";
|
|
65
|
+
return { kind: "run", primary: ws[0], context: [], why: `the only folder on this machine bound to this team${note}`, notHere };
|
|
66
|
+
}
|
|
67
|
+
const primary = [...ws].sort((a, b) => activity(b) - activity(a))[0];
|
|
68
|
+
return withContext(primary, `nothing in the request says which, so the most recently active (${primary.name}), with the others open to read`);
|
|
69
|
+
}
|
|
70
|
+
//# sourceMappingURL=repo-route.js.map
|
package/dist/roster-file.js
CHANGED
|
@@ -1,8 +1,19 @@
|
|
|
1
|
-
import { readFileSync,
|
|
1
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
2
2
|
import { randomBytes } from "node:crypto";
|
|
3
3
|
import { createHash, timingSafeEqual } from "node:crypto";
|
|
4
|
-
import {
|
|
4
|
+
import { join } from "node:path";
|
|
5
5
|
import { fingerprint } from "./identity.js";
|
|
6
|
+
import { writeFileAtomic } from "./store.js";
|
|
7
|
+
export class RosterUnreadable extends Error {
|
|
8
|
+
file;
|
|
9
|
+
constructor(file, detail) {
|
|
10
|
+
super(`the member list ${file} cannot be read (${detail})`);
|
|
11
|
+
this.file = file;
|
|
12
|
+
this.name = "RosterUnreadable";
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
export const PENDING_CAP = 50;
|
|
16
|
+
export const PENDING_TTL_MS = 7 * 24 * 3_600_000;
|
|
6
17
|
function hashToken(token) {
|
|
7
18
|
return createHash("sha256").update(token).digest("hex");
|
|
8
19
|
}
|
|
@@ -10,35 +21,109 @@ export class Allowlist {
|
|
|
10
21
|
entries = new Map();
|
|
11
22
|
invites = new Map();
|
|
12
23
|
tokenHash = null;
|
|
24
|
+
retiredHashes = [];
|
|
25
|
+
removedAt = null;
|
|
13
26
|
id = null;
|
|
27
|
+
founderName = null;
|
|
28
|
+
founderGuessed = null;
|
|
29
|
+
name = null;
|
|
30
|
+
extra = {};
|
|
14
31
|
repos = new Map();
|
|
15
32
|
file;
|
|
16
33
|
constructor(dir, space) {
|
|
17
34
|
this.file = join(dir, `${space}.members.json`);
|
|
18
35
|
if (existsSync(this.file)) {
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
36
|
+
let raw;
|
|
37
|
+
try {
|
|
38
|
+
raw = JSON.parse(readFileSync(this.file, "utf8"));
|
|
39
|
+
}
|
|
40
|
+
catch (e) {
|
|
41
|
+
throw new RosterUnreadable(this.file, e.message);
|
|
42
|
+
}
|
|
43
|
+
if (!raw || typeof raw !== "object")
|
|
44
|
+
throw new RosterUnreadable(this.file, "not a member list");
|
|
45
|
+
const doc = (Array.isArray(raw) ? { members: raw, invites: [] } : raw);
|
|
46
|
+
if ((doc.members !== undefined && !Array.isArray(doc.members)) || (doc.invites !== undefined && !Array.isArray(doc.invites))) {
|
|
47
|
+
throw new RosterUnreadable(this.file, "not a member list");
|
|
48
|
+
}
|
|
23
49
|
for (const i of doc.invites ?? [])
|
|
24
50
|
this.invites.set(i.token, i);
|
|
51
|
+
let legacyRemoval = false;
|
|
52
|
+
for (const e of doc.members ?? []) {
|
|
53
|
+
if (!e.admitted && e.admitted_by && !e.revoked_at) {
|
|
54
|
+
e.revoked_at = Date.now();
|
|
55
|
+
e.may_wake = false;
|
|
56
|
+
legacyRemoval = true;
|
|
57
|
+
}
|
|
58
|
+
this.entries.set(e.member, e);
|
|
59
|
+
}
|
|
60
|
+
if (legacyRemoval) {
|
|
61
|
+
for (const e of this.entries.values())
|
|
62
|
+
if (e.revoked_at && !e.admitted)
|
|
63
|
+
this.cancelInvitesBy(e.member);
|
|
64
|
+
}
|
|
25
65
|
this.tokenHash = doc.tokenHash ?? null;
|
|
66
|
+
if ("retiredTokenHashes" in doc && Array.isArray(doc.retiredTokenHashes))
|
|
67
|
+
this.retiredHashes = doc.retiredTokenHashes.filter((h) => typeof h === "string");
|
|
68
|
+
if ("removed_at" in doc && typeof doc.removed_at === "number")
|
|
69
|
+
this.removedAt = doc.removed_at;
|
|
70
|
+
if (legacyRemoval && this.removedAt === null)
|
|
71
|
+
this.removedAt = Date.now();
|
|
72
|
+
const known = new Set(["members", "invites", "tokenHash", "retiredTokenHashes", "removed_at", "id", "name", "repos", "founder", "founder_guessed"]);
|
|
73
|
+
if (!Array.isArray(raw))
|
|
74
|
+
for (const [k, v] of Object.entries(raw))
|
|
75
|
+
if (!known.has(k))
|
|
76
|
+
this.extra[k] = v;
|
|
26
77
|
this.id = doc.id ?? null;
|
|
78
|
+
this.name = typeof doc.name === "string" && doc.name ? doc.name : null;
|
|
27
79
|
for (const r of doc.repos ?? [])
|
|
28
80
|
this.repos.set(r.fingerprint, r);
|
|
81
|
+
if (typeof doc.founder === "string" && doc.founder) {
|
|
82
|
+
this.founderName = doc.founder;
|
|
83
|
+
const g = raw.founder_guessed;
|
|
84
|
+
if (typeof g === "string" && g)
|
|
85
|
+
this.founderGuessed = g;
|
|
86
|
+
}
|
|
87
|
+
else if (this.entries.size) {
|
|
88
|
+
const d = Allowlist.deriveFounderHow([...this.entries.values()]);
|
|
89
|
+
this.founderName = d?.member ?? null;
|
|
90
|
+
if (d?.how === "earliest")
|
|
91
|
+
this.founderGuessed = "no member was admitted by themselves, so the earliest member to connect was taken";
|
|
92
|
+
if (this.founderName) {
|
|
93
|
+
try {
|
|
94
|
+
this.flush();
|
|
95
|
+
}
|
|
96
|
+
catch { }
|
|
97
|
+
}
|
|
98
|
+
}
|
|
29
99
|
}
|
|
30
100
|
}
|
|
101
|
+
static deriveFounder(entries) {
|
|
102
|
+
return Allowlist.deriveFounderHow(entries)?.member ?? null;
|
|
103
|
+
}
|
|
104
|
+
static deriveFounderHow(entries) {
|
|
105
|
+
const byAge = [...entries].sort((a, b) => a.first_seen - b.first_seen);
|
|
106
|
+
const self = byAge.find((e) => e.admitted && (e.admitted_by === e.member || e.admitted_by === undefined));
|
|
107
|
+
if (self)
|
|
108
|
+
return { member: self.member, how: "self" };
|
|
109
|
+
const m = byAge.find((e) => e.admitted_by !== undefined || e.admitted)?.member;
|
|
110
|
+
return m ? { member: m, how: "earliest" } : null;
|
|
111
|
+
}
|
|
112
|
+
founderGuess() { return this.founderGuessed; }
|
|
31
113
|
flush() {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
writeFileSync(tmp, JSON.stringify({
|
|
114
|
+
writeFileAtomic(this.file, JSON.stringify({
|
|
115
|
+
...this.extra,
|
|
35
116
|
...(this.id ? { id: this.id } : {}),
|
|
117
|
+
...(this.founderName ? { founder: this.founderName } : {}),
|
|
118
|
+
...(this.founderName && this.founderGuessed ? { founder_guessed: this.founderGuessed } : {}),
|
|
119
|
+
...(this.name ? { name: this.name } : {}),
|
|
36
120
|
members: [...this.entries.values()],
|
|
37
121
|
invites: [...this.invites.values()],
|
|
38
122
|
...(this.tokenHash ? { tokenHash: this.tokenHash } : {}),
|
|
123
|
+
...(this.retiredHashes.length ? { retiredTokenHashes: this.retiredHashes } : {}),
|
|
124
|
+
...(this.removedAt !== null ? { removed_at: this.removedAt } : {}),
|
|
39
125
|
...(this.repos.size ? { repos: [...this.repos.values()] } : {}),
|
|
40
|
-
}, null, 2) + "\n"
|
|
41
|
-
renameSync(tmp, this.file);
|
|
126
|
+
}, null, 2) + "\n");
|
|
42
127
|
}
|
|
43
128
|
peekId() { return this.id; }
|
|
44
129
|
spaceId() {
|
|
@@ -48,6 +133,13 @@ export class Allowlist {
|
|
|
48
133
|
}
|
|
49
134
|
return this.id;
|
|
50
135
|
}
|
|
136
|
+
recordedName() { return this.name; }
|
|
137
|
+
recordName(name) {
|
|
138
|
+
if (this.name || !name)
|
|
139
|
+
return;
|
|
140
|
+
this.name = name;
|
|
141
|
+
this.flush();
|
|
142
|
+
}
|
|
51
143
|
repoList() { return [...this.repos.values()]; }
|
|
52
144
|
repoCount() { return this.repos.size; }
|
|
53
145
|
hasRepo(fingerprint) { return this.repos.has(fingerprint); }
|
|
@@ -80,6 +172,42 @@ export class Allowlist {
|
|
|
80
172
|
this.tokenHash = h;
|
|
81
173
|
this.flush();
|
|
82
174
|
}
|
|
175
|
+
tokenRetired(token) {
|
|
176
|
+
if (!token)
|
|
177
|
+
return false;
|
|
178
|
+
const a = Buffer.from(hashToken(token), "hex");
|
|
179
|
+
return this.retiredHashes.some((h) => { const b = Buffer.from(h, "hex"); return a.length === b.length && timingSafeEqual(a, b); });
|
|
180
|
+
}
|
|
181
|
+
tokenHashes() {
|
|
182
|
+
return [...(this.tokenHash ? [this.tokenHash] : []), ...this.retiredHashes];
|
|
183
|
+
}
|
|
184
|
+
static RETIRED_KEPT = 64;
|
|
185
|
+
rotateToken(next) {
|
|
186
|
+
if (this.tokenHash) {
|
|
187
|
+
this.retiredHashes.push(this.tokenHash);
|
|
188
|
+
if (this.retiredHashes.length > Allowlist.RETIRED_KEPT)
|
|
189
|
+
this.retiredHashes = this.retiredHashes.slice(-Allowlist.RETIRED_KEPT);
|
|
190
|
+
}
|
|
191
|
+
this.tokenHash = hashToken(next);
|
|
192
|
+
for (const i of this.invites.values())
|
|
193
|
+
if (!i.used_by && i.space_token)
|
|
194
|
+
i.space_token = next;
|
|
195
|
+
this.flush();
|
|
196
|
+
}
|
|
197
|
+
currentTokenFromInvites() {
|
|
198
|
+
for (const i of this.invites.values()) {
|
|
199
|
+
if (i.space_token && this.tokenMatches(i.space_token) === true)
|
|
200
|
+
return i.space_token;
|
|
201
|
+
}
|
|
202
|
+
return null;
|
|
203
|
+
}
|
|
204
|
+
noteRemoval(at = Date.now()) {
|
|
205
|
+
this.removedAt = at;
|
|
206
|
+
this.flush();
|
|
207
|
+
}
|
|
208
|
+
removalRecorded() {
|
|
209
|
+
return this.removedAt !== null || [...this.entries.values()].some((e) => !!e.revoked_at);
|
|
210
|
+
}
|
|
83
211
|
get hasToken() {
|
|
84
212
|
return this.tokenHash !== null;
|
|
85
213
|
}
|
|
@@ -102,28 +230,29 @@ export class Allowlist {
|
|
|
102
230
|
if (Date.now() > inv.expires_at)
|
|
103
231
|
return { ok: false, why: "that invite has expired — ask for a fresh one" };
|
|
104
232
|
const existing = this.entries.get(member);
|
|
105
|
-
|
|
106
|
-
|
|
233
|
+
const removedAt = this.removedWhen(member);
|
|
234
|
+
if (removedAt !== null && (inv.created_at <= removedAt || (this.founderName && inv.created_by !== this.founderName))) {
|
|
235
|
+
return { ok: false, why: `"${member}" was removed from this team${existing?.revoked_by ? ` by ${existing.revoked_by}` : ""}, so only an invite from the founder${this.founderName ? ` (${this.founderName})` : ""}, made since, can bring them back` };
|
|
107
236
|
}
|
|
108
237
|
if (existing && existing.publicKey !== publicKey) {
|
|
109
238
|
return {
|
|
110
239
|
ok: false,
|
|
111
|
-
why: `"${member}" is registered here with a different key
|
|
240
|
+
why: `"${member}" is registered here with a different key — the name is taken in this team`,
|
|
112
241
|
fix: [
|
|
113
|
-
`If you
|
|
114
|
-
`
|
|
115
|
-
`
|
|
116
|
-
` Then redeem this same invite again
|
|
117
|
-
` Joining as --me <another-name> also works, but you would leave behind every goal and obligation held by "${member}".`,
|
|
242
|
+
`If you are not "${member}", join under your own name: --me <your-name> (this same invite is still unused).`,
|
|
243
|
+
` If you ARE "${member}" on a new machine: restore your original ~/.pantheon/config.json if you still have it — the key is in it.`,
|
|
244
|
+
` Failing that, a teammate who has confirmed it is you retires the old key (this removes whoever holds "${member}" now): pantheon admit ${member} --forget`,
|
|
245
|
+
` Then redeem this same invite again.`,
|
|
118
246
|
].join("\n"),
|
|
119
247
|
};
|
|
120
248
|
}
|
|
121
249
|
const entry = {
|
|
122
|
-
member, publicKey, admitted: true, admitted_by: inv.created_by,
|
|
250
|
+
member, publicKey, admitted: true, admitted_by: this.founderName === member ? member : inv.created_by,
|
|
123
251
|
first_seen: existing?.first_seen ?? Date.now(), may_wake: inv.may_wake,
|
|
124
252
|
};
|
|
125
253
|
this.entries.set(member, entry);
|
|
126
254
|
inv.used_by = member;
|
|
255
|
+
this.clearForgotten(member);
|
|
127
256
|
inv.used_at = Date.now();
|
|
128
257
|
this.flush();
|
|
129
258
|
return { ok: true, entry };
|
|
@@ -168,9 +297,9 @@ export class Allowlist {
|
|
|
168
297
|
return true;
|
|
169
298
|
}
|
|
170
299
|
admittedCount() { return [...this.entries.values()].filter((e) => e.admitted).length; }
|
|
300
|
+
pendingCount() { return [...this.entries.values()].filter((e) => !e.admitted && !e.revoked_at && !e.admitted_by).length; }
|
|
171
301
|
founder() {
|
|
172
|
-
|
|
173
|
-
return f?.member ?? null;
|
|
302
|
+
return this.founderName;
|
|
174
303
|
}
|
|
175
304
|
get(member) {
|
|
176
305
|
return this.entries.get(member);
|
|
@@ -181,15 +310,30 @@ export class Allowlist {
|
|
|
181
310
|
get empty() {
|
|
182
311
|
return this.entries.size === 0;
|
|
183
312
|
}
|
|
184
|
-
present(member, publicKey) {
|
|
313
|
+
present(member, publicKey, now = Date.now()) {
|
|
185
314
|
const cur = this.entries.get(member);
|
|
186
315
|
if (!cur) {
|
|
187
316
|
const founder = this.empty;
|
|
317
|
+
if (!founder) {
|
|
318
|
+
let expired = false;
|
|
319
|
+
for (const [k, e] of this.entries) {
|
|
320
|
+
if (!e.admitted && !e.revoked_at && !e.admitted_by && now - e.first_seen > PENDING_TTL_MS) {
|
|
321
|
+
this.entries.delete(k);
|
|
322
|
+
expired = true;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
if (expired)
|
|
326
|
+
this.flush();
|
|
327
|
+
if (this.pendingCount() >= PENDING_CAP)
|
|
328
|
+
return "pending-full";
|
|
329
|
+
}
|
|
188
330
|
this.entries.set(member, {
|
|
189
331
|
member, publicKey, admitted: founder,
|
|
190
332
|
...(founder ? { admitted_by: member } : {}),
|
|
191
333
|
first_seen: Date.now(), may_wake: founder,
|
|
192
334
|
});
|
|
335
|
+
if (founder)
|
|
336
|
+
this.founderName = member;
|
|
193
337
|
this.flush();
|
|
194
338
|
return founder ? "founder" : "new";
|
|
195
339
|
}
|
|
@@ -201,8 +345,10 @@ export class Allowlist {
|
|
|
201
345
|
const e = this.entries.get(member);
|
|
202
346
|
if (!e)
|
|
203
347
|
return null;
|
|
348
|
+
if (!e.admitted || e.admitted_by === undefined)
|
|
349
|
+
e.admitted_by = by;
|
|
204
350
|
e.admitted = true;
|
|
205
|
-
|
|
351
|
+
this.clearForgotten(member);
|
|
206
352
|
e.may_wake = mayWake;
|
|
207
353
|
delete e.revoked_at;
|
|
208
354
|
delete e.revoked_by;
|
|
@@ -218,11 +364,29 @@ export class Allowlist {
|
|
|
218
364
|
if (i.created_by === member && !i.used_by)
|
|
219
365
|
this.invites.delete(t);
|
|
220
366
|
}
|
|
367
|
+
removedWhen(member) {
|
|
368
|
+
const e = this.entries.get(member);
|
|
369
|
+
if (e?.revoked_at && !e.admitted)
|
|
370
|
+
return e.revoked_at;
|
|
371
|
+
const f = this.extra["forgotten"]?.[member];
|
|
372
|
+
return typeof f === "number" ? f : null;
|
|
373
|
+
}
|
|
374
|
+
clearForgotten(member) {
|
|
375
|
+
const f = this.extra["forgotten"];
|
|
376
|
+
if (f && member in f)
|
|
377
|
+
delete f[member];
|
|
378
|
+
}
|
|
221
379
|
forget(member) {
|
|
222
380
|
const gone = this.entries.delete(member);
|
|
381
|
+
if (gone) {
|
|
382
|
+
const f = (this.extra["forgotten"] ??= {});
|
|
383
|
+
f[member] = Date.now();
|
|
384
|
+
}
|
|
223
385
|
this.cancelInvitesBy(member);
|
|
224
|
-
if (gone)
|
|
386
|
+
if (gone) {
|
|
387
|
+
this.removedAt = Date.now();
|
|
225
388
|
this.flush();
|
|
389
|
+
}
|
|
226
390
|
return gone;
|
|
227
391
|
}
|
|
228
392
|
revoke(member, by) {
|
|
@@ -232,6 +396,7 @@ export class Allowlist {
|
|
|
232
396
|
e.admitted = false;
|
|
233
397
|
e.may_wake = false;
|
|
234
398
|
e.revoked_at = Date.now();
|
|
399
|
+
this.removedAt = e.revoked_at;
|
|
235
400
|
if (by)
|
|
236
401
|
e.revoked_by = by;
|
|
237
402
|
this.cancelInvitesBy(member);
|