@join-pantheon/cli 0.29.0 → 0.30.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/README.md +45 -22
  2. package/bin/pantheon.js +12 -0
  3. package/dist/accounts.js +151 -26
  4. package/dist/agents.js +197 -14
  5. package/dist/claude-confine.js +283 -0
  6. package/dist/cli.js +725 -119
  7. package/dist/client.js +37 -2
  8. package/dist/command-state.js +79 -0
  9. package/dist/config.js +211 -10
  10. package/dist/doctor.js +103 -13
  11. package/dist/grants.js +1 -1
  12. package/dist/history-view.js +2 -1
  13. package/dist/home-guard.js +210 -0
  14. package/dist/hooks-install.js +2 -1
  15. package/dist/identity.js +24 -0
  16. package/dist/install.js +76 -26
  17. package/dist/kiro-files.js +2 -2
  18. package/dist/ledger.js +111 -28
  19. package/dist/machine.js +507 -38
  20. package/dist/mcp-supervisor.js +25 -6
  21. package/dist/mcp-windows.js +47 -25
  22. package/dist/mcp.js +25 -10
  23. package/dist/network.js +39 -8
  24. package/dist/notify.js +17 -6
  25. package/dist/pending.js +8 -14
  26. package/dist/persona.js +21 -0
  27. package/dist/profile.js +4 -1
  28. package/dist/quickstart.js +88 -15
  29. package/dist/reads.js +30 -0
  30. package/dist/refusal.js +14 -1
  31. package/dist/relay.js +1193 -185
  32. package/dist/roster-file.js +169 -23
  33. package/dist/run-failure.js +83 -3
  34. package/dist/secret-guard.js +141 -0
  35. package/dist/service.js +105 -36
  36. package/dist/setup-guide.js +28 -9
  37. package/dist/spend.js +6 -2
  38. package/dist/store.js +94 -10
  39. package/dist/text-file.js +74 -0
  40. package/dist/tray.js +11 -2
  41. package/dist/update-check.js +2 -0
  42. package/dist/which.js +69 -12
  43. package/dist/worker-policy.js +137 -3
  44. package/dist/worker.js +702 -81
  45. package/helper/macos/menubar.swift +15 -1
  46. package/package.json +2 -2
package/README.md CHANGED
@@ -42,8 +42,8 @@ That gives you a verified account and a unique username no one else can claim,
42
42
  across every space on the hosted relay. A second machine joins the same account
43
43
  with `pantheon login --email you@work.com`. See your devices and sign any of them
44
44
  out with `pantheon sessions`; every identity and membership event is in `pantheon
45
- audit`. (Self-hosting or on a LAN/tailnet? Accounts are optional — skip straight
46
- to `pantheon start`.)
45
+ audit`. (Self-hosting or on a LAN/tailnet? Accounts are optional there — skip
46
+ signing up and point `start` at your own relay: `pantheon start --relay ws://<host>:8787`.)
47
47
 
48
48
  **Then, from your project's repo — or anywhere, if there is no repo yet (see below):**
49
49
 
@@ -76,8 +76,8 @@ That's it — one command each sets up the **whole machine**: a relay and space
76
76
  (them), pantheon wired into whichever agents each of you already has (**user scope** — every project,
77
77
  every Conductor window, nothing to commit), the wake-up hook so an agent finishes what a peer is
78
78
  waiting for, the **worker** so the machine answers the team even while its human is away (on by
79
- default when run inside a git repo; `--no-worker` opts out — and a peer still needs an explicit
80
- `--may-wake` grant before they can use it), native decision alerts, and the menu bar. Each command
79
+ default when run inside a git repo; `--no-worker` opts out — and a peer can use it only once you
80
+ approve them on this machine with `pantheon wake allow <name>`), native decision alerts, and the menu bar. Each command
81
81
  ends with a "You're set" summary, then a short guided setup on a terminal: three questions — your
82
82
  name, your role, and (inside a repo) whether a run a teammate wakes may execute commands — and one
83
83
  screen of defaults to keep or change: who may wake your machine while you are away, which alerts you
@@ -108,10 +108,10 @@ so and asks for a fresh invite; a relay that cannot be reached asks you for the
108
108
  Already a member — new laptop, or just pulled a newer build? The same full setup is one command:
109
109
  `pantheon install --user` (from your project's repo if you have one).
110
110
 
111
- **Consent happens once, here.** Setup pre-approves exactly the six pantheon *message* tools in
111
+ **Consent happens once, here.** Setup pre-approves exactly the nine pantheon tools — messages, status and reads — in
112
112
  each agent's own permission system (Claude Code `permissions.allow`, Cursor `Mcp()` tokens,
113
113
  Codex per-server approval), so day-to-day coordination never interrupts you with "allow
