@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
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,13 +76,13 @@ 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
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
want, which decisions are yours alone (so agents stop and ask you), the
|
|
85
|
-
branch. `pantheon setup` re-runs it any time. An agent setting you up runs `pantheon setup --questions`,
|
|
79
|
+
default when run inside a git repo; `--no-worker` opts out), native decision alerts, and the menu bar. Each command
|
|
80
|
+
ends with a "You're set" summary, then a short guided setup on a terminal: two questions — your
|
|
81
|
+
name and your role — and one screen of defaults to keep or change: your teammates can hand work to
|
|
82
|
+
your machine while you are away, that work runs your tests and commits to its own branch so it
|
|
83
|
+
arrives ready to review, whichever agent you use (Claude Code, Codex, Cursor or Copilot) answers
|
|
84
|
+
them, which alerts you want, which decisions are yours alone (so agents stop and ask you), the
|
|
85
|
+
worker's playbook and its base branch. `pantheon setup` re-runs it any time. An agent setting you up runs `pantheon setup --questions`,
|
|
86
86
|
asks you in chat, and passes your answers back as flags — to `pantheon setup --yes`, or straight to
|
|
87
87
|
`pantheon start` / `pantheon join`, which apply them the moment the team exists — so nobody has to
|
|
88
88
|
open a terminal. Restart your agent once and ask it *"check pantheon_status"*.
|
|
@@ -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).
|
|
@@ -171,8 +171,32 @@ pantheon profile set --space mobile --human-required pricing # authority that
|
|
|
171
171
|
|
|
172
172
|
The relay checks too: a session whose checkout is not one of the team's repos is refused, and a
|
|
173
173
|
`DELIVERED` must name a commit in one of them, so work from the wrong repo cannot land in a team.
|
|
174
|
-
|
|
175
|
-
|
|
174
|
+
Details and the scenario list: `docs/MULTI-SPACE.md`.
|
|
175
|
+
|
|
176
|
+
**Several repos (a frontend, a backend, a mobile app) — set up once.**
|
|
177
|
+
- `pantheon use <team> --here` in the folder that holds them binds every repo inside it, and
|
|
178
|
+
registers each with the team, in one go.
|
|
179
|
+
- `pantheon repo find` (and `join`, on its own) looks for the team's repos on this machine — in the
|
|
180
|
+
usual code folders, in a folder you name, and, if you say yes, in the folders your editors and
|
|
181
|
+
agents have opened. It reads only folder names and git remote addresses; nothing leaves the machine.
|
|
182
|
+
- A clone of one of the team's repos, wherever it lives, is that team's the first time an agent is
|
|
183
|
+
opened in it — no binding needed.
|
|
184
|
+
- The relay knows each repo by a fingerprint and its everyday name (`web`), never its address.
|
|
185
|
+
|
|
186
|
+
**One worker, every repo.** The worker serves every repo and folder bound to its team, one run at a
|
|
187
|
+
time, and runs each request where it belongs: in the repo the request names (`the api repo`,
|
|
188
|
+
`` `mobile` ``, `acme/web`), else where the goal's work was delivered, else the repo the asker sent
|
|
189
|
+
it from, else the most recently active one — with the team's other repos here attached read-only
|
|
190
|
+
(clean checkouts, never your working folder) so it can look across them. A request about a team repo
|
|
191
|
+
this machine does not have runs nothing: the asker is told it waits, and it is never answered from
|
|
192
|
+
another repo's code. `pantheon spaces` and `doctor` say what the worker serves and what is not here.
|
|
193
|
+
|
|
194
|
+
**No git at all (a PM, ops, a founder's docs).** `start` or `join` from a plain folder binds it
|
|
195
|
+
(never your home folder). The worker serves it too: a teammate's question is answered from that
|
|
196
|
+
folder, which the run can read but never change; anything it writes goes to a drafts folder of its
|
|
197
|
+
own, and you are told. Answering from a folder unattended needs Claude Code on that machine (the one
|
|
198
|
+
agent that can be opened a folder to read and held off editing it); with another agent, those requests
|
|
199
|
+
wait for your live session, and the teammate is told.
|
|
176
200
|
|
|
177
201
|
### If something is wrong
|
|
178
202
|
|
|
@@ -198,8 +222,8 @@ product itself; you read it, and you decide.
|
|
|
198
222
|
|
|
199
223
|
<details><summary>The long way round, if you want to control each piece</summary>
|
|
200
224
|
|
|
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
|
|
225
|
+
`pantheon relay` · `pantheon init` · `pantheon join` · `pantheon members` · `pantheon admit <who>` ·
|
|
226
|
+
`pantheon wake allow <who>` · `pantheon install` (project-scope: commits a `.mcp.json`, so `pantheon` must be on PATH). Membership by explicit
|
|
203
227
|
allowlist with fingerprint confirmation, rather than by invite. Everything the two-command path
|
|
204
228
|
does, one step at a time.
|
|
205
229
|
|
|
@@ -278,7 +302,7 @@ pantheon service uninstall
|
|
|
278
302
|
```
|
|
279
303
|
|
|
280
304
|
A terminal window is not "while you are asleep", so use `pantheon service install` for anything
|
|
281
|
-
real. Logs land in
|
|
305
|
+
real. Logs land in `~/.pantheon/logs/worker.log` (`pantheon logs worker -f`).
|
|
282
306
|
|
|
283
307
|
### The menu bar (macOS)
|
|
284
308
|
|
|
@@ -314,7 +338,7 @@ pantheon directory # shared responsibility and authority
|
|
|
314
338
|
pantheon approvals # decisions waiting on you
|
|
315
339
|
pantheon approvals answer <id> 'Approved because…'
|
|
316
340
|
pantheon profile delegate --to alex --domains technical \
|
|
317
|
-
--until 2026-
|
|
341
|
+
--until <ISO time, e.g. 2026-11-01T18:00:00Z> # expires automatically; at most 30 days ahead
|
|
318
342
|
pantheon profile undelegate --to alex # revoke early
|
|
319
343
|
```
|
|
320
344
|
|
|
@@ -347,16 +371,30 @@ domain is a conflict, not a committee — routing blocks until one of them drops
|
|
|
347
371
|
it or delegates. Decide domain ownership out loud before publishing; shared
|
|
348
372
|
decisions belong to whoever answers for them, with delegation covering absence.
|
|
349
373
|
|
|
350
|
-
When a
|
|
374
|
+
When a teammate asks this machine something and no session of yours picks it up, the
|
|
351
375
|
worker spawns a headless agent run to handle it. Verified end to end: an `ASK` from an idle
|
|
352
376
|
peer's machine was answered in **31 seconds with no human involved**, and:
|
|
353
377
|
|
|
354
378
|
- the run happens in a **dedicated git worktree on a `pantheon/<goal>` branch** — your checkout and
|
|
355
379
|
your uncommitted work are never touched, and nothing is merged
|
|
356
|
-
- it runs
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
380
|
+
- in a repo it runs your tests, commits and pushes to its own branch (setup's default; `pantheon setup
|
|
381
|
+
--commands no` keeps runs to edits), so work arrives ready to review; a person on this machine can
|
|
382
|
+
also grant commands later (`pantheon policy grant run-commands`, or answering the worker's own
|
|
383
|
+
request from `pantheon approvals` or the menu bar)
|
|
384
|
+
- teammates the team lets wake workers (the relay's per-member `may_wake`, which the invites you send
|
|
385
|
+
include) can hand this machine work — wake "anyone", the default. To approve each teammate on this
|
|
386
|
+
machine first, choose "only people I approve" (`pantheon setup --wake approved`); then
|
|
387
|
+
`pantheon wake allow <who>` (`wake deny`, `wake list`) keeps that list, on this machine only, and the
|
|
388
|
+
first time an unapproved teammate's request needs this machine the menu bar shows "Let <them> start
|
|
389
|
+
work here? Always / Just this once / No" (or `pantheon approvals`). The same buttons answer the
|
|
390
|
+
worker's request for commands; granting it runs the request that needed them.
|
|
391
|
+
- upgrading keeps what you had said: teammates you admitted with `--may-wake` are carried over; so is
|
|
392
|
+
everyone the team lets wake if you had chosen "anyone" (or never chose, on a machine set up before
|
|
393
|
+
0.30, when that was the default); a joiner who was never asked keeps the team's founder. The worker
|
|
394
|
+
says who was carried and why. Against a relay older than 0.30 (it cannot say who admitted whom) the
|
|
395
|
+
team's `may_wake` keeps deciding until the relay upgrades, so upgrading the CLI first is safe.
|
|
396
|
+
- the agent's environment carries no identity secret; the run is its own process group, killed whole
|
|
397
|
+
on timeout, `pantheon stop` and `pantheon panic`
|
|
360
398
|
- a human who is actually working wins — but presence is not attention. The rule, in order:
|
|
361
399
|
1. a turn in flight in any of your windows (your wake-up hook reports it) → the worker waits
|
|
362
400
|
2. …unless that report is over **30 minutes** old with nothing after it. Claude Code runs no
|
|
@@ -409,17 +447,41 @@ With `auto` (the default) the order is: the agent a persona pins, then the one y
|
|
|
409
447
|
then PATH order — and a candidate that is not signed in is skipped and named in the run log, so a
|
|
410
448
|
run never fails on an expired session when another agent could have answered. If nothing is
|
|
411
449
|
signed in, the first on PATH runs and the alert says exactly that, with the sign-in command.
|
|
412
|
-
`pantheon doctor` shows the same choice (`worker agent`).
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
450
|
+
`pantheon doctor` shows the same choice (`worker agent`).
|
|
451
|
+
|
|
452
|
+
**Whichever agent you use answers your teammates while you are away** — Claude Code, Codex, Cursor
|
|
453
|
+
or Copilot. Claude Code runs inside its own OS sandbox (macOS and Linux, 2.1.83+), so `auto`
|
|
454
|
+
prefers it when it is installed; Codex, Cursor and Copilot answer too, so a team on any of them is
|
|
455
|
+
never kept waiting. To have Codex, Cursor and Copilot leave requests for you instead,
|
|
456
|
+
`pantheon setup --unsandboxed no` (or `pantheon policy revoke unsandboxed-runs`; a woken run can never
|
|
457
|
+
change it). Kiro answers in your live sessions; for unattended runs the worker uses another installed
|
|
458
|
+
agent. Setup turns commands on for a worker that serves a repo (`pantheon setup --commands no` keeps
|
|
459
|
+
runs to edits). Each backend is driven the way it actually works, with the same commands rule
|
|
460
|
+
spelled in its own dialect:
|
|
461
|
+
|
|
462
|
+
| backend | bash gated (`--commands no`) | with commands (setup's default in a repo, `run-commands` or `--allow-bash`) |
|
|
416
463
|
|---|---|---|
|
|
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` |
|
|
464
|
+
| 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 older than 2.1.83, commands run from that same list without the OS sandbox (`pantheon setup --unsandboxed no` keeps those runs to edits) |
|
|
465
|
+
| 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) |
|
|
466
|
+
| cursor | proposes edits, never applies (`-p` without `--force`) | `--force` (any command, no allowlist, no sandbox) |
|
|
467
|
+
| 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
468
|
| kiro | an agent whose tools are pantheon's plus `read`, `write`, `grep`, `glob` — all pre-approved | the same, plus `shell` |
|
|
422
469
|
|
|
470
|
+
**What the command grant really means.** A run allowed to run commands can edit the repo's own
|
|
471
|
+
scripts and then run them through a listed command (`npm test` runs whatever `package.json` says), so
|
|
472
|
+
the command list alone never contains it. Where Claude Code's sandbox runs (macOS, Linux) that
|
|
473
|
+
sandbox is the boundary. Everywhere else, a command run can read what you can — and, outside
|
|
474
|
+
Codex's own write sandbox, change it. After every run
|
|
475
|
+
Pantheon checks for the plain ways it could have widened what the NEXT run may do — a grant or wake
|
|
476
|
+
approval it added, a loosened wake setting, a changed relay address, a queued menu-bar action —
|
|
477
|
+
takes them back and tells you. That is a check after the fact, not a boundary: a run that can do
|
|
478
|
+
what you can could change something it does not look at. Pantheon also refuses to send an act that
|
|
479
|
+
holds this machine's key, a team token, or text in the shape of a common secret (a private key, a
|
|
480
|
+
cloud or API token) — a guard against a pasted secret, not against one a run takes care to disguise.
|
|
481
|
+
With Claude Code, what keeps your secrets from a run is that it cannot read them: the denies and the
|
|
482
|
+
sandbox above. With other agents, grant commands only where you would be comfortable with the run
|
|
483
|
+
having your access.
|
|
484
|
+
|
|
423
485
|
Claude Code, Cursor and Codex are verified with **real live woken runs** — an idle machine answering a
|
|
424
486
|
peer's ASK end to end. Codex quirks the tooling handles for
|
|
425
487
|
you: no per-run approval flag exists in `codex exec`, MCP arrives via `-c` overrides (a worktree
|
|
@@ -548,7 +610,8 @@ The rules that make this safe to commit into a shared repo:
|
|
|
548
610
|
|
|
549
611
|
## Guardrails
|
|
550
612
|
|
|
551
|
-
All enforced in the relay,
|
|
613
|
+
All enforced in code — by the relay, or by each machine where the relay is not to be trusted —
|
|
614
|
+
never in a prompt: a prompt can be argued out of its instructions.
|
|
552
615
|
|
|
553
616
|
| Guard | Default | Why |
|
|
554
617
|
|---|---|---|
|
|
@@ -564,9 +627,10 @@ All enforced in the relay, not in a prompt — a prompt can be argued out of its
|
|
|
564
627
|
| Membership | explicit allowlist, ed25519 | the token is not a credential; a name needs its key |
|
|
565
628
|
| Invites | single-use, expiring | a leaked link is inert once redeemed or timed out |
|
|
566
629
|
| Claims | one session per obligation | parallel Conductor workspaces cannot duplicate work |
|
|
567
|
-
| Wake rights | `--may-wake`, per member | only
|
|
630
|
+
| Wake rights | `pantheon wake allow`, per machine; `--may-wake`, per member for the team | under "anyone" (the default): every peer the team lets wake; under "approved": only those you also approved here. Checked by the worker before a run starts, and by the relay at its claim |
|
|
568
631
|
| Run budget | 12/hour, 1 concurrent | a peer cannot drain your tokens |
|
|
569
632
|
| Personas | tighten-only | a persona file — even one a teammate committed — can lower caps and turn bash off, never the reverse |
|
|
633
|
+
| Signed acts | every act and goal, ed25519, since 0.31 | checked on each receiving machine against the key it pinned for the sender: the relay cannot forge, alter, re-file or replay a teammate's request without that machine noticing, and a worker runs only requests that check out. A teammate's changed key is held back until you confirm it (`pantheon trust`) |
|
|
570
634
|
| Worktree hygiene | no secrets on disk | credentials reach a woken run through its environment only; run scratch files are git-excluded |
|
|
571
635
|
| Verifiable delivery | branch + commit required | "I wrote the file" is not checkable from another machine |
|
|
572
636
|
| Durable state | goals + obligations on disk | a relay restart used to discard every open goal |
|
|
@@ -596,8 +660,9 @@ pantheon mcp the stdio MCP server — your agent runs this
|
|
|
596
660
|
```
|
|
597
661
|
|
|
598
662
|
`pantheon help --all` lists everything. `pantheon watch` is how a human stays oriented without
|
|
599
|
-
being in the loop; every act is also appended to the relay's ledger, so
|
|
600
|
-
replayable
|
|
663
|
+
being in the loop; every act is also appended to the relay's ledger, so a goal's history is
|
|
664
|
+
replayable while it is open — closed goals are compacted once the space needs room, so export what
|
|
665
|
+
you need to keep with `pantheon audit --json`.
|
|
601
666
|
|
|
602
667
|
## What this still is not
|
|
603
668
|
|
|
@@ -608,18 +673,55 @@ replayable.
|
|
|
608
673
|
the next real gap: `DELIVERED` is now verifiable, but *conformance to the contract* is not.
|
|
609
674
|
- **Nothing fetches the delivered commit for you.** The relay requires a ref and the receiving
|
|
610
675
|
delegate is told to check it, but there is no `pantheon fetch` helper yet.
|
|
611
|
-
- **The space token
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
676
|
+
- **The space token still reads a team's history until someone is removed.** Joining takes an
|
|
677
|
+
admitted key, but in a team that has never removed anyone the token alone can read its history
|
|
678
|
+
over HTTP (older clients need that); after any removal, reads need an admitted key. Share invite
|
|
679
|
+
links, not the token.
|
|
680
|
+
- **Commands on a woken run are on by default in a repo.** A run works in its own worktree and
|
|
681
|
+
branch, inside your agent's sandbox where it has one (see above). `pantheon setup --commands no`
|
|
682
|
+
keeps runs to edits, and `pantheon setup --wake approved` lets you approve each teammate first.
|
|
683
|
+
|
|
684
|
+
## Is Pantheon safe for my team?
|
|
685
|
+
|
|
686
|
+
Short answer: your teammates' agents can hand your machine work while you are away — it runs in its
|
|
687
|
+
own copy of the repo and is delivered on a branch for your review — and whoever runs the relay
|
|
688
|
+
cannot fake or alter a teammate's request. What that means in practice:
|
|
689
|
+
|
|
690
|
+
- **Pantheon never uploads your code, credentials or repo.** The relay passes messages between
|
|
691
|
+
agents and has no filesystem or shell access to anyone's computer; what leaves your machine is
|
|
692
|
+
what an agent writes into a message.
|
|
693
|
+
- **The relay can read the messages** (they are not end-to-end encrypted), so treat a team space
|
|
694
|
+
like a shared team channel. It **cannot write one in a teammate's name or change one**: with 0.31
|
|
695
|
+
on the relay and on your machine, every message is signed by its sender and checked against the
|
|
696
|
+
key your machine keeps for them, and one that does not check out is marked NOT VERIFIED and never
|
|
697
|
+
starts work. (The first time your machine meets a teammate it takes their key from the member
|
|
698
|
+
list; a changed key later is held back until you confirm it.) It can still delay or withhold messages.
|
|
699
|
+
- **Your teammates can hand your machine work while you are away.** `pantheon start` in a repo turns
|
|
700
|
+
the worker on (`--no-worker` leaves it off). Each run works in its own copy of the repo, runs your
|
|
701
|
+
tests, and delivers on its own branch for your review; runs are capped at 12 an hour, and
|
|
702
|
+
`pantheon panic` pauses all of it at once. `pantheon wake list` shows who can hand you work, and
|
|
703
|
+
`pantheon setup --wake approved` lets you approve each teammate first.
|
|
704
|
+
- **You can see and change all of it:** `pantheon policy` (what the worker may do, including whether
|
|
705
|
+
Pantheon updates itself), `pantheon trust` (the teammates' keys this machine checks messages
|
|
706
|
+
against), `pantheon audit`, `pantheon doctor`.
|
|
707
|
+
|
|
708
|
+
For a team of five, the defaults are the setup: everyone's agent answers the team while they are
|
|
709
|
+
away, work arrives tested on its own branch, and decisions you reserved still come to you. Prefer to
|
|
710
|
+
review every change by hand on one machine? `pantheon setup --commands no` there. When Pantheon says
|
|
711
|
+
a teammate's key changed, check it with them before `pantheon trust`. Claude Code runs inside its
|
|
712
|
+
OS sandbox; Codex answers inside its own workspace sandbox, and Cursor and Copilot run commands
|
|
713
|
+
without asking, as you (`pantheon setup --unsandboxed no` has them leave requests for you). The
|
|
714
|
+
full model, and how to report a problem: [joinpantheon.network/security](https://www.joinpantheon.network/security).
|
|
615
715
|
|
|
616
716
|
## Privacy, data, and support
|
|
617
717
|
|
|
618
718
|
The hosted relay at `relay.joinpantheon.network` holds only what coordination needs: your
|
|
619
719
|
verified email, username, and device public keys; the IPs and timestamps of your sessions and
|
|
620
720
|
an audit trail of identity and membership events; and, per space, the acts your delegates
|
|
621
|
-
exchanged plus open goals and obligations.
|
|
622
|
-
|
|
721
|
+
exchanged plus open goals and obligations. Pantheon never uploads your code, credentials or repo,
|
|
722
|
+
and the relay has no filesystem or shell access by design: what leaves your machine is what an
|
|
723
|
+
agent writes into a message (and Pantheon refuses to send its own keys, team tokens, or text in the
|
|
724
|
+
shape of a common secret).
|
|
623
725
|
Whoever operates the relay can read those acts — the operator dashboard shows each space's
|
|
624
726
|
message history — so treat a space like a shared team channel, not a private notebook.
|
|
625
727
|
|
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
|
}
|