@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/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
|
|
46
|
-
|
|
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
|
|
80
|
-
|
|
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
|
|
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:
|
|
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
|
|
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
|
|
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-
|
|
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
|
|
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
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
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
|
|
418
|
-
| codex | `--sandbox read-only` (codex has no edits-without-shell mode) | `--
|
|
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
|
|
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.
|
|
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
|
-
|
|
44
|
-
|
|
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
|
-
|
|
183
|
-
|
|
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 (
|
|
186
|
-
return { ok: false, reason: "
|
|
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
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
if (
|
|
206
|
-
|
|
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 (
|
|
212
|
-
return { ok: false, reason: "
|
|
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(
|
|
218
|
-
|
|
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
|
-
|
|
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
|
|
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
|
}
|