114
- pantheon_wait?" again. Nothing that executes is pre-approved: `--may-wake` and `--allow-bash`
114
+ pantheon_wait?" again. Nothing that executes is pre-approved: wake rights and `--allow-bash`
115
115
  keep their prompts on purpose — they are the two decisions worth reading. `pantheon leave`
116
116
  removes the grants. What remains is one one-time click per editor that only it can ask for
117
117
  (folder trust in Claude Code, MCP enable in Cursor, `/hooks` approval in Codex).
@@ -198,8 +198,8 @@ product itself; you read it, and you decide.
198
198
 
199
199
  <details><summary>The long way round, if you want to control each piece</summary>
200
200
 
201
- `pantheon relay` · `pantheon init` · `pantheon join` · `pantheon members` · `pantheon admit <who> --may-wake`
202
- · `pantheon install` (project-scope: commits a `.mcp.json`, so `pantheon` must be on PATH). Membership by explicit
201
+ `pantheon relay` · `pantheon init` · `pantheon join` · `pantheon members` · `pantheon admit <who>` ·
202
+ `pantheon wake allow <who>` · `pantheon install` (project-scope: commits a `.mcp.json`, so `pantheon` must be on PATH). Membership by explicit
203
203
  allowlist with fingerprint confirmation, rather than by invite. Everything the two-command path
204
204
  does, one step at a time.
205
205
 
@@ -278,7 +278,7 @@ pantheon service uninstall
278
278
  ```
279
279
 
280
280
  A terminal window is not "while you are asleep", so use `pantheon service install` for anything
281
- real. Logs land in `.pantheon/worker.log`.
281
+ real. Logs land in `~/.pantheon/logs/worker.log` (`pantheon logs worker -f`).
282
282
 
283
283
  ### The menu bar (macOS)
284
284
 
@@ -314,7 +314,7 @@ pantheon directory # shared responsibility and authority
314
314
  pantheon approvals # decisions waiting on you
315
315
  pantheon approvals answer <id> 'Approved because…'
316
316
  pantheon profile delegate --to alex --domains technical \
317
- --until 2026-09-05T18:00:00Z # expires automatically; max 30 days
317
+ --until <ISO time, e.g. 2026-11-01T18:00:00Z> # expires automatically; at most 30 days ahead
318
318
  pantheon profile undelegate --to alex # revoke early
319
319
  ```
320
320
 
@@ -347,16 +347,27 @@ domain is a conflict, not a committee — routing blocks until one of them drops
347
347
  it or delegates. Decide domain ownership out loud before publishing; shared
348
348
  decisions belong to whoever answers for them, with delegation covering absence.
349
349
 
350
- When a peer you granted `--may-wake` asks something and no session of yours picks it up, the
350
+ When a teammate you approved to wake this machine asks something and no session of yours picks it up, the
351
351
  worker spawns a headless agent run to handle it. Verified end to end: an `ASK` from an idle
352
352
  peer's machine was answered in **31 seconds with no human involved**, and:
353
353
 
354
354
  - the run happens in a **dedicated git worktree on a `pantheon/<goal>` branch** — your checkout and
355
355
  your uncommitted work are never touched, and nothing is merged
356
- - it runs with **only the six pantheon message tools** approved; bash stays gated unless you pass
357
- `--allow-bash`
358
- - the relay — not the daemon — enforces who may wake you, so a peer without `--may-wake` is
359
- refused even if your worker tries
356
+ - it runs with **only the pantheon message tools** approved; commands stay gated unless a person on
357
+ this machine grants them (`pantheon policy grant run-commands`, or answering the worker's own request
358
+ from `pantheon approvals` or the menu bar — never from the ledger) or you pass `--allow-bash`
359
+ - two conditions, both enforced: the team must let the peer wake workers at all (the relay's per-member
360
+ `may_wake`, set by `pantheon admit <who> --may-wake` — the relay refuses a claim without it), and,
361
+ unless you chose wake "anyone", a person on THIS machine must have approved them:
362
+ `pantheon wake allow <who>` (`wake deny`, `wake list`). That list lives only on this machine and
363
+ nothing about it is sent to the team. Nobody is approved until you approve them.
364
+ - upgrading keeps what you had said: teammates you admitted with `--may-wake` are carried over; so is
365
+ everyone the team lets wake if you had chosen "anyone" (or never chose, on a machine set up before
366
+ 0.30, when that was the default); a joiner who was never asked keeps the team's founder. The worker
367
+ says who was carried and why. Against a relay older than 0.30 (it cannot say who admitted whom) the
368
+ team's `may_wake` keeps deciding until the relay upgrades, so upgrading the CLI first is safe.
369
+ - the agent's environment carries no identity secret; the run is its own process group, killed whole
370
+ on timeout, `pantheon stop` and `pantheon panic`
360
371
  - a human who is actually working wins — but presence is not attention. The rule, in order:
