@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.
Files changed (57) hide show
  1. package/README.md +142 -40
  2. package/bin/pantheon.js +12 -0
  3. package/dist/accounts.js +151 -26
  4. package/dist/agents.js +224 -17
  5. package/dist/claude-confine.js +299 -0
  6. package/dist/cli.js +1116 -137
  7. package/dist/client.js +294 -10
  8. package/dist/command-state.js +102 -0
  9. package/dist/config.js +287 -14
  10. package/dist/continued.js +42 -10
  11. package/dist/doctor.js +191 -23
  12. package/dist/file-lock.js +197 -0
  13. package/dist/grants.js +1 -1
  14. package/dist/history-view.js +2 -1
  15. package/dist/home-guard.js +565 -0
  16. package/dist/hooks-install.js +2 -1
  17. package/dist/identity.js +101 -1
  18. package/dist/install.js +76 -26
  19. package/dist/kiro-files.js +2 -2
  20. package/dist/ledger.js +111 -28
  21. package/dist/machine.js +494 -32
  22. package/dist/mcp-supervisor.js +29 -7
  23. package/dist/mcp-windows.js +47 -25
  24. package/dist/mcp.js +99 -36
  25. package/dist/network.js +39 -8
  26. package/dist/nonces.js +130 -0
  27. package/dist/notify.js +26 -9
  28. package/dist/pending.js +121 -22
  29. package/dist/persona.js +21 -0
  30. package/dist/pins.js +161 -0
  31. package/dist/profile.js +22 -1
  32. package/dist/protocol.js +40 -1
  33. package/dist/quickstart.js +173 -24
  34. package/dist/reads.js +37 -0
  35. package/dist/refusal.js +14 -1
  36. package/dist/relay.js +1341 -207
  37. package/dist/repo-find.js +127 -0
  38. package/dist/repo-route.js +70 -0
  39. package/dist/roster-file.js +190 -25
  40. package/dist/run-failure.js +99 -3
  41. package/dist/secret-guard.js +141 -0
  42. package/dist/service.js +91 -35
  43. package/dist/setup-guide.js +91 -31
  44. package/dist/spend.js +6 -2
  45. package/dist/store.js +94 -10
  46. package/dist/text-file.js +74 -0
  47. package/dist/tray.js +24 -3
  48. package/dist/untrusted.js +24 -5
  49. package/dist/update-check.js +2 -0
  50. package/dist/upgrade.js +9 -5
  51. package/dist/verify-act.js +102 -0
  52. package/dist/which.js +69 -12
  53. package/dist/worker-fleet.js +30 -9
  54. package/dist/worker-policy.js +231 -7
  55. package/dist/worker.js +1717 -130
  56. package/helper/macos/menubar.swift +63 -2
  57. 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
@@ -1,8 +1,19 @@
1
- import { readFileSync, writeFileSync, existsSync, mkdirSync, renameSync } from "node:fs";
1
+ import { readFileSync, existsSync } from "node:fs";
2
2
  import { randomBytes } from "node:crypto";
3
3
  import { createHash, timingSafeEqual } from "node:crypto";
4
- import { dirname, join } from "node:path";
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
- const raw = JSON.parse(readFileSync(this.file, "utf8"));
20
- const doc = Array.isArray(raw) ? { members: raw, invites: [] } : raw;
21
- for (const e of doc.members ?? [])
22
- this.entries.set(e.member, e);
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
- mkdirSync(dirname(this.file), { recursive: true });
33
- const tmp = `${this.file}.tmp`;
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", { mode: 0o600 });
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
- if (existing?.revoked_at && inv.created_at <= existing.revoked_at) {
106
- return { ok: false, why: `"${member}" was removed from this team${existing.revoked_by ? ` by ${existing.revoked_by}` : ""}, so an invite from before that cannot bring them back — ask the founder for a new one` };
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, so this machine cannot claim that name`,
240
+ why: `"${member}" is registered here with a different key — the name is taken in this team`,
112
241
  fix: [
113
- `If you still have the original ~/.pantheon/config.json, restore it — the key is in there and nothing else needs to change.`,
114
- ` Otherwise an admitted member retires the old key, after checking your new fingerprint with you out of band:`,
115
- ` pantheon admit ${member} --forget`,
116
- ` Then redeem this same invite again; it is still unused.`,
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
- const f = [...this.entries.values()].filter((e) => e.admitted && (e.admitted_by === e.member || e.admitted_by === undefined)).sort((a, b) => a.first_seen - b.first_seen)[0];
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
- e.admitted_by = by;
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);