361
372
  1. a turn in flight in any of your windows (your wake-up hook reports it) → the worker waits
362
373
  2. …unless that report is over **30 minutes** old with nothing after it. Claude Code runs no
@@ -412,14 +423,26 @@ signed in, the first on PATH runs and the alert says exactly that, with the sign
412
423
  `pantheon doctor` shows the same choice (`worker agent`). Each backend is driven the way it
413
424
  actually works, with the same "no shell unless you said so" rule spelled in its own dialect:
414
425
 
415
- | backend | bash gated (default) | with `--allow-bash` |
426
+ | backend | bash gated (default) | with the command grant (`run-commands` or `--allow-bash`) |
416
427
  |---|---|---|
417
- | claude-code | `acceptEdits` + the six pantheon tools | `bypassPermissions` |
418
- | codex | `--sandbox read-only` (codex has no edits-without-shell mode) | `--sandbox workspace-write` |
419
- | cursor | proposes edits, never applies (`-p` without `--force`) | `--force` |
420
- | copilot | `--allow-tool=pantheon-worker --allow-tool=write` (edits without shell, Copilot's `write` kind) | `--allow-all-tools` |
428
+ | claude-code | `acceptEdits`, no shell, reads held to the worktree, `~/.ssh` `~/.aws` `~/.config/gh` `~/.pantheon` and similar denied; the repo's own `.claude` settings never load (`--setting-sources user`) | the same file rules, plus Claude Code's OS sandbox (macOS/Linux): commands may write only the worktree, its git data and temp dirs, may not read the secret paths, and a run whose sandbox cannot start does not start. Commands: git subcommands (with its run-a-program options denied), the repo's scripts via npm/pnpm/yarn/bun, make, cargo, go, pytest, mvn/gradle, dotnet, plus the committed `.pantheon/commands` on the base branch (one per line; shells, wrappers, package runners like `npx`, and inline code are refused — use `./node_modules/.bin/vitest`). The run names any command it needed that was refused. On Windows, or a Claude Code too old for these settings, the list is all there is — it is not a boundary |
429
+ | codex | `--sandbox read-only` (codex has no edits-without-shell mode; reads are not restricted) | `--approve-for-me` (Codex's workspace-write sandbox, its auto-reviewer approves; any command) |
430
+ | cursor | proposes edits, never applies (`-p` without `--force`) | `--force` (any command, no allowlist, no sandbox) |
431
+ | copilot | `--allow-tool=pantheon-worker --allow-tool=write` (edits without shell, Copilot's `write` kind) | `--allow-all-tools` (any command, no allowlist, no sandbox) |
421
432
  | kiro | an agent whose tools are pantheon's plus `read`, `write`, `grep`, `glob` — all pre-approved | the same, plus `shell` |
422
433
 
434
+ **What the command grant really means.** A run allowed to run commands can edit the repo's own
435
+ scripts and then run them through a listed command (`npm test` runs whatever `package.json` says), so
436
+ the command list alone never contains it. Where Claude Code's sandbox runs (macOS, Linux) that
437
+ sandbox is the boundary. Everywhere else, a command run can do what you can. After every run
438
+ Pantheon checks for the plain ways it could have widened what the NEXT run may do — a grant or wake
439
+ approval it added, a loosened wake setting, a changed relay address, a queued menu-bar action —
440
+ takes them back and tells you. That is a check after the fact, not a boundary: a run that can do
441
+ what you can could change something it does not look at. Pantheon also refuses to send an act that
442
+ holds this machine's key, a team token, or text in the shape of a common secret (a private key, a
443
+ cloud or API token) — a guard against a pasted secret, not against one a run takes care to disguise.
444
+ What keeps your secrets from a run is that it cannot read them: the denies and the sandbox above.
445
+
423
446
  Claude Code, Cursor and Codex are verified with **real live woken runs** — an idle machine answering a
424
447
  peer's ASK end to end. Codex quirks the tooling handles for
425
448
  you: no per-run approval flag exists in `codex exec`, MCP arrives via `-c` overrides (a worktree
@@ -564,7 +587,7 @@ All enforced in the relay, not in a prompt — a prompt can be argued out of its
564
587
  | Membership | explicit allowlist, ed25519 | the token is not a credential; a name needs its key |
565
588
  | Invites | single-use, expiring | a leaked link is inert once redeemed or timed out |
566
589
  | Claims | one session per obligation | parallel Conductor workspaces cannot duplicate work |
567
- | Wake rights | `--may-wake`, per member | only named peers may start a run on your machine |
590
+ | Wake rights | `pantheon wake allow`, per machine; `--may-wake`, per member for the team | only peers the team lets wake AND you approved here may start a run on your machine |
568
591
  | Run budget | 12/hour, 1 concurrent | a peer cannot drain your tokens |
569
592
  | Personas | tighten-only | a persona file — even one a teammate committed — can lower caps and turn bash off, never the reverse |
570
593
  | Worktree hygiene | no secrets on disk | credentials reach a woken run through its environment only; run scratch files are git-excluded |
@@ -611,7 +634,7 @@ replayable.
611
634
  - **The space token is still a shared secret** for *reaching* the relay, even though membership is
612
635
  now key-based. Treat the relay endpoint as semi-public and rely on the allowlist.
613
636
  - **A woken run is trusted with your repo.** `--allow-bash` in particular means a peer's request
614
- can execute commands on your machine. Grant `--may-wake` deliberately.
637
+ can execute commands on your machine. Approve wakers (`pantheon wake allow`) deliberately.
615
638
 
616
639
  ## Privacy, data, and support
617
640
 
package/bin/pantheon.js CHANGED
@@ -11,6 +11,18 @@ import { existsSync } from "node:fs";
11
11
  import { dirname, resolve } from "node:path";
12
12
  import { fileURLToPath, pathToFileURL } from "node:url";
13
13
 
14
+ // Node first, before anything that needs a newer one is loaded. On an older
15
+ // Node the first failure was deep and unreadable: node:sqlite missing (it is
16
+ // behind a flag through 22.12), or — on Node 18 behind a proxy — undici 7
17
+ // dying with "File is not defined" on import. This file must stay free of any
18
+ // syntax newer than Node 16, so the check itself runs on the Node it rejects.
19
+ var nodeVersion = process.versions.node.split(".").map(Number);
20
+ var nodeMajor = nodeVersion[0], nodeMinor = nodeVersion[1];
21
+ if (nodeMajor < 22 || (nodeMajor === 22 && nodeMinor < 13) || (nodeMajor === 23 && nodeMinor < 4)) {
22
+ console.error("pantheon needs Node 22.13 or newer (it uses node:sqlite); this is Node " + process.versions.node + ". Install a current Node LTS from https://nodejs.org and run pantheon again.");
23
+ process.exit(1);
24
+ }
25
+
14
26
  const here = dirname(fileURLToPath(import.meta.url));
15
27
  const root = resolve(here, "..");
16
28
  const entry = resolve(root, "dist", "cli.js");
package/dist/accounts.js CHANGED
@@ -1,13 +1,17 @@
1
1
  import { DEFAULT_PLANS, parsePlans, parseLimits, planLimits } from "./plans.js";
2
2
  import { createRequire } from "node:module";
3
3
  import { createHash, randomBytes, randomInt, timingSafeEqual } from "node:crypto";
4
- import { chmodSync, mkdirSync, readdirSync, rmSync } from "node:fs";
4
+ import { chmodSync, closeSync, mkdirSync, openSync, readdirSync, rmSync } from "node:fs";
5
5
  import { dirname, join } from "node:path";
6
6
  import { fingerprint } from "./identity.js";
7
7
  const nodeRequire = createRequire(import.meta.url);
8
8
  const CODE_TTL_MS = 10 * 60 * 1_000;
9
9
  const CODE_RESEND_COOLDOWN_MS = 60 * 1_000;
10
10
  const MAX_ATTEMPTS = 5;
11
+ export const MAX_FAILS_PER_KEY = 10;
12
+ export const MAX_FAILS_PER_NETWORK = 50;
13
+ export const FAIL_WINDOW_MS = 60 * 60 * 1_000;
14
+ const MAX_PENDING_PER_EMAIL = 5;
11
15
  const now = () => Date.now();
12
16
  const newId = (p) => `${p}_${randomBytes(9).toString("base64url")}`;
13
17
  const sha = (s) => createHash("sha256").update(s).digest("hex");
@@ -27,7 +31,12 @@ export class Accounts {
27
31
  db;
28
32
  constructor(dbPath) {
29
33
  if (dbPath !== ":memory:") {
30
- mkdirSync(dirname(dbPath), { recursive: true });
34
+ mkdirSync(dirname(dbPath), { recursive: true, mode: 0o700 });
35
+ try {
36
+ closeSync(openSync(dbPath, "a", 0o600));
37
+ chmodSync(dbPath, 0o600);
38
+ }
39
+ catch { }
31
40
  }
32
41
  let DB;
33
42
  try {
@@ -40,10 +49,12 @@ export class Accounts {
40
49
  this.db.exec("PRAGMA journal_mode = WAL; PRAGMA foreign_keys = ON;");
41
50
  this.migrate();
42
51
  if (dbPath !== ":memory:") {
43
- try {
44
- chmodSync(dbPath, 0o600);
52
+ for (const f of [dbPath, `${dbPath}-wal`, `${dbPath}-shm`]) {
53
+ try {
54
+ chmodSync(f, 0o600);
55
+ }
56
+ catch { }
45
57
  }
46
- catch { }
47
58
  }
48
59
  }
49
60
  migrate() {
@@ -65,6 +76,11 @@ export class Accounts {
65
76
  created_at INTEGER NOT NULL,
66
77
  last_seen INTEGER NOT NULL
67
78
  );
79
+ -- The table as 0.29.1 and earlier keep it, one code per email, left in
80
+ -- exactly that shape: a relay rolled back to one of them runs
81
+ -- INSERT ... ON CONFLICT(email) against it, which needs email to be the
82
+ -- key. This build keeps its codes in verifications_by_device below and
83
+ -- only drains this one (see migrate).
68
84
  CREATE TABLE IF NOT EXISTS verifications (
69
85
  email TEXT PRIMARY KEY,
70
86
  code_hash TEXT NOT NULL,
@@ -76,6 +92,23 @@ export class Accounts {
76
92
  attempts INTEGER NOT NULL DEFAULT 0,
77
93
  created_at INTEGER NOT NULL
78
94
  );
95
+ CREATE TABLE IF NOT EXISTS verifications_by_device (
96
+ email TEXT NOT NULL,
97
+ code_hash TEXT NOT NULL,
98
+ purpose TEXT NOT NULL,
99
+ pending_username TEXT,
100
+ pending_pubkey TEXT NOT NULL DEFAULT '',
101
+ pending_label TEXT,
102
+ expires_at INTEGER NOT NULL,
103
+ attempts INTEGER NOT NULL DEFAULT 0,
104
+ created_at INTEGER NOT NULL,
105
+ PRIMARY KEY (email, pending_pubkey)
106
+ );
107
+ CREATE TABLE IF NOT EXISTS verify_failures (
108
+ email TEXT PRIMARY KEY,
109
+ window_start INTEGER NOT NULL,
110
+ fails INTEGER NOT NULL
111
+ );
79
112
  CREATE TABLE IF NOT EXISTS sessions (
80
113
  id TEXT PRIMARY KEY,
81
114
  account_id TEXT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
@@ -128,6 +161,28 @@ export class Accounts {
128
161
  this.db.exec("ALTER TABLE sessions ADD COLUMN ended_at INTEGER");
129
162
  if (!scols.has("role"))
130
163
  this.db.exec("ALTER TABLE sessions ADD COLUMN role TEXT");
164
+ const spcols = new Set(this.db.prepare("PRAGMA table_info(spaces)").all().map((c) => c.name));
165
+ if (!spcols.has("owner_deleted_at"))
166
+ this.db.exec("ALTER TABLE spaces ADD COLUMN owner_deleted_at INTEGER");
167
+ const vcols = this.db.prepare("PRAGMA table_info(verifications)").all();
168
+ this.db.exec("BEGIN");
169
+ try {
170
+ this.db.exec(`INSERT OR IGNORE INTO verifications_by_device (email, code_hash, purpose, pending_username, pending_pubkey, pending_label, expires_at, attempts, created_at)
171
+ SELECT email, code_hash, purpose, pending_username, COALESCE(pending_pubkey, ''), pending_label, expires_at, attempts, created_at FROM verifications`);
172
+ if (vcols.some((c) => c.name === "pending_pubkey" && c.pk > 0)) {
173
+ this.db.exec(`DROP TABLE verifications;
174
+ CREATE TABLE verifications (email TEXT PRIMARY KEY, code_hash TEXT NOT NULL, purpose TEXT NOT NULL, pending_username TEXT,
175
+ pending_pubkey TEXT, pending_label TEXT, expires_at INTEGER NOT NULL, attempts INTEGER NOT NULL DEFAULT 0, created_at INTEGER NOT NULL);`);
176
+ }
177
+ else {
178
+ this.db.exec("DELETE FROM verifications");
179
+ }
180
+ this.db.exec("COMMIT");
181
+ }
182
+ catch (e) {
183
+ this.db.exec("ROLLBACK");
184
+ throw e;
185
+ }
131
186
  }
132
187
  close() { this.db.close(); }
133
188
  accountByUsername(username) {
@@ -179,43 +234,91 @@ export class Accounts {
179
234
  if (!acct || acct.status !== "active")
180
235
  return { ok: false, reason: "no_such_account" };
181
236
  }
182
- const prev = this.db.prepare("SELECT created_at, expires_at, pending_pubkey FROM verifications WHERE email = ?").get(e);
183
- if (prev && now() - prev.created_at < CODE_RESEND_COOLDOWN_MS)
237
+ if (this.deviceOfOtherAccount(e, opts.pubkey))
238
+ return { ok: false, reason: "device_other_account" };
239
+ const key = opts.pubkey;
240
+ const t = now();
241
+ const acct0 = this.accountByEmail(e);
242
+ const ownKey = !!acct0 && this.deviceByPubkey(key)?.account_id === acct0.id;
243
+ const prev = this.db.prepare(ownKey ? "SELECT MAX(created_at) created_at FROM verifications_by_device WHERE email = ? AND pending_pubkey = ?" : "SELECT MAX(created_at) created_at FROM verifications_by_device WHERE email = ? AND ? IS NOT NULL")
244
+ .get(e, key);
245
+ if (prev?.created_at && t - prev.created_at < CODE_RESEND_COOLDOWN_MS)
184
246
  return { ok: false, reason: "too_soon" };
185
- if (prev && now() < prev.expires_at && prev.pending_pubkey && prev.pending_pubkey !== opts.pubkey) {
186
- return { ok: false, reason: "in_progress_elsewhere" };
247
+ if (this.failsOf(`key:${e}|${key}`, t) >= MAX_FAILS_PER_KEY)
248
+ return { ok: false, reason: "locked" };
249
+ this.db.prepare("DELETE FROM verifications_by_device WHERE email = ? AND expires_at < ?").run(e, t);
250
+ if (ownKey) {
251
+ for (const r of this.db.prepare("SELECT pending_pubkey FROM verifications_by_device WHERE email = ? AND pending_pubkey != ?").all(e, key)) {
252
+ if (this.deviceByPubkey(r.pending_pubkey)?.account_id !== acct0.id)
253
+ this.db.prepare("DELETE FROM verifications_by_device WHERE email = ? AND pending_pubkey = ?").run(e, r.pending_pubkey);
254
+ }
255
+ }
256
+ const others = this.db.prepare("SELECT pending_pubkey FROM verifications_by_device WHERE email = ? AND pending_pubkey != ? ORDER BY created_at ASC").all(e, key);
257
+ for (const r of others.slice(0, Math.max(0, others.length - (MAX_PENDING_PER_EMAIL - 1)))) {
258
+ this.db.prepare("DELETE FROM verifications_by_device WHERE email = ? AND pending_pubkey = ?").run(e, r.pending_pubkey);
187
259
  }
188
260
  const code = String(randomInt(0, 1_000_000)).padStart(6, "0");
189
261
  this.db.prepare(`
190
- INSERT INTO verifications (email, code_hash, purpose, pending_username, pending_pubkey, pending_label, expires_at, attempts, created_at)
262
+ INSERT INTO verifications_by_device (email, code_hash, purpose, pending_username, pending_pubkey, pending_label, expires_at, attempts, created_at)
191
263
  VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?)
192
- ON CONFLICT(email) DO UPDATE SET
264
+ ON CONFLICT(email, pending_pubkey) DO UPDATE SET
193
265
  code_hash = excluded.code_hash, purpose = excluded.purpose,
194
- pending_username = excluded.pending_username, pending_pubkey = excluded.pending_pubkey,
266
+ pending_username = excluded.pending_username,
195
267
  pending_label = excluded.pending_label, expires_at = excluded.expires_at,
196
268
  attempts = 0, created_at = excluded.created_at
197
- `).run(e, sha(code), purpose, purpose === "signup" ? normalizeUsername(opts.username) : null, opts.pubkey, opts.label, now() + CODE_TTL_MS, now());
269
+ `).run(e, sha(code), purpose, purpose === "signup" ? normalizeUsername(opts.username) : null, key, opts.label, t + CODE_TTL_MS, t);
198
270
  return { ok: true, code };
199
271
  }
200
- verify(email, code, ip, pubkey) {
272
+ failsOf(key, t = now()) {
273
+ const f = this.db.prepare("SELECT window_start, fails FROM verify_failures WHERE email = ?").get(key);
274
+ return f && t - f.window_start < FAIL_WINDOW_MS ? f.fails : 0;
275
+ }
276
+ noteFail(key, t = now()) {
277
+ const f = this.db.prepare("SELECT window_start FROM verify_failures WHERE email = ?").get(key);
278
+ if (f && t - f.window_start < FAIL_WINDOW_MS)
279
+ this.db.prepare("UPDATE verify_failures SET fails = fails + 1 WHERE email = ?").run(key);
280
+ else
281
+ this.db.prepare("INSERT INTO verify_failures (email, window_start, fails) VALUES (?, ?, 1) ON CONFLICT(email) DO UPDATE SET window_start = excluded.window_start, fails = 1").run(key, t);
282
+ }
283
+ verify(email, code, ip, pubkey, net = null) {
201
284
  const e = normalizeEmail(email);
202
- const v = this.db.prepare("SELECT * FROM verifications WHERE email = ?").get(e);
203
- if (!v)
204
- return { ok: false, reason: "no_pending" };
205
- if (now() > v.expires_at) {
206
- this.db.prepare("DELETE FROM verifications WHERE email = ?").run(e);
285
+ const t = now();
286
+ const rows = this.db.prepare("SELECT * FROM verifications_by_device WHERE email = ? ORDER BY created_at ASC").all(e);
287
+ let v;
288
+ if (pubkey) {
289
+ v = rows.find((r) => r.pending_pubkey === pubkey);
290
+ if (!v)
291
+ return { ok: false, reason: rows.some((r) => t <= r.expires_at) ? "other_device" : rows.length ? "expired" : "no_pending" };
292
+ }
293
+ else {
294
+ const live = rows.filter((r) => t <= r.expires_at);
295
+ if (live.length > 1)
296
+ return { ok: false, reason: "several_pending" };
297
+ v = live[0] ?? rows[rows.length - 1];
298
+ if (!v)
299
+ return { ok: false, reason: "no_pending" };
300
+ }
301
+ const where = "email = ? AND pending_pubkey = ?";
302
+ if (t > v.expires_at) {
303
+ this.db.prepare(`DELETE FROM verifications_by_device WHERE ${where}`).run(e, v.pending_pubkey);
207
304
  return { ok: false, reason: "expired" };
208
305
  }
209
306
  if (v.attempts >= MAX_ATTEMPTS)
210
307
  return { ok: false, reason: "too_many_attempts" };
211
- if (pubkey && v.pending_pubkey && pubkey !== v.pending_pubkey)
212
- return { ok: false, reason: "other_device" };
308
+ if (v.pending_pubkey && this.deviceOfOtherAccount(e, v.pending_pubkey))
309
+ return { ok: false, reason: "device_other_account" };
310
+ const keyId = `key:${e}|${v.pending_pubkey}`, netId = net ? `net:${net}` : null;
311
+ if (this.failsOf(keyId, t) >= MAX_FAILS_PER_KEY || (netId && this.failsOf(netId, t) >= MAX_FAILS_PER_NETWORK))
312
+ return { ok: false, reason: "locked" };
213
313
  const want = Buffer.from(v.code_hash, "hex");
214
314
  const got = Buffer.from(sha(code), "hex");
215
315
  const good = want.length === got.length && timingSafeEqual(want, got);
216
316
  if (!good) {
217
- this.db.prepare("UPDATE verifications SET attempts = attempts + 1 WHERE email = ?").run(e);
218
- return { ok: false, reason: "bad_code" };
317
+ this.db.prepare(`UPDATE verifications_by_device SET attempts = attempts + 1 WHERE ${where}`).run(e, v.pending_pubkey);
318
+ this.noteFail(keyId, t);
319
+ if (netId)
320
+ this.noteFail(netId, t);
321
+ return { ok: false, reason: "bad_code", tries_left: Math.max(0, MAX_ATTEMPTS - v.attempts - 1) };
219
322
  }
220
323
  const run = this.db.prepare.bind(this.db);
221
324
  this.db.exec("BEGIN");
@@ -243,7 +346,11 @@ export class Accounts {
243
346
  }
244
347
  const device = this.bindDevice(account.id, v.pending_pubkey, v.pending_label ?? "a device");
245
348
  const session = this.openSession(account.id, device.id, "", ip);
246
- run("DELETE FROM verifications WHERE email = ?").run(e);
349
+ if (v.purpose === "signup")
350
+ run("DELETE FROM verifications_by_device WHERE email = ?").run(e);
351
+ else
352
+ run(`DELETE FROM verifications_by_device WHERE ${where}`).run(e, v.pending_pubkey);
353
+ run("DELETE FROM verify_failures WHERE email = ?").run(keyId);
247
354
  this.db.exec("COMMIT");
248
355
  this.audit({ actor_account: account.id, event: v.purpose === "signup" ? "email_verified" : "login", target: device.fingerprint, ip });
249
356
  return { ok: true, account, session, device };
@@ -253,8 +360,22 @@ export class Accounts {
253
360
  throw err;
254
361
  }
255
362
  }
363
+ deviceOfOtherAccount(email, pubkey) {
364
+ const dev = this.deviceByPubkey(pubkey);
365
+ if (!dev)
366
+ return false;
367
+ return dev.account_id !== this.accountByEmail(email)?.id;
368
+ }
369
+ isDeviceOf(email, pubkey) {
370
+ if (!pubkey)
371
+ return false;
372
+ const dev = this.deviceByPubkey(pubkey);
373
+ return !!dev && dev.account_id === this.accountByEmail(email)?.id;
374
+ }
256
375
  bindDevice(accountId, pubkey, label) {
257
376
  const existing = this.deviceByPubkey(pubkey);
377
+ if (existing && existing.account_id !== accountId)
378
+ throw new Error("this device key is bound to a different account");
258
379
  if (existing) {
259
380
  this.db.prepare("UPDATE devices SET last_seen = ? WHERE id = ?").run(now(), existing.id);
260
381
  return existing;
@@ -318,6 +439,7 @@ export class Accounts {
318
439
  return false;
319
440
  this.db.exec("BEGIN");
320
441
  try {
442
+ this.db.prepare("UPDATE spaces SET owner_deleted_at = ? WHERE owner_account = ?").run(now(), accountId);
321
443
  this.db.prepare("DELETE FROM accounts WHERE id = ?").run(accountId);
322
444
  this.db.exec("COMMIT");
323
445
  }
@@ -382,7 +504,7 @@ export class Accounts {
382
504
  return this.db.prepare("SELECT * FROM blocks ORDER BY created_at DESC").all();
383
505
  }
384
506
  recentVerifications(limit = 50) {
385
- return this.db.prepare("SELECT email, purpose, created_at, expires_at, attempts FROM verifications ORDER BY created_at DESC LIMIT ?").all(limit);
507
+ return this.db.prepare("SELECT email, purpose, created_at, expires_at, attempts FROM verifications_by_device ORDER BY created_at DESC LIMIT ?").all(limit);
386
508
  }
387
509
  plans() {
388
510
  const row = this.db.prepare("SELECT json FROM settings WHERE key = 'plans'").get();
@@ -444,7 +566,7 @@ export class Accounts {
444
566
  }
445
567
  recordSpace(name, ownerAccount) {
446
568
  this.db.prepare("INSERT INTO spaces (name, owner_account, created_at, deleted_at) VALUES (?, ?, ?, NULL) " +
447
- "ON CONFLICT(name) DO UPDATE SET owner_account = excluded.owner_account, created_at = excluded.created_at, deleted_at = NULL WHERE spaces.deleted_at IS NOT NULL").run(name, ownerAccount, now());
569
+ "ON CONFLICT(name) DO UPDATE SET owner_account = excluded.owner_account, created_at = excluded.created_at, deleted_at = NULL, owner_deleted_at = NULL WHERE spaces.deleted_at IS NOT NULL").run(name, ownerAccount, now());
448
570
  }
449
571
  markSpaceDeleted(name) {
450
572
  this.db.prepare("UPDATE spaces SET deleted_at = ? WHERE name = ? AND deleted_at IS NULL").run(now(), name);
@@ -453,6 +575,9 @@ export class Accounts {
453
575
  const r = this.db.prepare("SELECT owner_account FROM spaces WHERE name = ? AND deleted_at IS NULL").get(name);
454
576
  return r?.owner_account ? this.accountById(r.owner_account) : null;
455
577
  }
578
+ spaceOrphaned(name) {
579
+ return !!this.db.prepare("SELECT 1 FROM spaces WHERE name = ? AND deleted_at IS NULL AND owner_account IS NULL AND owner_deleted_at IS NOT NULL").get(name);
580
+ }
456
581
  spacesOwned(accountId) {
457
582
  return this.db.prepare("SELECT name FROM spaces WHERE owner_account = ? AND deleted_at IS NULL ORDER BY created_at").all(accountId).map((r) => r.name);
458
583
  }