Compare commits

...

100 commits

Author SHA1 Message Date
Nico
7c1f0c52d5
refactor(repo-mutex): use effect.ts (#950)
Some checks failed
Checks / lint (push) Has been cancelled
Checks / typecheck (push) Has been cancelled
Checks / test-server (push) Has been cancelled
Checks / test-client (push) Has been cancelled
Checks / build (push) Has been cancelled
Scorecard analysis / Scorecard analysis (push) Has been cancelled
* refactor(repo-mutex): use effect.ts

* refactor(mutext): run with signal to preserve abort reason
2026-06-06 15:06:26 +02:00
Nico
2318b6bdd0
fix: limit concurrent ls to 2 in flight calls (#948)
* fix: limit concurrent ls to 2 in flight calls

* refactor: get shared lock after semaphore take
2026-06-05 18:07:59 +02:00
Copilot
34fc4f0cbb
docs: update Docker Compose Zerobyte image tags to v0.38 (#945)
* Initial plan

* docs: update Docker Compose Zerobyte image tags to v0.38

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-06-04 21:49:15 +02:00
Nico
68002b0308
fix: disable add passkey in insecure contexts (#943)
Some checks failed
Release Workflow / determine-release-type (push) Has been cancelled
Release Workflow / checks (push) Has been cancelled
Release Workflow / e2e-tests (push) Has been cancelled
Release Workflow / integration-tests (push) Has been cancelled
Release Workflow / request-docs-version-update (push) Has been cancelled
Release Workflow / build-images (push) Has been cancelled
Release Workflow / publish-release (push) Has been cancelled
2026-06-04 21:06:48 +02:00
Nicolas Meienberger
4bba2c2493
refactor: remove repository lock diagnostics logs 2026-06-04 19:58:02 +02:00
Nicolas Meienberger
9d63f7cb2d
Revert "fix(volumes): decrypt values before testing connection (#939)"
Some checks failed
Release Workflow / integration-tests (push) Has been cancelled
Release Workflow / determine-release-type (push) Has been cancelled
Release Workflow / checks (push) Has been cancelled
Release Workflow / e2e-tests (push) Has been cancelled
Release Workflow / build-images (push) Has been cancelled
Release Workflow / publish-release (push) Has been cancelled
Release Workflow / request-docs-version-update (push) Has been cancelled
This reverts commit 885ea10f2a.
2026-06-03 19:56:55 +02:00
Nicolas Meienberger
4a4e5c0abe
ci: skip nfs container entirely in github ci 2026-06-03 19:26:45 +02:00
Nico
885ea10f2a
fix(volumes): decrypt values before testing connection (#939)
* fix(volumes): decrypt values before testing connection

* chore: lint issue
2026-06-03 19:05:39 +02:00
Nicolas Meienberger
edab1b231b
fix(restic): validate lock diagnostics object IDs 2026-06-03 18:47:04 +02:00
Nico
333c11986d
feat: enforce protocol version between agent and controller (#938)
* feat: enforce protocol version between agent and controller

* chore: add logging for protocol rejected message
2026-06-03 18:29:36 +02:00
Nicolas Meienberger
be3182793d
fix(agent-backups): validate agent ownership 2026-06-03 17:28:43 +02:00
Nicolas Meienberger
a279129ad8
ci(integration): include only necessary logs in artifacts 2026-06-03 17:21:32 +02:00
Nicolas Meienberger
027c6efb32
ci(docs): allow deploy only on repo default branch 2026-06-03 17:18:10 +02:00
renovate[bot]
755cbe4dae
fix(deps): update bun minor and patch dependencies (#925)
* fix(deps): update bun minor and patch dependencies

* chore: fix linting issues

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Nicolas Meienberger <github@thisprops.com>
2026-06-03 17:15:53 +02:00
Nicolas Meienberger
111a5843ef
refactor: reject all non uv passkeys 2026-06-03 17:03:00 +02:00
renovate[bot]
49e3977199
fix(deps): update dependency fumadocs-mdx to v15 (#870)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-03 16:59:25 +02:00
Nicolas Meienberger
ca325a01c5
chore: re-generate openapi client 2026-06-02 21:04:04 +02:00
renovate[bot]
25f60db703
chore(deps): update docker/build-push-action digest to f9f3042 (#910)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-02 20:56:25 +02:00
renovate[bot]
478a5fcba3
chore(deps): update docker/login-action digest to 650006c (#913)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-02 20:56:03 +02:00
renovate[bot]
bf69fc5f65
chore(deps): update docker/metadata-action digest to 80c7e94 (#915)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-02 20:55:42 +02:00
renovate[bot]
0fd88b2cdf
chore(deps): update docker/setup-buildx-action digest to d7f5e7f (#916)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-02 20:55:18 +02:00
renovate[bot]
8302893233
fix(deps): update dependency commander to v15 (#932)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-02 20:47:47 +02:00
Nicolas Meienberger
1526e3d441
test: automate NFS integration 2026-06-02 20:36:33 +02:00
Nicolas Meienberger
dfd787c8ae
test(integration): skip volume mounting in CI 2026-06-02 20:26:50 +02:00
Nico
a488bbc754
fix: block login for 2fa users with un-verified passkeys (#934)
* fix: block login for 2fa users with un-verified passkeys

* refactor(passkey): show proper login error

* refactor: show passkey generic error on all failures
2026-06-02 19:48:40 +02:00
Nicolas Meienberger
ce23bded90
test: automate SMB integration 2026-06-02 19:45:17 +02:00
Nicolas Meienberger
c793785e30
docs: user and role permission model 2026-06-02 19:21:25 +02:00
Nicolas Meienberger
036382d82d
test: automate webdav integration 2026-06-02 17:30:49 +02:00
Nicolas Meienberger
756ecbddcd
test: automate SFTP integration coverage 2026-06-02 17:25:10 +02:00
Nico
648ccae5fc
test(integration): s3 repository with rustfs & rclone (#933)
* test(integration): s3 repository with rustfs

* ci: run integration tests before release

* chore: fix linting issue

* ci: persist-creds -> false
2026-06-01 21:37:12 +02:00
Nicolas Meienberger
62cdf5dcca
fix(backup-config): throw if include patterns have un-supported characters 2026-06-01 20:14:12 +02:00
Nico
00d1dac515
fix(backup-config): throw if include patterns have un-supported chars (#931) 2026-06-01 20:02:07 +02:00
Nico
d479bfaddc
feat: snapshot restores through rpc (#930)
* feat: snapshot restores through rpc

* fix(restore): do not wait for lock before returning response

* chore: fix liniting issue
2026-05-31 21:48:32 +02:00
Nico
8fedeef4d1
feat: add restore agent RPC foundation (#929)
* feat: add restore agent RPC foundation

* chore: temp event handlers

* refactor: export restore progress from dto file
2026-05-31 19:39:20 +02:00
Nico
2d877cee5a
feat: durable tasks (#927) 2026-05-30 16:54:49 +02:00
Nico
0a2c6bca0c
refactor(restic): auto try to unlock and remove stale locks (#926) 2026-05-30 13:21:22 +02:00
Nico
d4436b0cdc
refactor(restic): all commands return effects (#924)
* refactor(restic): all commands are effects

* fix(restic): preserve effect failure errors

* chore: pr feedbacks
2026-05-30 10:10:54 +02:00
Nicolas Meienberger
ddf7cab503 chore(vite): allow tailscale hosts for dev server 2026-05-30 10:09:01 +02:00
renovate[bot]
74ddf574a8
fix(deps): update bun minor and patch dependencies (#902)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-29 22:24:59 +02:00
Nicolas Meienberger
d502b96509
test: fix webhook mocks 2026-05-29 20:45:16 +02:00
Nicolas Meienberger
dee3bb098a
e2e: fix 2fa leakage in next tests 2026-05-29 20:45:16 +02:00
Nicolas Meienberger
5ee65bf0af
refactor(sftp): mixed options style 2026-05-29 20:45:15 +02:00
Nico
7b5c53bb7d
fix(sftp): allow legacy ssh rsa to add support for older servers (#921)
* refactor(e2e): use more stable assertion

* feat(sftp): add legacy ssh-rsa option
2026-05-27 21:19:23 +02:00
Nicolas Meienberger
17a8838569
deps: bump rclone & shoutrrr to latest versions 2026-05-27 21:16:08 +02:00
Nicolas Meienberger
25b2f71b24
e2e: add missing host-gateway mapping 2026-05-27 21:15:03 +02:00
Nico
008296238b
test(e2e): add test suite to test webhooks (#922) 2026-05-27 21:12:53 +02:00
Nicolas Meienberger
2f78c4a1fb
chore: bump nitro & @better-auth/passkey 2026-05-27 21:07:12 +02:00
Nico
20d202c20e
fix(core): enforce webhook timeout (#920) 2026-05-27 20:35:01 +02:00
Nicolas Meienberger
47f8f2f03a
docs: update local dev server instructions 2026-05-27 17:40:43 +02:00
Nicolas Meienberger
4ea9f34154
chore: portless setup 2026-05-27 17:14:34 +02:00
Nico
e4898b97ea
fix(2fa): add missing 2fa column (#917) 2026-05-22 20:09:30 +02:00
Nico
98338e80c3
Add passkey authentication support (#845)
* feat(auth): add passkey authentication support

* fix: implement AI review feedback

* fix: use non-unique index for passkey_credentialID_idx in migration

* refactor(passkeys): use TanStack mutations for passkey CRUD operations

* chore: restore lockfile from main and add @better-auth/passkey

* chore: fix conflicts

* refactor(passkey-login): simplify passkey autofill event

* refactor(settings-passkeys): ux improvements

---------

Co-authored-by: Nicolas Meienberger <github@thisprops.com>
2026-05-21 21:18:46 +02:00
Nico
273408cdb8
fix(shutdown): keep the current volume status after shutdown cleanup (#906)
Some checks failed
Release Workflow / determine-release-type (push) Has been cancelled
Release Workflow / checks (push) Has been cancelled
Release Workflow / e2e-tests (push) Has been cancelled
Release Workflow / build-images (push) Has been cancelled
Release Workflow / publish-release (push) Has been cancelled
Release Workflow / request-docs-version-update (push) Has been cancelled
2026-05-20 15:04:48 +02:00
Copilot
0589fd63b1
Update Docker Compose docs examples to Zerobyte v0.37 image tag (#905)
* Initial plan

* docs: update docker compose image tags to v0.37

Agent-Logs-Url: https://github.com/nicotsx/zerobyte/sessions/067f22a5-8223-4fd3-8e9c-c50f6ab2cc6e

Co-authored-by: nicotsx <47644445+nicotsx@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: nicotsx <47644445+nicotsx@users.noreply.github.com>
2026-05-20 14:38:51 +02:00
Nicolas Meienberger
6bcb0bef71
chore: bump rclone & shoutrrr versions
Some checks failed
Release Workflow / determine-release-type (push) Has been cancelled
Release Workflow / checks (push) Has been cancelled
Release Workflow / e2e-tests (push) Has been cancelled
Release Workflow / build-images (push) Has been cancelled
Release Workflow / publish-release (push) Has been cancelled
Release Workflow / request-docs-version-update (push) Has been cancelled
2026-05-20 13:41:34 +02:00
renovate[bot]
705b8eb748
fix(deps): update bun minor and patch dependencies (#892)
Some checks failed
Release Workflow / determine-release-type (push) Has been cancelled
Release Workflow / checks (push) Has been cancelled
Release Workflow / e2e-tests (push) Has been cancelled
Release Workflow / build-images (push) Has been cancelled
Release Workflow / publish-release (push) Has been cancelled
Release Workflow / request-docs-version-update (push) Has been cancelled
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-19 22:00:51 +02:00
renovate[bot]
05073e667e
chore(deps): update cloudflare/wrangler-action action to v4 (#884)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-19 21:56:11 +02:00
renovate[bot]
970a7fa42f
fix(deps): update dependency content-disposition to v2 (#880)
* fix(deps): update dependency content-disposition to v2

* refactor(content-disposition): use new named export

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Nicolas Meienberger <github@thisprops.com>
2026-05-19 21:55:43 +02:00
renovate[bot]
575e01cab9
chore(deps): update dependency bun to v1.3.14 (#885)
* chore(deps): update dependency bun to v1.3.14

* chore: update bun base docker image

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Nicolas Meienberger <github@thisprops.com>
2026-05-19 21:28:46 +02:00
renovate[bot]
eb36621b30
chore(deps): update actions/upload-artifact digest to 043fb46 (#777)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-19 20:51:48 +02:00
renovate[bot]
da1d4d30b2
chore(deps): update actions/cache digest to 27d5ce7 (#786)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-19 20:51:23 +02:00
renovate[bot]
68a7b0beb1
chore(deps): update github/codeql-action digest to 9e0d7b8 (#793)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-19 20:51:04 +02:00
renovate[bot]
4d5a5d399b
chore(deps): update voidzero-dev/setup-vp digest to ca1c466 (#866)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-19 20:50:45 +02:00
Nico
f82494abab
test: add repository import e2e coverage (#901) 2026-05-19 20:46:08 +02:00
Nico
4dcafa0708
feat(auth): allow skipping forced recovery key download (#900)
* feat(auth): allow skipping forced recovery key download

* refactor: move from session storage to cookie
2026-05-19 20:36:45 +02:00
Nico
c071596151
fix(system): block recovery key download without credential password (#899) 2026-05-19 20:28:09 +02:00
Nico
4b66ad73a7
fix(auth): verify reauth passwords against credential account (#898) 2026-05-19 20:20:55 +02:00
Nico
4a06891db9
ci: remove stale file upload (#897) 2026-05-19 20:14:16 +02:00
Nico
3b9f2086ee
fix(backups): preserve retry settings when toggling schedules (#896) 2026-05-19 20:08:05 +02:00
Nico
94f6d0529f
refactor(mutext): persist repository locks in database (#895)
* refactor(mutext): persist repository locks in database

* fix: clean up promoted repository lock on queued abort

* fix: throttle repository lock cleanup during polling
2026-05-19 18:59:40 +02:00
Nicolas Meienberger
b292f94186
refactor(crypto): reject encrypted values from another secret in sealSecret 2026-05-18 22:00:21 +02:00
Nicolas Meienberger
66ebc249ca
fix: force recovery key redownload after possible truncated download 2026-05-18 21:35:53 +02:00
Nicolas Meienberger
0f5a6823ec
refactor(crypto): reject encrypted values from another secret 2026-05-18 21:14:20 +02:00
Nicolas Meienberger
419204d587
fix(download-password): revoke url after 60 secs 2026-05-18 21:13:07 +02:00
Nico
11dacd7c71
refactor(agent): store SFTP volume keys in tmp (#888)
* refactor(agent): store SFTP volume keys in tmp

* refactor: store temp keys in /run/zerobyte subfolders

* fix(restic): clean temp secrets dir on env setup failure

* fix: one secrets temp dir per env building
2026-05-17 18:19:09 +02:00
Nico
19a0781667
test: backend integration (#889)
* test: backend integration

* docs: mounted shares acls

* feat: smb expose real ACLs when available

* fix: re-init repo on setup

* chore: add missing @hono/standard-validator package

* chore: add happy-dom dev dep
2026-05-17 15:18:56 +02:00
Nico
3142cb026a
chore: move access logs to debug (#891) 2026-05-17 14:26:40 +02:00
Nico
2a1351382f
fix(agent-manager): disable ws server when not needed (#890) 2026-05-17 14:04:49 +02:00
renovate[bot]
0dccd00539
fix(deps): update bun minor and patch dependencies (#867)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-17 13:54:02 +02:00
Nicolas Meienberger
ecbf407058
deps: minimum release age -> 3 days 2026-05-16 11:59:34 +02:00
John Veness
18a91d9b78
README: Fix some punctuation, styling, grammar (#879)
* README: Fix some punctuation, styling, grammar

* Apply suggestion from @JohnVeness

* Update README.md
2026-05-13 17:08:07 +02:00
Nico
a58fe82d48
refactor: move backup path resolution into agent contract (#872) 2026-05-10 21:46:03 +02:00
Nico
aa7da321ba
refactor: dedpulicate volume schemas across packages (#864) 2026-05-09 15:36:25 +02:00
Nico
2ada5acd5a
refactor(agent): harden local agent volume lifecycle (#863)
* refactor(agent): harden local agent volume lifecycle

* chore(test): remove un-used variable

* refactor(agent): create dedicated jobs for recurring tasks

* chore: pr feedbacks

* test: add missing fake agent controller
2026-05-09 12:13:04 +02:00
Nico
2062beac68
refactor(server): route volume operations through agents (#862)
* refactor(server): route volume operations through agents

* chore: pr feedbacks
2026-05-07 20:51:25 +02:00
Nico
df4b668560
feat(agent): add volume operation RPC (#861) 2026-05-07 18:11:57 +02:00
renovate[bot]
5e4742488f
fix(deps): update bun minor and patch dependencies (#865)
* fix(deps): update bun minor and patch dependencies

* fix: ci

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Nicolas Meienberger <github@thisprops.com>
2026-05-07 17:11:53 +02:00
renovate[bot]
cda008a53e
chore(deps): update voidzero-dev/setup-vp digest to 4f5aa3e (#818)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-07 17:08:04 +02:00
Nicolas Meienberger
e91df3eab0
chore(renovate): range strategy bump 2026-05-07 09:32:42 +02:00
Nicolas Meienberger
3c7eb65c7c
chore(package-core): remove lock 2026-05-07 09:17:46 +02:00
Nicolas Meienberger
b13b6d606a
chore(renovate): disable minimumRelease age for gh actions 2026-05-07 08:48:01 +02:00
Nicolas Meienberger
66cd9e185b
chore(renovate): rangeStrategy bump 2026-05-07 08:17:45 +02:00
Nicolas Meienberger
1ca1598c58
fix: change dev panel shortcut to D+E+V 2026-05-07 08:17:44 +02:00
renovate[bot]
89ae52d036
chore(deps): update docker/build-push-action digest to bcafcac (#772)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-07 08:10:59 +02:00
Nico
700fa1cd4a
feat: route backups through volume agents (#860) 2026-05-06 22:13:51 +02:00
Nico
e65a135676
feat(agents): create agent registry and service (#849)
* feat(agents): create agent registry and service

* fix: mark agent offline only if the session was removed properly

* refactor: centralize agent backup lifecycle state

* refactor: simplify session management

* refactor: move effect / async boundary in one place

* chore: regen migration

* refactor: improve error handling

* chore: pr feedback
2026-05-05 19:34:10 +02:00
Nicolas Meienberger
e981211a2d
fix(notifications): validate webhook headers and show delivery health 2026-05-05 10:24:20 +02:00
Nicolas Meienberger
cd69eea27f
fix: avoid unnecessary webhook allowlist checks on notification edits 2026-05-05 10:24:20 +02:00
Nicolas Meienberger
b1ae85e2c1
fix(notifications): preserve existing destinations with target allowlist 2026-05-05 10:24:20 +02:00
Copilot
497a0e8bee
docs: update Docker Compose image tags to v0.36 (#857)
* Initial plan

* docs: update Docker image tags to v0.36

Closes nicotsx/zerobyte#856

Agent-Logs-Url: https://github.com/nicotsx/zerobyte/sessions/685fd718-282c-4843-b5bb-082bf8ed0571

Co-authored-by: nicotsx <47644445+nicotsx@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: nicotsx <47644445+nicotsx@users.noreply.github.com>
2026-05-04 22:02:35 +02:00
304 changed files with 32826 additions and 5679 deletions

View file

@ -3,6 +3,7 @@
!bun.lock
!package.json
!.gitignore
!bunfig.toml
!**/package.json
!**/bun.lock
@ -23,3 +24,4 @@
node_modules/**
dist/**
.output/**
app/test/integration/artifacts/**

View file

@ -4,6 +4,6 @@ RESTIC_CACHE_DIR=./data/restic/cache
ZEROBYTE_REPOSITORIES_DIR=./data/repositories
ZEROBYTE_VOLUMES_DIR=./data/volumes
APP_SECRET=<openssl rand -hex 32>
BASE_URL=http://localhost:300
BASE_URL=http://*.localhost
TRUST_PROXY=false
PORT=3000

View file

@ -17,9 +17,9 @@ runs:
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
name: Install Bun
with:
bun-version: "1.3.13"
bun-version: "1.3.14"
- uses: voidzero-dev/setup-vp@8ecb39174989ce55af90f45cf55b02738599831d # v1
- uses: voidzero-dev/setup-vp@ca1c46663915d6c1042ae23bd39ab85718bfb0fa # v1
with:
node-version: '24'
cache: true

View file

@ -2,8 +2,14 @@
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": ["config:recommended", "helpers:pinGitHubActionDigests"],
"enabledManagers": ["bun", "github-actions"],
"minimumReleaseAge": "1 day",
"minimumReleaseAge": "3 days",
"rangeStrategy": "bump",
"packageRules": [
{
"matchManagers": ["github-actions"],
"matchUpdateTypes": ["digest", "pinDigest"],
"minimumReleaseAge": null
},
{
"matchManagers": ["bun"],
"matchUpdateTypes": ["minor", "patch"],

View file

@ -13,6 +13,7 @@ on:
jobs:
deploy:
name: Deploy docs
if: github.event.repository.default_branch == github.ref_name
runs-on: ubuntu-latest
timeout-minutes: 10
@ -27,7 +28,7 @@ jobs:
run: bun run build
- name: Build and deploy docs
uses: cloudflare/wrangler-action@9acf94ace14e7dc412b076f2c5c20b8ce93c79cd # v3
uses: cloudflare/wrangler-action@ebbaa1584979971c8614a24965b4405ff95890e0 # v4
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}

View file

@ -26,7 +26,7 @@ jobs:
- name: Cache Playwright Browsers
id: playwright-cache
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
with:
path: ~/.cache/ms-playwright
key: ${{ runner.os }}-playwright-${{ steps.playwright-version.outputs.version }}
@ -96,7 +96,7 @@ jobs:
run: |
docker exec zerobyte /bin/ash -c "ls -la /test-data" || true
- uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
if: always()
with:
name: playwright-report

35
.github/workflows/integration.yml vendored Normal file
View file

@ -0,0 +1,35 @@
name: Integration Tests
permissions:
contents: read
on:
workflow_dispatch:
workflow_call:
jobs:
integration:
name: Integration
timeout-minutes: 30
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
persist-credentials: false
- name: Install dependencies
uses: "./.github/actions/install-dependencies"
- name: Run integration tests
env:
SKIP_VOLUME_MOUNT_INTEGRATION_TESTS: "true"
run: bun run test:integration
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
if: failure()
with:
name: integration-artifacts
path: |
app/test/integration/artifacts/compose.log
app/test/integration/artifacts/docker-output.log
app/test/integration/artifacts/runs/**
retention-days: 5

View file

@ -20,20 +20,20 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Log in to Docker Hub
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4
with:
driver: cloud
endpoint: "meienberger/runtipi-builder"
install: true
- name: Login to GitHub Container Registry
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
@ -41,14 +41,14 @@ jobs:
- name: Docker meta
id: meta
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6
with:
images: ghcr.io/${{ github.repository_owner }}/zerobyte
tags: |
type=raw,value=nightly
- name: Push images to GitHub Container Registry
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7
with:
context: .
target: production

View file

@ -43,10 +43,13 @@ jobs:
e2e-tests:
uses: ./.github/workflows/e2e.yml
integration-tests:
uses: ./.github/workflows/integration.yml
build-images:
environment: release
timeout-minutes: 15
needs: [determine-release-type, checks, e2e-tests]
needs: [determine-release-type, checks, e2e-tests, integration-tests]
runs-on: ubuntu-latest
permissions:
contents: read
@ -60,56 +63,28 @@ jobs:
ref: ${{ github.ref }}
- name: Log in to Docker Hub
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4
with:
driver: cloud
endpoint: "meienberger/runtipi-builder"
install: true
- name: Login to GitHub Container Registry
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build docker image
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7
with:
context: .
target: production
platforms: linux/amd64
push: false
load: true
tags: local/zerobyte:ci
build-args: |
APP_VERSION=${{ needs.determine-release-type.outputs.tagname }}
- name: Scan new image for vulnerabilities
if: needs.determine-release-type.outputs.release_type == 'release'
uses: anchore/scan-action@e1165082ffb1fe366ebaf02d8526e7c4989ea9d2 # v7
id: scan
with:
image: local/zerobyte:ci
fail-build: false
only-fixed: true
severity-cutoff: critical
- name: upload Anchore scan report
if: needs.determine-release-type.outputs.release_type == 'release'
uses: github/codeql-action/upload-sarif@c10b8064de6f491fea524254123dbe5e09572f13 # v4
with:
sarif_file: ${{ steps.scan.outputs.sarif }}
- name: Docker meta
id: meta
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6
with:
images: ghcr.io/${{ github.repository_owner }}/zerobyte
tags: |
@ -121,7 +96,7 @@ jobs:
latest=${{ needs.determine-release-type.outputs.release_type == 'release' }}
- name: Push images to GitHub Container Registry
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7
with:
context: .
target: production
@ -153,7 +128,6 @@ jobs:
name: ${{ needs.determine-release-type.outputs.tagname }}
draft: false
prerelease: true
files: cli/runtipi-cli-*
request-docs-version-update:
uses: ./.github/workflows/request-docs-version-update.yml

View file

@ -36,6 +36,6 @@ jobs:
retention-days: 5
- name: Upload to code-scanning
uses: github/codeql-action/upload-sarif@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
uses: github/codeql-action/upload-sarif@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4
with:
sarif_file: results.sarif

5
.gitignore vendored
View file

@ -15,7 +15,7 @@ notes.md
smb-password.txt
cache.db
data/
/data
.env*
!.env.example
@ -34,8 +34,11 @@ node_modules/
playwright/.auth
playwright/temp
playwright/data
.idea/
.deepsec/
.codex/
# OpenAPI error logs
openapi-ts-error-*.log

View file

@ -2,7 +2,7 @@
- Never create migration files manually. Always use the provided command to generate migrations
- If you realize an automated migration is incorrect, make sure to remove all the associated entries from the `_journal.json` and the newly created files located in `app/drizzle/` before re-generating the migration
- The dev server is running at http://localhost:3000. Username is `admin` and password is `password`
- The dev server runs through Portless. Start it with `bun run dev`, then use `portless get zerobyte` to get the current worktree-specific URL. Do not assume a fixed port like `3000` or `4096`. Username is `admin` and password is `password`
- The repo is https://github.com/nicotsx/zerobyte
- If you need to run a specific restic command on a repository, you can open and use the dev panel with `Meta+Shift+D`
@ -10,6 +10,21 @@
Zerobyte is a backup automation tool built on top of Restic that provides a web interface for scheduling, managing, and monitoring encrypted backups. It supports multiple volume backends (NFS, SMB, WebDAV, SFTP, local directories) and repository backends (S3, Azure, GCS, local, and rclone-based storage).
### Development Server
```bash
# Start the dev server through Portless
bun run dev
# Get the current app URL for this worktree
portless get zerobyte
# Inspect active Portless routes if needed
portless list
```
Portless applies git worktree prefixes automatically, so linked worktrees may return URLs like `https://branch-name.zerobyte.localhost`. Use the Portless URL for browser testing and manual verification.
### Type Checking
```bash

View file

@ -1,8 +1,8 @@
FROM oven/bun:1.3.13-alpine@sha256:4de475389889577f346c636f956b42a5c31501b654664e9ae5726f94d7bb5349 AS base
FROM oven/bun:1.3.14-alpine@sha256:5acc90a93e91ff07bf72aa90a7c9f0fa189765aec90b47bdbf2152d2196383c0 AS base
ARG RESTIC_VERSION="0.18.1"
ARG RCLONE_VERSION="1.74.0"
ARG SHOUTRRR_VERSION="0.14.3"
ARG RCLONE_VERSION="1.74.2"
ARG SHOUTRRR_VERSION="0.15.1"
ENV VITE_RESTIC_VERSION=${RESTIC_VERSION} \
VITE_RCLONE_VERSION=${RCLONE_VERSION} \
@ -10,7 +10,7 @@ ENV VITE_RESTIC_VERSION=${RESTIC_VERSION} \
RUN apk update --no-cache && \
apk upgrade --no-cache && \
apk add --no-cache davfs2=1.6.1-r2 openssh-client fuse3 sshfs tini tzdata
apk add --no-cache acl attr cifs-utils davfs2=1.6.1-r2 openssh-client fuse3 sshfs tini tzdata
ENTRYPOINT ["/sbin/tini", "-s", "--"]
@ -44,6 +44,15 @@ RUN bzip2 -d restic.bz2 && chmod +x restic
RUN mv rclone-v*-linux-*/rclone /deps/rclone && chmod +x /deps/rclone
RUN tar -xzf shoutrrr.tar.gz && chmod +x shoutrrr
# ------------------------------
# RUNTIME TOOLS
# ------------------------------
FROM base AS runtime-tools
COPY --from=deps /deps/restic /usr/local/bin/restic
COPY --from=deps /deps/rclone /usr/local/bin/rclone
COPY --from=deps /deps/shoutrrr /usr/local/bin/shoutrrr
# ------------------------------
# DEVELOPMENT
# ------------------------------

128
README.md
View file

@ -20,13 +20,13 @@
[![Discord](https://img.shields.io/discord/1466834119873925263?label=discord&logo=discord)](https://discord.gg/XjgVyXrcEH)
> [!WARNING]
> Zerobyte is still in version 0.x.x and is subject to major changes from version to version. I am developing the core features and collecting feedbacks. Please open issues for bugs or feature requests
> Zerobyte is still in version 0.x.x and is subject to major changes from version to version. I am developing the core features and collecting feedback. Please open issues for bugs or feature requests.
<p align="center">
<a href="https://www.buymeacoffee.com/nicotsx" target="_blank"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy Me A Coffee" style="height: 60px !important;width: 217px !important;" ></a>
</p>
## Intro
## Introduction
Zerobyte is a backup automation tool that helps you save your data across multiple storage backends. Built on top of Restic, it provides an modern web interface to schedule, manage, and monitor encrypted backups of your remote storage.
@ -38,10 +38,10 @@ It contains up-to-date setup guides, configuration reference, and usage document
### Features
- &nbsp; **Automated backups** with encryption, compression and retention policies powered by Restic
- &nbsp; **Flexible scheduling** For automated backup jobs with fine-grained retention policies
- &nbsp; **End-to-end encryption** ensuring your data is always protected
- &nbsp; **Multi-protocol support**: Backup from NFS, SMB, WebDAV, SFTP, or local directories
- **Automated backups** with encryption, compression, and retention policies, powered by Restic
- **Flexible scheduling** for automated backup jobs with fine-grained retention policies
- **End-to-end encryption** will ensure your data is always protected
- **Multi-protocol support** for backup from NFS, SMB, WebDAV, SFTP, or local directories
## Installation
@ -50,7 +50,7 @@ In order to run Zerobyte, you need to have Docker and Docker Compose installed o
```yaml
services:
zerobyte:
image: ghcr.io/nicotsx/zerobyte:v0.35
image: ghcr.io/nicotsx/zerobyte:v0.38
container_name: zerobyte
restart: unless-stopped
cap_add:
@ -69,10 +69,10 @@ services:
```
> [!WARNING]
> It is highly discouraged to run Zerobyte on a server that is accessible from the internet (VPS or home server with port forwarding) If you do, make sure to change the port mapping to "127.0.0.1:4096:4096" and use a secure tunnel (SSH tunnel, Cloudflare Tunnel, etc.) with authentication.
> It is highly discouraged to run Zerobyte on a server that is accessible from the internet (VPS or home server with port forwarding). If you do, make sure to change the port mapping to "127.0.0.1:4096:4096" and use a secure tunnel (SSH tunnel, Cloudflare Tunnel, etc.) with authentication.
> [!WARNING]
> Do not try to point `/var/lib/zerobyte` on a network share. You will face permission issues and strong performance degradation.
> Do not try to point `/var/lib/zerobyte` to a network share. You will face permission issues and strong performance degradation.
> [!NOTE]
> **TrueNAS Users:** The host path `/var/lib` is ephemeral on TrueNAS and will be reset during system upgrades. Instead of using `/var/lib/zerobyte:/var/lib/zerobyte`, create a dedicated ZFS dataset (e.g., `tank/docker/zerobyte`) and mount it instead:
@ -97,26 +97,46 @@ Once the container is running, you can access the web interface at `http://<your
Zerobyte can be customized using environment variables. Below are the available options:
### Environment Variables
### Environment variables
| Variable | Description | Default |
| :-------------------- | :---------------------------------------------------------------------------------------------------------------------------------------- | :--------------------- |
| `BASE_URL` | **Required.** The base URL of your Zerobyte instance (e.g., `https://zerobyte.example.com`). See [Authentication](#authentication) below. | (none) |
| `APP_SECRET` | **Required.** A random secret key (32+ chars) used to encrypt sensitive data in the database. Generate with `openssl rand -hex 32`. | (none) |
| `APP_SECRET_FILE` | Path to a file containing `APP_SECRET`, useful with Docker or Kubernetes secrets. Mutually exclusive with `APP_SECRET`. | (none) |
| `PORT` | The port the web interface and API will listen on. | `4096` |
| `RESTIC_HOSTNAME` | The hostname used by Restic when creating snapshots. Automatically detected if a custom hostname is set in Docker. | `zerobyte` |
| `TZ` | Timezone for the container (e.g., `Europe/Zurich`). **Crucial for accurate backup scheduling.** | `UTC` |
| `TRUST_PROXY` | When `true`, trust an existing `X-Forwarded-For` header from your reverse proxy. Leave `false` for direct deployments. | `false` |
| `TRUSTED_ORIGINS` | Comma-separated list of extra trusted origins for CORS (e.g., `http://localhost:3000,http://example.com`). | (none) |
| `WEBHOOK_ALLOWED_ORIGINS` | Comma-separated list of HTTP origins allowed for backup webhooks and outbound HTTP notification destinations. | (none) |
| `WEBHOOK_TIMEOUT` | Timeout for backup webhook requests in seconds. | `60` |
| `LOG_LEVEL` | Logging verbosity. Options: `debug`, `info`, `warn`, `error`. | `info` |
| `SERVER_IDLE_TIMEOUT` | Idle timeout for the server in seconds. | `60` |
| `RCLONE_CONFIG_DIR` | Path to the directory containing `rclone.conf` inside the container. Change this if running as a non-root user. | `/root/.config/rclone` |
| `PROVISIONING_PATH` | Path to a JSON file with operator-managed repositories and volumes to sync at startup. | (none) |
| Variable | Description | Default |
| :------------------------ | :---------------------------------------------------------------------------------------------------------------------------------------- | :--------------------- |
| `BASE_URL` | **Required.** The base URL of your Zerobyte instance (e.g., `https://zerobyte.example.com`). See [Authentication](#authentication) below. | (none) |
| `APP_SECRET` | **Required.** A random secret key (32+ chars) used to encrypt sensitive data in the database. Generate with `openssl rand -hex 32`. | (none) |
| `APP_SECRET_FILE` | Path to a file containing `APP_SECRET`, useful with Docker or Kubernetes secrets. Mutually exclusive with `APP_SECRET`. | (none) |
| `PORT` | The port the web interface and API will listen on. | `4096` |
| `RESTIC_HOSTNAME` | The hostname used by Restic when creating snapshots. Automatically detected if a custom hostname is set in Docker. | `zerobyte` |
| `TZ` | Timezone for the container (e.g., `Europe/Zurich`). **Crucial for accurate backup scheduling.** | `UTC` |
| `TRUST_PROXY` | When `true`, trust an existing `X-Forwarded-For` header from your reverse proxy. Leave `false` for direct deployments. | `false` |
| `TRUSTED_ORIGINS` | Comma-separated list of extra trusted origins for CORS (e.g., `http://localhost:3000,http://example.com`). | (none) |
| `WEBHOOK_ALLOWED_ORIGINS` | Comma-separated list of HTTP origins allowed for backup webhooks and outbound HTTP notification destinations. | (none) |
| `WEBHOOK_TIMEOUT` | Timeout for backup webhook requests in seconds. | `60` |
| `LOG_LEVEL` | Logging verbosity. Options: `debug`, `info`, `warn`, `error`. | `info` |
| `SERVER_IDLE_TIMEOUT` | Idle timeout for the server in seconds. | `60` |
| `RCLONE_CONFIG_DIR` | Path to the directory containing `rclone.conf` inside the container. Change this if running as a non-root user. | `/root/.config/rclone` |
| `PROVISIONING_PATH` | Path to a JSON file with operator-managed repositories and volumes to sync at startup. | (none) |
### Provisioned Resources
### Webhook and notification network policy
Backup webhooks and outbound notification destinations that can target arbitrary network hosts are restricted by `WEBHOOK_ALLOWED_ORIGINS`.
The allowlist matches exact origins only: scheme, host, and port must match. Paths are ignored, so `https://hooks.example.com/backups` allows any path on `https://hooks.example.com`, but it does not allow `http://hooks.example.com`, `https://hooks.example.com:8443`, or `https://other.example.com`.
This policy applies to:
- backup pre/post webhook URLs
- Generic HTTP notification URLs
- Gotify server URLs
- self-hosted ntfy server URLs
- custom Shoutrrr URLs that point at generic HTTP or SMTP network targets
The public ntfy.sh service and fixed-provider notification services such as Slack, Discord, Pushover, and Telegram do not need `WEBHOOK_ALLOWED_ORIGINS`.
Backup webhooks do not follow redirects. Add the final destination origin to `WEBHOOK_ALLOWED_ORIGINS` and configure that final URL directly.
Webhook headers are stored as plain text and must use one `Key: Value` header per line. `WEBHOOK_TIMEOUT` controls backup pre/post webhook request timeouts; notification delivery uses the underlying provider sender behavior.
### Provisioned resources
Zerobyte can sync operator-managed repositories and volumes from a JSON file at startup. This is useful when you want credentials or connection details to live in deployment-time configuration instead of being entered through the UI.
@ -129,14 +149,14 @@ The complete provisioning documentation is available at [zerobyte.app/docs/guide
See `examples/provisioned-resources/README.md` for a full example.
### Simplified setup (No remote mounts)
### Simplified setup (no remote mounts)
If you only need to back up locally mounted folders and don't require remote share mounting capabilities, you can remove the `SYS_ADMIN` capability and FUSE device from your `docker-compose.yml`:
If you only need to back up locally-mounted folders and don't require remote share mounting capabilities, you can remove the `SYS_ADMIN` capability and FUSE device from your `docker-compose.yml`:
```yaml
services:
zerobyte:
image: ghcr.io/nicotsx/zerobyte:v0.35
image: ghcr.io/nicotsx/zerobyte:v0.38
container_name: zerobyte
restart: unless-stopped
ports:
@ -154,8 +174,8 @@ services:
**Trade-offs:**
- ✅ Improved security by reducing container capabilities
- ✅ Support for local directories
- ✅ Keep support all repository types (local, S3, GCS, Azure, rclone)
- ✅ Support for local directories as backup sources
- ✅ Support all repository types, local and remote (S3, GCS, Azure, rclone)
- ❌ Cannot mount NFS, SMB, WebDAV, or SFTP shares directly from Zerobyte
If you need remote mount capabilities, keep the original configuration with `cap_add: SYS_ADMIN` and `devices: /dev/fuse:/dev/fuse`.
@ -170,12 +190,12 @@ Zerobyte supports multiple volume backends including NFS, SMB, WebDAV, SFTP, and
To add your first volume, navigate to the "Volumes" section in the web interface and click on "Create volume". Fill in the required details such as volume name, type, and connection settings.
If you want to track a local directory on the same server where Zerobyte is running, you'll first need to mount that directory into the Zerobyte container. You can do this by adding a volume mapping in your `docker-compose.yml` file. For example, to mount `/path/to/your/directory` from the host to `/mydata` in the container, you would add the following line under the `volumes` section:
If you want to backup a local directory on the same host where Zerobyte is running, you'll first need to mount that directory into the Zerobyte container. You can do this by adding a volume mapping in your `docker-compose.yml` file. For example, to mount `/path/to/your/directory` from the host to `/mydata` in the container, you would add the following line under the `volumes` section:
```diff
services:
zerobyte:
image: ghcr.io/nicotsx/zerobyte:v0.35
image: ghcr.io/nicotsx/zerobyte:v0.38
container_name: zerobyte
restart: unless-stopped
cap_add:
@ -203,17 +223,17 @@ docker compose up -d
Now, when adding a new volume in the Zerobyte web interface, you can select "Directory" as the volume type and search for your mounted path (e.g., `/mydata`) as the source path.
![Preview](https://github.com/nicotsx/zerobyte/blob/main/screenshots/add-volume.png?raw=true)
![Add a new volume UI](https://github.com/nicotsx/zerobyte/blob/main/screenshots/add-volume.png?raw=true)
## Creating a repository
A repository is where your backups will be securely stored encrypted. Zerobyte supports multiple storage backends for your backup repositories:
- **Local directories** - Store backups on local disk subfolder of `/var/lib/zerobyte/repositories/` or any other (mounted) path
- **S3-compatible storage** - Amazon S3, MinIO, Wasabi, DigitalOcean Spaces, etc.
- **Google Cloud Storage** - Google's cloud storage service
- **Azure Blob Storage** - Microsoft Azure storage
- **rclone remotes** - 40+ cloud storage providers via rclone (see below)
- **Local directories**: Store backups on local disk subfolders in `/var/lib/zerobyte/repositories/` or any other (mounted) path
- **S3-compatible storage**: Amazon S3, MinIO, Wasabi, DigitalOcean Spaces, etc.
- **Google Cloud Storage**: Google's cloud storage service
- **Azure Blob Storage**: Microsoft Azure storage
- **rclone remotes**: 40+ cloud storage providers via rclone (see below)
Repositories are optimized for storage efficiency and data integrity, leveraging Restic's deduplication and encryption features.
@ -250,7 +270,7 @@ Zerobyte can use [rclone](https://rclone.org/) to support 40+ cloud storage prov
```diff
services:
zerobyte:
image: ghcr.io/nicotsx/zerobyte:v0.35
image: ghcr.io/nicotsx/zerobyte:v0.38
container_name: zerobyte
restart: unless-stopped
cap_add:
@ -304,24 +324,30 @@ When creating a backup job, you can specify the following settings:
After configuring the backup job, save it and Zerobyte will automatically execute the backup according to the defined schedule.
You can monitor the progress and status of your backup jobs in the "Backups" section of the web interface.
![Preview](https://github.com/nicotsx/zerobyte/blob/main/screenshots/backups-list.png?raw=true)
![Backups UI](https://github.com/nicotsx/zerobyte/blob/main/screenshots/backups-list.png?raw=true)
## Restoring data
Zerobyte allows you to easily restore your data from backups. To restore data, navigate to the "Backups" section and select the backup job from which you want to restore data. You can then choose a specific backup snapshot and select the files or directories you wish to restore. The data you select will be restored to their original location.
![Preview](https://github.com/nicotsx/zerobyte/blob/main/screenshots/restoring.png?raw=true)
![Restore UI](https://github.com/nicotsx/zerobyte/blob/main/screenshots/restoring.png?raw=true)
## Authentication
Zerobyte uses [better-auth](https://github.com/better-auth/better-auth) for authentication and session management. The authentication system automatically adapts to your deployment scenario:
### Cookie Security
### Users and roles
- **IP Address / HTTP access**: Set `BASE_URL=http://192.168.1.50:4096` (or your IP). Cookies will use `Secure: false`, allowing immediate login without SSL.
- **Domain / HTTPS access**: Set `BASE_URL=https://zerobyte.example.com`. Cookies will automatically use `Secure: true` for protected sessions.
Zerobyte does not currently provide fine-grained RBAC for backup operations. Authenticated organization members are trusted operators for repositories, volumes, backup schedules, restores, and notification destinations in their organization.
### Reverse Proxy Setup
Organization roles mainly restrict organization-management and instance-management actions. For example, normal members cannot manage members, SSO settings, invitations, registration, or global users, but they can still operate backup resources. Only add users to an organization if you are comfortable with them using the storage, volume, and notification capabilities configured for the instance.
### Cookie security
- **IP Address/HTTP access**: Set `BASE_URL=http://192.168.1.50:4096` (or your IP). Cookies will use `Secure: false`, allowing immediate login without SSL.
- **Domain/HTTPS access**: Set `BASE_URL=https://zerobyte.example.com`. Cookies will automatically use `Secure: true` for protected sessions.
### Reverse proxy setup
If you're running Zerobyte behind a reverse proxy (Nginx, Traefik, Caddy, etc.):
@ -329,7 +355,7 @@ If you're running Zerobyte behind a reverse proxy (Nginx, Traefik, Caddy, etc.):
2. The app will automatically enable secure cookies based on the `https://` prefix
3. Ensure your proxy passes the `X-Forwarded-Proto` header
### Important Notes
### Important notes
- The `BASE_URL` must start with `https://` for secure cookies to be enabled
- Local IP addresses (e.g., `http://192.168.x.x`) are **not** treated as secure contexts by browsers, so secure cookies are disabled automatically
@ -340,7 +366,7 @@ If you're running Zerobyte behind a reverse proxy (Nginx, Traefik, Caddy, etc.):
For troubleshooting common issues, please refer to the [TROUBLESHOOTING.md](TROUBLESHOOTING.md) file.
## Third-Party Software
## Third-party software
This project includes the following third-party software components:
@ -378,10 +404,10 @@ RESTIC_PASS_FILE=./data/restic.pass
RESTIC_CACHE_DIR=./data/restic/cache
ZEROBYTE_REPOSITORIES_DIR=./data/repositories
ZEROBYTE_VOLUMES_DIR=./data/volumes
BASE_URL=http://localhost:4096
BASE_URL=https://*.localhost
```
Notes:
- Remote mount backends (NFS/SMB/WebDAV/SFTP) rely on Linux mount tooling and `CAP_SYS_ADMIN`; on macOS they are expected to be unavailable.
- To actually run backups/repository checks, install `restic` on your machine (e.g. via Homebrew). If `restic` is not installed, the app still starts but backup operations will fail with a clear error.
- To actually run backups/repository checks, install `restic` on your machine (e.g., via Homebrew). If `restic` is not installed, the app still starts but backup operations will fail with a clear error.

View file

@ -49,10 +49,7 @@ export const createClient = (config: Config = {}): Client => {
};
if (opts.security) {
await setAuthParams({
...opts,
security: opts.security,
});
await setAuthParams(opts);
}
if (opts.requestValidator) {

View file

@ -119,14 +119,12 @@ const checkForExistence = (
return false;
};
export const setAuthParams = async ({
security,
...options
}: Pick<Required<RequestOptions>, 'security'> &
Pick<RequestOptions, 'auth' | 'query'> & {
export async function setAuthParams(
options: Pick<RequestOptions, 'auth' | 'query' | 'security'> & {
headers: Headers;
}) => {
for (const auth of security) {
},
): Promise<void> {
for (const auth of options.security ?? []) {
if (checkForExistence(options, auth.name)) {
continue;
}
@ -155,7 +153,7 @@ export const setAuthParams = async ({
break;
}
}
};
}
export const buildUrl: Client['buildUrl'] = (options) =>
getUrl({

View file

@ -337,14 +337,7 @@ export type ListVolumesResponses = {
200: Array<{
id: number;
shortId: string;
provisioningId: string | null;
name: string;
type: 'nfs' | 'smb' | 'directory' | 'webdav' | 'rclone' | 'sftp';
status: 'mounted' | 'unmounted' | 'error';
lastError: string | null;
createdAt: number;
updatedAt: number;
lastHealthCheck: number;
config: {
backend: 'nfs';
server: string;
@ -359,6 +352,7 @@ export type ListVolumesResponses = {
username?: string;
password?: string;
guest?: boolean;
mapToContainerUidGid?: boolean;
vers?: '1.0' | '2.0' | '2.1' | '3.0' | 'auto';
domain?: string;
port?: string | number;
@ -392,7 +386,15 @@ export type ListVolumesResponses = {
readOnly?: boolean;
skipHostKeyCheck?: boolean;
knownHosts?: string;
allowLegacySshRsa?: boolean;
};
createdAt: number;
updatedAt: number;
lastHealthCheck: number;
type: 'nfs' | 'smb' | 'directory' | 'webdav' | 'rclone' | 'sftp';
status: 'mounted' | 'unmounted' | 'error';
lastError: string | null;
provisioningId?: string | null;
autoRemount: boolean;
}>;
};
@ -416,6 +418,7 @@ export type CreateVolumeData = {
username?: string;
password?: string;
guest?: boolean;
mapToContainerUidGid?: boolean;
vers?: '1.0' | '2.0' | '2.1' | '3.0' | 'auto';
domain?: string;
port?: string | number;
@ -449,6 +452,7 @@ export type CreateVolumeData = {
readOnly?: boolean;
skipHostKeyCheck?: boolean;
knownHosts?: string;
allowLegacySshRsa?: boolean;
};
};
path?: never;
@ -463,14 +467,7 @@ export type CreateVolumeResponses = {
201: {
id: number;
shortId: string;
provisioningId: string | null;
name: string;
type: 'nfs' | 'smb' | 'directory' | 'webdav' | 'rclone' | 'sftp';
status: 'mounted' | 'unmounted' | 'error';
lastError: string | null;
createdAt: number;
updatedAt: number;
lastHealthCheck: number;
config: {
backend: 'nfs';
server: string;
@ -485,6 +482,7 @@ export type CreateVolumeResponses = {
username?: string;
password?: string;
guest?: boolean;
mapToContainerUidGid?: boolean;
vers?: '1.0' | '2.0' | '2.1' | '3.0' | 'auto';
domain?: string;
port?: string | number;
@ -518,7 +516,15 @@ export type CreateVolumeResponses = {
readOnly?: boolean;
skipHostKeyCheck?: boolean;
knownHosts?: string;
allowLegacySshRsa?: boolean;
};
createdAt: number;
updatedAt: number;
lastHealthCheck: number;
type: 'nfs' | 'smb' | 'directory' | 'webdav' | 'rclone' | 'sftp';
status: 'mounted' | 'unmounted' | 'error';
lastError: string | null;
provisioningId?: string | null;
autoRemount: boolean;
};
};
@ -541,6 +547,7 @@ export type TestConnectionData = {
username?: string;
password?: string;
guest?: boolean;
mapToContainerUidGid?: boolean;
vers?: '1.0' | '2.0' | '2.1' | '3.0' | 'auto';
domain?: string;
port?: string | number;
@ -574,6 +581,7 @@ export type TestConnectionData = {
readOnly?: boolean;
skipHostKeyCheck?: boolean;
knownHosts?: string;
allowLegacySshRsa?: boolean;
};
};
path?: never;
@ -637,14 +645,7 @@ export type GetVolumeResponses = {
volume: {
id: number;
shortId: string;
provisioningId: string | null;
name: string;
type: 'nfs' | 'smb' | 'directory' | 'webdav' | 'rclone' | 'sftp';
status: 'mounted' | 'unmounted' | 'error';
lastError: string | null;
createdAt: number;
updatedAt: number;
lastHealthCheck: number;
config: {
backend: 'nfs';
server: string;
@ -659,6 +660,7 @@ export type GetVolumeResponses = {
username?: string;
password?: string;
guest?: boolean;
mapToContainerUidGid?: boolean;
vers?: '1.0' | '2.0' | '2.1' | '3.0' | 'auto';
domain?: string;
port?: string | number;
@ -692,13 +694,21 @@ export type GetVolumeResponses = {
readOnly?: boolean;
skipHostKeyCheck?: boolean;
knownHosts?: string;
allowLegacySshRsa?: boolean;
};
createdAt: number;
updatedAt: number;
lastHealthCheck: number;
type: 'nfs' | 'smb' | 'directory' | 'webdav' | 'rclone' | 'sftp';
status: 'mounted' | 'unmounted' | 'error';
lastError: string | null;
provisioningId?: string | null;
autoRemount: boolean;
};
statfs: {
total: number;
used: number;
free: number;
total?: number;
used?: number;
free?: number;
};
};
};
@ -723,6 +733,7 @@ export type UpdateVolumeData = {
username?: string;
password?: string;
guest?: boolean;
mapToContainerUidGid?: boolean;
vers?: '1.0' | '2.0' | '2.1' | '3.0' | 'auto';
domain?: string;
port?: string | number;
@ -756,6 +767,7 @@ export type UpdateVolumeData = {
readOnly?: boolean;
skipHostKeyCheck?: boolean;
knownHosts?: string;
allowLegacySshRsa?: boolean;
};
};
path: {
@ -779,14 +791,7 @@ export type UpdateVolumeResponses = {
200: {
id: number;
shortId: string;
provisioningId: string | null;
name: string;
type: 'nfs' | 'smb' | 'directory' | 'webdav' | 'rclone' | 'sftp';
status: 'mounted' | 'unmounted' | 'error';
lastError: string | null;
createdAt: number;
updatedAt: number;
lastHealthCheck: number;
config: {
backend: 'nfs';
server: string;
@ -801,6 +806,7 @@ export type UpdateVolumeResponses = {
username?: string;
password?: string;
guest?: boolean;
mapToContainerUidGid?: boolean;
vers?: '1.0' | '2.0' | '2.1' | '3.0' | 'auto';
domain?: string;
port?: string | number;
@ -834,7 +840,15 @@ export type UpdateVolumeResponses = {
readOnly?: boolean;
skipHostKeyCheck?: boolean;
knownHosts?: string;
allowLegacySshRsa?: boolean;
};
createdAt: number;
updatedAt: number;
lastHealthCheck: number;
type: 'nfs' | 'smb' | 'directory' | 'webdav' | 'rclone' | 'sftp';
status: 'mounted' | 'unmounted' | 'error';
lastError: string | null;
provisioningId?: string | null;
autoRemount: boolean;
};
};
@ -855,8 +869,8 @@ export type MountVolumeResponses = {
* Volume mounted successfully
*/
200: {
error?: string;
status: 'mounted' | 'unmounted' | 'error';
error?: string;
};
};
@ -876,8 +890,8 @@ export type UnmountVolumeResponses = {
* Volume unmounted successfully
*/
200: {
error?: string;
status: 'mounted' | 'unmounted' | 'error';
error?: string;
};
};
@ -904,8 +918,8 @@ export type HealthCheckVolumeResponses = {
* Volume health check result
*/
200: {
error?: string;
status: 'mounted' | 'unmounted' | 'error';
error?: string;
};
};
@ -932,7 +946,7 @@ export type ListFilesResponses = {
files: Array<{
name: string;
path: string;
type: 'file' | 'directory';
type: 'directory' | 'file';
size?: number;
modifiedAt?: number;
}>;
@ -966,8 +980,8 @@ export type BrowseFilesystemResponses = {
directories: Array<{
name: string;
path: string;
type: 'file' | 'directory';
size?: number;
type: 'directory';
size?: unknown;
modifiedAt?: number;
}>;
path: string;
@ -1136,6 +1150,7 @@ export type ListRepositoriesResponses = {
privateKey: string;
skipHostKeyCheck?: boolean;
knownHosts?: string;
allowLegacySshRsa?: boolean;
isExistingRepository?: boolean;
customPassword?: string;
cacert?: string;
@ -1319,6 +1334,7 @@ export type CreateRepositoryData = {
privateKey: string;
skipHostKeyCheck?: boolean;
knownHosts?: string;
allowLegacySshRsa?: boolean;
isExistingRepository?: boolean;
customPassword?: string;
cacert?: string;
@ -1557,6 +1573,7 @@ export type GetRepositoryResponses = {
privateKey: string;
skipHostKeyCheck?: boolean;
knownHosts?: string;
allowLegacySshRsa?: boolean;
isExistingRepository?: boolean;
customPassword?: string;
cacert?: string;
@ -1740,6 +1757,7 @@ export type UpdateRepositoryData = {
privateKey: string;
skipHostKeyCheck?: boolean;
knownHosts?: string;
allowLegacySshRsa?: boolean;
isExistingRepository?: boolean;
customPassword?: string;
cacert?: string;
@ -1931,6 +1949,7 @@ export type UpdateRepositoryResponses = {
privateKey: string;
skipHostKeyCheck?: boolean;
knownHosts?: string;
allowLegacySshRsa?: boolean;
isExistingRepository?: boolean;
customPassword?: string;
cacert?: string;
@ -2248,6 +2267,7 @@ export type RestoreSnapshotData = {
excludeXattr?: Array<string>;
delete?: boolean;
targetPath?: string;
targetAgentId?: string;
overwrite?: 'always' | 'if-changed' | 'if-newer' | 'never';
};
path: {
@ -2259,13 +2279,11 @@ export type RestoreSnapshotData = {
export type RestoreSnapshotResponses = {
/**
* Snapshot restored successfully
* Snapshot restore started
*/
200: {
success: boolean;
message: string;
filesRestored: number;
filesSkipped: number;
202: {
restoreId: string;
status: 'started';
};
};
@ -2457,14 +2475,7 @@ export type ListBackupSchedulesResponses = {
volume: {
id: number;
shortId: string;
provisioningId: string | null;
name: string;
type: 'nfs' | 'smb' | 'directory' | 'webdav' | 'rclone' | 'sftp';
status: 'mounted' | 'unmounted' | 'error';
lastError: string | null;
createdAt: number;
updatedAt: number;
lastHealthCheck: number;
config: {
backend: 'nfs';
server: string;
@ -2479,6 +2490,7 @@ export type ListBackupSchedulesResponses = {
username?: string;
password?: string;
guest?: boolean;
mapToContainerUidGid?: boolean;
vers?: '1.0' | '2.0' | '2.1' | '3.0' | 'auto';
domain?: string;
port?: string | number;
@ -2512,7 +2524,15 @@ export type ListBackupSchedulesResponses = {
readOnly?: boolean;
skipHostKeyCheck?: boolean;
knownHosts?: string;
allowLegacySshRsa?: boolean;
};
createdAt: number;
updatedAt: number;
lastHealthCheck: number;
type: 'nfs' | 'smb' | 'directory' | 'webdav' | 'rclone' | 'sftp';
status: 'mounted' | 'unmounted' | 'error';
lastError: string | null;
provisioningId?: string | null;
autoRemount: boolean;
};
repository: {
@ -2664,6 +2684,7 @@ export type ListBackupSchedulesResponses = {
privateKey: string;
skipHostKeyCheck?: boolean;
knownHosts?: string;
allowLegacySshRsa?: boolean;
isExistingRepository?: boolean;
customPassword?: string;
cacert?: string;
@ -2875,14 +2896,7 @@ export type GetBackupScheduleResponses = {
volume: {
id: number;
shortId: string;
provisioningId: string | null;
name: string;
type: 'nfs' | 'smb' | 'directory' | 'webdav' | 'rclone' | 'sftp';
status: 'mounted' | 'unmounted' | 'error';
lastError: string | null;
createdAt: number;
updatedAt: number;
lastHealthCheck: number;
config: {
backend: 'nfs';
server: string;
@ -2897,6 +2911,7 @@ export type GetBackupScheduleResponses = {
username?: string;
password?: string;
guest?: boolean;
mapToContainerUidGid?: boolean;
vers?: '1.0' | '2.0' | '2.1' | '3.0' | 'auto';
domain?: string;
port?: string | number;
@ -2930,7 +2945,15 @@ export type GetBackupScheduleResponses = {
readOnly?: boolean;
skipHostKeyCheck?: boolean;
knownHosts?: string;
allowLegacySshRsa?: boolean;
};
createdAt: number;
updatedAt: number;
lastHealthCheck: number;
type: 'nfs' | 'smb' | 'directory' | 'webdav' | 'rclone' | 'sftp';
status: 'mounted' | 'unmounted' | 'error';
lastError: string | null;
provisioningId?: string | null;
autoRemount: boolean;
};
repository: {
@ -3082,6 +3105,7 @@ export type GetBackupScheduleResponses = {
privateKey: string;
skipHostKeyCheck?: boolean;
knownHosts?: string;
allowLegacySshRsa?: boolean;
isExistingRepository?: boolean;
customPassword?: string;
cacert?: string;
@ -3274,14 +3298,7 @@ export type GetBackupScheduleForVolumeResponses = {
volume: {
id: number;
shortId: string;
provisioningId: string | null;
name: string;
type: 'nfs' | 'smb' | 'directory' | 'webdav' | 'rclone' | 'sftp';
status: 'mounted' | 'unmounted' | 'error';
lastError: string | null;
createdAt: number;
updatedAt: number;
lastHealthCheck: number;
config: {
backend: 'nfs';
server: string;
@ -3296,6 +3313,7 @@ export type GetBackupScheduleForVolumeResponses = {
username?: string;
password?: string;
guest?: boolean;
mapToContainerUidGid?: boolean;
vers?: '1.0' | '2.0' | '2.1' | '3.0' | 'auto';
domain?: string;
port?: string | number;
@ -3329,7 +3347,15 @@ export type GetBackupScheduleForVolumeResponses = {
readOnly?: boolean;
skipHostKeyCheck?: boolean;
knownHosts?: string;
allowLegacySshRsa?: boolean;
};
createdAt: number;
updatedAt: number;
lastHealthCheck: number;
type: 'nfs' | 'smb' | 'directory' | 'webdav' | 'rclone' | 'sftp';
status: 'mounted' | 'unmounted' | 'error';
lastError: string | null;
provisioningId?: string | null;
autoRemount: boolean;
};
repository: {
@ -3481,6 +3507,7 @@ export type GetBackupScheduleForVolumeResponses = {
privateKey: string;
skipHostKeyCheck?: boolean;
knownHosts?: string;
allowLegacySshRsa?: boolean;
isExistingRepository?: boolean;
customPassword?: string;
cacert?: string;
@ -3955,6 +3982,7 @@ export type GetScheduleMirrorsResponses = {
privateKey: string;
skipHostKeyCheck?: boolean;
knownHosts?: string;
allowLegacySshRsa?: boolean;
isExistingRepository?: boolean;
customPassword?: string;
cacert?: string;
@ -4167,6 +4195,7 @@ export type UpdateScheduleMirrorsResponses = {
privateKey: string;
skipHostKeyCheck?: boolean;
knownHosts?: string;
allowLegacySshRsa?: boolean;
isExistingRepository?: boolean;
customPassword?: string;
cacert?: string;

View file

@ -1,6 +1,6 @@
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { useHotkey } from "@tanstack/react-hotkeys";
import { useHotkeySequence } from "@tanstack/react-hotkeys";
import { getDevPanelOptions } from "~/client/api-client/@tanstack/react-query.gen";
import { DevPanel } from "./dev-panel";
@ -10,7 +10,12 @@ export function DevPanelListener() {
...getDevPanelOptions(),
});
useHotkey("Mod+Shift+D", () => setIsOpen(true), { enabled: !!devPanelStatus?.enabled, preventDefault: true });
useHotkeySequence(["D", "E", "V"], () => setIsOpen(true), {
enabled: !!devPanelStatus?.enabled,
preventDefault: true,
ignoreInputs: true,
timeout: 1000,
});
if (!devPanelStatus?.enabled) {
return null;

View file

@ -224,29 +224,6 @@ describe("SnapshotTreeBrowser", () => {
});
});
test("prefetches using the query path when display and query roots differ", async () => {
const requests = mockListSnapshotFiles();
renderSnapshotTreeBrowser();
const row = await screen.findByRole("button", { name: "project" });
const initialRequestCount = requests.length;
await userEvent.hover(row);
await waitFor(() => {
expect(requests.length).toBe(initialRequestCount + 1);
});
expect(requests.at(-1)).toEqual({
shortId: "repo-1",
snapshotId: "snap-1",
path: "/mnt/project",
offset: "0",
limit: "500",
});
});
test("shows the query root contents when display and query roots differ", async () => {
mockListSnapshotFiles();

View file

@ -5,7 +5,6 @@ import { FileBrowser, type FileBrowserUiProps } from "~/client/components/file-b
import { useFileBrowser } from "~/client/hooks/use-file-browser";
import { parseError } from "~/client/lib/errors";
import { isPathWithin, normalizeAbsolutePath } from "@zerobyte/core/utils";
import { logger } from "~/client/lib/logger";
function createPathPrefixFns(basePath: string) {
return {
@ -84,16 +83,6 @@ export const SnapshotTreeBrowser = (props: SnapshotTreeBrowserProps) => {
}),
);
},
prefetchFolder: (displayPath) => {
void queryClient
.prefetchQuery(
listSnapshotFilesOptions({
path: { shortId: repositoryId, snapshotId },
query: { path: displayPath, offset: 0, limit: pageSize },
}),
)
.catch((e) => logger.error(e));
},
pathTransform: displayPathFns,
});

View file

@ -1,6 +1,5 @@
import * as React from "react";
import { OTPInput, OTPInputContext } from "input-otp";
import { MinusIcon } from "lucide-react";
import { cn } from "~/client/lib/utils";
@ -55,12 +54,8 @@ function InputOTPSlot({
);
}
function InputOTPSeparator({ ...props }: React.ComponentProps<"div">) {
return (
<div data-slot="input-otp-separator" role="separator" {...props}>
<MinusIcon />
</div>
);
function InputOTPSeparator({ ...props }: React.ComponentProps<"hr">) {
return <hr data-slot="input-otp-separator" {...props} />;
}
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator };

View file

@ -2,7 +2,7 @@ import { useTheme } from "~/client/components/theme-provider";
import { Toaster as Sonner, type ToasterProps } from "sonner";
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "dark" } = useTheme();
const { theme } = useTheme();
return (
<Sonner

View file

@ -1,5 +1,5 @@
import { Cloud, Folder, Server } from "lucide-react";
import type { BackendType } from "~/schemas/volumes";
import type { BackendType } from "@zerobyte/contracts/volumes";
type VolumeIconProps = {
backend: BackendType;

View file

@ -0,0 +1,36 @@
import { describe, expect, test } from "vitest";
import { getIsSecureContextForRequest, isSecureContextOrigin } from "../is-secure-context";
describe("isSecureContextOrigin", () => {
test("treats HTTPS and loopback HTTP origins as secure contexts", () => {
expect(isSecureContextOrigin("https://example.com")).toBe(true);
expect(isSecureContextOrigin("http://localhost:3000")).toBe(true);
expect(isSecureContextOrigin("http://app.localhost")).toBe(true);
expect(isSecureContextOrigin("http://127.0.0.1:3000")).toBe(true);
expect(isSecureContextOrigin("http://[::1]:3000")).toBe(true);
});
test("treats non-local HTTP origins as insecure contexts", () => {
expect(isSecureContextOrigin("http://example.com")).toBe(false);
expect(isSecureContextOrigin("http://127.example.com")).toBe(false);
});
});
describe("getIsSecureContextForRequest", () => {
test("uses forwarded protocol and host when present", () => {
const request = new Request("http://internal.local/settings", {
headers: {
host: "internal.local",
"x-forwarded-host": "zerobyte.example.com",
"x-forwarded-proto": "https",
},
});
expect(getIsSecureContextForRequest(request)).toBe(true);
});
test("uses the request URL when forwarded headers are absent", () => {
expect(getIsSecureContextForRequest(new Request("http://zerobyte.example.com/settings"))).toBe(false);
expect(getIsSecureContextForRequest(new Request("https://zerobyte.example.com/settings"))).toBe(true);
});
});

View file

@ -0,0 +1,51 @@
import { createIsomorphicFn } from "@tanstack/react-start";
import { getRequest } from "@tanstack/react-start/server";
import ipaddr from "ipaddr.js";
const getFirstHeaderValue = (value: string | null) => value?.split(",")[0]?.trim();
const isLoopbackIp = (hostname: string) => {
try {
return ipaddr.parse(hostname).range() === "loopback";
} catch {
return false;
}
};
const getRequestOrigin = (request: Request) => {
const requestUrl = new URL(request.url);
const forwardedProto = getFirstHeaderValue(request.headers.get("x-forwarded-proto"));
const forwardedHost = getFirstHeaderValue(request.headers.get("x-forwarded-host"));
const host = forwardedHost || getFirstHeaderValue(request.headers.get("host"));
if (!host) {
return requestUrl.origin;
}
const protocol = (forwardedProto || requestUrl.protocol).replace(/:$/, "");
return `${protocol}://${host}`;
};
export const isSecureContextOrigin = (origin: string) => {
const url = new URL(origin);
if (url.protocol === "https:") {
return true;
}
const hostname = url.hostname.replace(/^\[(.*)\]$/, "$1").toLowerCase();
return hostname === "localhost" || hostname.endsWith(".localhost") || isLoopbackIp(hostname);
};
export const getIsSecureContextForRequest = (request: Request) => isSecureContextOrigin(getRequestOrigin(request));
export const getIsSecureContext = createIsomorphicFn()
.server(() => {
try {
return getIsSecureContextForRequest(getRequest());
} catch {
return true;
}
})
.client(() => window.isSecureContext ?? true);

View file

@ -7,6 +7,7 @@ import {
inferAdditionalFields,
} from "better-auth/client/plugins";
import { ssoClient } from "@better-auth/sso/client";
import { passkeyClient } from "@better-auth/passkey/client";
import type { auth } from "~/server/lib/auth";
export const authClient = createAuthClient({
@ -17,5 +18,6 @@ export const authClient = createAuthClient({
organizationClient(),
ssoClient(),
twoFactorClient(),
passkeyClient(),
],
});

View file

@ -0,0 +1,35 @@
import { useSuspenseQuery } from "@tanstack/react-query";
import { useServerFn } from "@tanstack/react-start";
import { getPublicSsoProvidersOptions } from "~/client/api-client/@tanstack/react-query.gen";
import { SsoLoginButtons } from "~/client/modules/sso/components/sso-login-buttons";
import { getLoginOptions } from "~/server/lib/functions/login-options";
import { PasskeySignInButton } from "./passkey-sign-in-button";
type AlternativeSignInSectionProps = {
onPasskeySignIn: () => Promise<void>;
};
export function AlternativeSignInSection({ onPasskeySignIn }: AlternativeSignInSectionProps) {
const getOptions = useServerFn(getLoginOptions);
const { data: ssoProviders } = useSuspenseQuery({
...getPublicSsoProvidersOptions(),
});
const { data: loginOptions } = useSuspenseQuery({
queryKey: ["login-options"],
queryFn: getOptions,
});
if (ssoProviders.providers.length === 0 && !loginOptions.hasPasskeySignIn) {
return null;
}
return (
<div className="pt-4 border-t border-border/60 space-y-3">
<p className="text-sm font-medium">Alternative Sign-in</p>
<div className="flex flex-col gap-2">
{loginOptions.hasPasskeySignIn && <PasskeySignInButton onSignIn={onPasskeySignIn} />}
<SsoLoginButtons providers={ssoProviders.providers} />
</div>
</div>
);
}

View file

@ -0,0 +1,64 @@
import { useMutation } from "@tanstack/react-query";
import { useNavigate } from "@tanstack/react-router";
import { Fingerprint } from "lucide-react";
import { Button } from "~/client/components/ui/button";
import { authClient } from "~/client/lib/auth-client";
import { logger } from "~/client/lib/logger";
import { LOGIN_ERROR_CODES, PASSKEY_LOGIN_FAILED_ERROR, type LoginErrorCode } from "~/lib/sso-errors";
type PasskeySignInButtonProps = {
onSignIn: () => Promise<void>;
};
type PasskeySignInError = {
code?: string;
message?: string;
status?: number;
statusText?: string;
};
const LOGIN_ERROR_CODE_SET = new Set<string>(LOGIN_ERROR_CODES);
function getPasskeyLoginErrorCode(code: string | undefined): LoginErrorCode {
if (code && LOGIN_ERROR_CODE_SET.has(code)) {
return code as LoginErrorCode;
}
return PASSKEY_LOGIN_FAILED_ERROR;
}
export function PasskeySignInButton({ onSignIn }: PasskeySignInButtonProps) {
const navigate = useNavigate();
const passkeyLoginMutation = useMutation<void, PasskeySignInError>({
mutationFn: async () => {
const { error } = await authClient.signIn.passkey();
if (error) throw error;
await onSignIn();
},
onError: (error) => {
logger.error(error);
void navigate({
to: "/login",
search: {
error: getPasskeyLoginErrorCode(error.code),
},
});
},
});
return (
<Button
type="button"
variant="outline"
className="w-full"
loading={passkeyLoginMutation.isPending}
disabled={passkeyLoginMutation.isPending}
onClick={() => passkeyLoginMutation.mutate()}
>
<Fingerprint className="h-4 w-4 mr-2" />
Sign in with passkey
</Button>
);
}

View file

@ -1,16 +1,49 @@
import { afterEach, describe, expect, test, vi } from "vitest";
import { HttpResponse, http, server } from "~/test/msw/server";
import { cleanup, render, screen } from "~/test/test-utils";
import { cleanup, render, screen, userEvent, waitFor } from "~/test/test-utils";
import { getLoginErrorDescription, PASSKEY_LOGIN_FAILED_ERROR } from "~/lib/sso-errors";
const { mockGetLoginOptions, mockNavigate, mockPasskeySignIn } = vi.hoisted(() => ({
mockGetLoginOptions: vi.fn(async () => ({ hasPasskeySignIn: false })),
mockNavigate: vi.fn(async () => {}),
mockPasskeySignIn: vi.fn(
async (): Promise<{
data: unknown;
error: { code: string; message: string } | null;
}> => ({ data: null, error: null }),
),
}));
vi.mock("@tanstack/react-router", async (importOriginal) => {
const actual = await importOriginal<typeof import("@tanstack/react-router")>();
return {
...actual,
useNavigate: (() => vi.fn(async () => {})) as typeof actual.useNavigate,
useNavigate: (() => mockNavigate) as typeof actual.useNavigate,
};
});
vi.mock("@tanstack/react-start", async (importOriginal) => {
const actual = await importOriginal<typeof import("@tanstack/react-start")>();
return {
...actual,
useServerFn: (fn: unknown) => fn,
};
});
vi.mock("~/server/lib/functions/login-options", () => ({
getLoginOptions: mockGetLoginOptions,
}));
vi.mock("~/client/lib/auth-client", () => ({
authClient: {
signIn: {
passkey: mockPasskeySignIn,
},
},
}));
import { LoginPage } from "../login";
const inviteOnlyMessage =
"Access is invite-only. Ask an organization admin to send you an invitation before signing in with SSO.";
@ -29,6 +62,12 @@ const mockSsoProvidersRequest = (
};
afterEach(() => {
mockGetLoginOptions.mockClear();
mockGetLoginOptions.mockResolvedValue({ hasPasskeySignIn: false });
mockNavigate.mockClear();
mockPasskeySignIn.mockClear();
mockPasskeySignIn.mockResolvedValue({ data: null, error: null });
vi.unstubAllGlobals();
cleanup();
});
@ -81,6 +120,14 @@ describe("LoginPage", () => {
expect(await screen.findByText("SSO authentication failed. Please try again.")).toBeTruthy();
});
test("shows passkey login failure message when passkey returns the login error code", async () => {
mockSsoProvidersRequest();
render(<LoginPage error={"PASSKEY_LOGIN_FAILED"} />, { withSuspense: true });
expect(await screen.findByText(getLoginErrorDescription(PASSKEY_LOGIN_FAILED_ERROR))).toBeTruthy();
});
test("does not show error message for invalid error codes", async () => {
mockSsoProvidersRequest();
@ -90,11 +137,141 @@ describe("LoginPage", () => {
expect(screen.queryByText(inviteOnlyMessage)).toBeNull();
});
test("renders available SSO providers from the real SSO section", async () => {
test("renders available SSO providers from the alternative sign-in section", async () => {
mockSsoProvidersRequest([{ providerId: "acme", organizationSlug: "acme-org" }]);
render(<LoginPage />, { withSuspense: true });
expect(await screen.findByRole("button", { name: "Log in with acme" })).toBeTruthy();
});
test("renders passkey sign-in when an active user has a passkey", async () => {
mockSsoProvidersRequest();
mockGetLoginOptions.mockResolvedValue({ hasPasskeySignIn: true });
render(<LoginPage />, { withSuspense: true });
expect(await screen.findByRole("button", { name: "Sign in with passkey" })).toBeTruthy();
});
test("redirects passkey verification failures to the login error box", async () => {
mockSsoProvidersRequest();
mockGetLoginOptions.mockResolvedValue({ hasPasskeySignIn: true });
mockPasskeySignIn.mockResolvedValue({
data: null,
error: { code: "AUTHENTICATION_FAILED", message: "Authentication failed" },
});
render(<LoginPage />, { withSuspense: true });
await userEvent.click(await screen.findByRole("button", { name: "Sign in with passkey" }));
await waitFor(() => {
expect(mockNavigate).toHaveBeenCalledWith({
to: "/login",
search: {
error: PASSKEY_LOGIN_FAILED_ERROR,
},
});
});
});
test("redirects unauthorized passkey failures to the login error box", async () => {
mockSsoProvidersRequest();
mockGetLoginOptions.mockResolvedValue({ hasPasskeySignIn: true });
mockPasskeySignIn.mockResolvedValue({
data: null,
error: { code: "UNAUTHORIZED", message: "Unauthorized" },
});
render(<LoginPage />, { withSuspense: true });
await userEvent.click(await screen.findByRole("button", { name: "Sign in with passkey" }));
await waitFor(() => {
expect(mockNavigate).toHaveBeenCalledWith({
to: "/login",
search: {
error: PASSKEY_LOGIN_FAILED_ERROR,
},
});
});
});
test("preserves specific passkey login error codes", async () => {
mockSsoProvidersRequest();
mockGetLoginOptions.mockResolvedValue({ hasPasskeySignIn: true });
mockPasskeySignIn.mockResolvedValue({
data: null,
error: { code: "ERROR_INVALID_RP_ID", message: "Auth cancelled" },
});
render(<LoginPage />, { withSuspense: true });
await userEvent.click(await screen.findByRole("button", { name: "Sign in with passkey" }));
await waitFor(() => {
expect(mockNavigate).toHaveBeenCalledWith({
to: "/login",
search: {
error: "ERROR_INVALID_RP_ID",
},
});
});
});
test("redirects conditional passkey autofill failures to the login error box", async () => {
mockSsoProvidersRequest();
mockPasskeySignIn.mockResolvedValue({
data: null,
error: { code: "AUTHENTICATION_FAILED", message: "Authentication failed" },
});
vi.stubGlobal("PublicKeyCredential", {
isConditionalMediationAvailable: vi.fn(async () => true),
});
render(<LoginPage />, { withSuspense: true });
await waitFor(() => {
expect(mockPasskeySignIn).toHaveBeenCalledWith({
autoFill: true,
});
expect(mockNavigate).toHaveBeenCalledWith({
to: "/login",
search: {
error: "PASSKEY_LOGIN_FAILED",
},
});
});
});
test("ignores conditional passkey autofill cancellation", async () => {
mockSsoProvidersRequest();
mockPasskeySignIn.mockResolvedValue({
data: null,
error: { code: "AUTH_CANCELLED", message: "Authentication cancelled" },
});
vi.stubGlobal("PublicKeyCredential", {
isConditionalMediationAvailable: vi.fn(async () => true),
});
render(<LoginPage />, { withSuspense: true });
await waitFor(() => {
expect(mockPasskeySignIn).toHaveBeenCalledWith({
autoFill: true,
});
});
expect(mockNavigate).not.toHaveBeenCalled();
});
test("hides alternative sign-in when no SSO providers or passkeys are available", async () => {
mockSsoProvidersRequest();
render(<LoginPage />, { withSuspense: true });
await screen.findByText("Login to your account");
expect(screen.queryByText("Alternative Sign-in")).toBeNull();
expect(screen.queryByRole("button", { name: "Sign in with passkey" })).toBeNull();
});
});

View file

@ -8,11 +8,25 @@ import { Button } from "~/client/components/ui/button";
import { Input } from "~/client/components/ui/input";
import { Label } from "~/client/components/ui/label";
import { downloadResticPasswordMutation } from "~/client/api-client/@tanstack/react-query.gen";
import { parseError } from "~/client/lib/errors";
import {
RECOVERY_KEY_DOWNLOAD_SKIPPED_COOKIE_MAX_AGE,
RECOVERY_KEY_DOWNLOAD_SKIPPED_COOKIE_NAME,
} from "~/lib/recovery-key-skip";
import { useNavigate } from "@tanstack/react-router";
export function DownloadRecoveryKeyPage() {
const RECOVERY_KEY_CREDENTIAL_REQUIRED_MESSAGE =
"Downloading the recovery key requires a local credential password. Ask an operator to run `docker exec -it zerobyte bun run cli reset-password` for your user, then sign in with that password and try again.";
type Props = {
hasCredentialPassword: boolean;
userId: string | null;
};
export function DownloadRecoveryKeyPage({ hasCredentialPassword, userId }: Props) {
const navigate = useNavigate();
const [password, setPassword] = useState("");
const [blockedMessage, setBlockedMessage] = useState<string | null>(null);
const downloadResticPassword = useMutation({
...downloadResticPasswordMutation(),
@ -25,13 +39,16 @@ export function DownloadRecoveryKeyPage() {
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
window.URL.revokeObjectURL(url);
window.setTimeout(() => window.URL.revokeObjectURL(url), 60_000);
toast.success("Recovery key downloaded successfully!");
setBlockedMessage(null);
void navigate({ to: "/volumes", replace: true });
},
onError: (error) => {
toast.error("Failed to download recovery key", { description: error.message });
const message = parseError(error)?.message;
setBlockedMessage(message?.includes("credential password") ? message : null);
toast.error("Failed to download recovery key", { description: message });
},
});
@ -43,11 +60,15 @@ export function DownloadRecoveryKeyPage() {
return;
}
downloadResticPassword.mutate({
body: {
password,
},
});
setBlockedMessage(null);
downloadResticPassword.mutate({ body: { password } });
};
const handleSkip = () => {
if (!userId) return;
document.cookie = `${RECOVERY_KEY_DOWNLOAD_SKIPPED_COOKIE_NAME}=${userId}; path=/; max-age=${RECOVERY_KEY_DOWNLOAD_SKIPPED_COOKIE_MAX_AGE}`;
void navigate({ to: "/volumes", replace: true });
};
return (
@ -59,30 +80,56 @@ export function DownloadRecoveryKeyPage() {
<AlertTriangle className="size-5" />
<AlertTitle>Important: Save This File Securely</AlertTitle>
<AlertDescription>
Your Restic password is essential for recovering your backup data. If you lose access to this server without
this file, your backups will be unrecoverable. Store it in a password manager or encrypted storage.
Your Restic password is essential for recovering your backup data. If you previously downloaded this
file, replace that saved copy with the new download. If you lose access to this server without this
file, your backups will be unrecoverable. Store it in a password manager or encrypted storage.
</AlertDescription>
</Alert>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="password">Confirm Your Password</Label>
<Input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Enter your password"
required
disabled={downloadResticPassword.isPending}
/>
<p className="text-xs text-muted-foreground">Enter your account password to download the recovery key</p>
</div>
{(!hasCredentialPassword || blockedMessage) && (
<Alert variant="warning">
<AlertTriangle className="size-5" />
<AlertTitle>Local password required</AlertTitle>
<AlertDescription>
{blockedMessage ?? RECOVERY_KEY_CREDENTIAL_REQUIRED_MESSAGE}
</AlertDescription>
</Alert>
)}
{hasCredentialPassword && (
<div className="space-y-2">
<Label htmlFor="password">Confirm Your Password</Label>
<Input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Enter your password"
required
disabled={downloadResticPassword.isPending}
/>
<p className="text-xs text-muted-foreground">
Enter your account password to download the recovery key
</p>
</div>
)}
<div className="flex flex-col gap-2">
<Button type="submit" loading={downloadResticPassword.isPending} className="w-full">
<Download size={16} className="mr-2" />
Download Recovery Key
{hasCredentialPassword && (
<Button type="submit" loading={downloadResticPassword.isPending} className="w-full">
<Download size={16} className="mr-2" />
Download Recovery Key
</Button>
)}
<Button
type="button"
variant="ghost"
onClick={handleSkip}
disabled={downloadResticPassword.isPending}
className="w-full"
>
Skip
</Button>
</div>
</form>

View file

@ -1,5 +1,5 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { useState } from "react";
import { useCallback, useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { AuthLayout } from "~/client/components/auth-layout";
@ -10,12 +10,14 @@ import { InputOTP, InputOTPGroup, InputOTPSeparator, InputOTPSlot } from "~/clie
import { Label } from "~/client/components/ui/label";
import { authClient } from "~/client/lib/auth-client";
import { logger } from "~/client/lib/logger";
import { RECOVERY_KEY_DOWNLOAD_SKIPPED_COOKIE_NAME } from "~/lib/recovery-key-skip";
import { decodeLoginError, getLoginErrorDescription } from "~/client/lib/sso-errors";
import { PASSKEY_LOGIN_FAILED_ERROR } from "~/lib/sso-errors";
import { ResetPasswordDialog } from "../components/reset-password-dialog";
import { useNavigate } from "@tanstack/react-router";
import { normalizeUsername } from "~/lib/username";
import { cn } from "~/client/lib/utils";
import { SsoLoginSection } from "~/client/modules/sso/components/sso-login-section";
import { AlternativeSignInSection } from "../components/alternative-sign-in-section";
import { z } from "zod";
const loginSchema = z.object({
@ -32,6 +34,23 @@ type LoginPageProps = {
error?: string;
};
type PasskeySignInError = {
code?: string;
message?: string;
status?: number;
statusText?: string;
};
function isPasskeyVerificationFailure(error: PasskeySignInError | null) {
return error?.code === "AUTHENTICATION_FAILED" || error?.code === "UNAUTHORIZED";
}
function hasSkippedRecoveryKeyDownload(userId: string) {
return document.cookie
.split(";")
.some((cookie) => cookie.trim() === `${RECOVERY_KEY_DOWNLOAD_SKIPPED_COOKIE_NAME}=${userId}`);
}
export function LoginPage({ error }: LoginPageProps = {}) {
const navigate = useNavigate();
const [showResetDialog, setShowResetDialog] = useState(false);
@ -43,6 +62,52 @@ export function LoginPage({ error }: LoginPageProps = {}) {
const errorCode = decodeLoginError(error);
const errorDescription = errorCode ? getLoginErrorDescription(errorCode) : null;
const navigateAfterLogin = useCallback(async () => {
const session = await authClient.getSession();
if (
session.data?.user &&
!session.data.user.hasDownloadedResticPassword &&
!hasSkippedRecoveryKeyDownload(session.data.user.id)
) {
void navigate({ to: "/download-recovery-key" });
} else {
void navigate({ to: "/volumes" });
}
}, [navigate]);
useEffect(() => {
const autoSignIn = async () => {
if (
typeof PublicKeyCredential === "undefined" ||
!PublicKeyCredential.isConditionalMediationAvailable ||
!(await PublicKeyCredential.isConditionalMediationAvailable())
) {
return;
}
const { data, error } = await authClient.signIn.passkey({
autoFill: true,
});
if (isPasskeyVerificationFailure(error)) {
void navigate({
to: "/login",
search: {
error: PASSKEY_LOGIN_FAILED_ERROR,
},
});
return;
}
if (data) {
await navigateAfterLogin();
}
};
void autoSignIn();
}, [navigate, navigateAfterLogin]);
const form = useForm<LoginFormValues>({
resolver: zodResolver(loginSchema),
defaultValues: {
@ -76,12 +141,7 @@ export function LoginPage({ error }: LoginPageProps = {}) {
return;
}
const d = await authClient.getSession();
if (data.user && !d.data?.user.hasDownloadedResticPassword) {
void navigate({ to: "/download-recovery-key" });
} else {
void navigate({ to: "/volumes" });
}
await navigateAfterLogin();
};
const handleVerify2FA = async () => {
@ -113,7 +173,11 @@ export function LoginPage({ error }: LoginPageProps = {}) {
if (data) {
toast.success("Login successful");
const session = await authClient.getSession();
if (session.data?.user && !session.data.user.hasDownloadedResticPassword) {
if (
session.data?.user &&
!session.data.user.hasDownloadedResticPassword &&
!hasSkippedRecoveryKeyDownload(session.data.user.id)
) {
void navigate({ to: "/download-recovery-key" });
} else {
void navigate({ to: "/volumes" });
@ -130,7 +194,10 @@ export function LoginPage({ error }: LoginPageProps = {}) {
if (requires2FA) {
return (
<AuthLayout title="Two-Factor Authentication" description="Enter the 6-digit code from your authenticator app">
<AuthLayout
title="Two-Factor Authentication"
description="Enter the 6-digit code from your authenticator app"
>
<div className="space-y-6">
<div className="space-y-4 flex flex-col items-center">
<Label htmlFor="totp-code">Authentication code</Label>
@ -161,6 +228,7 @@ export function LoginPage({ error }: LoginPageProps = {}) {
<input
type="checkbox"
id="trust-device"
aria-label="Trust this device for 30 days"
checked={trustDevice}
onChange={(e) => setTrustDevice(e.target.checked)}
className="h-4 w-4"
@ -199,7 +267,11 @@ export function LoginPage({ error }: LoginPageProps = {}) {
<AuthLayout title="Login to your account" description="Enter your credentials below to login to your account">
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<div className={cn("rounded-md border border-destructive/50 p-3 text-sm", { hidden: !errorDescription })}>
<div
className={cn("rounded-md border border-destructive/50 p-3 text-sm", {
hidden: !errorDescription,
})}
>
{errorDescription}
</div>
<FormField
@ -209,7 +281,13 @@ export function LoginPage({ error }: LoginPageProps = {}) {
<FormItem>
<FormLabel>Username</FormLabel>
<FormControl>
<Input {...field} type="text" placeholder="admin" disabled={isLoggingIn} />
<Input
{...field}
type="text"
placeholder="admin"
disabled={isLoggingIn}
autoComplete="username webauthn"
/>
</FormControl>
<FormMessage />
</FormItem>
@ -231,7 +309,12 @@ export function LoginPage({ error }: LoginPageProps = {}) {
</button>
</div>
<FormControl>
<Input {...field} type="password" disabled={isLoggingIn} />
<Input
{...field}
type="password"
disabled={isLoggingIn}
autoComplete="current-password webauthn"
/>
</FormControl>
<FormMessage />
</FormItem>
@ -243,7 +326,7 @@ export function LoginPage({ error }: LoginPageProps = {}) {
</form>
</Form>
<SsoLoginSection />
<AlternativeSignInSection onPasskeySignIn={navigateAfterLogin} />
<ResetPasswordDialog open={showResetDialog} onOpenChange={setShowResetDialog} />
</AuthLayout>

View file

@ -31,7 +31,9 @@ const WebhookFields = ({ form, phase, urlPlaceholder, bodyPlaceholder, descripti
<FormControl>
<Input {...field} type="url" placeholder={urlPlaceholder} />
</FormControl>
<FormDescription>{description}</FormDescription>
<FormDescription>
{description} The URL origin must be listed in WEBHOOK_ALLOWED_ORIGINS; redirects are not followed.
</FormDescription>
<FormMessage />
</FormItem>
)}

View file

@ -63,9 +63,7 @@ export function ScheduleDetailsPage(props: Props) {
const queryClient = useQueryClient();
const navigate = useNavigate();
const searchParams = useSearch({ from: "/(dashboard)/backups/$backupId/" });
const [selectedSnapshotId, setSelectedSnapshotId] = useState<string | undefined>(
initialSnapshotId ?? loaderData.snapshots?.at(-1)?.short_id,
);
const [selectedSnapshotId, setSelectedSnapshotId] = useState<string | undefined>(initialSnapshotId);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [snapshotToDelete, setSnapshotToDelete] = useState<string | null>(null);
@ -79,7 +77,10 @@ export function ScheduleDetailsPage(props: Props) {
isLoading,
failureReason,
} = useQuery({
...listSnapshotsOptions({ path: { shortId: schedule.repository.shortId }, query: { backupId: schedule.shortId } }),
...listSnapshotsOptions({
path: { shortId: schedule.repository.shortId },
query: { backupId: schedule.shortId },
}),
initialData: loaderData.snapshots,
});
@ -168,6 +169,8 @@ export function ScheduleDetailsPage(props: Props) {
oneFileSystem: schedule.oneFileSystem,
customResticParams: schedule.customResticParams || [],
backupWebhooks: schedule.backupWebhooks,
maxRetries: schedule.maxRetries,
retryDelay: schedule.retryDelay,
},
});
};
@ -253,8 +256,8 @@ export function ScheduleDetailsPage(props: Props) {
<AlertDialogHeader>
<AlertDialogTitle>Delete snapshot?</AlertDialogTitle>
<AlertDialogDescription>
This action cannot be undone. This will permanently delete the snapshot and all its data from the
repository.
This action cannot be undone. This will permanently delete the snapshot and all its data
from the repository.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>

View file

@ -31,7 +31,8 @@ export const CustomForm = ({ form }: Props) => {
>
Shoutrrr documentation
</a>
&nbsp;for supported services and URL formats.
&nbsp;for supported services and URL formats. Custom HTTP, generic, and SMTP network targets must be listed
in WEBHOOK_ALLOWED_ORIGINS.
</FormDescription>
<FormMessage />
</FormItem>

View file

@ -15,7 +15,7 @@ const WebhookPreview = ({ values }: { values: Partial<NotificationFormValues> })
if (values.type !== "generic") return null;
const contentType = values.contentType || "application/json";
const headers = values.headers || [];
const headers = values.headers?.filter(Boolean) || [];
const useJson = values.useJson;
const titleKey = values.titleKey || "title";
const messageKey = values.messageKey || "message";
@ -60,7 +60,7 @@ export const GenericForm = ({ form }: Props) => {
<FormControl>
<Input {...field} placeholder="https://api.example.com/webhook" />
</FormControl>
<FormDescription>The target URL for the webhook.</FormDescription>
<FormDescription>The target origin must be listed in WEBHOOK_ALLOWED_ORIGINS.</FormDescription>
<FormMessage />
</FormItem>
)}
@ -110,10 +110,19 @@ export const GenericForm = ({ form }: Props) => {
{...field}
placeholder="Authorization: Bearer token&#10;X-Custom-Header: value"
value={Array.isArray(field.value) ? field.value.join("\n") : ""}
onChange={(e) => field.onChange(e.target.value.split("\n"))}
onChange={(e) =>
field.onChange(
e.target.value
.split("\n")
.map((header) => header.trim())
.filter(Boolean),
)
}
/>
</FormControl>
<FormDescription>One header per line in Key: Value format.</FormDescription>
<FormDescription>
One header per line in Key: Value format. Values are stored as plain text.
</FormDescription>
<FormMessage />
</FormItem>
)}

View file

@ -20,7 +20,9 @@ export const GotifyForm = ({ form }: Props) => {
<FormControl>
<Input {...field} placeholder="https://gotify.example.com" />
</FormControl>
<FormDescription>Your self-hosted Gotify server URL.</FormDescription>
<FormDescription>
Your self-hosted Gotify server URL. Its origin must be listed in WEBHOOK_ALLOWED_ORIGINS.
</FormDescription>
<FormMessage />
</FormItem>
)}

View file

@ -21,7 +21,10 @@ export const NtfyForm = ({ form }: Props) => {
<FormControl>
<Input {...field} placeholder="https://ntfy.example.com" />
</FormControl>
<FormDescription>Leave empty to use ntfy.sh public service.</FormDescription>
<FormDescription>
Leave empty to use ntfy.sh public service. Self-hosted ntfy origins must be listed in
WEBHOOK_ALLOWED_ORIGINS.
</FormDescription>
<FormMessage />
</FormItem>
)}

View file

@ -41,6 +41,7 @@ import {
Mail,
AtSign,
AlertCircle,
Clock,
MessageSquare,
Pencil,
Server,
@ -298,6 +299,26 @@ export function NotificationDetailsPage({ notificationId }: { notificationId: st
</div>
</Card>
<Card className="px-6 py-6">
<CardTitle className="flex items-center gap-2 mb-5">
<Clock className="h-4 w-4 text-muted-foreground" />
Delivery Health
</CardTitle>
<div className="space-y-0 divide-y divide-border/50">
<ConfigRow
icon={<Bell className="h-4 w-4" />}
label="Status"
value={getStatusLabel(data.enabled, data.status)}
/>
<ConfigRow icon={<Settings className="h-4 w-4" />} label="Enabled" value={data.enabled ? "Yes" : "No"} />
<ConfigRow
icon={<Clock className="h-4 w-4" />}
label="Last Checked"
value={data.lastChecked ? formatDateTime(data.lastChecked) : "Never"}
/>
</div>
</Card>
<Card className={cn("px-6 py-6", { hidden: !data.lastError })}>
<CardTitle className="flex items-center gap-2 mb-3 text-red-600">
<AlertCircle className="h-4 w-4" />

View file

@ -92,7 +92,13 @@ const defaultValuesForType = (repoBase: string) => ({
azure: { backend: "azure" as const, compressionMode: "auto" as const },
rclone: { backend: "rclone" as const, compressionMode: "auto" as const },
rest: { backend: "rest" as const, compressionMode: "auto" as const },
sftp: { backend: "sftp" as const, compressionMode: "auto" as const, port: 22, skipHostKeyCheck: false },
sftp: {
backend: "sftp" as const,
compressionMode: "auto" as const,
port: 22,
skipHostKeyCheck: false,
allowLegacySshRsa: false,
},
});
export const CreateRepositoryForm = ({
@ -251,7 +257,9 @@ export const CreateRepositoryForm = ({
</FormControl>
<div className="space-y-1">
<FormLabel>Import existing repository</FormLabel>
<FormDescription>Check this if the repository already exists at the specified location</FormDescription>
<FormDescription>
Check this if the repository already exists at the specified location
</FormDescription>
</div>
</FormItem>
)}
@ -281,8 +289,8 @@ export const CreateRepositoryForm = ({
</SelectContent>
</Select>
<FormDescription>
Choose whether to use Zerobyte's recovery key (which you downloaded when creating your account) or enter
a custom password for the existing repository.
Choose whether to use Zerobyte's recovery key (which you downloaded when creating your
account) or enter a custom password for the existing repository.
</FormDescription>
</FormItem>

View file

@ -26,6 +26,7 @@ export const AdvancedForm = ({ form }: Props) => {
const cacert = useWatch({ control: form.control, name: "cacert" });
const uploadLimitEnabled = useWatch({ control: form.control, name: "uploadLimit.enabled" });
const downloadLimitEnabled = useWatch({ control: form.control, name: "downloadLimit.enabled" });
const backend = useWatch({ control: form.control, name: "backend" });
return (
<Collapsible>
@ -44,7 +45,9 @@ export const AdvancedForm = ({ form }: Props) => {
</FormControl>
<div className="space-y-1">
<FormLabel>Enable upload speed limit</FormLabel>
<FormDescription className="text-xs">Limit upload speed to the repository</FormDescription>
<FormDescription className="text-xs">
Limit upload speed to the repository
</FormDescription>
</div>
</FormItem>
)}
@ -66,7 +69,9 @@ export const AdvancedForm = ({ form }: Props) => {
placeholder="10"
className="pr-12"
{...field}
onChange={(e) => field.onChange(parseFloat(e.target.value) || 1)}
onChange={(e) =>
field.onChange(parseFloat(e.target.value) || 1)
}
/>
<div className="absolute inset-y-0 right-0 flex items-center pr-3">
<div className="h-4 w-px bg-border" />
@ -120,7 +125,9 @@ export const AdvancedForm = ({ form }: Props) => {
</FormControl>
<div className="space-y-1">
<FormLabel>Enable download speed limit</FormLabel>
<FormDescription className="text-xs">Limit download speed from the repository</FormDescription>
<FormDescription className="text-xs">
Limit download speed from the repository
</FormDescription>
</div>
</FormItem>
)}
@ -144,7 +151,9 @@ export const AdvancedForm = ({ form }: Props) => {
disabled={!downloadLimitEnabled}
className="pr-12"
{...field}
onChange={(e) => field.onChange(parseFloat(e.target.value) || 1)}
onChange={(e) =>
field.onChange(parseFloat(e.target.value) || 1)
}
/>
<div className="absolute inset-y-0 right-0 flex items-center pr-3">
<div className="h-4 w-px bg-border" />
@ -208,8 +217,8 @@ export const AdvancedForm = ({ form }: Props) => {
</TooltipTrigger>
<TooltipContent className={cn({ hidden: !cacert })}>
<p className="max-w-xs">
This option is disabled because a CA certificate is provided. Remove the CA certificate to skip
TLS validation instead.
This option is disabled because a CA certificate is provided. Remove the CA
certificate to skip TLS validation instead.
</p>
</TooltipContent>
</Tooltip>
@ -217,8 +226,8 @@ export const AdvancedForm = ({ form }: Props) => {
<div className="space-y-1 leading-none">
<FormLabel>Skip TLS certificate verification</FormLabel>
<FormDescription>
Disable TLS certificate verification for HTTPS connections with self-signed certificates. This is
insecure and should only be used for testing.
Disable TLS certificate verification for HTTPS connections with self-signed
certificates. This is insecure and should only be used for testing.
</FormDescription>
</div>
</FormItem>
@ -235,7 +244,9 @@ export const AdvancedForm = ({ form }: Props) => {
<TooltipTrigger asChild>
<div>
<Textarea
placeholder={"-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----"}
placeholder={
"-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----"
}
rows={6}
disabled={insecureTls}
{...field}
@ -244,8 +255,8 @@ export const AdvancedForm = ({ form }: Props) => {
</TooltipTrigger>
<TooltipContent className={cn({ hidden: !insecureTls })}>
<p className="max-w-xs">
CA certificate is disabled because TLS validation is being skipped. Uncheck "Skip TLS Certificate
Verification" to provide a custom CA certificate.
CA certificate is disabled because TLS validation is being skipped. Uncheck
"Skip TLS Certificate Verification" to provide a custom CA certificate.
</p>
</TooltipContent>
</Tooltip>
@ -266,6 +277,26 @@ export const AdvancedForm = ({ form }: Props) => {
</FormItem>
)}
/>
{backend === "sftp" && (
<FormField
control={form.control}
name="allowLegacySshRsa"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm">
<div className="space-y-0.5">
<FormLabel>Allow legacy SSH RSA/SHA1 algorithms</FormLabel>
<FormDescription>
Only enable this for legacy SFTP servers that offer <code>ssh-rsa</code> only.
It permits RSA/SHA1 signatures, which are weaker than modern SSH algorithms.
</FormDescription>
</div>
<FormControl>
<Checkbox checked={field.value ?? false} onCheckedChange={field.onChange} />
</FormControl>
</FormItem>
)}
/>
)}
</CollapsibleContent>
</Collapsible>
);

View file

@ -0,0 +1,322 @@
import { useState } from "react";
import { useMutation, useQuery } from "@tanstack/react-query";
import { Fingerprint, Plus, Trash2, Pencil } from "lucide-react";
import { toast } from "sonner";
import { Button } from "~/client/components/ui/button";
import { CardContent, CardDescription, CardTitle } from "~/client/components/ui/card";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "~/client/components/ui/alert-dialog";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "~/client/components/ui/dialog";
import { Input } from "~/client/components/ui/input";
import { Label } from "~/client/components/ui/label";
import { Tooltip, TooltipContent, TooltipTrigger } from "~/client/components/ui/tooltip";
import { getIsSecureContext } from "~/client/functions/is-secure-context";
import { authClient } from "~/client/lib/auth-client";
import { logger } from "~/client/lib/logger";
import { useTimeFormat } from "~/client/lib/datetime";
import { cn } from "~/client/lib/utils";
type PasskeyEntry = {
id: string;
name?: string | null;
createdAt: Date | string;
deviceType?: string;
};
export function PasskeysSection() {
const { formatDateTime } = useTimeFormat();
const isSecureContext = getIsSecureContext();
const { data: passkeys, isPending } = useQuery({
queryKey: ["passkeys"],
queryFn: async () => {
const { data, error } = await authClient.passkey.listUserPasskeys();
if (error) throw error;
return data;
},
});
const [deletePasskeyOpen, setDeletePasskeyOpen] = useState(false);
const [addDialogOpen, setAddDialogOpen] = useState(false);
const [newPasskeyName, setNewPasskeyName] = useState("");
const [renameTarget, setRenameTarget] = useState<PasskeyEntry | null>(null);
const [renameValue, setRenameValue] = useState("");
const [deleteTarget, setDeleteTarget] = useState<PasskeyEntry | null>(null);
const addPasskeyMutation = useMutation({
mutationFn: async (name: string | undefined) => {
const { error } = await authClient.passkey.addPasskey({ name });
if (error) throw error;
},
onSuccess: () => {
toast.success("Passkey added");
setAddDialogOpen(false);
setNewPasskeyName("");
},
onError: (error: Error) => {
logger.error(error);
toast.error("Failed to add passkey", { description: error.message });
},
});
const renamePasskeyMutation = useMutation({
mutationFn: async ({ id, name }: { id: string; name: string }) => {
const { error } = await authClient.$fetch("/passkey/update-passkey", {
method: "POST",
body: { id, name },
});
if (error) throw error;
},
onSuccess: () => {
toast.success("Passkey renamed");
setRenameTarget(null);
setRenameValue("");
},
onError: (error: Error) => {
logger.error(error);
toast.error("Failed to rename passkey", { description: error.message });
},
});
const deletePasskeyMutation = useMutation({
mutationFn: async (id: string) => {
const { error } = await authClient.passkey.deletePasskey({ id });
if (error) throw error;
},
onMutate: () => {
setDeletePasskeyOpen(false);
},
onSuccess: () => {
toast.success("Passkey deleted");
setDeleteTarget(null);
},
onError: (error: Error) => {
logger.error(error);
toast.error("Failed to delete passkey", { description: error.message });
},
});
const handleAddPasskey = (e: React.ChangeEvent) => {
e.preventDefault();
if (!isSecureContext) return;
const name = newPasskeyName.trim() || undefined;
addPasskeyMutation.mutate(name);
};
const handleRename = (e: React.ChangeEvent) => {
e.preventDefault();
if (!renameTarget) return;
const name = renameValue.trim();
if (!name) {
toast.error("Name is required");
return;
}
renamePasskeyMutation.mutate({ id: renameTarget.id, name });
};
const handleDelete = () => {
if (!deleteTarget) return;
deletePasskeyMutation.mutate(deleteTarget.id);
};
return (
<>
<div className="border-t border-border/50 bg-card-header p-6">
<CardTitle className="flex items-center gap-2">
<Fingerprint className="size-5" />
Passkeys
</CardTitle>
<CardDescription className="mt-1.5">
Sign in faster and more securely with passkeys stored on your device or password manager. You can
add more than one.
</CardDescription>
</div>
<CardContent className="p-6 space-y-4">
<div className="flex items-start justify-between gap-4">
<p className="text-xs text-muted-foreground max-w-xl">
Passkeys use your device's biometrics or screen lock instead of a password. They are
phishing-resistant and cannot be reused across sites.
</p>
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex">
<Button onClick={() => setAddDialogOpen(true)} disabled={!isSecureContext}>
<Plus className="h-4 w-4 mr-2" />
Add passkey
</Button>
</span>
</TooltipTrigger>
<TooltipContent className={cn({ hidden: isSecureContext })}>
<p>Passkeys can only be added over HTTPS or from localhost.</p>
</TooltipContent>
</Tooltip>
</div>
<p className={cn("text-sm text-muted-foreground", { hidden: !isPending })}>Loading passkeys...</p>
<p className={cn("text-sm text-muted-foreground", { hidden: passkeys && passkeys.length > 0 })}>
No passkeys yet. Add one to enable passwordless sign-in.
</p>
<ul
className={cn("divide-y divide-border/50 rounded-md border border-border/50", {
hidden: passkeys?.length === 0,
})}
>
{passkeys?.map((p) => (
<li key={p.id} className="flex items-center justify-between gap-4 p-3">
<div className="min-w-0 flex-1">
<p className="text-sm font-medium truncate">{p.name?.trim() || "Unnamed passkey"}</p>
<p className="text-xs text-muted-foreground">
Added {formatDateTime(new Date(p.createdAt))}
</p>
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
aria-label={`Rename passkey ${p.name?.trim() || "Unnamed passkey"}`}
title={`Rename passkey ${p.name?.trim() || "Unnamed passkey"}`}
onClick={() => {
setRenameTarget(p);
setRenameValue(p.name ?? "");
}}
>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="destructive"
size="sm"
aria-label={`Delete passkey ${p.name?.trim() || "Unnamed passkey"}`}
title={`Delete passkey ${p.name?.trim() || "Unnamed passkey"}`}
onClick={() => {
setDeleteTarget(p);
setDeletePasskeyOpen(true);
}}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</li>
))}
</ul>
</CardContent>
<Dialog
open={addDialogOpen}
onOpenChange={(open) => {
setAddDialogOpen(open);
if (!open) setNewPasskeyName("");
}}
>
<DialogContent>
<form onSubmit={handleAddPasskey}>
<DialogHeader>
<DialogTitle>Add a passkey</DialogTitle>
<DialogDescription>
Give this passkey a name so you can recognize it later (e.g. "MacBook Touch ID").
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="passkey-name">Name (optional)</Label>
<Input
id="passkey-name"
value={newPasskeyName}
onChange={(e) => setNewPasskeyName(e.target.value)}
placeholder="My Laptop"
/>
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setAddDialogOpen(false)}>
Cancel
</Button>
<Button type="submit" loading={addPasskeyMutation.isPending} disabled={!isSecureContext}>
<Fingerprint className="h-4 w-4 mr-2" />
Add passkey
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
<Dialog
open={Boolean(renameTarget)}
onOpenChange={(open) => {
if (!open) {
setRenameTarget(null);
setRenameValue("");
}
}}
>
<DialogContent>
<form onSubmit={handleRename}>
<DialogHeader>
<DialogTitle>Rename passkey</DialogTitle>
<DialogDescription>Choose a name to recognize this passkey.</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="passkey-rename">Name</Label>
<Input
id="passkey-rename"
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
required
/>
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setRenameTarget(null)}>
Cancel
</Button>
<Button type="submit" loading={renamePasskeyMutation.isPending}>
Save
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
<AlertDialog open={deletePasskeyOpen} onOpenChange={setDeletePasskeyOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete passkey?</AlertDialogTitle>
<AlertDialogDescription>
This will remove "{deleteTarget?.name?.trim() || "this passkey"}" from your account. You
won't be able to use it to sign in anymore.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deletePasskeyMutation.isPending}>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={(e) => {
e.preventDefault();
handleDelete();
}}
disabled={deletePasskeyMutation.isPending}
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}

View file

@ -1,11 +1,21 @@
import { useMutation } from "@tanstack/react-query";
import { Download, Fingerprint, KeyRound, User, X, Settings as SettingsIcon, Building2 } from "lucide-react";
import {
AlertTriangle,
Download,
Fingerprint,
KeyRound,
User,
X,
Settings as SettingsIcon,
Building2,
} from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { downloadResticPasswordMutation } from "~/client/api-client/@tanstack/react-query.gen";
import type { GetOrgMembersResponse, GetSsoSettingsResponse } from "~/client/api-client/types.gen";
import { Button } from "~/client/components/ui/button";
import { Card, CardContent, CardDescription, CardTitle } from "~/client/components/ui/card";
import { Alert, AlertDescription, AlertTitle } from "~/client/components/ui/alert";
import {
Dialog,
DialogContent,
@ -29,12 +39,18 @@ import {
useTimeFormat,
} from "~/client/lib/datetime";
import { logger } from "~/client/lib/logger";
import { parseError } from "~/client/lib/errors";
import { type AppContext } from "~/context";
import { TwoFactorSection } from "../components/two-factor-section";
import { PasskeysSection } from "../components/passkeys-section";
import { useNavigate, useSearch } from "@tanstack/react-router";
import { SsoSettingsSection } from "~/client/modules/sso/components/sso-settings-section";
import { OrgMembersSection } from "../components/org-members-section";
import { useOrganizationContext } from "~/client/hooks/use-org-context";
import { cn } from "~/client/lib/utils";
const RECOVERY_KEY_CREDENTIAL_REQUIRED_MESSAGE =
"Downloading the recovery key requires a local credential password. Ask an operator to run `docker exec -it zerobyte bun run cli reset-password` for your user, then sign in with that password and try again.";
type Props = {
appContext: AppContext;
@ -49,6 +65,7 @@ export function SettingsPage({ appContext, initialMembers, initialSsoSettings, i
const [confirmPassword, setConfirmPassword] = useState("");
const [downloadDialogOpen, setDownloadDialogOpen] = useState(false);
const [downloadPassword, setDownloadPassword] = useState("");
const [downloadBlockedMessage, setDownloadBlockedMessage] = useState<string | null>(null);
const [isChangingPassword, setIsChangingPassword] = useState(false);
const { dateFormat, timeFormat } = useRootLoaderData();
@ -59,6 +76,7 @@ export function SettingsPage({ appContext, initialMembers, initialSsoSettings, i
const { activeMember, activeOrganization } = useOrganizationContext();
const isOrgAdmin = activeMember?.role === "owner" || activeMember?.role === "admin";
const { formatDateTime } = useTimeFormat();
const hasCredentialPassword = appContext.user?.hasCredentialPassword !== false;
const handleLogout = async () => {
await authClient.signOut({
@ -85,15 +103,18 @@ export function SettingsPage({ appContext, initialMembers, initialSsoSettings, i
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
window.URL.revokeObjectURL(url);
window.setTimeout(() => window.URL.revokeObjectURL(url), 60_000);
toast.success("Restic password file downloaded successfully");
setDownloadDialogOpen(false);
setDownloadPassword("");
setDownloadBlockedMessage(null);
},
onError: (error) => {
const message = parseError(error)?.message;
setDownloadBlockedMessage(message?.includes("credential password") ? message : null);
toast.error("Failed to download Restic password", {
description: error.message,
description: message,
});
},
});
@ -145,6 +166,7 @@ export function SettingsPage({ appContext, initialMembers, initialSsoSettings, i
return;
}
setDownloadBlockedMessage(null);
downloadResticPassword.mutate({
body: {
password: downloadPassword,
@ -213,11 +235,22 @@ export function SettingsPage({ appContext, initialMembers, initialSsoSettings, i
<CardContent className="p-6 space-y-4">
<div className="space-y-2">
<Label htmlFor="username">Username</Label>
<Input id="username" value={appContext.user?.username} disabled className="max-w-md" />
<Input
id="username"
value={appContext.user?.username}
disabled
className="max-w-md"
/>
</div>
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input id="email" type="email" value={appContext.user?.email} disabled className="max-w-md" />
<Input
id="email"
type="email"
value={appContext.user?.email}
disabled
className="max-w-md"
/>
</div>
</CardContent>
@ -237,7 +270,9 @@ export function SettingsPage({ appContext, initialMembers, initialSsoSettings, i
<Label htmlFor="date-format">Date format</Label>
<Select
value={dateFormat}
onValueChange={(value) => void handleDateFormatChange(value as DateFormatPreference)}
onValueChange={(value) =>
void handleDateFormatChange(value as DateFormatPreference)
}
>
<SelectTrigger id="date-format">
<SelectValue />
@ -255,7 +290,9 @@ export function SettingsPage({ appContext, initialMembers, initialSsoSettings, i
<Label htmlFor="time-format">Time format</Label>
<Select
value={timeFormat}
onValueChange={(value) => void handleTimeFormatChange(value as TimeFormatPreference)}
onValueChange={(value) =>
void handleTimeFormatChange(value as TimeFormatPreference)
}
>
<SelectTrigger id="time-format">
<SelectValue />
@ -270,18 +307,26 @@ export function SettingsPage({ appContext, initialMembers, initialSsoSettings, i
</Select>
</div>
</div>
<p className="text-sm text-muted-foreground">Preview: {formatDateTime(new Date())}</p>
<p className="text-sm text-muted-foreground">
Preview: {formatDateTime(new Date())}
</p>
</div>
</CardContent>
<div className="border-t border-border/50 bg-card-header p-6">
<div
className={cn("border-t border-border/50 bg-card-header p-6", {
hidden: !hasCredentialPassword,
})}
>
<CardTitle className="flex items-center gap-2">
<KeyRound className="size-5" />
Change Password
</CardTitle>
<CardDescription className="mt-1.5">Update your password to keep your account secure</CardDescription>
<CardDescription className="mt-1.5">
Update your password to keep your account secure
</CardDescription>
</div>
<CardContent className="p-6">
<CardContent className={cn("p-6", { hidden: !hasCredentialPassword })}>
<form onSubmit={handleChangePassword} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="current-password">Current Password</Label>
@ -305,7 +350,9 @@ export function SettingsPage({ appContext, initialMembers, initialSsoSettings, i
required
minLength={8}
/>
<p className="text-xs text-muted-foreground">Must be at least 8 characters long</p>
<p className="text-xs text-muted-foreground">
Must be at least 8 characters long
</p>
</div>
<div className="space-y-2">
<Label htmlFor="confirm-password">Confirm New Password</Label>
@ -331,13 +378,15 @@ export function SettingsPage({ appContext, initialMembers, initialSsoSettings, i
<Download className="size-5" />
Backup Recovery Key
</CardTitle>
<CardDescription className="mt-1.5">Download your recovery key for Restic backups</CardDescription>
<CardDescription className="mt-1.5">
Download your recovery key for Restic backups
</CardDescription>
</div>
<CardContent className="p-6 space-y-4">
<p className="text-sm text-muted-foreground max-w-2xl">
This file contains the encryption password used by Restic to secure your backups. Store it in a safe
place (like a password manager or encrypted storage). If you lose access to this server, you'll need
this file to recover your backup data.
This file contains the encryption password used by Restic to secure your backups.
Store it in a safe place (like a password manager or encrypted storage). If you lose
access to this server, you'll need this file to recover your backup data.
</p>
<Dialog open={downloadDialogOpen} onOpenChange={setDownloadDialogOpen}>
@ -352,11 +401,26 @@ export function SettingsPage({ appContext, initialMembers, initialSsoSettings, i
<DialogHeader>
<DialogTitle>Download Recovery Key</DialogTitle>
<DialogDescription>
For security reasons, please enter your account password to download the recovery key file.
{!hasCredentialPassword
? "A local credential password is required before this recovery key can be downloaded."
: "For security reasons, please enter your account password to download the recovery key file."}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Alert
variant="warning"
className={cn({
hidden: hasCredentialPassword && !downloadBlockedMessage,
})}
>
<AlertTriangle className="size-5" />
<AlertTitle>Local password required</AlertTitle>
<AlertDescription>
{downloadBlockedMessage ??
RECOVERY_KEY_CREDENTIAL_REQUIRED_MESSAGE}
</AlertDescription>
</Alert>
<div className={cn("space-y-2", { hidden: !hasCredentialPassword })}>
<Label htmlFor="download-password">Your Password</Label>
<Input
id="download-password"
@ -380,7 +444,11 @@ export function SettingsPage({ appContext, initialMembers, initialSsoSettings, i
<X className="h-4 w-4 mr-2" />
Cancel
</Button>
<Button type="submit" loading={downloadResticPassword.isPending}>
<Button
type="submit"
loading={downloadResticPassword.isPending}
className={cn({ hidden: !hasCredentialPassword })}
>
<Download className="h-4 w-4 mr-2" />
Download
</Button>
@ -391,6 +459,8 @@ export function SettingsPage({ appContext, initialMembers, initialSsoSettings, i
</CardContent>
<TwoFactorSection twoFactorEnabled={appContext.user?.twoFactorEnabled} />
<PasskeysSection />
</Card>
</TabsContent>
@ -402,7 +472,9 @@ export function SettingsPage({ appContext, initialMembers, initialSsoSettings, i
<Fingerprint className="size-5" />
Organization Details
</CardTitle>
<CardDescription className="mt-1.5">Reference details for the active organization</CardDescription>
<CardDescription className="mt-1.5">
Reference details for the active organization
</CardDescription>
</div>
<CardContent className="p-6 space-y-2">
<Label htmlFor="organization-id">Organization ID</Label>
@ -421,7 +493,9 @@ export function SettingsPage({ appContext, initialMembers, initialSsoSettings, i
<Building2 className="size-5" />
Members
</CardTitle>
<CardDescription className="mt-1.5">Manage organization members and roles</CardDescription>
<CardDescription className="mt-1.5">
Manage organization members and roles
</CardDescription>
</div>
<CardContent className="p-6">
<OrgMembersSection initialMembers={initialMembers} />
@ -434,10 +508,15 @@ export function SettingsPage({ appContext, initialMembers, initialSsoSettings, i
<SettingsIcon className="size-5" />
Single Sign-On
</CardTitle>
<CardDescription className="mt-1.5">Configure OIDC provider settings</CardDescription>
<CardDescription className="mt-1.5">
Configure OIDC provider settings
</CardDescription>
</div>
<CardContent className="p-6">
<SsoSettingsSection initialSettings={initialSsoSettings} initialOrigin={initialOrigin} />
<SsoSettingsSection
initialSettings={initialSsoSettings}
initialOrigin={initialOrigin}
/>
</CardContent>
</Card>
</TabsContent>

View file

@ -0,0 +1,54 @@
import { useMutation } from "@tanstack/react-query";
import { toast } from "sonner";
import { Button } from "~/client/components/ui/button";
import { authClient } from "~/client/lib/auth-client";
import { logger } from "~/client/lib/logger";
type SsoProvider = {
providerId: string;
};
type SsoLoginButtonsProps = {
providers: SsoProvider[];
};
export function SsoLoginButtons({ providers }: SsoLoginButtonsProps) {
const ssoLoginMutation = useMutation({
mutationFn: async (providerId: string) => {
const callbackPath = "/login";
const { data, error } = await authClient.signIn.sso({
providerId: providerId,
callbackURL: callbackPath,
errorCallbackURL: "/api/v1/auth/login-error",
});
if (error) throw error;
return data;
},
onSuccess: (data) => {
window.location.href = data.url;
},
onError: (error) => {
logger.error(error);
toast.error("SSO Login failed", { description: error.message });
},
});
return (
<>
{providers.map((provider) => (
<Button
key={provider.providerId}
type="button"
variant="outline"
className="w-full"
loading={ssoLoginMutation.isPending}
disabled={ssoLoginMutation.isPending}
onClick={() => ssoLoginMutation.mutate(provider.providerId)}
>
Log in with {provider.providerId}
</Button>
))}
</>
);
}

View file

@ -1,58 +0,0 @@
import { useMutation, useSuspenseQuery } from "@tanstack/react-query";
import { toast } from "sonner";
import { Button } from "~/client/components/ui/button";
import { authClient } from "~/client/lib/auth-client";
import { logger } from "~/client/lib/logger";
import { getPublicSsoProvidersOptions } from "~/client/api-client/@tanstack/react-query.gen";
export function SsoLoginSection() {
const { data: ssoProviders } = useSuspenseQuery({
...getPublicSsoProvidersOptions(),
});
const ssoLoginMutation = useMutation({
mutationFn: async (providerId: string) => {
const callbackPath = "/login";
const { data, error } = await authClient.signIn.sso({
providerId: providerId,
callbackURL: callbackPath,
errorCallbackURL: "/api/v1/auth/login-error",
});
if (error) throw error;
return data;
},
onSuccess: (data) => {
window.location.href = data.url;
},
onError: (error) => {
logger.error(error);
toast.error("SSO Login failed", { description: error.message });
},
});
if (ssoProviders.providers.length === 0) {
return null;
}
return (
<div className="pt-4 border-t border-border/60 space-y-3">
<p className="text-sm font-medium">Alternative Sign-in</p>
<div className="flex flex-col gap-2">
{ssoProviders.providers.map((provider) => (
<Button
key={provider.providerId}
type="button"
variant="outline"
className="w-full"
loading={ssoLoginMutation.isPending}
disabled={ssoLoginMutation.isPending}
onClick={() => ssoLoginMutation.mutate(provider.providerId)}
>
Log in with {provider.providerId}
</Button>
))}
</div>
</div>
);
}

View file

@ -25,7 +25,7 @@ import {
smbConfigSchema,
volumeConfigSchema,
webdavConfigSchema,
} from "~/schemas/volumes";
} from "@zerobyte/contracts/volumes";
import { testConnectionMutation } from "../../../api-client/@tanstack/react-query.gen";
import { Tooltip, TooltipContent, TooltipTrigger } from "../../../components/ui/tooltip";
import { useSystemInfo } from "~/client/hooks/use-system-info";
@ -66,10 +66,10 @@ type Props = {
const defaultValuesForType = {
directory: { backend: "directory" as const, path: "/" },
nfs: { backend: "nfs" as const, port: 2049, version: "4.1" as const },
smb: { backend: "smb" as const, port: 445, vers: "3.0" as const },
smb: { backend: "smb" as const, port: 445, vers: "3.0" as const, mapToContainerUidGid: false },
webdav: { backend: "webdav" as const, port: 80, ssl: false, path: "/webdav" },
rclone: { backend: "rclone" as const, path: "/" },
sftp: { backend: "sftp" as const, port: 22, path: "/", skipHostKeyCheck: false },
sftp: { backend: "sftp" as const, port: 22, path: "/", skipHostKeyCheck: false, allowLegacySshRsa: false },
};
export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, formId, loading, className }: Props) => {
@ -230,7 +230,10 @@ export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, for
<Tooltip>
<TooltipTrigger asChild>
<div>
<SelectItem disabled={!capabilities.rclone || !capabilities.sysAdmin} value="rclone">
<SelectItem
disabled={!capabilities.rclone || !capabilities.sysAdmin}
value="rclone"
>
rclone
</SelectItem>
</div>
@ -238,7 +241,11 @@ export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, for
<TooltipContent className={cn({ hidden: capabilities.sysAdmin })}>
<p>Remote mounts require SYS_ADMIN capability</p>
</TooltipContent>
<TooltipContent className={cn({ hidden: !capabilities.sysAdmin || capabilities.rclone })}>
<TooltipContent
className={cn({
hidden: !capabilities.sysAdmin || capabilities.rclone,
})}
>
<p>Setup rclone to use this backend</p>
</TooltipContent>
</Tooltip>

View file

@ -101,6 +101,7 @@ export const NFSForm = ({ form }: Props) => {
<div className="flex items-center space-x-2">
<input
type="checkbox"
aria-label="Mount volume as read-only"
checked={field.value ?? false}
onChange={(e) => field.onChange(e.target.checked)}
className="rounded border-gray-300"

View file

@ -108,6 +108,7 @@ export const RcloneForm = ({ form }: Props) => {
<div className="flex items-center space-x-2">
<input
type="checkbox"
aria-label="Mount volume as read-only"
checked={field.value ?? false}
onChange={(e) => field.onChange(e.target.checked)}
className="rounded border-gray-300"

View file

@ -12,6 +12,7 @@ import { Input } from "../../../../components/ui/input";
import { SecretInput } from "../../../../components/ui/secret-input";
import { Textarea } from "../../../../components/ui/textarea";
import { Switch } from "../../../../components/ui/switch";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "../../../../components/ui/collapsible";
type Props = {
form: UseFormReturn<FormValues>;
@ -78,7 +79,9 @@ export const SFTPForm = ({ form }: Props) => {
<FormControl>
<SecretInput placeholder="••••••••" value={field.value ?? ""} onChange={field.onChange} />
</FormControl>
<FormDescription>Password for SFTP authentication (optional if using private key).</FormDescription>
<FormDescription>
Password for SFTP authentication (optional if using private key).
</FormDescription>
<FormMessage />
</FormItem>
)}
@ -98,7 +101,9 @@ export const SFTPForm = ({ form }: Props) => {
value={field.value ?? ""}
/>
</FormControl>
<FormDescription>SSH private key for authentication (optional if using password).</FormDescription>
<FormDescription>
SSH private key for authentication (optional if using password).
</FormDescription>
<FormMessage />
</FormItem>
)}
@ -158,6 +163,29 @@ export const SFTPForm = ({ form }: Props) => {
)}
/>
)}
<Collapsible>
<CollapsibleTrigger>Advanced Settings</CollapsibleTrigger>
<CollapsibleContent className="pb-4 pt-4">
<FormField
control={form.control}
name="allowLegacySshRsa"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm">
<div className="space-y-0.5">
<FormLabel>Allow legacy SSH RSA/SHA1 algorithms</FormLabel>
<FormDescription>
Only enable this for legacy SFTP servers that offer <code>ssh-rsa</code> only.
It permits RSA/SHA1 signatures, which are weaker than modern SSH algorithms.
</FormDescription>
</div>
<FormControl>
<Switch checked={field.value ?? false} onCheckedChange={field.onChange} />
</FormControl>
</FormItem>
)}
/>
</CollapsibleContent>
</Collapsible>
</>
);
};

View file

@ -60,6 +60,7 @@ export const SMBForm = ({ form }: Props) => {
<div className="flex items-center space-x-2">
<input
type="checkbox"
aria-label="Connect as guest"
checked={field.value ?? false}
onChange={(e) => {
field.onChange(e.target.checked);
@ -168,6 +169,33 @@ export const SMBForm = ({ form }: Props) => {
</FormItem>
)}
/>
<FormField
control={form.control}
name="mapToContainerUidGid"
defaultValue={false}
render={({ field }) => (
<FormItem>
<FormLabel>Ownership Mapping</FormLabel>
<FormControl>
<div className="flex items-center space-x-2">
<input
type="checkbox"
aria-label="Map all files to container user/group"
checked={field.value ?? false}
onChange={(e) => field.onChange(e.target.checked)}
className="rounded border-gray-300"
/>
<span className="text-sm">Map all files to container user/group</span>
</div>
</FormControl>
<FormDescription>
Keep the old behavior by forcing the SMB mount to present every file and directory as owned
by the container user and group instead of using server reported ownership.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="readOnly"
@ -179,6 +207,7 @@ export const SMBForm = ({ form }: Props) => {
<div className="flex items-center space-x-2">
<input
type="checkbox"
aria-label="Mount volume as read-only"
checked={field.value ?? false}
onChange={(e) => field.onChange(e.target.checked)}
className="rounded border-gray-300"

View file

@ -105,6 +105,7 @@ export const WebDAVForm = ({ form }: Props) => {
<div className="flex items-center space-x-2">
<input
type="checkbox"
aria-label="Enable HTTPS for secure connections"
checked={field.value ?? false}
onChange={(e) => field.onChange(e.target.checked)}
className="rounded border-gray-300"
@ -128,6 +129,7 @@ export const WebDAVForm = ({ form }: Props) => {
<div className="flex items-center space-x-2">
<input
type="checkbox"
aria-label="Mount volume as read-only"
checked={field.value ?? false}
onChange={(e) => field.onChange(e.target.checked)}
className="rounded border-gray-300"

View file

@ -33,7 +33,9 @@ function ConfigRow({ icon, label, value, mono }: ConfigRowProps) {
<div className="flex items-center gap-3 py-3 first:pt-0 last:pb-0">
<span className="text-muted-foreground shrink-0">{icon}</span>
<span className="text-sm text-muted-foreground w-40 shrink-0">{label}</span>
<span className={cn("text-sm break-all", { "font-mono bg-muted/50 px-2 py-0.5 rounded": mono })}>{value}</span>
<span className={cn("text-sm break-all", { "font-mono bg-muted/50 px-2 py-0.5 rounded": mono })}>
{value}
</span>
</div>
);
}
@ -43,12 +45,19 @@ function BackendConfigRows({ volume }: { volume: Volume }) {
switch (config.backend) {
case "directory":
return <ConfigRow icon={<FolderOpen className="h-4 w-4" />} label="Directory Path" value={config.path} mono />;
return (
<ConfigRow icon={<FolderOpen className="h-4 w-4" />} label="Directory Path" value={config.path} mono />
);
case "nfs":
return (
<>
<ConfigRow icon={<FolderOpen className="h-4 w-4" />} label="Server" value={config.server} mono />
<ConfigRow icon={<FolderOpen className="h-4 w-4" />} label="Export Path" value={config.exportPath} mono />
<ConfigRow
icon={<FolderOpen className="h-4 w-4" />}
label="Export Path"
value={config.exportPath}
mono
/>
</>
);
case "smb":
@ -84,6 +93,8 @@ function BackendConfigRows({ volume }: { volume: Volume }) {
}
function DonutChart({ statfs }: { statfs: StatFs }) {
const { used = 0, total = 0 } = statfs;
const chartData = useMemo(
() => [
{ name: "Used", value: statfs.used, fill: "var(--strong-accent)" },
@ -93,8 +104,8 @@ function DonutChart({ statfs }: { statfs: StatFs }) {
);
const usagePercentage = useMemo(() => {
return Math.round((statfs.used / statfs.total) * 100);
}, [statfs]);
return Math.round((used / total) * 100);
}, [used, total]);
return (
<ChartContainer config={{}} className="mx-auto aspect-square max-h-[200px]">
@ -114,10 +125,18 @@ function DonutChart({ statfs }: { statfs: StatFs }) {
if (viewBox && "cx" in viewBox && "cy" in viewBox) {
return (
<text x={viewBox.cx} y={viewBox.cy} textAnchor="middle" dominantBaseline="middle">
<tspan x={viewBox.cx} y={viewBox.cy} className="fill-foreground text-2xl font-bold">
<tspan
x={viewBox.cx}
y={viewBox.cy}
className="fill-foreground text-2xl font-bold"
>
{usagePercentage}%
</tspan>
<tspan x={viewBox.cx} y={(viewBox.cy || 0) + 20} className="fill-muted-foreground text-xs">
<tspan
x={viewBox.cx}
y={(viewBox.cy || 0) + 20}
className="fill-muted-foreground text-xs"
>
Used
</tspan>
</text>
@ -132,7 +151,9 @@ function DonutChart({ statfs }: { statfs: StatFs }) {
}
export const VolumeInfoTabContent = ({ volume, statfs }: Props) => {
const hasStorage = statfs.total > 0;
const { total = 0, used = 0, free = 0 } = statfs;
const hasStorage = total > 0;
return (
<Card className="px-6 py-6 @container/inner">
@ -144,7 +165,11 @@ export const VolumeInfoTabContent = ({ volume, statfs }: Props) => {
</CardTitle>
<div className="space-y-0 divide-y divide-border/50">
<ConfigRow icon={<HardDrive className="h-4 w-4" />} label="Name" value={volume.name} />
<ConfigRow icon={<HardDrive className="h-4 w-4" />} label="Backend" value={backendLabels[volume.type]} />
<ConfigRow
icon={<HardDrive className="h-4 w-4" />}
label="Backend"
value={backendLabels[volume.type]}
/>
<BackendConfigRows volume={volume} />
</div>
</div>
@ -162,21 +187,21 @@ export const VolumeInfoTabContent = ({ volume, statfs }: Props) => {
<HardDrive className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium">Total</span>
</div>
<ByteSize bytes={statfs.total} className="font-mono text-sm" />
<ByteSize bytes={total} className="font-mono text-sm" />
</div>
<div className="flex items-center justify-between p-3 rounded-lg bg-muted/50">
<div className="flex items-center gap-3">
<div className="h-3 w-3 rounded-full bg-strong-accent" />
<span className="text-sm font-medium">Used</span>
</div>
<ByteSize bytes={statfs.used} className="font-mono text-sm" />
<ByteSize bytes={used} className="font-mono text-sm" />
</div>
<div className="flex items-center justify-between p-3 rounded-lg bg-muted/50">
<div className="flex items-center gap-3">
<div className="h-3 w-3 rounded-full bg-primary" />
<span className="text-sm font-medium">Free</span>
</div>
<ByteSize bytes={statfs.free} className="font-mono text-sm" />
<ByteSize bytes={free} className="font-mono text-sm" />
</div>
</div>
</div>

View file

@ -7,6 +7,7 @@ type User = {
dateFormat: string;
timeFormat: string;
twoFactorEnabled?: boolean | null;
hasCredentialPassword?: boolean;
role?: string | null | undefined;
};
@ -14,4 +15,5 @@ export type AppContext = {
user: User | null;
hasUsers: boolean;
sidebarOpen: boolean;
hasSkippedRecoveryKeyDownload: boolean;
};

View file

@ -0,0 +1,2 @@
ALTER TABLE `volumes_table` ADD `agent_id` text DEFAULT 'local' NOT NULL;--> statement-breakpoint
CREATE INDEX `volumes_table_agent_id_idx` ON `volumes_table` (`agent_id`);

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,17 @@
CREATE TABLE `passkey` (
`id` text PRIMARY KEY,
`name` text,
`public_key` text NOT NULL,
`user_id` text NOT NULL,
`credential_id` text NOT NULL,
`counter` integer NOT NULL,
`device_type` text NOT NULL,
`backed_up` integer NOT NULL,
`transports` text,
`created_at` integer DEFAULT (unixepoch() * 1000) NOT NULL,
`aaguid` text,
CONSTRAINT `fk_passkey_user_id_users_table_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users_table`(`id`) ON DELETE CASCADE
);
--> statement-breakpoint
CREATE INDEX `passkey_userId_idx` ON `passkey` (`user_id`);--> statement-breakpoint
CREATE INDEX `passkey_credentialID_idx` ON `passkey` (`credential_id`);

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,16 @@
CREATE TABLE `agents_table` (
`id` text PRIMARY KEY,
`organization_id` text,
`name` text NOT NULL,
`kind` text NOT NULL,
`status` text DEFAULT 'offline' NOT NULL,
`capabilities` text DEFAULT '{}' NOT NULL,
`last_seen_at` integer,
`last_ready_at` integer,
`created_at` integer DEFAULT (unixepoch() * 1000) NOT NULL,
`updated_at` integer DEFAULT (unixepoch() * 1000) NOT NULL,
CONSTRAINT `fk_agents_table_organization_id_organization_id_fk` FOREIGN KEY (`organization_id`) REFERENCES `organization`(`id`) ON DELETE CASCADE
);
--> statement-breakpoint
CREATE INDEX `agents_table_organization_id_idx` ON `agents_table` (`organization_id`);--> statement-breakpoint
CREATE INDEX `agents_table_status_idx` ON `agents_table` (`status`);

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,28 @@
CREATE TABLE `repository_lock_waiters` (
`id` text PRIMARY KEY,
`repository_id` text NOT NULL,
`type` text NOT NULL,
`operation` text NOT NULL,
`owner_id` text NOT NULL,
`requested_at` integer NOT NULL,
`expires_at` integer NOT NULL,
`heartbeat_at` integer NOT NULL
);
--> statement-breakpoint
CREATE TABLE `repository_locks` (
`id` text PRIMARY KEY,
`repository_id` text NOT NULL,
`type` text NOT NULL,
`operation` text NOT NULL,
`owner_id` text NOT NULL,
`acquired_at` integer NOT NULL,
`expires_at` integer NOT NULL,
`heartbeat_at` integer NOT NULL
);
--> statement-breakpoint
CREATE INDEX `repository_lock_waiters_repository_id_idx` ON `repository_lock_waiters` (`repository_id`);--> statement-breakpoint
CREATE INDEX `repository_lock_waiters_expires_at_idx` ON `repository_lock_waiters` (`expires_at`);--> statement-breakpoint
CREATE INDEX `repository_lock_waiters_owner_id_idx` ON `repository_lock_waiters` (`owner_id`);--> statement-breakpoint
CREATE INDEX `repository_locks_repository_id_idx` ON `repository_locks` (`repository_id`);--> statement-breakpoint
CREATE INDEX `repository_locks_expires_at_idx` ON `repository_locks` (`expires_at`);--> statement-breakpoint
CREATE INDEX `repository_locks_owner_id_idx` ON `repository_locks` (`owner_id`);

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1 @@
ALTER TABLE `two_factor` ADD `verified` integer DEFAULT true NOT NULL;

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,22 @@
CREATE TABLE `tasks` (
`id` text PRIMARY KEY,
`organization_id` text NOT NULL,
`kind` text NOT NULL,
`status` text NOT NULL,
`resource_type` text NOT NULL,
`resource_id` text NOT NULL,
`target_agent_id` text,
`input` text NOT NULL,
`progress` text,
`result` text,
`error` text,
`cancellation_requested` integer DEFAULT false NOT NULL,
`created_at` integer DEFAULT (unixepoch() * 1000) NOT NULL,
`started_at` integer,
`updated_at` integer DEFAULT (unixepoch() * 1000) NOT NULL,
`finished_at` integer,
CONSTRAINT `fk_tasks_organization_id_organization_id_fk` FOREIGN KEY (`organization_id`) REFERENCES `organization`(`id`) ON DELETE CASCADE
);
--> statement-breakpoint
CREATE INDEX `tasks_org_kind_resource_status_idx` ON `tasks` (`organization_id`,`kind`,`resource_type`,`resource_id`,`status`);--> statement-breakpoint
CREATE INDEX `tasks_org_status_updated_at_idx` ON `tasks` (`organization_id`,`status`,`updated_at`);

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,2 @@
export const RECOVERY_KEY_DOWNLOAD_SKIPPED_COOKIE_NAME = "zerobyte_recovery_key_download_skipped";
export const RECOVERY_KEY_DOWNLOAD_SKIPPED_COOKIE_MAX_AGE = 60 * 60;

View file

@ -1,9 +1,13 @@
export const PASSKEY_LOGIN_FAILED_ERROR = "PASSKEY_LOGIN_FAILED";
export const LOGIN_ERROR_CODES = [
"ACCOUNT_LINK_REQUIRED",
"EMAIL_NOT_VERIFIED",
"INVITE_REQUIRED",
"BANNED_USER",
"SSO_LOGIN_FAILED",
PASSKEY_LOGIN_FAILED_ERROR,
"ERROR_INVALID_RP_ID",
] as const;
export type LoginErrorCode = (typeof LOGIN_ERROR_CODES)[number];
@ -20,5 +24,9 @@ export function getLoginErrorDescription(errorCode: LoginErrorCode): string {
return "You have been banned from this application. Please contact support if you believe this is an error.";
case "SSO_LOGIN_FAILED":
return "SSO authentication failed. Please try again.";
case PASSKEY_LOGIN_FAILED_ERROR:
return "Passkey sign-in failed. The passkey didn't verify your identity with a PIN, biometrics, or screen lock. Please use a verified passkey or sign in with your password.";
case "ERROR_INVALID_RP_ID":
return "You can only sign in with a passkey on the domain set by the BASE_URL environment variable";
}
}

View file

@ -1,9 +1,10 @@
import { createMiddleware } from "@tanstack/react-start";
import { redirect } from "@tanstack/react-router";
import { auth } from "~/server/lib/auth";
import { getRequestHeaders } from "@tanstack/react-start/server";
import { getCookie, getRequestHeaders } from "@tanstack/react-start/server";
import { authService } from "~/server/modules/auth/auth.service";
import { isAuthRoute } from "~/lib/auth-routes";
import { RECOVERY_KEY_DOWNLOAD_SKIPPED_COOKIE_NAME } from "~/lib/recovery-key-skip";
export const authMiddleware = createMiddleware().server(async ({ next, request }) => {
const headers = getRequestHeaders();
@ -20,7 +21,13 @@ export const authMiddleware = createMiddleware().server(async ({ next, request }
}
if (session?.user?.id) {
if (!session.user.hasDownloadedResticPassword && pathname !== "/download-recovery-key") {
const hasSkippedRecoveryKeyDownload = getCookie(RECOVERY_KEY_DOWNLOAD_SKIPPED_COOKIE_NAME) === session.user.id;
if (
!session.user.hasDownloadedResticPassword &&
!hasSkippedRecoveryKeyDownload &&
pathname !== "/download-recovery-key"
) {
throw redirect({ to: "/download-recovery-key" });
}

View file

@ -1,9 +1,24 @@
import { createFileRoute } from "@tanstack/react-router";
import { createServerFn } from "@tanstack/react-start";
import { getRequestHeaders } from "@tanstack/react-start/server";
import { DownloadRecoveryKeyPage } from "~/client/modules/auth/routes/download-recovery-key";
import { auth } from "~/server/lib/auth";
import { userHasCredentialPassword } from "~/server/modules/auth/helpers";
const getRecoveryKeyUserState = createServerFn({ method: "GET" }).handler(async () => {
const headers = getRequestHeaders();
const session = await auth.api.getSession({ headers });
return {
hasCredentialPassword: session?.user ? await userHasCredentialPassword(session.user.id) : false,
userId: session?.user.id ?? null,
};
});
export const Route = createFileRoute("/(auth)/download-recovery-key")({
component: DownloadRecoveryKeyPage,
component: RouteComponent,
errorComponent: () => <div>Failed to load recovery key</div>,
loader: async () => getRecoveryKeyUserState(),
head: () => ({
meta: [
{ title: "Zerobyte - Download Recovery Key" },
@ -14,3 +29,9 @@ export const Route = createFileRoute("/(auth)/download-recovery-key")({
],
}),
});
function RouteComponent() {
const { hasCredentialPassword, userId } = Route.useLoaderData();
return <DownloadRecoveryKeyPage hasCredentialPassword={hasCredentialPassword} userId={userId} />;
}

View file

@ -1,4 +1,4 @@
import { createFileRoute } from "@tanstack/react-router";
import { createFileRoute, redirect } from "@tanstack/react-router";
import { createServerFn } from "@tanstack/react-start";
import { getCookie, getRequestHeaders } from "@tanstack/react-start/server";
import { Layout } from "~/client/components/layout";
@ -7,17 +7,27 @@ import { authMiddleware } from "~/middleware/auth";
import { auth } from "~/server/lib/auth";
import { getOrganizationContext } from "~/server/lib/functions/organization-context";
import { getServerConstants } from "~/server/lib/functions/server-constants";
import { userHasCredentialPassword } from "~/server/modules/auth/helpers";
import { authService } from "~/server/modules/auth/auth.service";
import { RECOVERY_KEY_DOWNLOAD_SKIPPED_COOKIE_NAME } from "~/lib/recovery-key-skip";
export const fetchUser = createServerFn({ method: "GET" }).handler(async () => {
const headers = getRequestHeaders();
const session = await auth.api.getSession({ headers });
const hasUsers = await authService.hasUsers();
const hasCredentialPassword = session?.user ? await userHasCredentialPassword(session.user.id) : false;
const sidebarCookie = getCookie(SIDEBAR_COOKIE_NAME);
const sidebarOpen = !sidebarCookie ? true : sidebarCookie === "true";
const hasSkippedRecoveryKeyDownload =
!!session?.user && getCookie(RECOVERY_KEY_DOWNLOAD_SKIPPED_COOKIE_NAME) === session.user.id;
return { user: session?.user ?? null, hasUsers, sidebarOpen };
return {
user: session?.user ? { ...session.user, hasCredentialPassword } : null,
hasUsers,
sidebarOpen,
hasSkippedRecoveryKeyDownload,
};
});
export const Route = createFileRoute("/(dashboard)")({
@ -39,6 +49,14 @@ export const Route = createFileRoute("/(dashboard)")({
}),
]);
if (
authContext.user &&
!authContext.user.hasDownloadedResticPassword &&
!authContext.hasSkippedRecoveryKeyDownload
) {
throw redirect({ to: "/download-recovery-key" });
}
return authContext;
},
});

View file

@ -2,7 +2,7 @@ import { z } from "zod";
import { resticBackupProgressMetricsSchema, resticBackupRunSummarySchema } from "@zerobyte/core/restic";
const backupEventStatusSchema = z.enum(["success", "error", "stopped", "warning"]);
const restoreEventStatusSchema = z.enum(["success", "error"]);
const restoreEventStatusSchema = z.enum(["success", "error", "cancelled"]);
const backupEventBaseSchema = z.object({
scheduleId: z.string(),
@ -15,6 +15,7 @@ const organizationScopedSchema = z.object({
});
const restoreEventBaseSchema = z.object({
restoreId: z.string(),
repositoryId: z.string(),
snapshotId: z.string(),
});
@ -51,6 +52,8 @@ const restoreProgressEventSchema = restoreEventBaseSchema.extend(restoreProgress
const restoreCompletedEventSchema = restoreEventBaseSchema.extend({
status: restoreEventStatusSchema,
error: z.string().optional(),
filesRestored: z.number().optional(),
filesSkipped: z.number().optional(),
});
const serverBackupStartedEventSchema = organizationScopedSchema.extend(backupStartedEventSchema.shape);

View file

@ -14,6 +14,13 @@ export const NOTIFICATION_TYPES = {
export type NotificationType = keyof typeof NOTIFICATION_TYPES;
const headerNamePattern = /^[A-Za-z0-9-]+$/;
const notificationHeaderSchema = z.string().refine((header) => {
const [key, value] = header.split(":", 2);
return !!key && headerNamePattern.test(key.trim()) && (value?.trim().length ?? 0) > 0;
}, "Headers must use non-empty Key: Value format with valid header names");
export const emailNotificationConfigSchema = z.object({
type: z.literal("email"),
smtpHost: z.string().min(1),
@ -79,7 +86,7 @@ export const genericNotificationConfigSchema = z.object({
url: z.string().min(1),
method: z.enum(["GET", "POST"]),
contentType: z.string().optional(),
headers: z.array(z.string()).optional(),
headers: z.array(notificationHeaderSchema).optional(),
useJson: z.boolean().optional(),
titleKey: z.string().optional(),
messageKey: z.string().optional(),

View file

@ -1,8 +1,8 @@
import { Scalar } from "@scalar/hono-api-reference";
import type { Context, Next } from "hono";
import { Hono } from "hono";
import { bodyLimit } from "hono/body-limit";
import { cors } from "hono/cors";
import { logger as honoLogger } from "hono/logger";
import { secureHeaders } from "hono/secure-headers";
import { rateLimiter } from "hono-rate-limiter";
import { openAPIRouteHandler } from "hono-openapi";
@ -21,6 +21,20 @@ import { config } from "./core/config";
import { auth } from "~/server/lib/auth";
import { db } from "./db/db";
const requestLogger = async (c: Context, next: Next) => {
const method = c.req.method;
const path = c.req.path;
const start = performance.now();
logger.debug(`<-- ${method} ${path}`);
try {
await next();
} finally {
logger.debug(`--> ${method} ${path} ${c.res.status} ${Math.round(performance.now() - start)}ms`);
}
};
const generalDescriptor = (app: Hono) =>
openAPIRouteHandler(app, {
documentation: {
@ -49,7 +63,7 @@ export const createApp = () => {
if (config.environment === "production") {
app.use(secureHeaders());
app.use(honoLogger());
app.use(requestLogger);
}
app.use(

View file

@ -1,6 +1,17 @@
import { describe, expect, test } from "vitest";
import { describe, expect, test, vi } from "vitest";
import { eq } from "drizzle-orm";
import { db } from "~/server/db/db";
import { repositoryLocksTable, repositoryLockWaitersTable } from "~/server/db/schema";
import { repoMutex } from "../repository-mutex";
const acquireWithin = <T>(promise: Promise<T>, ms = 500) =>
Promise.race([
promise,
new Promise<never>((_, reject) => {
setTimeout(() => reject(new Error(`Timed out after ${ms}ms`)), ms);
}),
]);
describe("RepositoryMutex", () => {
test("should prioritize waiting exclusive locks over new shared locks", async () => {
const repoId = "test-repo";
@ -123,6 +134,37 @@ describe("RepositoryMutex", () => {
expect(repoMutex.isLocked(repoId)).toBe(false);
});
test("should reject aborted queued acquisitions only after waiter cleanup finishes", async () => {
const repoId = "abort-waits-for-cleanup";
const releaseHolder = await repoMutex.acquireExclusive(repoId, "holder");
const controller = new AbortController();
const waitingAcquisition = repoMutex.acquireShared(repoId, "waiter", controller.signal);
try {
await new Promise((resolve) => setTimeout(resolve, 50));
const waitersBeforeAbort = await db.query.repositoryLockWaitersTable.findMany({
where: { repositoryId: { eq: repoId } },
});
expect(waitersBeforeAbort.map((waiter) => waiter.operation)).toEqual(["waiter"]);
controller.abort(new Error("stop"));
await expect(waitingAcquisition).rejects.toThrow("stop");
const waitersAfterAbort = await db.query.repositoryLockWaitersTable.findMany({
where: { repositoryId: { eq: repoId } },
});
expect(waitersAfterAbort).toEqual([]);
expect(repoMutex.isLocked(repoId)).toBe(true);
} finally {
releaseHolder();
await db.delete(repositoryLockWaitersTable).where(eq(repositoryLockWaitersTable.repositoryId, repoId));
await db.delete(repositoryLocksTable).where(eq(repositoryLocksTable.repositoryId, repoId));
}
});
test("should allow concurrent shared locks", async () => {
const repoId = "concurrent-shared";
const release1 = await repoMutex.acquireShared(repoId, "op1");
@ -327,6 +369,43 @@ describe("RepositoryMutex", () => {
expect(repoMutex.isLocked(repoB)).toBe(false);
});
test("should not leave a promoted shared lock behind if that waiter aborts before observing acquisition", async () => {
vi.useFakeTimers();
const repoId = "shared-abort-after-promotion";
const releaseExclusive = await repoMutex.acquireExclusive(repoId, "holder");
const firstSharedPromise = repoMutex.acquireShared(repoId, "shared-1");
try {
await vi.advanceTimersByTimeAsync(100);
const controller = new AbortController();
const secondSharedPromise = repoMutex.acquireShared(repoId, "shared-2", controller.signal);
await vi.advanceTimersByTimeAsync(140);
releaseExclusive();
// The first shared waiter wakes first and promotes both shared waiters.
await vi.advanceTimersByTimeAsync(10);
const releaseShared1 = await firstSharedPromise;
controller.abort(new Error("abort after promotion"));
await expect(secondSharedPromise).rejects.toThrow("abort after promotion");
releaseShared1();
const remainingLocks = await db.query.repositoryLocksTable.findMany({
where: { repositoryId: { eq: repoId } },
orderBy: { operation: "asc" },
});
expect(remainingLocks).toEqual([]);
} finally {
await db.delete(repositoryLockWaitersTable).where(eq(repositoryLockWaitersTable.repositoryId, repoId));
await db.delete(repositoryLocksTable).where(eq(repositoryLocksTable.repositoryId, repoId));
vi.useRealTimers();
}
});
test("should safely handle multiple calls to the release function", async () => {
const repoId = "idempotent-release";
@ -376,4 +455,100 @@ describe("RepositoryMutex", () => {
releaseExclusive();
expect(repoMutex.isLocked(repoId)).toBe(false);
});
test("should ignore and clean expired active lock rows during acquisition", async () => {
const repoId = "expired-active-lock";
const expiredLockId = "expired-active-lock-row";
const now = Date.now();
await db.insert(repositoryLocksTable).values({
id: expiredLockId,
repositoryId: repoId,
type: "exclusive",
operation: "stale-check",
ownerId: "stale-owner",
acquiredAt: now - 10_000,
expiresAt: now - 1,
heartbeatAt: now - 10_000,
});
const releaseShared = await acquireWithin(repoMutex.acquireShared(repoId, "backup"));
try {
const expiredLock = await db.query.repositoryLocksTable.findFirst({
where: { id: { eq: expiredLockId } },
});
expect(expiredLock).toBeUndefined();
expect(repoMutex.isLocked(repoId)).toBe(true);
} finally {
releaseShared();
await db.delete(repositoryLocksTable).where(eq(repositoryLocksTable.repositoryId, repoId));
}
});
test("should ignore and clean expired waiters during acquisition", async () => {
const repoId = "expired-waiter";
const expiredWaiterId = "expired-waiter-row";
const now = Date.now();
await db.insert(repositoryLockWaitersTable).values({
id: expiredWaiterId,
repositoryId: repoId,
type: "exclusive",
operation: "stale-exclusive",
ownerId: "stale-owner",
requestedAt: now - 10_000,
expiresAt: now - 1,
heartbeatAt: now - 10_000,
});
const releaseShared = await acquireWithin(repoMutex.acquireShared(repoId, "backup"));
try {
const expiredWaiter = await db.query.repositoryLockWaitersTable.findFirst({
where: { id: { eq: expiredWaiterId } },
});
expect(expiredWaiter).toBeUndefined();
expect(repoMutex.isLocked(repoId)).toBe(true);
} finally {
releaseShared();
await db.delete(repositoryLockWaitersTable).where(eq(repositoryLockWaitersTable.repositoryId, repoId));
await db.delete(repositoryLocksTable).where(eq(repositoryLocksTable.repositoryId, repoId));
}
});
test("should release only the caller lock row", async () => {
const repoId = "release-own-row";
const foreignLockId = "foreign-release-own-row";
const releaseShared = await repoMutex.acquireShared(repoId, "owned-shared");
const now = Date.now();
try {
await db.insert(repositoryLocksTable).values({
id: foreignLockId,
repositoryId: repoId,
type: "shared",
operation: "foreign-shared",
ownerId: "foreign-owner",
acquiredAt: now,
expiresAt: now + 60_000,
heartbeatAt: now,
});
releaseShared();
const remainingLocks = await db.query.repositoryLocksTable.findMany({
where: { repositoryId: { eq: repoId } },
orderBy: { operation: "asc" },
});
expect(remainingLocks.map((lock) => lock.operation)).toEqual(["foreign-shared"]);
expect(repoMutex.isLocked(repoId)).toBe(true);
} finally {
releaseShared();
await db.delete(repositoryLocksTable).where(eq(repositoryLocksTable.repositoryId, repoId));
}
});
});

View file

@ -1,31 +1,11 @@
import { readFileSync } from "node:fs";
import os from "node:os";
import { prettifyError, z } from "zod";
import "dotenv/config";
import { resolveResticHostname } from "../../../apps/agent/src/restic/hostname";
import { buildAllowedHosts } from "../lib/auth/base-url";
import { toMessage } from "@zerobyte/core/utils";
const unquote = (str: string) => str.trim().replace(/^(['"])(.*)\1$/, "$2");
const getResticHostname = () => {
try {
const mountinfo = readFileSync("/proc/self/mountinfo", "utf-8");
const hostnameLine = mountinfo.split("\n").find((line) => line.includes(" /etc/hostname "));
const hostname = os.hostname();
if (hostnameLine) {
const containerIdMatch = hostnameLine.match(/[0-9a-f]{64}/);
const containerId = containerIdMatch ? containerIdMatch[0] : null;
if (containerId?.startsWith(hostname)) {
return "zerobyte";
}
return hostname || "zerobyte";
}
} catch {}
return "zerobyte";
};
const envSchema = z
.object({
@ -38,6 +18,8 @@ const envSchema = z
MIGRATIONS_PATH: z.string().optional(),
APP_VERSION: z.string().default("dev"),
TRUSTED_ORIGINS: z.string().optional(),
PORTLESS_URL: z.string().optional(),
PORTLESS_TAILSCALE_URL: z.string().optional(),
TRUST_PROXY: z.string().default("false"),
DISABLE_RATE_LIMITING: z.string().default("false"),
APP_SECRET: z.preprocess((value) => (value === "" ? undefined : value), z.string().min(32).max(256).optional()),
@ -49,10 +31,24 @@ const envSchema = z
PROVISIONING_PATH: z.string().optional(),
})
.transform((s, ctx) => {
const baseUrl = unquote(s.BASE_URL);
const trustedOrigins = s.TRUSTED_ORIGINS?.split(",").map(unquote).filter(Boolean).concat(baseUrl) ?? [baseUrl];
let baseUrl = unquote(s.BASE_URL);
const trustedOrigins = s.TRUSTED_ORIGINS?.split(",").map(unquote).filter(Boolean) ?? [];
if (s.NODE_ENV === "development") {
if (s.PORTLESS_URL) {
trustedOrigins.push(unquote(s.PORTLESS_URL));
}
if (s.PORTLESS_TAILSCALE_URL) {
baseUrl = unquote(s.PORTLESS_TAILSCALE_URL);
trustedOrigins.push(baseUrl);
}
}
trustedOrigins.push(baseUrl);
const uniqueTrustedOrigins = Array.from(new Set(trustedOrigins));
const webhookAllowedOrigins = s.WEBHOOK_ALLOWED_ORIGINS?.split(",").map(unquote).filter(Boolean) ?? [];
const authOrigins = [baseUrl, ...trustedOrigins];
const authOrigins = [baseUrl, ...uniqueTrustedOrigins];
const { allowedHosts, invalidOrigins } = buildAllowedHosts(authOrigins);
let appSecret = s.APP_SECRET;
@ -124,11 +120,11 @@ const envSchema = z
serverIp: s.SERVER_IP,
serverIdleTimeout: s.SERVER_IDLE_TIMEOUT,
webhookTimeout: s.WEBHOOK_TIMEOUT,
resticHostname: s.RESTIC_HOSTNAME || getResticHostname(),
resticHostname: s.RESTIC_HOSTNAME || resolveResticHostname(),
port: s.PORT,
migrationsPath: s.MIGRATIONS_PATH,
appVersion: s.APP_VERSION,
trustedOrigins: trustedOrigins,
trustedOrigins: uniqueTrustedOrigins,
trustProxy: s.TRUST_PROXY === "true",
appSecret: appSecret ?? "",
baseUrl,

View file

@ -9,11 +9,10 @@ export const RESTIC_CACHE_DIR = process.env.RESTIC_CACHE_DIR || "/var/lib/zeroby
export const DATABASE_URL = process.env.ZEROBYTE_DATABASE_URL || "/var/lib/zerobyte/data/zerobyte.db";
export const RESTIC_PASS_FILE = process.env.RESTIC_PASS_FILE || "/var/lib/zerobyte/data/restic.pass";
export const SSH_KEYS_DIR = "/var/lib/zerobyte/ssh";
export const RCLONE_CONFIG_DIR = process.env.RCLONE_CONFIG_DIR || "/root/.config/rclone";
export const RCLONE_CONFIG_FILE = path.join(RCLONE_CONFIG_DIR, "rclone.conf");
export const RESTORE_BLOCKED_ROOTS = [REPOSITORY_BASE, RESTIC_CACHE_DIR, SSH_KEYS_DIR, RCLONE_CONFIG_DIR, "/app"];
export const RESTORE_BLOCKED_ROOTS = [REPOSITORY_BASE, RESTIC_CACHE_DIR, RCLONE_CONFIG_DIR, "/app"];
export const DEFAULT_EXCLUDES = [RESTIC_PASS_FILE, REPOSITORY_BASE];

View file

@ -1,4 +1,13 @@
import { and, eq, lte } from "drizzle-orm";
import { logger } from "@zerobyte/core/node";
import { db } from "../db/db";
import {
repositoryLocksTable,
repositoryLockWaitersTable,
type RepositoryLock,
type RepositoryLockWaiter,
} from "../db/schema";
import { Effect, Exit, Fiber, Schedule, Scope } from "effect";
type LockType = "shared" | "exclusive";
@ -8,420 +17,545 @@ interface LockRequest {
operation: string;
}
interface LockHolder {
interface AcquiredLock {
id: string;
repositoryId: string;
type: LockType;
operation: string;
acquiredAt: number;
}
interface RepositoryLockState {
sharedHolders: Map<string, LockHolder>;
exclusiveHolder: LockHolder | null;
waitQueue: Array<{
type: LockType;
operation: string;
resolve: (lockId: string) => void;
}>;
type RepositoryMutexTransaction = Parameters<Parameters<typeof db.transaction>[0]>[0];
type HeartbeatTarget = "lock" | "waiter";
type QueueAttempt = { status: "acquired"; lock: AcquiredLock } | { status: "waiting" } | { status: "missing" };
const LOCK_LEASE_MS = 30_000;
const LOCK_HEARTBEAT_MS = 5_000;
const LOCK_POLL_MS = 250;
const LOCK_POLL_CLEANUP_MS = 5_000;
const REPOSITORY_MUTEX_INSTANCE = Symbol.for("zerobyte.repositoryMutex.instance");
function getRepositoryMutex() {
const globalObject = globalThis as typeof globalThis & Record<symbol, RepositoryMutex | undefined>;
const mutex = globalObject[REPOSITORY_MUTEX_INSTANCE];
if (mutex) return mutex;
const newMutex = new RepositoryMutex();
globalObject[REPOSITORY_MUTEX_INSTANCE] = newMutex;
return newMutex;
}
class RepositoryMutex {
private locks = new Map<string, RepositoryLockState>();
private changeListeners = new Map<string, Set<() => void>>();
private lockIdCounter = 0;
private getOrCreateState(repositoryId: string): RepositoryLockState {
let state = this.locks.get(repositoryId);
if (!state) {
state = {
sharedHolders: new Map(),
exclusiveHolder: null,
waitQueue: [],
};
this.locks.set(repositoryId, state);
}
return state;
}
private ownerId = `owner_${Bun.randomUUIDv7()}`;
private nextPollCleanupAt = 0;
private generateLockId(): string {
return `lock_${++this.lockIdCounter}_${Date.now()}`;
return `lock_${Bun.randomUUIDv7()}`;
}
private cleanupStateIfEmpty(repositoryId: string): void {
const state = this.locks.get(repositoryId);
if (state && state.sharedHolders.size === 0 && !state.exclusiveHolder && state.waitQueue.length === 0) {
this.locks.delete(repositoryId);
}
private abortReason(signal: AbortSignal): Error {
return signal.reason || new Error("Operation aborted");
}
private notifyChange(repositoryId: string): void {
const listeners = this.changeListeners.get(repositoryId);
if (!listeners) {
return;
}
for (const listener of listeners) {
listener();
}
private cleanupExpired(tx: RepositoryMutexTransaction, now: number) {
tx.delete(repositoryLocksTable).where(lte(repositoryLocksTable.expiresAt, now)).run();
tx.delete(repositoryLockWaitersTable).where(lte(repositoryLockWaitersTable.expiresAt, now)).run();
}
private canAcquireImmediately(state: RepositoryLockState | undefined, type: LockType): boolean {
if (!state) {
return true;
}
private cleanupExpiredDuringPolling(tx: RepositoryMutexTransaction, now: number) {
if (now < this.nextPollCleanupAt) return;
this.cleanupExpired(tx, now);
this.nextPollCleanupAt = now + LOCK_POLL_CLEANUP_MS;
}
private getActiveLocks(tx: RepositoryMutexTransaction, repositoryId: string, now: number) {
return tx.query.repositoryLocksTable
.findMany({
where: { AND: [{ repositoryId: { eq: repositoryId } }, { expiresAt: { gt: now } }] },
orderBy: { acquiredAt: "asc", id: "asc" },
})
.sync();
}
private getWaiters(tx: RepositoryMutexTransaction, repositoryId: string, now: number) {
return tx.query.repositoryLockWaitersTable
.findMany({
where: { AND: [{ repositoryId: { eq: repositoryId } }, { expiresAt: { gt: now } }] },
orderBy: { requestedAt: "asc", id: "asc" },
})
.sync();
}
private getActiveLockById(tx: RepositoryMutexTransaction, lockId: string, now: number) {
return tx.query.repositoryLocksTable
.findFirst({ where: { AND: [{ id: { eq: lockId } }, { expiresAt: { gt: now } }] } })
.sync();
}
private getWaiterById(tx: RepositoryMutexTransaction, waiterId: string, now: number) {
return tx.query.repositoryLockWaitersTable
.findFirst({ where: { AND: [{ id: { eq: waiterId } }, { expiresAt: { gt: now } }] } })
.sync();
}
private canAcquireImmediately(type: LockType, activeLocks: RepositoryLock[], waiters: RepositoryLockWaiter[]) {
if (type === "shared") {
const hasExclusiveInQueue = state.waitQueue.some((item) => item.type === "exclusive");
return !state.exclusiveHolder && !hasExclusiveInQueue;
return (
!activeLocks.some((lock) => lock.type === "exclusive") &&
!waiters.some((waiter) => waiter.type === "exclusive")
);
}
return !state.exclusiveHolder && state.sharedHolders.size === 0 && state.waitQueue.length === 0;
return activeLocks.length === 0 && waiters.length === 0;
}
private waitForChange(repositoryIds: string[], signal?: AbortSignal) {
if (signal?.aborted) {
throw signal.reason || new Error("Operation aborted");
private insertLock(
tx: RepositoryMutexTransaction,
request: LockRequest & { id: string; ownerId: string },
now: number,
) {
const lock = {
id: request.id,
repositoryId: request.repositoryId,
type: request.type,
operation: request.operation,
ownerId: request.ownerId,
acquiredAt: now,
expiresAt: now + LOCK_LEASE_MS,
heartbeatAt: now,
};
tx.insert(repositoryLocksTable).values(lock).run();
return lock;
}
private tryAcquireManyRows(requests: LockRequest[]) {
const now = Date.now();
return db.transaction((tx) => {
this.cleanupExpired(tx, now);
for (const request of requests) {
const activeLocks = this.getActiveLocks(tx, request.repositoryId, now);
const waiters = this.getWaiters(tx, request.repositoryId, now);
if (!this.canAcquireImmediately(request.type, activeLocks, waiters)) {
return null;
}
}
return requests.map((request) =>
this.insertLock(tx, { ...request, id: this.generateLockId(), ownerId: this.ownerId }, now),
);
});
}
private tryAcquireImmediately(request: LockRequest) {
return Effect.gen(this, function* () {
const locks = this.tryAcquireManyRows([request]);
if (!locks || locks.length === 0) return null;
const [lock] = locks;
return yield* this.createRelease(lock);
});
}
private createWaiter(request: LockRequest, waiterId: string) {
return Effect.sync(() => {
const now = Date.now();
db.transaction((tx) => {
this.cleanupExpired(tx, now);
tx.insert(repositoryLockWaitersTable)
.values({
id: waiterId,
repositoryId: request.repositoryId,
type: request.type,
operation: request.operation,
ownerId: this.ownerId,
requestedAt: now,
expiresAt: now + LOCK_LEASE_MS,
heartbeatAt: now,
})
.run();
});
});
}
private deleteWaiter(waiterId: string) {
return Effect.sync(() =>
db
.delete(repositoryLockWaitersTable)
.where(
and(
eq(repositoryLockWaitersTable.id, waiterId),
eq(repositoryLockWaitersTable.ownerId, this.ownerId),
),
)
.run(),
);
}
private deleteWaiterRow(tx: RepositoryMutexTransaction, waiterId: string): void {
tx.delete(repositoryLockWaitersTable).where(eq(repositoryLockWaitersTable.id, waiterId)).run();
}
private promoteWaiter(tx: RepositoryMutexTransaction, waiter: RepositoryLockWaiter, now: number) {
this.deleteWaiterRow(tx, waiter.id);
return this.insertLock(tx, { ...waiter, id: waiter.id }, now);
}
private getLeadingSharedWaiters(waiters: RepositoryLockWaiter[]) {
const leadingSharedWaiters: RepositoryLockWaiter[] = [];
for (const waiter of waiters) {
if (waiter.type === "exclusive") break;
leadingSharedWaiters.push(waiter);
}
return new Promise<void>((resolve, reject) => {
const uniqueRepositoryIds = [...new Set(repositoryIds)];
const cleanupCallbacks: Array<() => void> = [];
let settled = false;
return leadingSharedWaiters;
}
const settle = (callback: () => void) => {
if (settled) {
return;
private tryPromoteWaiter(waiterId: string): QueueAttempt {
const now = Date.now();
return db.transaction((tx) => {
this.cleanupExpiredDuringPolling(tx, now);
const activeLock = this.getActiveLockById(tx, waiterId, now);
if (activeLock) {
return { status: "acquired", lock: activeLock };
}
const waiter = this.getWaiterById(tx, waiterId, now);
if (!waiter) {
return { status: "missing" };
}
const activeLocks = this.getActiveLocks(tx, waiter.repositoryId, now);
const waiters = this.getWaiters(tx, waiter.repositoryId, now);
if (waiter.type === "exclusive") {
if (activeLocks.length > 0 || waiters[0]?.id !== waiter.id) {
return { status: "waiting" };
}
return { status: "acquired", lock: this.promoteWaiter(tx, waiter, now) };
}
if (activeLocks.some((lock) => lock.type === "exclusive")) {
return { status: "waiting" };
}
const leadingSharedWaiters = this.getLeadingSharedWaiters(waiters);
if (!leadingSharedWaiters.some((queuedWaiter) => queuedWaiter.id === waiter.id)) {
return { status: "waiting" };
}
let acquiredLock: AcquiredLock | null = null;
for (const sharedWaiter of leadingSharedWaiters) {
const lock = this.promoteWaiter(tx, sharedWaiter, now);
if (sharedWaiter.id === waiter.id) {
acquiredLock = lock;
}
}
if (!acquiredLock) {
return { status: "waiting" };
}
return { status: "acquired", lock: acquiredLock };
});
}
private waitForQueuedLock(request: LockRequest) {
const waiterId = this.generateLockId();
const attempt = Effect.sync(() => this.tryPromoteWaiter(waiterId)).pipe(
Effect.flatMap((attempt) => {
if (attempt.status === "acquired") {
return Effect.succeed(attempt.lock);
}
if (attempt.status === "missing") {
return Effect.gen(this, function* () {
yield* this.createWaiter(request, waiterId);
yield* this.startHeartbeat("waiter", waiterId);
return yield* Effect.fail("retry");
});
}
return Effect.fail("retry");
}),
);
const cleanupAbandonedWaiter = Effect.gen(this, function* () {
yield* this.deleteWaiter(waiterId);
yield* this.release({ id: waiterId });
});
return Effect.scoped(
Effect.gen(this, function* () {
const lock = yield* attempt.pipe(
Effect.retry(Schedule.spaced(LOCK_POLL_MS)),
Effect.onExit((exit) => {
if (Exit.isSuccess(exit)) {
return Effect.void;
}
return cleanupAbandonedWaiter;
}),
);
return yield* this.createRelease(lock);
}),
);
}
isLocked(repositoryId: string) {
const now = Date.now();
return db.transaction((tx) => {
this.cleanupExpired(tx, now);
return this.getActiveLocks(tx, repositoryId, now).length > 0;
});
}
private createReleaseMany(locks: AcquiredLock[]) {
return Effect.gen(this, function* () {
const releases = yield* Effect.all(locks.map((lock) => this.createRelease(lock)));
let released = false;
return () => {
if (released) return;
released = true;
for (const release of releases.toReversed()) {
release();
}
};
});
}
private createRelease(lock: AcquiredLock) {
return Effect.gen(this, function* () {
const heartbeatFiber = yield* this.startHeartbeat("lock", lock.id);
let released = false;
return () => {
if (released) return;
released = true;
Effect.runFork(Fiber.interrupt(heartbeatFiber));
Effect.runSync(this.release(lock));
};
});
}
private release(lock: Pick<AcquiredLock, "id">) {
return Effect.gen(this, function* () {
const releasedLock = yield* Effect.sync(() =>
db.transaction((tx) => {
const row = tx.query.repositoryLocksTable
.findFirst({ where: { AND: [{ id: { eq: lock.id } }, { ownerId: { eq: this.ownerId } }] } })
.sync();
if (!row) return null;
tx.delete(repositoryLocksTable)
.where(
and(eq(repositoryLocksTable.id, lock.id), eq(repositoryLocksTable.ownerId, this.ownerId)),
)
.run();
return row;
}),
);
if (!releasedLock) return;
const duration = Date.now() - releasedLock.acquiredAt;
yield* logger.effect.debug(
`[Mutex] Released ${releasedLock.type} lock for repo ${releasedLock.repositoryId}: ${releasedLock.operation} (held for ${duration}ms)`,
);
});
}
private startHeartbeat(
target: "waiter",
lockId: string,
): Effect.Effect<Fiber.RuntimeFiber<void, never>, never, Scope.Scope>;
private startHeartbeat(
target: "lock",
lockId: string,
): Effect.Effect<Fiber.RuntimeFiber<void, never>, never, never>;
private startHeartbeat(
target: HeartbeatTarget,
lockId: string,
): Effect.Effect<Fiber.RuntimeFiber<unknown, never>, never, Scope.Scope> {
const heartbeat = Effect.gen(this, function* () {
const now = Date.now();
const values = { heartbeatAt: now, expiresAt: now + LOCK_LEASE_MS };
if (target === "lock") {
yield* Effect.try(() => {
db.update(repositoryLocksTable)
.set(values)
.where(and(eq(repositoryLocksTable.id, lockId), eq(repositoryLocksTable.ownerId, this.ownerId)))
.run();
});
} else {
yield* Effect.try(() => {
db.update(repositoryLockWaitersTable)
.set(values)
.where(
and(
eq(repositoryLockWaitersTable.id, lockId),
eq(repositoryLockWaitersTable.ownerId, this.ownerId),
),
)
.run();
});
}
}).pipe(
Effect.catchAll((error) =>
logger.effect.warn(`[Mutex] Failed to heartbeat ${target} ${lockId}: ${String(error)}`),
),
);
const repeat = heartbeat.pipe(Effect.repeat(Schedule.spaced(LOCK_HEARTBEAT_MS)));
if (target === "waiter") {
// For waiters, we can stop heartbeating when the releaser is dropped, so we use a scoped fiber
return repeat.pipe(Effect.forkScoped);
}
// For locks, the heartbeat must outlive the acquire scope.
// It is interrupted manually by the returned release function.
// TODO: max lifetime for lock heartbeats to prevent leaks if the releaser is never called?
return repeat.pipe(Effect.forkDaemon);
}
private acquireSharedEffect(repositoryId: string, operation: string) {
return Effect.gen(this, function* () {
const request: LockRequest = { repositoryId, type: "shared", operation };
const releaseLock = yield* this.tryAcquireImmediately(request);
if (releaseLock) return releaseLock;
yield* logger.effect.debug(`[Mutex] Waiting for shared lock on repo ${repositoryId}: ${operation}`);
return yield* this.waitForQueuedLock(request);
});
}
private acquireExclusiveEffect(repositoryId: string, operation: string) {
return Effect.gen(this, function* () {
const request: LockRequest = { repositoryId, type: "exclusive", operation };
const releaseLock = yield* this.tryAcquireImmediately(request);
if (releaseLock) {
yield* logger.effect.debug(`[Mutex] Acquired exclusive lock for repo ${repositoryId}: ${operation}`);
return releaseLock;
}
yield* logger.effect.debug(`[Mutex] Waiting for exclusive lock on repo ${repositoryId}: ${operation}`);
const queuedReleaseLock = yield* this.waitForQueuedLock(request);
yield* logger.effect.debug(`[Mutex] Acquired exclusive lock for repo ${repositoryId}: ${operation}`);
return queuedReleaseLock;
});
}
private acquireManyEffect(requests: LockRequest[]) {
return Effect.gen(this, function* () {
if (requests.length === 0) {
return () => {};
}
const seenRepositoryIds = new Set<string>();
for (const request of requests) {
if (seenRepositoryIds.has(request.repositoryId)) {
throw new Error(`Duplicate repository lock request: ${request.repositoryId}`);
}
seenRepositoryIds.add(request.repositoryId);
}
const sortedRequests = [...requests].sort((a, b) => a.repositoryId.localeCompare(b.repositoryId));
const locks = yield* Effect.sync(() => this.tryAcquireManyRows(sortedRequests)).pipe(
Effect.flatMap((locks) => {
if (locks) return Effect.succeed(locks);
return Effect.fail("retry");
}),
Effect.retry(Schedule.spaced(LOCK_POLL_MS)),
);
return yield* this.createReleaseMany(locks);
});
}
private runWithSignal<A, E>(effect: Effect.Effect<A, E>, signal?: AbortSignal) {
if (!signal) return Effect.runPromise(effect);
if (signal.aborted) {
return Promise.reject(this.abortReason(signal));
}
return new Promise<A>((resolve, reject) => {
const fiber = Effect.runFork(effect);
let settled = false;
let aborting = false;
const complete = (callback: () => void) => {
if (settled) return;
settled = true;
for (const cleanup of cleanupCallbacks) {
cleanup();
}
signal.removeEventListener("abort", onAbort);
callback();
};
for (const repositoryId of uniqueRepositoryIds) {
let listeners = this.changeListeners.get(repositoryId);
if (!listeners) {
listeners = new Set();
this.changeListeners.set(repositoryId, listeners);
}
const onAbort = () => {
aborting = true;
Effect.runPromise(Fiber.interrupt(fiber)).then(
(exit) =>
complete(() => {
if (Exit.isSuccess(exit)) {
resolve(exit.value);
return;
}
const listener = () => settle(resolve);
listeners.add(listener);
cleanupCallbacks.push(() => {
const currentListeners = this.changeListeners.get(repositoryId);
if (!currentListeners) {
return;
}
currentListeners.delete(listener);
if (currentListeners.size === 0) {
this.changeListeners.delete(repositoryId);
}
});
}
if (signal) {
const onAbort = () => {
settle(() => reject(signal.reason || new Error("Operation aborted")));
};
signal.addEventListener("abort", onAbort);
cleanupCallbacks.push(() => {
signal.removeEventListener("abort", onAbort);
});
}
});
}
private tryAcquireMany(requests: LockRequest[]) {
for (const request of requests) {
if (!this.canAcquireImmediately(this.locks.get(request.repositoryId), request.type)) {
return null;
}
}
const releases = requests.map((request) => {
const state = this.getOrCreateState(request.repositoryId);
const lockId = this.generateLockId();
if (request.type === "shared") {
state.sharedHolders.set(lockId, {
id: lockId,
operation: request.operation,
acquiredAt: Date.now(),
});
} else {
state.exclusiveHolder = {
id: lockId,
operation: request.operation,
acquiredAt: Date.now(),
};
}
return this.createRelease(request.type, request.repositoryId, lockId);
});
let released = false;
return () => {
if (released) {
return;
}
released = true;
for (const release of releases.toReversed()) {
release();
}
};
}
async acquireShared(repositoryId: string, operation: string, signal?: AbortSignal): Promise<() => void> {
if (signal?.aborted) {
throw signal.reason || new Error("Operation aborted");
}
const state = this.getOrCreateState(repositoryId);
const hasExclusiveInQueue = state.waitQueue.some((item) => item.type === "exclusive");
if (!state.exclusiveHolder && !hasExclusiveInQueue) {
const lockId = this.generateLockId();
state.sharedHolders.set(lockId, {
id: lockId,
operation,
acquiredAt: Date.now(),
});
return () => this.releaseShared(repositoryId, lockId);
}
logger.debug(
`[Mutex] Waiting for shared lock on repo ${repositoryId}: ${operation} (exclusive held by: ${state.exclusiveHolder?.operation ?? "none"}, queue: ${state.waitQueue.length})`,
);
let onAbort: () => void = () => {};
let lockId: string | undefined;
try {
lockId = await new Promise<string>((resolve, reject) => {
const waiter = { type: "shared" as const, operation, resolve };
state.waitQueue.push(waiter);
if (signal) {
onAbort = () => {
const index = state.waitQueue.indexOf(waiter);
if (index !== -1) {
state.waitQueue.splice(index, 1);
this.cleanupStateIfEmpty(repositoryId);
this.notifyChange(repositoryId);
reject(signal.reason || new Error("Operation aborted"));
}
};
signal.addEventListener("abort", onAbort);
}
});
} finally {
signal?.removeEventListener("abort", onAbort);
}
if (signal?.aborted) {
if (lockId) {
this.releaseShared(repositoryId, lockId);
}
throw signal.reason || new Error("Operation aborted");
}
return this.createRelease("shared", repositoryId, lockId!);
}
async acquireExclusive(repositoryId: string, operation: string, signal?: AbortSignal): Promise<() => void> {
if (signal?.aborted) {
throw signal.reason || new Error("Operation aborted");
}
const state = this.getOrCreateState(repositoryId);
if (!state.exclusiveHolder && state.sharedHolders.size === 0 && state.waitQueue.length === 0) {
const lockId = this.generateLockId();
state.exclusiveHolder = {
id: lockId,
operation,
acquiredAt: Date.now(),
reject(this.abortReason(signal));
}),
(error) => complete(() => reject(error)),
);
};
return () => this.releaseExclusive(repositoryId, lockId);
}
logger.debug(
`[Mutex] Waiting for exclusive lock on repo ${repositoryId}: ${operation} (shared: ${state.sharedHolders.size}, exclusive: ${state.exclusiveHolder ? "yes" : "no"}, queue: ${state.waitQueue.length})`,
);
signal.addEventListener("abort", onAbort, { once: true });
let onAbort: () => void = () => {};
let lockId: string | undefined;
try {
lockId = await new Promise<string>((resolve, reject) => {
const waiter = { type: "exclusive" as const, operation, resolve };
state.waitQueue.push(waiter);
Effect.runPromise(Fiber.join(fiber)).then(
(value) => complete(() => resolve(value)),
(error) => {
if (!aborting) {
complete(() => reject(error));
}
},
);
});
}
if (signal) {
onAbort = () => {
const index = state.waitQueue.indexOf(waiter);
if (index !== -1) {
state.waitQueue.splice(index, 1);
this.cleanupStateIfEmpty(repositoryId);
this.notifyChange(repositoryId);
reject(signal.reason || new Error("Operation aborted"));
}
};
signal.addEventListener("abort", onAbort);
}
});
} finally {
signal?.removeEventListener("abort", onAbort);
}
async acquireShared(repositoryId: string, operation: string, signal?: AbortSignal) {
return await this.runWithSignal(this.acquireSharedEffect(repositoryId, operation), signal);
}
if (signal?.aborted) {
if (lockId) {
this.releaseExclusive(repositoryId, lockId);
}
throw signal.reason || new Error("Operation aborted");
}
logger.debug(`[Mutex] Acquired exclusive lock for repo ${repositoryId}: ${operation} (${lockId})`);
return this.createRelease("exclusive", repositoryId, lockId!);
async acquireExclusive(repositoryId: string, operation: string, signal?: AbortSignal) {
return await this.runWithSignal(this.acquireExclusiveEffect(repositoryId, operation), signal);
}
async acquireMany(requests: LockRequest[], signal?: AbortSignal) {
if (signal?.aborted) {
throw signal.reason || new Error("Operation aborted");
}
if (requests.length === 0) {
return () => {};
}
const seenRepositoryIds = new Set<string>();
for (const request of requests) {
if (seenRepositoryIds.has(request.repositoryId)) {
throw new Error(`Duplicate repository lock request: ${request.repositoryId}`);
}
seenRepositoryIds.add(request.repositoryId);
}
const sortedRequests = [...requests].sort((a, b) => a.repositoryId.localeCompare(b.repositoryId));
while (true) {
const releaseLocks = this.tryAcquireMany(sortedRequests);
if (releaseLocks) {
return releaseLocks;
}
await this.waitForChange(
sortedRequests.map((request) => request.repositoryId),
signal,
);
if (signal?.aborted) {
throw signal.reason || new Error("Operation aborted");
}
}
}
private releaseShared(repositoryId: string, lockId: string): void {
const state = this.locks.get(repositoryId);
if (!state) {
return;
}
const holder = state.sharedHolders.get(lockId);
if (!holder) {
return;
}
state.sharedHolders.delete(lockId);
const duration = Date.now() - holder.acquiredAt;
logger.debug(`[Mutex] Released shared lock for repo ${repositoryId}: ${holder.operation} (held for ${duration}ms)`);
this.processWaitQueue(repositoryId);
this.cleanupStateIfEmpty(repositoryId);
this.notifyChange(repositoryId);
}
private releaseExclusive(repositoryId: string, lockId: string): void {
const state = this.locks.get(repositoryId);
if (!state) {
return;
}
if (!state.exclusiveHolder || state.exclusiveHolder.id !== lockId) {
return;
}
const duration = Date.now() - state.exclusiveHolder.acquiredAt;
logger.debug(
`[Mutex] Released exclusive lock for repo ${repositoryId}: ${state.exclusiveHolder.operation} (held for ${duration}ms)`,
);
state.exclusiveHolder = null;
this.processWaitQueue(repositoryId);
this.cleanupStateIfEmpty(repositoryId);
this.notifyChange(repositoryId);
}
private processWaitQueue(repositoryId: string): void {
const state = this.locks.get(repositoryId);
if (!state || state.waitQueue.length === 0) {
return;
}
if (state.exclusiveHolder) {
return;
}
const firstWaiter = state.waitQueue[0];
if (firstWaiter.type === "exclusive") {
if (state.sharedHolders.size === 0) {
state.waitQueue.shift();
const lockId = this.generateLockId();
state.exclusiveHolder = {
id: lockId,
operation: firstWaiter.operation,
acquiredAt: Date.now(),
};
firstWaiter.resolve(lockId);
}
} else {
while (state.waitQueue.length > 0 && state.waitQueue[0].type === "shared") {
const waiter = state.waitQueue.shift();
if (!waiter) break;
const lockId = this.generateLockId();
state.sharedHolders.set(lockId, {
id: lockId,
operation: waiter.operation,
acquiredAt: Date.now(),
});
waiter.resolve(lockId);
}
}
}
isLocked(repositoryId: string): boolean {
const state = this.locks.get(repositoryId);
if (!state) return false;
return state.exclusiveHolder !== null || state.sharedHolders.size > 0;
}
private createRelease(type: LockType, repositoryId: string, lockId: string) {
let released = false;
return () => {
if (released) return;
released = true;
if (type === "shared") {
this.releaseShared(repositoryId, lockId);
} else {
this.releaseExclusive(repositoryId, lockId);
}
};
return await this.runWithSignal(this.acquireManyEffect(requests), signal);
}
}
export const repoMutex = new RepositoryMutex();
export const repoMutex = getRepositoryMutex();

View file

@ -33,6 +33,7 @@ const runMigrations = async () => {
migrate(db, { migrationsFolder });
sqlite.run("PRAGMA foreign_keys = ON;");
sqlite.run("PRAGMA busy_timeout = 5000;");
};
export const runDbMigrations = () => {

View file

@ -76,6 +76,7 @@ export const relations = defineRelations(schema, (r) => ({
sessions: r.many.sessionsTable(),
members: r.many.member(),
twoFactors: r.many.twoFactor(),
passkeys: r.many.passkey(),
ssoProviders: r.many.ssoProvider(),
organizations: r.many.organization({
from: r.usersTable.id.through(r.member.userId),
@ -95,10 +96,17 @@ export const relations = defineRelations(schema, (r) => ({
to: r.usersTable.id,
}),
},
passkey: {
usersTable: r.one.usersTable({
from: r.passkey.userId,
to: r.usersTable.id,
}),
},
organization: {
users: r.many.usersTable({
alias: "usersTable_id_organization_id_via_member",
}),
agents: r.many.agentsTable(),
backupSchedules: r.many.backupSchedulesTable(),
notificationDestinations: r.many.notificationDestinationsTable(),
repositories: r.many.repositoriesTable(),
@ -119,6 +127,13 @@ export const relations = defineRelations(schema, (r) => ({
optional: false,
}),
},
agentsTable: {
organization: r.one.organization({
from: r.agentsTable.organizationId,
to: r.organization.id,
optional: true,
}),
},
volumesTable: {
backupSchedules: r.many.backupSchedulesTable(),
organization: r.one.organization({

View file

@ -10,9 +10,10 @@ import type {
ResticStatsDto,
} from "@zerobyte/core/restic";
import type { BackupWebhooks } from "@zerobyte/core/backup-hooks";
import type { BackendConfig, BackendStatus, BackendType } from "~/schemas/volumes";
import type { BackendConfig, BackendStatus, BackendType } from "@zerobyte/contracts/volumes";
import type { NotificationConfig, NotificationType } from "~/schemas/notifications";
import type { ShortId } from "~/server/utils/branded";
import { LOCAL_AGENT_ID } from "../modules/agents/constants";
/**
* Users Table
@ -198,6 +199,36 @@ export const ssoProvider = sqliteTable("sso_provider", {
.default(sql`(unixepoch() * 1000)`),
});
export type AgentKind = "local" | "remote";
export type AgentStatus = "offline" | "connecting" | "online" | "degraded";
export type AgentCapabilities = Record<string, unknown>;
export const agentsTable = sqliteTable(
"agents_table",
{
id: text("id").primaryKey(),
organizationId: text("organization_id").references(() => organization.id, { onDelete: "cascade" }),
name: text("name").notNull(),
kind: text("kind").$type<AgentKind>().notNull(),
status: text("status").$type<AgentStatus>().notNull().default("offline"),
capabilities: text("capabilities", { mode: "json" }).$type<AgentCapabilities>().notNull().default({}),
lastSeenAt: int("last_seen_at", { mode: "number" }),
lastReadyAt: int("last_ready_at", { mode: "number" }),
createdAt: int("created_at", { mode: "number" })
.notNull()
.default(sql`(unixepoch() * 1000)`),
updatedAt: int("updated_at", { mode: "number" })
.notNull()
.$onUpdate(() => Date.now())
.default(sql`(unixepoch() * 1000)`),
},
(table) => [
index("agents_table_organization_id_idx").on(table.organizationId),
index("agents_table_status_idx").on(table.status),
],
);
export type Agent = typeof agentsTable.$inferSelect;
/**
* Volumes Table
*/
@ -223,12 +254,14 @@ export const volumesTable = sqliteTable(
.default(sql`(unixepoch() * 1000)`),
config: text("config", { mode: "json" }).$type<BackendConfig>().notNull(),
autoRemount: int("auto_remount", { mode: "boolean" }).notNull().default(true),
agentId: text("agent_id").notNull().default(LOCAL_AGENT_ID),
organizationId: text("organization_id")
.notNull()
.references(() => organization.id, { onDelete: "cascade" }),
},
(table) => [
unique().on(table.name, table.organizationId),
index("volumes_table_agent_id_idx").on(table.agentId),
uniqueIndex("volumes_table_org_provisioning_id_uidx").on(table.organizationId, table.provisioningId),
],
);
@ -277,6 +310,90 @@ export const repositoriesTable = sqliteTable(
export type Repository = typeof repositoriesTable.$inferSelect;
export type RepositoryInsert = typeof repositoriesTable.$inferInsert;
export type RepositoryLockType = "shared" | "exclusive";
export const repositoryLocksTable = sqliteTable(
"repository_locks",
{
id: text("id").primaryKey(),
repositoryId: text("repository_id").notNull(),
type: text("type").$type<RepositoryLockType>().notNull(),
operation: text("operation").notNull(),
ownerId: text("owner_id").notNull(),
acquiredAt: int("acquired_at", { mode: "number" }).notNull(),
expiresAt: int("expires_at", { mode: "number" }).notNull(),
heartbeatAt: int("heartbeat_at", { mode: "number" }).notNull(),
},
(table) => [
index("repository_locks_repository_id_idx").on(table.repositoryId),
index("repository_locks_expires_at_idx").on(table.expiresAt),
index("repository_locks_owner_id_idx").on(table.ownerId),
],
);
export type RepositoryLock = typeof repositoryLocksTable.$inferSelect;
export const repositoryLockWaitersTable = sqliteTable(
"repository_lock_waiters",
{
id: text("id").primaryKey(),
repositoryId: text("repository_id").notNull(),
type: text("type").$type<RepositoryLockType>().notNull(),
operation: text("operation").notNull(),
ownerId: text("owner_id").notNull(),
requestedAt: int("requested_at", { mode: "number" }).notNull(),
expiresAt: int("expires_at", { mode: "number" }).notNull(),
heartbeatAt: int("heartbeat_at", { mode: "number" }).notNull(),
},
(table) => [
index("repository_lock_waiters_repository_id_idx").on(table.repositoryId),
index("repository_lock_waiters_expires_at_idx").on(table.expiresAt),
index("repository_lock_waiters_owner_id_idx").on(table.ownerId),
],
);
export type RepositoryLockWaiter = typeof repositoryLockWaitersTable.$inferSelect;
type TaskJson = Record<string, unknown>;
export const tasksTable = sqliteTable(
"tasks",
{
id: text("id").primaryKey(),
organizationId: text("organization_id")
.notNull()
.references(() => organization.id, { onDelete: "cascade" }),
kind: text("kind").notNull(),
status: text("status").notNull(),
resourceType: text("resource_type").notNull(),
resourceId: text("resource_id").notNull(),
targetAgentId: text("target_agent_id"),
input: text("input", { mode: "json" }).$type<TaskJson>().notNull(),
progress: text("progress", { mode: "json" }).$type<TaskJson | null>(),
result: text("result", { mode: "json" }).$type<TaskJson | null>(),
error: text("error"),
cancellationRequested: int("cancellation_requested", { mode: "boolean" }).notNull().default(false),
createdAt: int("created_at", { mode: "number" })
.notNull()
.default(sql`(unixepoch() * 1000)`),
startedAt: int("started_at", { mode: "number" }),
updatedAt: int("updated_at", { mode: "number" })
.notNull()
.default(sql`(unixepoch() * 1000)`),
finishedAt: int("finished_at", { mode: "number" }),
},
(table) => [
index("tasks_org_kind_resource_status_idx").on(
table.organizationId,
table.kind,
table.resourceType,
table.resourceId,
table.status,
),
index("tasks_org_status_updated_at_idx").on(table.organizationId, table.status, table.updatedAt),
],
);
export type Task = typeof tasksTable.$inferSelect;
export type TaskInsert = typeof tasksTable.$inferInsert;
/**
* Backup Schedules Table
*/
@ -430,6 +547,30 @@ export const twoFactor = sqliteTable(
userId: text("user_id")
.notNull()
.references(() => usersTable.id, { onDelete: "cascade" }),
verified: integer("verified", { mode: "boolean" }).notNull().default(true),
},
(table) => [index("twoFactor_secret_idx").on(table.secret), index("twoFactor_userId_idx").on(table.userId)],
);
export const passkey = sqliteTable(
"passkey",
{
id: text("id").primaryKey(),
name: text("name"),
publicKey: text("public_key").notNull(),
userId: text("user_id")
.notNull()
.references(() => usersTable.id, { onDelete: "cascade" }),
credentialID: text("credential_id").notNull(),
counter: integer("counter").notNull(),
deviceType: text("device_type").notNull(),
backedUp: integer("backed_up", { mode: "boolean" }).notNull(),
transports: text("transports"),
createdAt: int("created_at", { mode: "timestamp_ms" })
.notNull()
.default(sql`(unixepoch() * 1000)`),
aaguid: text("aaguid"),
},
(table) => [index("passkey_userId_idx").on(table.userId), index("passkey_credentialID_idx").on(table.credentialID)],
);
export type Passkey = typeof passkey.$inferSelect;

View file

@ -0,0 +1,18 @@
import { afterEach, expect, test, vi } from "vitest";
import { config } from "../../core/config";
import { CleanupDanglingMountsJob } from "../cleanup-dangling";
import * as mountinfo from "../../utils/mountinfo";
afterEach(() => {
config.flags.enableLocalAgent = true;
vi.restoreAllMocks();
});
test("skips controller-local mount inspection when local volume execution is agent-owned", async () => {
config.flags.enableLocalAgent = true;
const readMountInfo = vi.spyOn(mountinfo, "readMountInfo");
await new CleanupDanglingMountsJob().run();
expect(readMountInfo).not.toHaveBeenCalled();
});

View file

@ -5,14 +5,21 @@ import { volumeService } from "../modules/volumes/volume.service";
import { readMountInfo } from "../utils/mountinfo";
import { getVolumePath } from "../modules/volumes/helpers";
import { logger } from "@zerobyte/core/node";
import { executeUnmount } from "../modules/backends/utils/backend-utils";
import { executeUnmount } from "../../../apps/agent/src/volume-host/backends/utils";
import { toMessage } from "../utils/errors";
import { VOLUME_MOUNT_BASE } from "../core/constants";
import { db } from "../db/db";
import { withContext } from "../core/request-context";
import { config } from "../core/config";
import { LOCAL_AGENT_ID } from "../modules/agents/constants";
export class CleanupDanglingMountsJob extends Job {
async run() {
if (config.flags.enableLocalAgent) {
logger.debug("Skipping controller-local dangling mount cleanup because local volume execution is agent-owned.");
return { done: true, timestamp: new Date() };
}
const organizations = await db.query.organization.findMany({});
if (organizations.length === 0) {
logger.warn("No organizations found; skipping dangling mount cleanup to avoid false positives.");
@ -22,7 +29,7 @@ export class CleanupDanglingMountsJob extends Job {
const allVolumes = [];
for (const org of organizations) {
const volumes = await withContext({ organizationId: org.id }, async () => volumeService.listVolumes());
allVolumes.push(...volumes);
allVolumes.push(...volumes.filter((volume) => volume.agentId === LOCAL_AGENT_ID));
}
const allSystemMounts = await readMountInfo();

View file

@ -8,6 +8,7 @@ import {
import { APIError } from "better-auth/api";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { admin, twoFactor, username, organization, testUtils } from "better-auth/plugins";
import { passkey } from "@better-auth/passkey";
import { createAuthMiddleware } from "better-auth/api";
import { config } from "../core/config";
import { db } from "../db/db";
@ -27,6 +28,7 @@ export const auth = betterAuth({
baseURL: {
allowedHosts: config.allowedHosts,
protocol: "auto",
fallback: config.baseUrl,
},
trustedOrigins: config.trustedOrigins,
rateLimit: {
@ -169,6 +171,26 @@ export const auth = betterAuth({
amount: 5,
},
}),
passkey({
rpID: new URL(config.baseUrl).hostname,
rpName: "Zerobyte",
authenticatorSelection: {
userVerification: "required",
residentKey: "required",
},
authentication: {
afterVerification: async ({ verification }) => {
if (verification.authenticationInfo.userVerified) {
return;
}
throw new APIError("UNAUTHORIZED", {
message:
"Your passkey was accepted, but it did not confirm your identity with a PIN, biometrics, or screen lock. Please use a verified passkey or sign in with your password.",
});
},
},
}),
tanstackStartCookies(),
...(process.env.NODE_ENV === "test" ? [testUtils()] : []),
],

View file

@ -0,0 +1,6 @@
import { createServerFn } from "@tanstack/react-start";
import { hasActivePasskeyUser } from "~/server/modules/auth/helpers";
export const getLoginOptions = createServerFn({ method: "GET" }).handler(async () => ({
hasPasskeySignIn: await hasActivePasskeyUser(),
}));

View file

@ -1,9 +1,10 @@
import { afterEach, expect, test, vi } from "vitest";
import waitForExpect from "wait-for-expect";
import { fromAny, fromPartial } from "@total-typescript/shoehorn";
import { Effect } from "effect";
import { agentManager, type ProcessWithAgentRuntime } from "../agents-manager";
import type { AgentManagerRuntime } from "../controller/server";
import type { BackupRunPayload } from "@zerobyte/contracts/agent-protocol";
import type { BackupRunPayload, VolumeCommand, VolumeCommandResponsePayload } from "@zerobyte/contracts/agent-protocol";
const setAgentRuntime = (agentManagerRuntime: Partial<AgentManagerRuntime> | null) => {
(process as ProcessWithAgentRuntime).__zerobyteAgentRuntime = {
@ -20,8 +21,8 @@ afterEach(() => {
});
test("cancelBackup resolves a running backup when the cancel command cannot be delivered", async () => {
const sendBackup = vi.fn().mockResolvedValue(true);
const cancelBackup = vi.fn().mockResolvedValue(false);
const sendBackup = vi.fn(() => Effect.succeed(true));
const cancelBackup = vi.fn(() => Effect.succeed(false));
setAgentRuntime({ sendBackup, cancelBackup });
const resultPromise = agentManager.runBackup("local", {
@ -45,3 +46,32 @@ test("cancelBackup resolves a running backup when the cancel command cannot be d
scheduleId: "schedule-1",
});
});
test("runVolumeCommand sends the command to the selected agent", async () => {
const runVolumeCommand = vi.fn(() =>
Effect.succeed({
commandId: "command-1",
status: "success",
command: { name: "volume.mount", result: { status: "mounted" } },
} satisfies VolumeCommandResponsePayload),
);
setAgentRuntime({ runVolumeCommand });
const command = fromPartial<VolumeCommand>({ name: "volume.mount", volume: { agentId: "agent-1" } });
await expect(agentManager.runVolumeCommand("agent-1", command)).resolves.toEqual({
name: "volume.mount",
result: { status: "mounted" },
});
expect(runVolumeCommand).toHaveBeenCalledWith("agent-1", command);
});
test("runVolumeCommand fails when the selected agent is unavailable", async () => {
setAgentRuntime(null);
const command = fromPartial<VolumeCommand>({ name: "volume.mount", volume: { agentId: "agent-1" } });
await expect(agentManager.runVolumeCommand("agent-1", command)).rejects.toThrow(
"Volume agent agent-1 is not connected",
);
});

View file

@ -0,0 +1,407 @@
import { afterEach, expect, test, vi } from "vitest";
import { Effect } from "effect";
import { fromAny, fromPartial } from "@total-typescript/shoehorn";
import type { BackupRunPayload, RestoreRunPayload } from "@zerobyte/contracts/agent-protocol";
import type { AgentManagerEvent } from "../controller/server";
import type { ProcessWithAgentRuntime } from "../agents-manager";
const controllerMock = vi.hoisted(() => ({
onEvent: null as null | ((event: AgentManagerEvent) => void),
sendBackup: vi.fn(),
cancelBackup: vi.fn(),
sendRestore: vi.fn(),
cancelRestore: vi.fn(),
stop: vi.fn(),
}));
vi.mock("../controller/server", async () => {
const { Effect } = await import("effect");
return {
createAgentManagerRuntime: vi.fn((onEvent: (event: AgentManagerEvent) => void) => {
controllerMock.onEvent = onEvent;
return {
start: Effect.void,
stop: Effect.sync(controllerMock.stop),
sendBackup: controllerMock.sendBackup,
cancelBackup: controllerMock.cancelBackup,
sendRestore: controllerMock.sendRestore,
cancelRestore: controllerMock.cancelRestore,
};
}),
};
});
const processWithAgentRuntime = process as ProcessWithAgentRuntime;
const resetAgentRuntime = () => {
processWithAgentRuntime.__zerobyteAgentRuntime = {
agentManager: null,
localAgent: null,
isStoppingLocalAgent: false,
localAgentRestartTimeout: null,
activeBackupsByScheduleId: new Map(),
activeBackupScheduleIdsByJobId: new Map(),
activeRestoresByRestoreId: new Map(),
};
};
const backupPayload = fromPartial<BackupRunPayload>({
jobId: "job-1",
scheduleId: "schedule-1",
});
const restorePayload = fromPartial<RestoreRunPayload>({
restoreId: "restore-1",
});
afterEach(() => {
delete processWithAgentRuntime.__zerobyteAgentRuntime;
controllerMock.onEvent = null;
controllerMock.sendBackup.mockReset();
controllerMock.cancelBackup.mockReset();
controllerMock.sendRestore.mockReset();
controllerMock.cancelRestore.mockReset();
controllerMock.stop.mockReset();
vi.resetModules();
vi.restoreAllMocks();
});
test("backup progress is delivered to the running backup callback", async () => {
resetAgentRuntime();
controllerMock.sendBackup.mockImplementation(() => Effect.succeed(true));
const { agentManager, startAgentController, stopAgentController } = await import("../agents-manager");
const onProgress = vi.fn();
await startAgentController();
const resultPromise = agentManager.runBackup("local", {
scheduleId: 42,
payload: backupPayload,
signal: new AbortController().signal,
onProgress,
});
controllerMock.onEvent?.({
type: "backup.progress",
agentId: "local",
agentName: "Local Agent",
payload: fromAny({
jobId: "job-1",
scheduleId: "schedule-1",
progress: { percentDone: 0.5 },
}),
});
controllerMock.onEvent?.({
type: "backup.completed",
agentId: "local",
agentName: "Local Agent",
payload: { jobId: "job-1", scheduleId: "schedule-1", exitCode: 0, result: null },
});
await expect(resultPromise).resolves.toEqual({
status: "completed",
exitCode: 0,
result: null,
warningDetails: null,
});
expect(onProgress).toHaveBeenCalledWith({ percentDone: 0.5 });
await stopAgentController();
});
test("backup events from agents that do not own the active run are ignored", async () => {
resetAgentRuntime();
controllerMock.sendBackup.mockImplementation(() => Effect.succeed(true));
const { agentManager, startAgentController, stopAgentController } = await import("../agents-manager");
await startAgentController();
const resultPromise = agentManager.runBackup("local", {
scheduleId: 42,
payload: backupPayload,
signal: new AbortController().signal,
onProgress: vi.fn(),
});
controllerMock.onEvent?.({
type: "backup.completed",
agentId: "remote",
agentName: "Remote Agent",
payload: { jobId: "job-1", scheduleId: "schedule-1", exitCode: 0, result: null },
});
controllerMock.onEvent?.({
type: "backup.completed",
agentId: "local",
agentName: "Local Agent",
payload: { jobId: "job-1", scheduleId: "schedule-1", exitCode: 0, result: null },
});
await expect(resultPromise).resolves.toEqual({
status: "completed",
exitCode: 0,
result: null,
warningDetails: null,
});
await stopAgentController();
});
test("backup failed and cancelled events resolve the matching running backup", async () => {
resetAgentRuntime();
controllerMock.sendBackup.mockImplementation(() => Effect.succeed(true));
const { agentManager, startAgentController, stopAgentController } = await import("../agents-manager");
await startAgentController();
const failedPromise = agentManager.runBackup("local", {
scheduleId: 42,
payload: backupPayload,
signal: new AbortController().signal,
onProgress: vi.fn(),
});
controllerMock.onEvent?.({
type: "backup.failed",
agentId: "local",
agentName: "Local Agent",
payload: {
jobId: "job-1",
scheduleId: "schedule-1",
error: "failed",
errorDetails: "restic failed",
},
});
await expect(failedPromise).resolves.toEqual({ status: "failed", error: "restic failed" });
const cancelledPromise = agentManager.runBackup("local", {
scheduleId: 43,
payload: fromPartial<BackupRunPayload>({ jobId: "job-2", scheduleId: "schedule-2" }),
signal: new AbortController().signal,
onProgress: vi.fn(),
});
controllerMock.onEvent?.({
type: "backup.cancelled",
agentId: "local",
agentName: "Local Agent",
payload: { jobId: "job-2", scheduleId: "schedule-2", message: "cancelled remotely" },
});
await expect(cancelledPromise).resolves.toEqual({
status: "cancelled",
message: "cancelled remotely",
});
await stopAgentController();
});
test("agent disconnect cancels only backups owned by that agent", async () => {
resetAgentRuntime();
controllerMock.sendBackup.mockImplementation(() => Effect.succeed(true));
const { agentManager, startAgentController, stopAgentController } = await import("../agents-manager");
await startAgentController();
const localPromise = agentManager.runBackup("local", {
scheduleId: 42,
payload: backupPayload,
signal: new AbortController().signal,
onProgress: vi.fn(),
});
const remotePromise = agentManager.runBackup("remote", {
scheduleId: 43,
payload: fromPartial<BackupRunPayload>({ jobId: "job-2", scheduleId: "schedule-2" }),
signal: new AbortController().signal,
onProgress: vi.fn(),
});
controllerMock.onEvent?.({
type: "agent.disconnected",
agentId: "local",
agentName: "Local Agent",
});
controllerMock.onEvent?.({
type: "backup.completed",
agentId: "remote",
agentName: "Remote Agent",
payload: { jobId: "job-2", scheduleId: "schedule-2", exitCode: 0, result: null },
});
await expect(localPromise).resolves.toEqual({
status: "cancelled",
message: "The connection to the backup agent was lost. Restart the backup to ensure it completes.",
});
await expect(remotePromise).resolves.toEqual({
status: "completed",
exitCode: 0,
result: null,
warningDetails: null,
});
await stopAgentController();
});
test("runBackup returns unavailable and clears the active run when the command cannot be sent", async () => {
resetAgentRuntime();
controllerMock.sendBackup.mockImplementation(() => Effect.succeed(false));
const { agentManager, startAgentController, stopAgentController } = await import("../agents-manager");
await startAgentController();
const result = await agentManager.runBackup("local", {
scheduleId: 42,
payload: backupPayload,
signal: new AbortController().signal,
onProgress: vi.fn(),
});
expect(result).toEqual({
status: "unavailable",
error: new Error("Failed to send backup command to agent local"),
});
await expect(agentManager.cancelBackup("local", 42)).resolves.toBe(false);
await stopAgentController();
});
test("runBackup rejects before sending when the abort signal is already aborted", async () => {
resetAgentRuntime();
controllerMock.sendBackup.mockImplementation(() => Effect.succeed(true));
const { agentManager, startAgentController, stopAgentController } = await import("../agents-manager");
const abortController = new AbortController();
abortController.abort(new Error("cancelled before send"));
await startAgentController();
await expect(
agentManager.runBackup("local", {
scheduleId: 42,
payload: backupPayload,
signal: abortController.signal,
onProgress: vi.fn(),
}),
).rejects.toThrow("cancelled before send");
expect(controllerMock.sendBackup).not.toHaveBeenCalled();
await stopAgentController();
});
test("runBackup requests cancellation when the abort signal fires while sending", async () => {
resetAgentRuntime();
const abortController = new AbortController();
controllerMock.sendBackup.mockImplementation(() =>
Effect.sync(() => {
abortController.abort();
return true;
}),
);
controllerMock.cancelBackup.mockImplementation(() => Effect.succeed(false));
const { agentManager, startAgentController, stopAgentController } = await import("../agents-manager");
await startAgentController();
const result = await agentManager.runBackup("local", {
scheduleId: 42,
payload: backupPayload,
signal: abortController.signal,
onProgress: vi.fn(),
});
expect(result).toEqual({ status: "cancelled" });
expect(controllerMock.cancelBackup).toHaveBeenCalledWith("local", {
jobId: "job-1",
scheduleId: "schedule-1",
});
await stopAgentController();
});
test("restore events are delivered to the running restore callbacks", async () => {
resetAgentRuntime();
controllerMock.sendRestore.mockImplementation(() => Effect.succeed(true));
const { agentManager, startAgentController, stopAgentController } = await import("../agents-manager");
const onStarted = vi.fn();
const onProgress = vi.fn();
await startAgentController();
const started = await agentManager.startRestore("local", {
payload: restorePayload,
signal: new AbortController().signal,
onStarted,
onProgress,
});
if (started.status !== "started") {
throw new Error("Expected restore to start");
}
controllerMock.onEvent?.({
type: "restore.started",
agentId: "local",
agentName: "Local Agent",
payload: {
restoreId: "restore-1",
organizationId: "org-1",
repositoryId: "repo-1",
snapshotId: "snapshot-1",
},
});
controllerMock.onEvent?.({
type: "restore.progress",
agentId: "local",
agentName: "Local Agent",
payload: fromAny({ restoreId: "restore-1", progress: { percent_done: 0.5 } }),
});
controllerMock.onEvent?.({
type: "restore.completed",
agentId: "local",
agentName: "Local Agent",
payload: {
restoreId: "restore-1",
organizationId: "org-1",
repositoryId: "repo-1",
snapshotId: "snapshot-1",
result: { message_type: "summary", files_restored: 2, files_skipped: 1 },
},
});
await expect(started.result).resolves.toEqual({
status: "completed",
result: { message_type: "summary", files_restored: 2, files_skipped: 1 },
});
expect(onStarted).toHaveBeenCalledOnce();
expect(onProgress).toHaveBeenCalledWith({ percent_done: 0.5 });
await stopAgentController();
});
test("agent disconnect cancels only restores owned by that agent", async () => {
resetAgentRuntime();
controllerMock.sendRestore.mockImplementation(() => Effect.succeed(true));
const { agentManager, startAgentController, stopAgentController } = await import("../agents-manager");
await startAgentController();
const localStarted = await agentManager.startRestore("local", {
payload: restorePayload,
signal: new AbortController().signal,
onStarted: vi.fn(),
onProgress: vi.fn(),
});
const remoteStarted = await agentManager.startRestore("remote", {
payload: fromPartial<RestoreRunPayload>({ restoreId: "restore-2" }),
signal: new AbortController().signal,
onStarted: vi.fn(),
onProgress: vi.fn(),
});
if (localStarted.status !== "started" || remoteStarted.status !== "started") {
throw new Error("Expected restores to start");
}
controllerMock.onEvent?.({
type: "agent.disconnected",
agentId: "local",
agentName: "Local Agent",
});
controllerMock.onEvent?.({
type: "restore.completed",
agentId: "remote",
agentName: "Remote Agent",
payload: {
restoreId: "restore-2",
organizationId: "org-1",
repositoryId: "repo-1",
snapshotId: "snapshot-1",
result: { message_type: "summary", files_restored: 1, files_skipped: 0 },
},
});
await expect(localStarted.result).resolves.toEqual({
status: "cancelled",
message: "The connection to the restore agent was lost. Restart the restore to ensure it completes.",
});
await expect(remoteStarted.result).resolves.toEqual({
status: "completed",
result: { message_type: "summary", files_restored: 1, files_skipped: 0 },
});
await stopAgentController();
});

View file

@ -1,6 +1,8 @@
import { EventEmitter } from "node:events";
import { PassThrough } from "node:stream";
import { Effect } from "effect";
import { afterEach, beforeEach, expect, test, vi } from "vitest";
import { fromAny } from "@total-typescript/shoehorn";
import type { ProcessWithAgentRuntime } from "../helpers/runtime-state.dev";
const spawnMock = vi.fn();
@ -10,13 +12,21 @@ vi.mock("node:child_process", async () => {
});
let startLocalAgent: (typeof import("../agents-manager"))["startLocalAgent"];
let startAgentController: (typeof import("../agents-manager"))["startAgentController"];
let stopLocalAgent: (typeof import("../agents-manager"))["stopLocalAgent"];
let stopAgentController: (typeof import("../agents-manager"))["stopAgentController"];
let config: (typeof import("~/server/core/config"))["config"];
let originalEnableLocalAgent: boolean;
const processWithAgentRuntime = process as ProcessWithAgentRuntime;
const setAgentRuntime = () => {
processWithAgentRuntime.__zerobyteAgentRuntime = {
agentManager: null,
agentManager: fromAny({
stop: Effect.void,
getControllerUrl: vi.fn(() => "ws://127.0.0.1:4567"),
waitForAgentReady: vi.fn(async () => true),
}),
localAgent: null,
isStoppingLocalAgent: false,
localAgentRestartTimeout: null,
@ -49,12 +59,18 @@ const createFakeChild = () => {
beforeEach(async () => {
vi.resetModules();
({ config } = await import("~/server/core/config"));
originalEnableLocalAgent = config.flags.enableLocalAgent;
config.flags.enableLocalAgent = true;
setAgentRuntime();
({ startLocalAgent, stopLocalAgent } = await import("../agents-manager"));
({ startAgentController, startLocalAgent, stopAgentController, stopLocalAgent } =
await import("../agents-manager"));
});
afterEach(async () => {
await stopLocalAgent();
await stopAgentController();
config.flags.enableLocalAgent = originalEnableLocalAgent;
delete processWithAgentRuntime.__zerobyteAgentRuntime;
spawnMock.mockReset();
vi.restoreAllMocks();
@ -76,6 +92,13 @@ test("respawns the local agent after an unexpected exit", async () => {
await vi.advanceTimersByTimeAsync(1_000);
expect(spawnMock).toHaveBeenCalledTimes(2);
expect(spawnMock).toHaveBeenLastCalledWith(
"bun",
expect.any(Array),
expect.objectContaining({
env: expect.objectContaining({ ZEROBYTE_CONTROLLER_URL: "ws://127.0.0.1:4567" }),
}),
);
});
test("does not respawn the local agent after an intentional stop", async () => {
@ -92,3 +115,13 @@ test("does not respawn the local agent after an intentional stop", async () => {
expect(spawnMock).toHaveBeenCalledTimes(1);
expect(child.kill).toHaveBeenCalledTimes(1);
});
test("does not start the websocket server when the local agent flag is disabled", async () => {
config.flags.enableLocalAgent = false;
const serve = vi.spyOn(Bun, "serve");
await startAgentController();
expect(serve).not.toHaveBeenCalled();
expect(processWithAgentRuntime.__zerobyteAgentRuntime?.agentManager).toBeNull();
});

View file

@ -0,0 +1,72 @@
import { beforeEach, expect, test } from "vitest";
import { db } from "~/server/db/db";
import { agentsTable } from "~/server/db/schema";
import { LOCAL_AGENT_ID, LOCAL_AGENT_KIND, LOCAL_AGENT_NAME } from "../constants";
import { agentsService } from "../agents.service";
beforeEach(async () => {
await db.delete(agentsTable);
});
test("ensureLocalAgent seeds the built-in local agent once", async () => {
await agentsService.ensureLocalAgent();
await agentsService.ensureLocalAgent();
const agents = await agentsService.listAgents();
expect(agents).toHaveLength(1);
});
test("markAgentConnecting creates and updates connection metadata", async () => {
await agentsService.markAgentConnecting({
agentId: "remote-agent",
organizationId: null,
agentName: "Remote Agent",
agentKind: "remote",
capabilities: { restic: true },
connectedAt: 1_000,
});
await agentsService.markAgentConnecting({
agentId: "remote-agent",
organizationId: null,
agentName: "Renamed Agent",
agentKind: "remote",
capabilities: { restic: true, webdav: true },
connectedAt: 2_000,
});
const agent = await agentsService.getAgent("remote-agent");
expect(agent).toMatchObject({
id: "remote-agent",
name: "Renamed Agent",
kind: "remote",
status: "connecting",
capabilities: { restic: true, webdav: true },
lastSeenAt: 2_000,
updatedAt: 2_000,
});
});
test("agent runtime status moves from connecting to online, seen, and offline", async () => {
await agentsService.markAgentConnecting({
agentId: LOCAL_AGENT_ID,
organizationId: null,
agentName: LOCAL_AGENT_NAME,
agentKind: LOCAL_AGENT_KIND,
connectedAt: 1_000,
});
await agentsService.markAgentOnline(LOCAL_AGENT_ID, 2_000);
await agentsService.markAgentSeen(LOCAL_AGENT_ID, 3_000);
await agentsService.markAgentOffline(LOCAL_AGENT_ID, 4_000);
const agent = await agentsService.getAgent(LOCAL_AGENT_ID);
expect(agent).toMatchObject({
id: LOCAL_AGENT_ID,
status: "offline",
lastSeenAt: 3_000,
lastReadyAt: 2_000,
updatedAt: 4_000,
});
});

View file

@ -0,0 +1,363 @@
import { Effect } from "effect";
import { afterEach, expect, test, vi } from "vitest";
import waitForExpect from "wait-for-expect";
import { fromPartial } from "@total-typescript/shoehorn";
import { createAgentMessage } from "@zerobyte/contracts/agent-protocol";
import type { Volume } from "@zerobyte/contracts/volumes";
import { LOCAL_AGENT_ID, LOCAL_AGENT_KIND, LOCAL_AGENT_NAME } from "../constants";
const agentsServiceMocks = vi.hoisted(() => ({
markAgentConnecting: vi.fn(() => Promise.resolve()),
markAgentOnline: vi.fn(() => Promise.resolve()),
markAgentSeen: vi.fn(() => Promise.resolve()),
markAgentOffline: vi.fn(() => Promise.resolve()),
}));
const tokenMocks = vi.hoisted(() => ({
validateAgentToken: vi.fn(),
}));
vi.mock("../agents.service", () => ({
agentsService: agentsServiceMocks,
}));
vi.mock("../helpers/tokens", () => ({
validateAgentToken: tokenMocks.validateAgentToken,
}));
const createSocket = (id: string, agentId = LOCAL_AGENT_ID) => ({
data: {
id,
agentId,
organizationId: null,
agentName: agentId === LOCAL_AGENT_ID ? LOCAL_AGENT_NAME : `${LOCAL_AGENT_NAME} ${agentId}`,
agentKind: LOCAL_AGENT_KIND,
},
send: vi.fn(() => 1),
close: vi.fn(),
});
const backupVolume = {
id: 1,
shortId: "volume-1",
name: "Volume 1",
config: { backend: "directory", path: "/tmp" },
createdAt: 0,
updatedAt: 0,
lastHealthCheck: 0,
type: "directory",
status: "mounted" as const,
lastError: null,
autoRemount: true,
agentId: LOCAL_AGENT_ID,
organizationId: "org-1",
} satisfies Volume;
const readyPayload = {
agentId: LOCAL_AGENT_ID,
protocolVersion: 1,
hostname: "host",
platform: "linux",
capabilities: { backup: true },
};
const backupPayload = {
jobId: "job-1",
scheduleId: "schedule-1",
organizationId: "org-1",
volume: backupVolume,
repositoryConfig: { backend: "local" as const, path: "/tmp/repository" },
options: {
oneFileSystem: false,
excludePatterns: null,
excludeIfPresent: null,
includePaths: null,
includePatterns: null,
customResticParams: null,
compressionMode: "auto" as const,
},
runtime: { password: "password" },
webhooks: { pre: null, post: null },
webhookAllowedOrigins: [],
webhookTimeoutMs: 60_000,
};
type CapturedFetch = NonNullable<Parameters<typeof Bun.serve>[0]["fetch"]>;
const invokeFetch = (fetch: CapturedFetch | undefined, request: Request, srv: Parameters<CapturedFetch>[1]) => {
if (!fetch) {
throw new Error("Bun.serve was not called with a fetch handler");
}
return Reflect.apply(fetch, fromPartial<ThisParameterType<CapturedFetch>>({}), [
request,
srv,
]) as ReturnType<CapturedFetch>;
};
const startRuntime = async (onEvent = vi.fn()) => {
const { createAgentManagerRuntime } = await import("../controller/server");
const runtime = createAgentManagerRuntime(onEvent);
await Effect.runPromise(runtime.start);
return { runtime, onEvent };
};
afterEach(() => {
vi.restoreAllMocks();
tokenMocks.validateAgentToken.mockReset();
vi.resetModules();
});
test("websocket fetch rejects requests without a bearer token", async () => {
const serve = vi
.spyOn(Bun, "serve")
.mockReturnValue(fromPartial({ port: 4567, stop: vi.fn(() => Promise.resolve()) }));
const { runtime } = await startRuntime();
const fetch = serve.mock.calls[0]?.[0].fetch;
const upgrade = vi.fn();
const srv = fromPartial<Parameters<NonNullable<typeof fetch>>[1]>({ upgrade });
const response = await invokeFetch(fetch, new Request("http://localhost:3001/agent"), srv);
await Effect.runPromise(runtime.stop);
expect(runtime.getControllerUrl()).toBeNull();
expect(response?.status).toBe(401);
expect(await response?.text()).toBe("Missing token");
expect(upgrade).not.toHaveBeenCalled();
});
test("websocket fetch rejects invalid bearer tokens", async () => {
tokenMocks.validateAgentToken.mockResolvedValue(undefined);
const serve = vi
.spyOn(Bun, "serve")
.mockReturnValue(fromPartial({ port: 4567, stop: vi.fn(() => Promise.resolve()) }));
const { runtime } = await startRuntime();
expect(runtime.getControllerUrl()).toBe("ws://127.0.0.1:4567");
const fetch = serve.mock.calls[0]?.[0].fetch;
const upgrade = vi.fn();
const srv = fromPartial<Parameters<NonNullable<typeof fetch>>[1]>({ upgrade });
const response = await invokeFetch(
fetch,
new Request("http://localhost:3001/agent", { headers: { authorization: "Bearer bad-token" } }),
srv,
);
await Effect.runPromise(runtime.stop);
expect(response?.status).toBe(401);
expect(await response?.text()).toBe("Invalid or revoked token");
expect(tokenMocks.validateAgentToken).toHaveBeenCalledWith("bad-token");
expect(upgrade).not.toHaveBeenCalled();
});
test("websocket fetch upgrades valid agent tokens with connection metadata", async () => {
tokenMocks.validateAgentToken.mockResolvedValue({
agentId: LOCAL_AGENT_ID,
organizationId: null,
agentName: LOCAL_AGENT_NAME,
agentKind: LOCAL_AGENT_KIND,
});
const serve = vi
.spyOn(Bun, "serve")
.mockReturnValue(fromPartial({ port: 3001, stop: vi.fn(() => Promise.resolve()) }));
const { runtime } = await startRuntime();
const fetch = serve.mock.calls[0]?.[0].fetch;
const upgrade = vi.fn(() => true);
const srv = fromPartial<Parameters<NonNullable<typeof fetch>>[1]>({ upgrade });
const response = await invokeFetch(
fetch,
new Request("http://localhost:3001/agent", { headers: { authorization: "Bearer valid-token" } }),
srv,
);
await Effect.runPromise(runtime.stop);
expect(response).toBeUndefined();
expect(tokenMocks.validateAgentToken).toHaveBeenCalledWith("valid-token");
expect(upgrade).toHaveBeenCalledWith(expect.any(Request), {
data: expect.objectContaining({
agentId: LOCAL_AGENT_ID,
organizationId: null,
agentName: LOCAL_AGENT_NAME,
agentKind: LOCAL_AGENT_KIND,
id: expect.any(String),
}),
});
});
test("websocket lifecycle updates agent connection status", async () => {
const stop = vi.fn(() => Promise.resolve());
const serve = vi.spyOn(Bun, "serve").mockReturnValue(fromPartial({ port: 3001, stop }));
const { runtime } = await startRuntime();
const websocket = serve.mock.calls[0]?.[0].websocket;
const socket = createSocket("connection-1");
await websocket?.open?.(fromPartial(socket));
await websocket?.message?.(fromPartial(socket), createAgentMessage("agent.ready", readyPayload));
await websocket?.message?.(fromPartial(socket), createAgentMessage("heartbeat.pong", { sentAt: 123 }));
await websocket?.close?.(fromPartial(socket), 1000, "done");
await Effect.runPromise(runtime.stop);
expect(agentsServiceMocks.markAgentConnecting).toHaveBeenCalledWith({
agentId: LOCAL_AGENT_ID,
organizationId: null,
agentName: LOCAL_AGENT_NAME,
agentKind: LOCAL_AGENT_KIND,
});
expect(agentsServiceMocks.markAgentOnline).toHaveBeenCalledWith(LOCAL_AGENT_ID, expect.any(Number), {
backup: true,
protocolVersion: 1,
protocolCompatible: true,
hostname: "host",
platform: "linux",
});
expect(agentsServiceMocks.markAgentSeen).toHaveBeenCalledWith(LOCAL_AGENT_ID, expect.any(Number));
expect(agentsServiceMocks.markAgentOffline).toHaveBeenCalledWith(LOCAL_AGENT_ID);
expect(stop).toHaveBeenCalledWith(true);
});
test("websocket protocol rejection forwards the event and closes the connection", async () => {
const serve = vi
.spyOn(Bun, "serve")
.mockReturnValue(fromPartial({ port: 3001, stop: vi.fn(() => Promise.resolve()) }));
const { runtime, onEvent } = await startRuntime(vi.fn());
const websocket = serve.mock.calls[0]?.[0].websocket;
const socket = createSocket("connection-1");
await websocket?.open?.(fromPartial(socket));
await websocket?.message?.(
fromPartial(socket),
JSON.stringify({
type: "agent.ready",
payload: {
protocolVersion: 2,
hostname: "host",
platform: "linux",
},
}),
);
await Effect.runPromise(runtime.stop);
expect(onEvent).toHaveBeenCalledWith(
expect.objectContaining({
type: "agent.protocolRejected",
agentId: LOCAL_AGENT_ID,
agentName: LOCAL_AGENT_NAME,
payload: expect.objectContaining({ reason: "agent_too_new" }),
}),
);
expect(agentsServiceMocks.markAgentOffline).toHaveBeenCalledWith(LOCAL_AGENT_ID);
expect(socket.close).toHaveBeenCalledWith(1002, "agent_too_new");
});
test("websocket restore events are forwarded with agent metadata", async () => {
const serve = vi
.spyOn(Bun, "serve")
.mockReturnValue(fromPartial({ port: 3001, stop: vi.fn(() => Promise.resolve()) }));
const { runtime, onEvent } = await startRuntime(vi.fn());
const websocket = serve.mock.calls[0]?.[0].websocket;
const socket = createSocket("connection-1");
await websocket?.open?.(fromPartial(socket));
await websocket?.message?.(fromPartial(socket), createAgentMessage("agent.ready", readyPayload));
onEvent.mockClear();
await websocket?.message?.(
fromPartial(socket),
createAgentMessage("restore.completed", {
restoreId: "restore-1",
organizationId: "org-1",
repositoryId: "repo-1",
snapshotId: "snapshot-1",
result: { message_type: "summary", files_restored: 2, files_skipped: 0 },
}),
);
await Effect.runPromise(runtime.stop);
expect(onEvent).toHaveBeenCalledWith(
expect.objectContaining({
type: "restore.completed",
agentId: LOCAL_AGENT_ID,
agentName: LOCAL_AGENT_NAME,
payload: expect.objectContaining({ restoreId: "restore-1" }),
}),
);
});
test("websocket open failure closes the upgraded socket", async () => {
agentsServiceMocks.markAgentConnecting.mockRejectedValueOnce(new Error("db unavailable"));
const serve = vi
.spyOn(Bun, "serve")
.mockReturnValue(fromPartial({ port: 3001, stop: vi.fn(() => Promise.resolve()) }));
const { runtime } = await startRuntime();
const websocket = serve.mock.calls[0]?.[0].websocket;
const socket = createSocket("connection-1");
await websocket?.open?.(fromPartial(socket));
expect(socket.close).toHaveBeenCalled();
await Effect.runPromise(runtime.stop);
});
test("shutdown closes all sessions and stops the server when marking one agent offline fails", async () => {
agentsServiceMocks.markAgentOffline.mockRejectedValueOnce(new Error("db unavailable"));
const stop = vi.fn(() => Promise.resolve());
const serve = vi.spyOn(Bun, "serve").mockReturnValue(fromPartial({ port: 3001, stop }));
const { runtime, onEvent } = await startRuntime(vi.fn());
const websocket = serve.mock.calls[0]?.[0].websocket;
const firstSocket = createSocket("connection-1", "agent-1");
const secondSocket = createSocket("connection-2", "agent-2");
await websocket?.open?.(fromPartial(firstSocket));
await websocket?.open?.(fromPartial(secondSocket));
await Effect.runPromise(runtime.stop);
expect(agentsServiceMocks.markAgentOffline).toHaveBeenCalledWith("agent-1");
expect(agentsServiceMocks.markAgentOffline).toHaveBeenCalledWith("agent-2");
expect(onEvent).toHaveBeenCalledWith(expect.objectContaining({ type: "agent.disconnected", agentId: "agent-1" }));
expect(onEvent).toHaveBeenCalledWith(expect.objectContaining({ type: "agent.disconnected", agentId: "agent-2" }));
expect(stop).toHaveBeenCalledWith(true);
});
test("closing a replaced connection reports disconnect without marking the active agent offline", async () => {
const serve = vi
.spyOn(Bun, "serve")
.mockReturnValue(fromPartial({ port: 3001, stop: vi.fn(() => Promise.resolve()) }));
const { runtime, onEvent } = await startRuntime(vi.fn());
const websocket = serve.mock.calls[0]?.[0].websocket;
const oldSocket = createSocket("connection-1");
const newSocket = createSocket("connection-2");
const offlineCallsBeforeClose = agentsServiceMocks.markAgentOffline.mock.calls.length;
await websocket?.open?.(fromPartial(oldSocket));
await websocket?.open?.(fromPartial(newSocket));
await websocket?.message?.(fromPartial(newSocket), createAgentMessage("agent.ready", readyPayload));
await websocket?.close?.(fromPartial(oldSocket), 1000, "replaced");
expect(onEvent).toHaveBeenCalledWith(
expect.objectContaining({ type: "agent.disconnected", agentId: LOCAL_AGENT_ID }),
);
expect(agentsServiceMocks.markAgentOffline).toHaveBeenCalledTimes(offlineCallsBeforeClose);
expect(await Effect.runPromise(runtime.sendBackup(LOCAL_AGENT_ID, backupPayload))).toBe(true);
await Effect.runPromise(runtime.stop);
});
test("sendBackup is only delivered after the agent is ready", async () => {
const serve = vi
.spyOn(Bun, "serve")
.mockReturnValue(fromPartial({ port: 3001, stop: vi.fn(() => Promise.resolve()) }));
const { runtime } = await startRuntime();
const websocket = serve.mock.calls[0]?.[0].websocket;
const socket = createSocket("connection-1");
const payload = backupPayload;
await websocket?.open?.(fromPartial(socket));
await expect(Effect.runPromise(runtime.sendBackup(LOCAL_AGENT_ID, payload))).resolves.toBe(false);
await websocket?.message?.(fromPartial(socket), createAgentMessage("agent.ready", readyPayload));
await expect(Effect.runPromise(runtime.sendBackup(LOCAL_AGENT_ID, payload))).resolves.toBe(true);
await waitForExpect(() => {
expect(socket.send).toHaveBeenCalledWith(expect.stringContaining('"type":"backup.run"'));
});
await Effect.runPromise(runtime.stop);
});

View file

@ -1,29 +1,46 @@
import { Effect, Exit, Scope } from "effect";
import { Effect, Exit, Fiber, Scope } from "effect";
import { expect, test, vi } from "vitest";
import waitForExpect from "wait-for-expect";
import { fromPartial } from "@total-typescript/shoehorn";
import { createAgentMessage } from "@zerobyte/contracts/agent-protocol";
import {
createAgentMessage,
SUPPORTED_AGENT_PROTOCOL_MAX_VERSION,
type AgentMessage,
} from "@zerobyte/contracts/agent-protocol";
import type { Volume } from "@zerobyte/contracts/volumes";
import { LOCAL_AGENT_ID, LOCAL_AGENT_KIND, LOCAL_AGENT_NAME } from "../constants";
import { createControllerAgentSession } from "../controller/session";
const createSocket = (overrides: Partial<Parameters<typeof createControllerAgentSession>[0]> = {}) => {
return {
data: { id: "connection-1", agentId: "local", organizationId: null, agentName: "Local Agent" },
data: {
id: "connection-1",
agentId: LOCAL_AGENT_ID,
organizationId: null,
agentName: LOCAL_AGENT_NAME,
agentKind: LOCAL_AGENT_KIND,
},
send: vi.fn(() => 1),
close: vi.fn(),
...overrides,
};
};
const createSession = (handlers: Parameters<typeof createControllerAgentSession>[1] = {}, socket = createSocket()) => {
const createSession = (
onEvent: Parameters<typeof createControllerAgentSession>[1] = () => Effect.void,
socket = createSocket(),
) => {
const scope = Effect.runSync(Scope.make());
try {
const session = Effect.runSync(Scope.extend(createControllerAgentSession(fromPartial(socket), handlers), scope));
const session = Effect.runSync(Scope.extend(createControllerAgentSession(fromPartial(socket), onEvent), scope));
return {
session,
run: () => {
Effect.runFork(Scope.extend(session.run, scope));
const fiber = Effect.runFork(Scope.extend(session.run, scope));
Effect.runSync(Scope.addFinalizer(scope, Fiber.interrupt(fiber)));
return fiber;
},
socket,
close: () => {
@ -39,110 +56,71 @@ const createSession = (handlers: Parameters<typeof createControllerAgentSession>
}
};
test("close emits a synthetic backup.cancelled for a started backup", () => {
const onBackupCancelled = vi.fn();
const { session, close } = createSession({
onBackupCancelled,
});
const backupVolume = {
id: 1,
shortId: "volume-1",
name: "Volume 1",
config: { backend: "directory", path: "/tmp" },
createdAt: 0,
updatedAt: 0,
lastHealthCheck: 0,
type: "directory",
status: "mounted" as const,
lastError: null,
autoRemount: true,
agentId: LOCAL_AGENT_ID,
organizationId: "org-1",
} satisfies Volume;
Effect.runSync(
session.handleMessage(
createAgentMessage("backup.started", {
jobId: "job-1",
scheduleId: "schedule-1",
}),
),
);
test("closing the session scope interrupts the session runner", async () => {
const { run, closeAsync } = createSession();
const fiber = run();
await closeAsync();
const exit = await Effect.runPromise(Fiber.await(fiber).pipe(Effect.timeout("100 millis")));
expect(Exit.isInterrupted(exit)).toBe(true);
});
test("close reports a transport disconnect", () => {
const onEvent = vi.fn(() => Effect.void);
const { close } = createSession(onEvent);
close();
expect(onBackupCancelled).toHaveBeenCalledTimes(1);
expect(onBackupCancelled).toHaveBeenCalledWith(
expect(onEvent).toHaveBeenCalledTimes(1);
expect(onEvent).toHaveBeenCalledWith(
expect.objectContaining({
jobId: "job-1",
scheduleId: "schedule-1",
type: "agent.disconnected",
}),
);
});
test.each([
{
name: "backup.completed",
jobId: "job-1",
scheduleId: "schedule-1",
terminalMessage: createAgentMessage("backup.completed", {
jobId: "job-1",
scheduleId: "schedule-1",
exitCode: 0,
result: null,
}),
expectedCancelledCalls: 0,
},
{
name: "backup.failed",
jobId: "job-2",
scheduleId: "schedule-2",
terminalMessage: createAgentMessage("backup.failed", {
jobId: "job-2",
scheduleId: "schedule-2",
error: "backup failed",
}),
expectedCancelledCalls: 0,
},
{
name: "backup.cancelled",
jobId: "job-3",
scheduleId: "schedule-3",
terminalMessage: createAgentMessage("backup.cancelled", {
jobId: "job-3",
scheduleId: "schedule-3",
message: "Backup was cancelled",
}),
expectedCancelledCalls: 1,
},
])("close does not emit an extra synthetic backup.cancelled after $name", (testCase) => {
const onBackupCancelled = vi.fn();
const { session, close } = createSession({
onBackupCancelled,
});
Effect.runSync(
session.handleMessage(
createAgentMessage("backup.started", {
jobId: testCase.jobId,
scheduleId: testCase.scheduleId,
}),
),
);
Effect.runSync(session.handleMessage(testCase.terminalMessage));
close();
expect(onBackupCancelled).toHaveBeenCalledTimes(testCase.expectedCancelledCalls);
});
test("close emits a synthetic backup.cancelled for a queued backup", () => {
const onBackupCancelled = vi.fn();
const { session, close } = createSession({
onBackupCancelled,
});
test("sendBackup only queues the transport message", () => {
const onEvent = vi.fn(() => Effect.void);
const { session, close } = createSession(onEvent);
Effect.runSync(
session.sendBackup({
jobId: "job-queued",
scheduleId: "schedule-queued",
organizationId: "org-1",
sourcePath: "/tmp/source",
volume: backupVolume,
repositoryConfig: {
backend: "local",
path: "/tmp/repository",
},
options: {},
options: {
oneFileSystem: false,
excludePatterns: null,
excludeIfPresent: null,
includePaths: null,
includePatterns: null,
customResticParams: null,
compressionMode: "auto",
},
runtime: {
password: "password",
cacheDir: "/tmp/cache",
passFile: "/tmp/pass",
defaultExcludes: [],
rcloneConfigFile: "/tmp/rclone.conf",
},
webhooks: { pre: null, post: null },
webhookAllowedOrigins: [],
@ -152,31 +130,169 @@ test("close emits a synthetic backup.cancelled for a queued backup", () => {
close();
expect(onBackupCancelled).toHaveBeenCalledTimes(1);
expect(onBackupCancelled).toHaveBeenLastCalledWith(
expect.objectContaining({
jobId: "job-queued",
scheduleId: "schedule-queued",
}),
);
expect(onEvent).not.toHaveBeenCalledWith(expect.objectContaining({ type: "backup.cancelled" }));
});
test("a dropped backup.cancel closes the session and emits a synthetic backup.cancelled", async () => {
test("invalid inbound messages are ignored", () => {
const onEvent = vi.fn(() => Effect.void);
const { session, close } = createSession(onEvent);
Effect.runSync(
session.handleMessage(
createAgentMessage("agent.ready", {
agentId: LOCAL_AGENT_ID,
protocolVersion: 1,
hostname: "host",
platform: "linux",
capabilities: { backup: true },
}),
),
);
onEvent.mockClear();
Effect.runSync(session.handleMessage("not json"));
Effect.runSync(session.handleMessage(JSON.stringify({ type: "backup.progress", payload: {} })));
expect(onEvent).not.toHaveBeenCalled();
close();
});
test("agent.ready marks the session ready and forwards the event", () => {
const onEvent = vi.fn(() => Effect.void);
const { session, close } = createSession(onEvent);
expect(Effect.runSync(session.isReady())).toBe(false);
Effect.runSync(
session.handleMessage(
createAgentMessage("agent.ready", {
agentId: LOCAL_AGENT_ID,
protocolVersion: 1,
hostname: "host",
platform: "linux",
capabilities: { backup: true },
}),
),
);
expect(Effect.runSync(session.isReady())).toBe(true);
expect(onEvent).toHaveBeenCalledWith({
type: "agent.ready",
payload: {
agentId: LOCAL_AGENT_ID,
protocolVersion: 1,
hostname: "host",
platform: "linux",
capabilities: { backup: true },
},
});
close();
});
test("backup agent messages are forwarded unchanged", () => {
const onEvent = vi.fn(() => Effect.void);
const { session, close } = createSession(onEvent);
const message = {
type: "backup.progress" as const,
payload: {
jobId: "job-1",
scheduleId: "schedule-1",
progress: {
message_type: "status" as const,
seconds_elapsed: 0,
seconds_remaining: 0,
percent_done: 0.5,
total_files: 0,
files_done: 0,
total_bytes: 0,
bytes_done: 0,
current_files: [],
},
},
} satisfies Extract<AgentMessage, { type: "backup.progress" }>;
Effect.runSync(
session.handleMessage(
createAgentMessage("agent.ready", {
agentId: LOCAL_AGENT_ID,
protocolVersion: 1,
hostname: "host",
platform: "linux",
capabilities: { backup: true },
}),
),
);
onEvent.mockClear();
Effect.runSync(session.handleMessage(createAgentMessage(message.type, message.payload)));
expect(onEvent).toHaveBeenCalledWith(
expect.objectContaining({
type: message.type,
payload: expect.objectContaining({
jobId: message.payload.jobId,
scheduleId: message.payload.scheduleId,
progress: expect.objectContaining(message.payload.progress),
}),
}),
);
close();
});
test("unsupported agent protocol rejects startup and closes the session", () => {
const onEvent = vi.fn(() => Effect.void);
const { session, socket } = createSession(onEvent);
Effect.runSync(
session.handleMessage(
JSON.stringify({
type: "agent.ready",
payload: {
protocolVersion: SUPPORTED_AGENT_PROTOCOL_MAX_VERSION + 1,
hostname: "host",
platform: "linux",
},
}),
),
);
expect(Effect.runSync(session.isReady())).toBe(false);
expect(onEvent).toHaveBeenCalledWith({
type: "agent.protocolRejected",
payload: expect.objectContaining({
reason: "agent_too_new",
protocolVersion: SUPPORTED_AGENT_PROTOCOL_MAX_VERSION + 1,
hostname: "host",
platform: "linux",
}),
});
expect(socket.close).toHaveBeenCalledWith(1002, "agent_too_new");
});
test("pre-ready non-ready messages reject startup and close the session", () => {
const onEvent = vi.fn(() => Effect.void);
const { session, socket } = createSession(onEvent);
Effect.runSync(session.handleMessage(createAgentMessage("heartbeat.pong", { sentAt: 123 })));
expect(Effect.runSync(session.isReady())).toBe(false);
expect(onEvent).toHaveBeenCalledWith({
type: "agent.protocolRejected",
payload: expect.objectContaining({
reason: "unexpected_startup_message",
messageType: "heartbeat.pong",
}),
});
expect(socket.close).toHaveBeenCalledWith(1002, "unexpected_startup_message");
});
test("a dropped backup.cancel closes the session and reports a transport disconnect", async () => {
const send = vi.fn(() => 0);
const socket = createSocket({ send, close: vi.fn() });
const onBackupCancelled = vi.fn();
const { session, run, closeAsync } = createSession({ onBackupCancelled }, socket);
const onEvent = vi.fn(() => Effect.void);
const { session, run, closeAsync } = createSession(onEvent, socket);
try {
run();
Effect.runSync(
session.handleMessage(
createAgentMessage("backup.started", {
jobId: "job-1",
scheduleId: "schedule-1",
}),
),
);
Effect.runSync(
session.sendBackupCancel({
jobId: "job-1",
@ -187,11 +303,10 @@ test("a dropped backup.cancel closes the session and emits a synthetic backup.ca
await waitForExpect(() => {
expect(send).toHaveBeenCalledTimes(1);
expect(socket.close).toHaveBeenCalledTimes(1);
expect(onBackupCancelled).toHaveBeenCalledTimes(1);
expect(onBackupCancelled).toHaveBeenCalledWith(
expect(onEvent).toHaveBeenCalledTimes(1);
expect(onEvent).toHaveBeenCalledWith(
expect.objectContaining({
jobId: "job-1",
scheduleId: "schedule-1",
type: "agent.disconnected",
}),
);
});

View file

@ -1,15 +1,35 @@
import { logger } from "@zerobyte/core/node";
import type { BackupRunPayload } from "@zerobyte/contracts/agent-protocol";
import type {
BackupRunPayload,
RestoreRunPayload,
VolumeCommand,
VolumeCommandResult,
} from "@zerobyte/contracts/agent-protocol";
import { Effect } from "effect";
import { config } from "../../core/config";
import type { AgentBackupEventHandlers } from "./controller/server";
import { createAgentManagerRuntime, type AgentManagerEvent } from "./controller/server";
import { LOCAL_AGENT_ID } from "./constants";
import { spawnLocalAgentProcess, stopLocalAgentProcess } from "./local/process";
import type { BackupExecutionProgress, BackupExecutionResult } from "./helpers/runtime-state";
import { createAgentRuntimeState } from "./helpers/runtime-state";
import {
createAgentRuntimeState,
type AgentRuntimeState,
type BackupExecutionProgress,
type BackupExecutionResult,
type RestoreExecutionProgress,
type RestoreExecutionResult,
} from "./helpers/runtime-state";
import { getDevAgentRuntimeState } from "./helpers/runtime-state.dev";
export type { BackupExecutionProgress, BackupExecutionResult } from "./helpers/runtime-state";
export type {
BackupExecutionProgress,
BackupExecutionResult,
RestoreExecutionProgress,
RestoreExecutionResult,
} from "./helpers/runtime-state";
export type { ProcessWithAgentRuntime } from "./helpers/runtime-state.dev";
const productionRuntimeState = createAgentRuntimeState();
type ProcessWithProductionAgentRuntime = NodeJS.Process & {
__zerobyteProductionAgentRuntime?: AgentRuntimeState;
};
type AgentRunBackupRequest = {
scheduleId: number;
@ -18,10 +38,33 @@ type AgentRunBackupRequest = {
onProgress: (progress: BackupExecutionProgress) => void;
};
const getAgentRuntimeState = () => (config.__prod__ ? productionRuntimeState : getDevAgentRuntimeState());
type AgentStartRestoreRequest = {
payload: RestoreRunPayload;
signal: AbortSignal;
onStarted: () => void;
onProgress: (progress: RestoreExecutionProgress) => void;
};
type AgentRestoreStartResult =
| { status: "started"; result: Promise<RestoreExecutionResult> }
| { status: "unavailable"; error: Error };
const getProductionAgentRuntimeState = () => {
// Nitro production builds can bundle startup plugins and API handlers into separate chunks.
// Keep the live controller on process so both chunks see the same agent sessions.
const runtimeProcess = process as ProcessWithProductionAgentRuntime;
if (!runtimeProcess.__zerobyteProductionAgentRuntime) {
runtimeProcess.__zerobyteProductionAgentRuntime = createAgentRuntimeState();
}
return runtimeProcess.__zerobyteProductionAgentRuntime;
};
const getAgentRuntimeState = () => (config.__prod__ ? getProductionAgentRuntimeState() : getDevAgentRuntimeState());
const getAgentManagerRuntime = () => getAgentRuntimeState().agentManager;
const getActiveBackupsByScheduleId = () => getAgentRuntimeState().activeBackupsByScheduleId;
const getActiveBackupScheduleIdsByJobId = () => getAgentRuntimeState().activeBackupScheduleIdsByJobId;
const getActiveRestoresByRestoreId = () => getAgentRuntimeState().activeRestoresByRestoreId;
const clearActiveBackupRun = (scheduleId: number) => {
const activeBackupsByScheduleId = getActiveBackupsByScheduleId();
@ -46,6 +89,49 @@ const resolveActiveBackupRun = (scheduleId: number, result: BackupExecutionResul
return true;
};
const clearActiveRestoreRun = (restoreId: string) => {
const activeRestoresByRestoreId = getActiveRestoresByRestoreId();
const activeRestoreRun = activeRestoresByRestoreId.get(restoreId);
if (!activeRestoreRun) {
return null;
}
activeRestoresByRestoreId.delete(restoreId);
return activeRestoreRun;
};
const resolveActiveRestoreRun = (restoreId: string, result: RestoreExecutionResult) => {
const activeRestoreRun = clearActiveRestoreRun(restoreId);
if (!activeRestoreRun) {
return false;
}
activeRestoreRun.resolve(result);
return true;
};
const cancelActiveBackupRunsForAgent = (agentId: string, message: string) => {
const activeBackupsByScheduleId = getActiveBackupsByScheduleId();
const matchingScheduleIds = [...activeBackupsByScheduleId.values()]
.filter((activeBackupRun) => activeBackupRun.agentId === agentId)
.map((activeBackupRun) => activeBackupRun.scheduleId);
for (const scheduleId of matchingScheduleIds) {
resolveActiveBackupRun(scheduleId, { status: "cancelled", message });
}
};
const cancelActiveRestoreRunsForAgent = (agentId: string, message: string) => {
const activeRestoresByRestoreId = getActiveRestoresByRestoreId();
const matchingRestoreIds = [...activeRestoresByRestoreId.values()]
.filter((activeRestoreRun) => activeRestoreRun.agentId === agentId)
.map((activeRestoreRun) => activeRestoreRun.restoreId);
for (const restoreId of matchingRestoreIds) {
resolveActiveRestoreRun(restoreId, { status: "cancelled", message });
}
};
const getActiveBackupRun = (jobId: string, scheduleId: string, eventName: string, agentId: string) => {
const trackedScheduleId = getActiveBackupScheduleIdsByJobId().get(jobId);
if (trackedScheduleId === undefined) {
@ -60,13 +146,35 @@ const getActiveBackupRun = (jobId: string, scheduleId: string, eventName: string
}
if (activeBackupRun.scheduleShortId !== scheduleId) {
logger.warn(`Ignoring ${eventName} for job ${jobId} due to schedule mismatch ${scheduleId} from agent ${agentId}`);
logger.warn(
`Ignoring ${eventName} for job ${jobId} due to schedule mismatch ${scheduleId} from agent ${agentId}`,
);
return null;
}
if (activeBackupRun.agentId !== agentId) {
logger.warn(`Ignoring ${eventName} for job ${jobId} from unexpected agent ${agentId}`);
return null;
}
return activeBackupRun;
};
const getActiveRestoreRun = (restoreId: string, eventName: string, agentId: string) => {
const activeRestoreRun = getActiveRestoresByRestoreId().get(restoreId);
if (!activeRestoreRun) {
logger.warn(`Received ${eventName} for unknown restore ${restoreId} from agent ${agentId}`);
return null;
}
if (activeRestoreRun.agentId !== agentId) {
logger.warn(`Ignoring ${eventName} for restore ${restoreId} from unexpected agent ${agentId}`);
return null;
}
return activeRestoreRun;
};
const requestBackupCancellation = async (agentId: string, scheduleId: number) => {
const activeBackupRun = getActiveBackupsByScheduleId().get(scheduleId);
if (!activeBackupRun) {
@ -86,10 +194,12 @@ const requestBackupCancellation = async (agentId: string, scheduleId: number) =>
}
if (
await runtime.cancelBackup(agentId, {
jobId: activeBackupRun.jobId,
scheduleId: activeBackupRun.scheduleShortId,
})
await Effect.runPromise(
runtime.cancelBackup(agentId, {
jobId: activeBackupRun.jobId,
scheduleId: activeBackupRun.scheduleShortId,
}),
)
) {
return true;
}
@ -98,68 +208,191 @@ const requestBackupCancellation = async (agentId: string, scheduleId: number) =>
return true;
};
const backupEventHandlers: AgentBackupEventHandlers = {
onBackupStarted: ({ agentId, payload }) => {
getActiveBackupRun(payload.jobId, payload.scheduleId, "backup.started", agentId);
},
onBackupProgress: ({ agentId, payload }) => {
const activeBackupRun = getActiveBackupRun(payload.jobId, payload.scheduleId, "backup.progress", agentId);
if (!activeBackupRun) {
return;
}
const requestRestoreCancellation = async (agentId: string, restoreId: string) => {
const activeRestoreRun = getActiveRestoresByRestoreId().get(restoreId);
if (!activeRestoreRun) {
return false;
}
activeBackupRun.onProgress(payload.progress);
},
onBackupCompleted: ({ agentId, payload }) => {
const activeBackupRun = getActiveBackupRun(payload.jobId, payload.scheduleId, "backup.completed", agentId);
if (!activeBackupRun) {
return;
}
if (activeRestoreRun.cancellationRequested) {
return true;
}
resolveActiveBackupRun(activeBackupRun.scheduleId, {
status: "completed",
exitCode: payload.exitCode,
result: payload.result,
warningDetails: payload.warningDetails ?? null,
});
},
onBackupFailed: ({ agentId, payload }) => {
const activeBackupRun = getActiveBackupRun(payload.jobId, payload.scheduleId, "backup.failed", agentId);
if (!activeBackupRun) {
return;
}
activeRestoreRun.cancellationRequested = true;
resolveActiveBackupRun(activeBackupRun.scheduleId, {
status: "failed",
error: payload.errorDetails ?? payload.error,
});
},
onBackupCancelled: ({ agentId, payload }) => {
const activeBackupRun = getActiveBackupRun(payload.jobId, payload.scheduleId, "backup.cancelled", agentId);
if (!activeBackupRun) {
return;
}
const runtime = getAgentManagerRuntime();
if (!runtime) {
resolveActiveRestoreRun(restoreId, { status: "cancelled" });
return true;
}
resolveActiveBackupRun(activeBackupRun.scheduleId, {
status: "cancelled",
message: activeBackupRun.cancellationRequested ? undefined : payload.message,
});
},
if (await Effect.runPromise(runtime.cancelRestore(agentId, { restoreId }))) {
return true;
}
resolveActiveRestoreRun(restoreId, { status: "cancelled" });
return true;
};
const handleAgentManagerEvent = (event: AgentManagerEvent) => {
switch (event.type) {
case "agent.disconnected": {
cancelActiveBackupRunsForAgent(
event.agentId,
"The connection to the backup agent was lost. Restart the backup to ensure it completes.",
);
cancelActiveRestoreRunsForAgent(
event.agentId,
"The connection to the restore agent was lost. Restart the restore to ensure it completes.",
);
break;
}
case "agent.protocolRejected": {
logger.warn(`Rejected agent protocol for ${event.agentName} (${event.agentId}): ${event.payload.reason}`);
break;
}
case "backup.started": {
getActiveBackupRun(event.payload.jobId, event.payload.scheduleId, event.type, event.agentId);
break;
}
case "backup.progress": {
const activeBackupRun = getActiveBackupRun(
event.payload.jobId,
event.payload.scheduleId,
event.type,
event.agentId,
);
if (!activeBackupRun) {
break;
}
activeBackupRun.onProgress(event.payload.progress);
break;
}
case "backup.completed": {
const activeBackupRun = getActiveBackupRun(
event.payload.jobId,
event.payload.scheduleId,
event.type,
event.agentId,
);
if (!activeBackupRun) {
break;
}
resolveActiveBackupRun(activeBackupRun.scheduleId, {
status: "completed",
exitCode: event.payload.exitCode,
result: event.payload.result,
warningDetails: event.payload.warningDetails ?? null,
});
break;
}
case "backup.failed": {
const activeBackupRun = getActiveBackupRun(
event.payload.jobId,
event.payload.scheduleId,
event.type,
event.agentId,
);
if (!activeBackupRun) {
break;
}
resolveActiveBackupRun(activeBackupRun.scheduleId, {
status: "failed",
error: event.payload.errorDetails ?? event.payload.error,
});
break;
}
case "backup.cancelled": {
const activeBackupRun = getActiveBackupRun(
event.payload.jobId,
event.payload.scheduleId,
event.type,
event.agentId,
);
if (!activeBackupRun) {
break;
}
resolveActiveBackupRun(activeBackupRun.scheduleId, {
status: "cancelled",
message: activeBackupRun.cancellationRequested ? undefined : event.payload.message,
});
break;
}
case "restore.started": {
const activeRestoreRun = getActiveRestoreRun(event.payload.restoreId, event.type, event.agentId);
if (!activeRestoreRun) {
break;
}
activeRestoreRun.onStarted();
break;
}
case "restore.progress": {
const activeRestoreRun = getActiveRestoreRun(event.payload.restoreId, event.type, event.agentId);
if (!activeRestoreRun) {
break;
}
activeRestoreRun.onProgress(event.payload.progress);
break;
}
case "restore.completed": {
const activeRestoreRun = getActiveRestoreRun(event.payload.restoreId, event.type, event.agentId);
if (!activeRestoreRun) {
break;
}
resolveActiveRestoreRun(activeRestoreRun.restoreId, {
status: "completed",
result: event.payload.result,
});
break;
}
case "restore.failed": {
const activeRestoreRun = getActiveRestoreRun(event.payload.restoreId, event.type, event.agentId);
if (!activeRestoreRun) {
break;
}
resolveActiveRestoreRun(activeRestoreRun.restoreId, {
status: "failed",
error: event.payload.errorDetails ?? event.payload.error,
});
break;
}
case "restore.cancelled": {
const activeRestoreRun = getActiveRestoreRun(event.payload.restoreId, event.type, event.agentId);
if (!activeRestoreRun) {
break;
}
resolveActiveRestoreRun(activeRestoreRun.restoreId, {
status: "cancelled",
message: activeRestoreRun.cancellationRequested ? undefined : event.payload.message,
});
break;
}
}
};
export const startAgentController = async () => {
const runtime = getAgentRuntimeState();
if (runtime.agentManager) {
await runtime.agentManager.stop();
await Effect.runPromise(runtime.agentManager.stop);
runtime.agentManager = null;
}
const { createAgentManagerRuntime } = await import("./controller/server");
const nextAgentManager = createAgentManagerRuntime();
nextAgentManager.setBackupEventHandlers(backupEventHandlers);
if (!config.flags.enableLocalAgent) {
return;
}
await nextAgentManager.start();
const nextAgentManager = createAgentManagerRuntime(handleAgentManagerEvent);
await Effect.runPromise(nextAgentManager.start);
runtime.agentManager = nextAgentManager;
};
@ -167,7 +400,9 @@ export const stopAgentController = async () => {
const runtime = getAgentRuntimeState();
const agentManagerRuntime = runtime.agentManager;
runtime.agentManager = null;
await agentManagerRuntime?.stop();
if (agentManagerRuntime) {
await Effect.runPromise(agentManagerRuntime.stop);
}
};
export const agentManager = {
@ -186,6 +421,7 @@ export const agentManager = {
const completion = new Promise<BackupExecutionResult>((resolve) => {
getActiveBackupsByScheduleId().set(request.scheduleId, {
agentId,
scheduleId: request.scheduleId,
jobId: request.payload.jobId,
scheduleShortId: request.payload.scheduleId,
@ -197,7 +433,7 @@ export const agentManager = {
});
try {
if (!(await runtime.sendBackup(agentId, request.payload))) {
if (!(await Effect.runPromise(runtime.sendBackup(agentId, request.payload)))) {
clearActiveBackupRun(request.scheduleId);
return {
status: "unavailable",
@ -218,10 +454,90 @@ export const agentManager = {
cancelBackup: async (agentId: string, scheduleId: number) => {
return requestBackupCancellation(agentId, scheduleId);
},
runVolumeCommand: async (agentId: string, command: VolumeCommand): Promise<VolumeCommandResult> => {
const runtime = getAgentManagerRuntime();
if (!runtime) {
throw new Error(`Volume agent ${agentId} is not connected`);
}
const response = await Effect.runPromise(runtime.runVolumeCommand(agentId, command));
if (!response) {
throw new Error(`Failed to send volume command ${command.name} to agent ${agentId}`);
}
if (response.status === "error") {
throw new Error(response.error);
}
return response.command;
},
startRestore: async (agentId: string, request: AgentStartRestoreRequest): Promise<AgentRestoreStartResult> => {
const runtime = getAgentManagerRuntime();
if (!runtime) {
return {
status: "unavailable",
error: new Error(`Restore agent ${agentId} is not connected`),
};
}
if (request.signal.aborted) {
throw request.signal.reason || new Error("Operation aborted");
}
const completion = new Promise<RestoreExecutionResult>((resolve) => {
getActiveRestoresByRestoreId().set(request.payload.restoreId, {
agentId,
restoreId: request.payload.restoreId,
onStarted: request.onStarted,
onProgress: request.onProgress,
resolve,
cancellationRequested: false,
});
});
try {
if (!(await Effect.runPromise(runtime.sendRestore(agentId, request.payload)))) {
clearActiveRestoreRun(request.payload.restoreId);
return {
status: "unavailable",
error: new Error(`Failed to send restore command to agent ${agentId}`),
};
}
if (request.signal.aborted) {
await requestRestoreCancellation(agentId, request.payload.restoreId);
}
return { status: "started", result: completion };
} catch (error) {
clearActiveRestoreRun(request.payload.restoreId);
throw error;
}
},
cancelRestore: async (agentId: string, restoreId: string) => {
return requestRestoreCancellation(agentId, restoreId);
},
};
export const startLocalAgent = async () => {
await spawnLocalAgentProcess(getAgentRuntimeState());
const runtime = getAgentRuntimeState();
if (!runtime.agentManager) {
throw new Error(
`startLocalAgent cannot spawn ${LOCAL_AGENT_ID} because runtime.agentManager is missing; waitForAgentReady cannot check readiness`,
);
}
const controllerUrl = runtime.agentManager.getControllerUrl();
if (!controllerUrl) {
throw new Error(`startLocalAgent cannot spawn ${LOCAL_AGENT_ID} because the controller URL is not available`);
}
await spawnLocalAgentProcess(runtime, controllerUrl);
if (!(await runtime.agentManager.waitForAgentReady(LOCAL_AGENT_ID))) {
throw new Error("Local agent did not become ready before startup");
}
};
// fallow-ignore-next-line unused-export

View file

@ -0,0 +1,130 @@
import { eq } from "drizzle-orm";
import { db } from "../../db/db";
import { agentsTable, type Agent, type AgentCapabilities, type AgentKind } from "../../db/schema";
import { LOCAL_AGENT_CAPABILITIES, LOCAL_AGENT_ID, LOCAL_AGENT_KIND, LOCAL_AGENT_NAME } from "./constants";
type AgentConnectionRegistration = {
agentId: string;
organizationId: string | null;
agentName: string;
agentKind: AgentKind;
capabilities?: AgentCapabilities;
connectedAt?: number;
};
const listAgents = async (organizationId?: string | null) => {
if (organizationId === undefined) {
return db.query.agentsTable.findMany({ orderBy: { createdAt: "asc" } });
}
if (organizationId === null) {
return db.query.agentsTable.findMany({
where: { organizationId: { isNull: true } },
orderBy: { createdAt: "asc" },
});
}
return db.query.agentsTable.findMany({
where: { organizationId },
orderBy: { createdAt: "asc" },
});
};
const getAgent = async (agentId: string) => {
return db.query.agentsTable.findFirst({ where: { id: agentId } });
};
const ensureLocalAgent = async () => {
const existing = await getAgent(LOCAL_AGENT_ID);
if (existing) {
return existing;
}
await db.insert(agentsTable).values({
id: LOCAL_AGENT_ID,
organizationId: null,
name: LOCAL_AGENT_NAME,
kind: LOCAL_AGENT_KIND,
status: "offline",
capabilities: LOCAL_AGENT_CAPABILITIES,
updatedAt: Date.now(),
});
return getAgent(LOCAL_AGENT_ID);
};
const markAgentConnecting = async (params: AgentConnectionRegistration) => {
const { agentId, organizationId, agentName, agentKind, capabilities, connectedAt = Date.now() } = params;
await db
.insert(agentsTable)
.values({
id: agentId,
organizationId,
name: agentName,
kind: agentKind,
status: "connecting",
capabilities: capabilities ?? {},
lastSeenAt: connectedAt,
updatedAt: connectedAt,
})
.onConflictDoUpdate({
target: agentsTable.id,
set: {
organizationId,
name: agentName,
kind: agentKind,
status: "connecting",
lastSeenAt: connectedAt,
updatedAt: connectedAt,
capabilities: capabilities ?? {},
},
});
return getAgent(agentId);
};
const updateAgentRuntime = async (agentId: string, values: Partial<Agent>) => {
const [updatedAgent] = await db.update(agentsTable).set(values).where(eq(agentsTable.id, agentId)).returning();
if (!updatedAgent) {
throw new Error(`Agent ${agentId} not found`);
}
return updatedAgent;
};
const markAgentOnline = async (agentId: string, readyAt = Date.now(), metadata?: AgentCapabilities) => {
return updateAgentRuntime(agentId, {
status: "online",
capabilities: metadata,
lastSeenAt: readyAt,
lastReadyAt: readyAt,
updatedAt: readyAt,
});
};
const markAgentSeen = async (agentId: string, seenAt = Date.now()) => {
return updateAgentRuntime(agentId, {
lastSeenAt: seenAt,
updatedAt: seenAt,
});
};
const markAgentOffline = async (agentId: string, disconnectedAt = Date.now()) => {
return updateAgentRuntime(agentId, {
status: "offline",
updatedAt: disconnectedAt,
});
};
export const agentsService = {
listAgents,
getAgent,
ensureLocalAgent,
markAgentConnecting,
markAgentOnline,
markAgentSeen,
markAgentOffline,
};

View file

@ -0,0 +1,6 @@
import type { AgentCapabilities, AgentKind } from "../../db/schema";
export const LOCAL_AGENT_ID = "local";
export const LOCAL_AGENT_NAME = "Local Agent";
export const LOCAL_AGENT_KIND: AgentKind = "local";
export const LOCAL_AGENT_CAPABILITIES: AgentCapabilities = {};

View file

@ -2,39 +2,37 @@ import { Data, Effect, Exit, Fiber, Scope } from "effect";
import { logger } from "@zerobyte/core/node";
import { toMessage } from "@zerobyte/core/utils";
import type {
AgentMessage,
AgentProtocolRejection,
BackupCancelPayload,
BackupCancelledPayload,
BackupCompletedPayload,
BackupFailedPayload,
BackupProgressPayload,
BackupRunPayload,
BackupStartedPayload,
RestoreCancelPayload,
RestoreRunPayload,
VolumeCommand,
VolumeCommandResponsePayload,
} from "@zerobyte/contracts/agent-protocol";
import { createControllerAgentSession, type AgentConnectionData, type ControllerAgentSession } from "./session";
import {
createControllerAgentSession,
type AgentConnectionData,
type ControllerAgentSession,
type ControllerAgentSessionEvent,
} from "./session";
import { agentsService } from "../agents.service";
import { validateAgentToken } from "../helpers/tokens";
type AgentBackupEventContext = {
type AgentEventContext = {
agentId: string;
agentName: string;
payload:
| BackupStartedPayload
| BackupProgressPayload
| BackupCompletedPayload
| BackupFailedPayload
| BackupCancelledPayload;
};
export type AgentBackupEventHandlers = {
onBackupStarted?: (context: AgentBackupEventContext & { payload: BackupStartedPayload }) => void;
onBackupProgress?: (context: AgentBackupEventContext & { payload: BackupProgressPayload }) => void;
onBackupCompleted?: (context: AgentBackupEventContext & { payload: BackupCompletedPayload }) => void;
onBackupFailed?: (context: AgentBackupEventContext & { payload: BackupFailedPayload }) => void;
onBackupCancelled?: (context: AgentBackupEventContext & { payload: BackupCancelledPayload }) => void;
};
export type AgentManagerEvent =
| (AgentEventContext & { type: "agent.disconnected" })
| (AgentEventContext & { type: "agent.protocolRejected"; payload: AgentProtocolRejection })
| (AgentEventContext & AgentMessage);
type ControllerAgentSessionHandle = {
agentId: string;
session: ControllerAgentSession;
runFiber: Fiber.RuntimeFiber<void, never>;
scope: Scope.CloseableScope;
};
@ -42,94 +40,186 @@ class StopAgentManagerServerError extends Data.TaggedError("StopAgentManagerServ
cause: unknown;
}> {}
export function createAgentManagerRuntime() {
export function createAgentManagerRuntime(onEvent: (event: AgentManagerEvent) => void) {
let sessions = new Map<string, ControllerAgentSessionHandle>();
let backupHandlers: AgentBackupEventHandlers = {};
let runtimeScope: Scope.CloseableScope | null = null;
let controllerUrl: string | null = null;
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
const closeSession = (sessionHandle: ControllerAgentSessionHandle) =>
Effect.gen(function* () {
yield* Fiber.interrupt(sessionHandle.runFiber);
yield* Scope.close(sessionHandle.scope, Exit.succeed(undefined));
yield* Effect.sync(() => {
if (sessions.get(sessionHandle.agentId) === sessionHandle) {
sessions.delete(sessionHandle.agentId);
}
});
});
const markAgentOfflineForShutdown = (agentId: string) =>
Effect.tryPromise({
try: () => agentsService.markAgentOffline(agentId),
catch: (error) => new StopAgentManagerServerError({ cause: error }),
}).pipe(
Effect.catchAll((error) =>
logger.effect.error(`Failed to mark agent ${agentId} offline during shutdown: ${toMessage(error)}`),
),
);
const closeAllSessions = Effect.gen(function* () {
const currentSessions = sessions;
sessions = new Map();
for (const sessionHandle of currentSessions.values()) {
const currentSessions = [...sessions.entries()];
for (const [agentId, sessionHandle] of currentSessions) {
yield* markAgentOfflineForShutdown(agentId);
yield* closeSession(sessionHandle);
}
sessions = new Map();
});
const getSessionHandle = (agentId: string) => sessions.get(agentId);
const getSession = (agentId: string) => getSessionHandle(agentId)?.session;
const createSessionHandlers = (ws: Bun.ServerWebSocket<AgentConnectionData>) => {
const agentId = ws.data.agentId;
const agentName = ws.data.agentName;
const isAgentReady = (agentId: string) => {
const session = getSession(agentId);
return !!session && Effect.runSync(session.isReady());
};
return {
onBackupStarted: (payload: BackupStartedPayload) => {
backupHandlers.onBackupStarted?.({ agentId, agentName, payload });
},
onBackupProgress: (payload: BackupProgressPayload) => {
backupHandlers.onBackupProgress?.({ agentId, agentName, payload });
},
onBackupCompleted: (payload: BackupCompletedPayload) => {
backupHandlers.onBackupCompleted?.({ agentId, agentName, payload });
},
onBackupFailed: (payload: BackupFailedPayload) => {
backupHandlers.onBackupFailed?.({ agentId, agentName, payload });
},
onBackupCancelled: (payload: BackupCancelledPayload) => {
backupHandlers.onBackupCancelled?.({ agentId, agentName, payload });
},
const handleSessionEvent = (params: { agentId: string; agentName: string }) => {
const { agentId, agentName } = params;
return (event: ControllerAgentSessionEvent) => {
switch (event.type) {
case "agent.ready": {
const at = Date.now();
return Effect.promise(async () => {
await agentsService.markAgentOnline(agentId, at, {
...event.payload.capabilities,
protocolVersion: event.payload.protocolVersion,
protocolCompatible: true,
hostname: event.payload.hostname,
platform: event.payload.platform,
});
});
}
case "agent.protocolRejected": {
return Effect.sync(() =>
onEvent({ type: "agent.protocolRejected", agentId, agentName, payload: event.payload }),
);
}
case "heartbeat.pong": {
const at = Date.now();
return Effect.promise(() => agentsService.markAgentSeen(agentId, at));
}
case "agent.disconnected": {
return Effect.sync(() => onEvent({ type: "agent.disconnected", agentId, agentName }));
}
default: {
return Effect.sync(() => onEvent({ ...event, agentId, agentName }));
}
}
};
};
const createSession = (ws: Bun.ServerWebSocket<AgentConnectionData>) => {
// Manual scope management because we are out of Effect
const scope = Effect.runSync(Scope.make());
const createSession = (ws: Bun.ServerWebSocket<AgentConnectionData>) =>
Effect.gen(function* () {
const scope = yield* Scope.make();
try {
const session = Effect.runSync(Scope.extend(createControllerAgentSession(ws, createSessionHandlers(ws)), scope));
const runFiber = Effect.runFork(Scope.extend(session.run, scope));
const session = yield* Scope.extend(
createControllerAgentSession(
ws,
handleSessionEvent({
agentId: ws.data.agentId,
agentName: ws.data.agentName,
}),
),
scope,
);
const runFiber = yield* Effect.forkDaemon(Scope.extend(session.run, scope));
yield* Scope.addFinalizer(scope, Fiber.interrupt(runFiber));
return { session, runFiber, scope };
} catch (error) {
Effect.runSync(Scope.close(scope, Exit.fail(error)));
throw error;
}
};
const setSession = (agentId: string, sessionHandle: ControllerAgentSessionHandle) => {
const existingSession = getSessionHandle(agentId);
if (existingSession) {
void Effect.runPromise(closeSession(existingSession)).catch((error) => {
logger.error(`Failed to close existing agent session for ${agentId}: ${toMessage(error)}`);
});
}
sessions.set(agentId, sessionHandle);
};
const removeSession = (agentId: string, connectionId: string) => {
const sessionHandle = getSessionHandle(agentId);
if (!sessionHandle || sessionHandle.session.connectionId !== connectionId) {
return;
}
sessions.delete(agentId);
void Effect.runPromise(closeSession(sessionHandle)).catch((error) => {
logger.error(`Failed to close agent session for ${agentId}: ${toMessage(error)}`);
return { agentId: ws.data.agentId, session, scope };
});
};
const setSession = (sessionHandle: ControllerAgentSessionHandle) =>
Effect.gen(function* () {
const existingSession = sessions.get(sessionHandle.agentId);
sessions.set(sessionHandle.agentId, sessionHandle);
if (existingSession) {
yield* closeSession(existingSession);
}
});
const removeSession = (agentId: string, connectionId: string) =>
Effect.gen(function* () {
const handle = sessions.get(agentId);
if (!handle || handle.session.connectionId !== connectionId) {
return false;
}
yield* closeSession(handle);
yield* Effect.promise(() => agentsService.markAgentOffline(agentId));
return true;
});
const handleMessage = (ws: Bun.ServerWebSocket<AgentConnectionData>, data: unknown) =>
Effect.gen(function* () {
if (typeof data !== "string") {
yield* logger.effect.warn(`Ignoring non-text message from agent ${ws.data.agentId}`);
return;
}
const session = getSession(ws.data.agentId);
if (!session || session.connectionId !== ws.data.id) {
yield* logger.effect.warn(`No active session for agent ${ws.data.agentId} on ${ws.data.id}`);
return;
}
yield* session.handleMessage(data);
});
const handleOpen = (ws: Bun.ServerWebSocket<AgentConnectionData>) =>
Effect.gen(function* () {
yield* Effect.promise(() =>
agentsService.markAgentConnecting({
agentId: ws.data.agentId,
organizationId: ws.data.organizationId,
agentName: ws.data.agentName,
agentKind: ws.data.agentKind,
}),
);
const sessionHandle = yield* createSession(ws);
yield* setSession(sessionHandle);
yield* logger.effect.info(`Agent "${ws.data.agentName}" (${ws.data.agentId}) connected on ${ws.data.id}`);
});
const handleClose = (ws: Bun.ServerWebSocket<AgentConnectionData>) =>
Effect.gen(function* () {
yield* removeSession(ws.data.agentId, ws.data.id);
yield* logger.effect.info(`Agent "${ws.data.agentName}" (${ws.data.agentId}) disconnected`);
});
const runWebSocketHandler = (
ws: Bun.ServerWebSocket<AgentConnectionData>,
event: string,
effect: Effect.Effect<void>,
) =>
Effect.runPromise(
effect.pipe(
Effect.catchAllCause((cause) =>
logger.effect.error(
`Agent websocket ${event} failed for ${ws.data.agentId} on ${ws.data.id}: ${toMessage(cause)}`,
),
),
),
);
const acquireServer = Effect.acquireRelease(
Effect.sync(() =>
Bun.serve<AgentConnectionData>({
port: 3001,
hostname: "127.0.0.1",
port: 0,
async fetch(req, srv) {
const authorizationHeader = req.headers.get("authorization");
const token = authorizationHeader?.slice("Bearer ".length);
@ -149,37 +239,24 @@ export function createAgentManagerRuntime() {
agentId: result.agentId,
organizationId: result.organizationId,
agentName: result.agentName,
agentKind: result.agentKind,
},
});
if (upgraded) return undefined;
return new Response("WebSocket upgrade failed", { status: 400 });
},
websocket: {
open: (ws) => {
setSession(ws.data.agentId, createSession(ws));
logger.info(`Agent "${ws.data.agentName}" (${ws.data.agentId}) connected on ${ws.data.id}`);
},
message: (ws, data) => {
if (typeof data !== "string") {
logger.warn(`Ignoring non-text message from agent ${ws.data.agentId}`);
return;
open: async (ws) => {
await runWebSocketHandler(ws, "open", handleOpen(ws));
if (getSession(ws.data.agentId)?.connectionId !== ws.data.id) {
ws.close();
}
const session = getSession(ws.data.agentId);
if (!session || session.connectionId !== ws.data.id) {
logger.warn(`No active session for agent ${ws.data.agentId} on ${ws.data.id}`);
return;
}
void Effect.runPromise(session.handleMessage(data)).catch((error) => {
logger.error(
`Failed to handle message from agent ${ws.data.agentId} on ${ws.data.id}: ${toMessage(error)}`,
);
});
},
close: (ws) => {
removeSession(ws.data.agentId, ws.data.id);
logger.info(`Agent "${ws.data.agentName}" (${ws.data.agentId}) disconnected`);
message: async (ws, data) => {
await runWebSocketHandler(ws, "message", handleMessage(ws, data));
},
close: async (ws) => {
await runWebSocketHandler(ws, "close", handleClose(ws));
},
},
}),
@ -192,15 +269,13 @@ export function createAgentManagerRuntime() {
catch: (error) => new StopAgentManagerServerError({ cause: error }),
}),
),
Effect.catchAll((error) =>
Effect.sync(() => {
logger.error(`Failed to stop Agent Manager server: ${toMessage(error.cause)}`);
}),
),
Effect.catchAll((error) => {
return logger.effect.error(`Failed to stop Agent Manager server: ${toMessage(error.cause)}`);
}),
),
);
const stop = async () => {
const stop = Effect.gen(function* () {
if (!runtimeScope) {
return;
}
@ -208,70 +283,150 @@ export function createAgentManagerRuntime() {
logger.info("Stopping Agent Manager...");
const scope = runtimeScope;
runtimeScope = null;
await Effect.runPromise(Scope.close(scope, Exit.succeed(undefined)));
};
controllerUrl = null;
yield* Scope.close(scope, Exit.succeed(undefined));
});
const start = async () => {
const start = Effect.gen(function* () {
if (runtimeScope) {
await stop();
yield* stop;
}
logger.info("Starting Agent Manager...");
const scope = Effect.runSync(Scope.make());
const scope = yield* Scope.make();
try {
const server = Effect.runSync(Scope.extend(acquireServer, scope));
runtimeScope = scope;
logger.info(`Agent Manager listening on port ${server.port}`);
} catch (error) {
await Effect.runPromise(Scope.close(scope, Exit.fail(error)));
throw error;
}
};
const server = yield* Scope.extend(acquireServer, scope).pipe(
Effect.catchAllCause((cause) =>
Scope.close(scope, Exit.failCause(cause)).pipe(Effect.andThen(Effect.failCause(cause))),
),
);
runtimeScope = scope;
controllerUrl = `ws://127.0.0.1:${server.port}`;
logger.info(`Agent Manager listening on port ${server.port}`);
});
return {
start,
sendBackup: async (agentId: string, payload: BackupRunPayload) => {
const session = getSession(agentId);
getControllerUrl: () => controllerUrl,
waitForAgentReady: async (agentId: string, timeoutMs = 10_000) => {
const deadline = Date.now() + timeoutMs;
if (!session) {
logger.warn(`Cannot send backup command. Agent ${agentId} is not connected.`);
return false;
while (Date.now() < deadline) {
if (isAgentReady(agentId)) {
return true;
}
await sleep(50);
}
if (!Effect.runSync(session.isReady())) {
logger.warn(`Cannot send backup command. Agent ${agentId} is not ready.`);
return false;
}
if (!(await Effect.runPromise(session.sendBackup(payload)))) {
logger.warn(`Cannot send backup command. Agent ${agentId} is no longer accepting commands.`);
return false;
}
logger.info(`Sent backup command ${payload.jobId} to agent ${agentId} for schedule ${payload.scheduleId}`);
return true;
return isAgentReady(agentId);
},
cancelBackup: async (agentId: string, payload: BackupCancelPayload) => {
const session = getSession(agentId);
sendBackup: (agentId: string, payload: BackupRunPayload) =>
Effect.gen(function* () {
const session = getSession(agentId);
if (!session) {
logger.warn(`Cannot cancel backup command. Agent ${agentId} is not connected.`);
return false;
}
if (!session) {
logger.warn(`Cannot send backup command. Agent ${agentId} is not connected.`);
return false;
}
if (!(await Effect.runPromise(session.sendBackupCancel(payload)))) {
logger.warn(`Cannot cancel backup command. Agent ${agentId} is no longer accepting commands.`);
return false;
}
if (!(yield* session.isReady())) {
logger.warn(`Cannot send backup command. Agent ${agentId} is not ready.`);
return false;
}
logger.info(`Sent backup cancel for command ${payload.jobId} to agent ${agentId}`);
return true;
},
setBackupEventHandlers: (handlers: AgentBackupEventHandlers) => {
backupHandlers = handlers;
},
getBackupEventHandlers: () => backupHandlers,
if (!(yield* session.sendBackup(payload))) {
logger.warn(`Cannot send backup command. Agent ${agentId} is no longer accepting commands.`);
return false;
}
logger.info(
`Sent backup command ${payload.jobId} to agent ${agentId} for schedule ${payload.scheduleId}`,
);
return true;
}),
cancelBackup: (agentId: string, payload: BackupCancelPayload) =>
Effect.gen(function* () {
const session = getSession(agentId);
if (!session) {
logger.warn(`Cannot cancel backup command. Agent ${agentId} is not connected.`);
return false;
}
if (!(yield* session.sendBackupCancel(payload))) {
logger.warn(`Cannot cancel backup command. Agent ${agentId} is no longer accepting commands.`);
return false;
}
logger.info(`Sent backup cancel for command ${payload.jobId} to agent ${agentId}`);
return true;
}),
sendRestore: (agentId: string, payload: RestoreRunPayload) =>
Effect.gen(function* () {
const session = getSession(agentId);
if (!session) {
logger.warn(`Cannot send restore command. Agent ${agentId} is not connected.`);
return false;
}
if (!(yield* session.isReady())) {
logger.warn(`Cannot send restore command. Agent ${agentId} is not ready.`);
return false;
}
if (!(yield* session.sendRestore(payload))) {
logger.warn(`Cannot send restore command. Agent ${agentId} is no longer accepting commands.`);
return false;
}
logger.info(
`Sent restore command ${payload.restoreId} to agent ${agentId} for snapshot ${payload.snapshotId}`,
);
return true;
}),
cancelRestore: (agentId: string, payload: RestoreCancelPayload) =>
Effect.gen(function* () {
const session = getSession(agentId);
if (!session) {
logger.warn(`Cannot cancel restore command. Agent ${agentId} is not connected.`);
return false;
}
if (!(yield* session.sendRestoreCancel(payload))) {
logger.warn(`Cannot cancel restore command. Agent ${agentId} is no longer accepting commands.`);
return false;
}
logger.info(`Sent restore cancel for command ${payload.restoreId} to agent ${agentId}`);
return true;
}),
runVolumeCommand: (
agentId: string,
command: VolumeCommand,
): Effect.Effect<VolumeCommandResponsePayload | null, Error> =>
Effect.gen(function* () {
const session = getSession(agentId);
if (!session) {
yield* logger.effect.warn(
`Cannot send volume command ${command.name}. Agent ${agentId} is not connected.`,
);
return null;
}
if (!(yield* session.isReady())) {
yield* logger.effect.warn(
`Cannot send volume command ${command.name}. Agent ${agentId} is not ready.`,
);
return null;
}
const result = yield* session.runVolumeCommand(command);
yield* logger.effect.info(`Completed volume command ${command.name} on agent ${agentId}`);
return result;
}),
stop,
};
}

View file

@ -1,16 +1,20 @@
import { Effect, Queue, Ref, type Scope } from "effect";
import { Deferred, Effect, Queue, Ref, type Scope } from "effect";
import type { AgentKind } from "../../../db/schema";
import {
createControllerMessage,
parseAgentMessage,
parseAgentStartupMessage,
SUPPORTED_AGENT_PROTOCOL_MAX_VERSION,
SUPPORTED_AGENT_PROTOCOL_MIN_VERSION,
type AgentMessage,
type AgentProtocolRejection,
type BackupCancelPayload,
type BackupCancelledPayload,
type BackupCompletedPayload,
type BackupFailedPayload,
type BackupProgressPayload,
type BackupRunPayload,
type BackupStartedPayload,
type ControllerWireMessage,
type RestoreCancelPayload,
type RestoreRunPayload,
type VolumeCommand,
type VolumeCommandResponsePayload,
} from "@zerobyte/contracts/agent-protocol";
import { logger } from "@zerobyte/core/node";
import { toMessage } from "@zerobyte/core/utils";
@ -20,48 +24,48 @@ export type AgentConnectionData = {
agentId: string;
organizationId: string | null;
agentName: string;
agentKind: AgentKind;
};
type AgentSocket = Bun.ServerWebSocket<AgentConnectionData>;
type SessionState = {
isReady: boolean;
protocolVersion: number | null;
lastSeenAt: number | null;
lastPongAt: number | null;
};
type TrackedBackupJob = {
scheduleId: string;
state: "pending" | "active";
};
type PendingCommand = { deferred: Deferred.Deferred<VolumeCommandResponsePayload, Error>; description: string };
type ControllerAgentSessionHandlers = {
onBackupStarted?: (payload: BackupStartedPayload) => void;
onBackupProgress?: (payload: BackupProgressPayload) => void;
onBackupCompleted?: (payload: BackupCompletedPayload) => void;
onBackupFailed?: (payload: BackupFailedPayload) => void;
onBackupCancelled?: (payload: BackupCancelledPayload) => void;
};
export type ControllerAgentSessionEvent =
| Exclude<AgentMessage, { type: "volume.commandResult" }>
| { type: "agent.protocolRejected"; payload: AgentProtocolRejection }
| { type: "agent.disconnected" };
export type ControllerAgentSession = {
readonly connectionId: string;
handleMessage: (data: string) => Effect.Effect<void>;
sendBackup: (payload: BackupRunPayload) => Effect.Effect<boolean>;
sendBackupCancel: (payload: BackupCancelPayload) => Effect.Effect<boolean>;
sendRestore: (payload: RestoreRunPayload) => Effect.Effect<boolean>;
sendRestoreCancel: (payload: RestoreCancelPayload) => Effect.Effect<boolean>;
runVolumeCommand: (command: VolumeCommand) => Effect.Effect<VolumeCommandResponsePayload, Error>;
isReady: () => Effect.Effect<boolean>;
run: Effect.Effect<void, never, Scope.Scope>;
};
export const createControllerAgentSession = (
socket: AgentSocket,
handlers: ControllerAgentSessionHandlers = {},
onEvent: (event: ControllerAgentSessionEvent) => Effect.Effect<void>,
): Effect.Effect<ControllerAgentSession, never, Scope.Scope> =>
Effect.gen(function* () {
let isClosed = false;
const outboundQueue = yield* Queue.bounded<ControllerWireMessage>(64);
const trackedBackupJobs = yield* Ref.make<Map<string, TrackedBackupJob>>(new Map());
const pendingCommands = yield* Ref.make(new Map<string, PendingCommand>());
const state = yield* Ref.make<SessionState>({
isReady: false,
protocolVersion: null,
lastSeenAt: null,
lastPongAt: null,
});
@ -70,7 +74,9 @@ export const createControllerAgentSession = (
Queue.offer(outboundQueue, message).pipe(
Effect.catchAllCause((cause) =>
Effect.sync(() => {
logger.error(`Failed to queue outbound message for agent ${socket.data.agentId}: ${toMessage(cause)}`);
logger.error(
`Failed to queue outbound message for agent ${socket.data.agentId}: ${toMessage(cause)}`,
);
return false;
}),
),
@ -78,37 +84,34 @@ export const createControllerAgentSession = (
const updateState = (update: (current: SessionState) => SessionState) => Ref.update(state, update);
const setTrackedBackupJob = (jobId: string, trackedBackupJob: TrackedBackupJob) => {
return Ref.update(trackedBackupJobs, (current) => {
const next = new Map(current);
next.set(jobId, trackedBackupJob);
return next;
});
};
const setPendingCommand = (commandId: string, pending: PendingCommand) =>
Ref.update(pendingCommands, (current) => new Map(current).set(commandId, pending));
const deleteTrackedBackupJob = (jobId: string) => {
return Ref.update(trackedBackupJobs, (current) => {
const removePendingCommand = (commandId: string) =>
Ref.modify(pendingCommands, (current) => {
const pending = current.get(commandId) ?? null;
const next = new Map(current);
next.delete(jobId);
return next;
next.delete(commandId);
return [pending, next];
});
};
const takeTrackedBackupJobs = Ref.modify(
trackedBackupJobs,
(current) => [current, new Map<string, TrackedBackupJob>()] as const,
);
const rejectPendingCommands = Effect.gen(function* () {
const pendingCommandEntries = yield* Ref.get(pendingCommands);
yield* Ref.set(pendingCommands, new Map());
for (const pending of pendingCommandEntries.values()) {
yield* Deferred.fail(
pending.deferred,
new Error(`Agent session closed before ${pending.description} completed`),
);
}
});
const releaseSession = Effect.gen(function* () {
yield* updateState((current) => ({ ...current, isReady: false }));
const trackedJobs = yield* takeTrackedBackupJobs;
for (const [jobId, trackedJob] of trackedJobs) {
const message = "The connection to the backup agent was lost. Restart the backup to ensure it completes.";
yield* Effect.sync(() => {
handlers.onBackupCancelled?.({ jobId, scheduleId: trackedJob.scheduleId, message });
});
}
const disconnectedAt = Date.now();
yield* updateState((current) => ({ ...current, isReady: false, lastSeenAt: disconnectedAt }));
yield* rejectPendingCommands;
yield* onEvent({ type: "agent.disconnected" });
yield* Queue.shutdown(outboundQueue);
});
@ -126,16 +129,13 @@ export const createControllerAgentSession = (
yield* Effect.addFinalizer(() => closeSession());
const handleSendFailure = (reason: string) => {
logger.error(
`Closing session for agent ${socket.data.agentId} on ${socket.data.id} after an outbound websocket send failed: ${reason}`,
);
socket.close();
void Effect.runPromise(closeSession()).catch((error) => {
return Effect.gen(function* () {
logger.error(
`Failed to close session for agent ${socket.data.agentId} on ${socket.data.id}: ${toMessage(error)}`,
`Closing session for agent ${socket.data.agentId} on ${socket.data.id} after an outbound websocket send failed: ${reason}`,
);
yield* Effect.sync(() => socket.close());
yield* closeSession();
});
};
@ -144,17 +144,16 @@ export const createControllerAgentSession = (
Effect.forever(
Effect.gen(function* () {
const message = yield* Queue.take(outboundQueue);
yield* Effect.sync(() => {
try {
const sendResult = socket.send(message);
if (sendResult === 0) {
handleSendFailure("connection issue");
}
} catch (error) {
handleSendFailure(toMessage(error));
}
const sendResult = yield* Effect.try({
try: () => socket.send(message),
catch: (error) => toMessage(error),
});
}),
if (sendResult === 0) {
yield* handleSendFailure("connection issue");
}
}).pipe(Effect.catchAll((reason) => handleSendFailure(reason))),
),
);
@ -175,100 +174,130 @@ export const createControllerAgentSession = (
return yield* Effect.never;
});
const handleVolumeCommandResult = (payload: VolumeCommandResponsePayload) =>
Effect.gen(function* () {
const pending = yield* removePendingCommand(payload.commandId);
if (!pending) {
yield* logger.effect.warn(`Received response for unknown volume command ${payload.commandId}`);
return;
}
yield* Deferred.succeed(pending.deferred, payload);
});
const handleAgentMessage = (message: AgentMessage) =>
Effect.gen(function* () {
yield* updateState((current) => ({ ...current, lastSeenAt: Date.now() }));
switch (message.type) {
case "agent.ready": {
yield* updateState((current) => ({ ...current, isReady: true }));
yield* Effect.sync(() => {
logger.info(`Agent "${socket.data.agentName}" (${socket.data.agentId}) is ready`);
});
break;
}
case "backup.started": {
yield* setTrackedBackupJob(message.payload.jobId, {
scheduleId: message.payload.scheduleId,
state: "active",
});
yield* Effect.sync(() => {
logger.info(
`Backup ${message.payload.jobId} started on agent ${socket.data.agentId} for schedule ${message.payload.scheduleId}`,
);
handlers.onBackupStarted?.(message.payload);
});
break;
}
case "backup.progress": {
yield* Effect.sync(() => {
handlers.onBackupProgress?.(message.payload);
});
break;
}
case "backup.completed": {
yield* deleteTrackedBackupJob(message.payload.jobId);
yield* Effect.sync(() => {
handlers.onBackupCompleted?.(message.payload);
});
break;
}
case "backup.failed": {
yield* deleteTrackedBackupJob(message.payload.jobId);
yield* Effect.sync(() => {
handlers.onBackupFailed?.(message.payload);
});
break;
}
case "backup.cancelled": {
yield* deleteTrackedBackupJob(message.payload.jobId);
yield* Effect.sync(() => {
handlers.onBackupCancelled?.(message.payload);
});
const readyAt = Date.now();
yield* updateState((current) => ({
...current,
isReady: true,
protocolVersion: message.payload.protocolVersion,
lastSeenAt: readyAt,
}));
yield* logger.effect.info(`Agent "${socket.data.agentName}" (${socket.data.agentId}) is ready`);
yield* onEvent(message);
break;
}
case "heartbeat.pong": {
yield* updateState((current) => ({ ...current, lastPongAt: message.payload.sentAt }));
const seenAt = Date.now();
yield* updateState((current) => ({
...current,
lastSeenAt: seenAt,
lastPongAt: message.payload.sentAt,
}));
yield* onEvent(message);
break;
}
case "volume.commandResult": {
yield* handleVolumeCommandResult(message.payload);
break;
}
default: {
yield* onEvent(message);
break;
}
}
});
const rejectStartupMessage = (rejection: AgentProtocolRejection) =>
Effect.gen(function* () {
yield* logger.effect.warn(
`Rejecting startup message from agent ${socket.data.agentId}: ${rejection.reason}`,
);
yield* onEvent({ type: "agent.protocolRejected", payload: rejection });
yield* Effect.sync(() => socket.close(1002, rejection.reason));
yield* closeSession();
});
return {
connectionId: socket.data.id,
handleMessage: (data: string) => {
return Effect.gen(function* () {
const currentState = yield* Ref.get(state);
if (!currentState.isReady) {
const startupMessage = parseAgentStartupMessage(data);
if (!("success" in startupMessage)) {
yield* rejectStartupMessage(startupMessage);
return;
}
}
const parsed = parseAgentMessage(data);
if (parsed === null) {
yield* Effect.sync(() => {
logger.warn(`Invalid JSON from agent ${socket.data.agentId}`);
});
yield* logger.effect.warn(`Invalid JSON from agent ${socket.data.agentId}`);
return;
}
if (!parsed.success) {
yield* Effect.sync(() => {
logger.warn(`Invalid agent message from ${socket.data.agentId}: ${parsed.error.message}`);
});
if (!currentState.isReady) {
yield* rejectStartupMessage({
reason: "invalid_agent_ready",
supportedProtocolMinVersion: SUPPORTED_AGENT_PROTOCOL_MIN_VERSION,
supportedProtocolMaxVersion: SUPPORTED_AGENT_PROTOCOL_MAX_VERSION,
});
return;
}
yield* logger.effect.warn(
`Invalid agent message from ${socket.data.agentId}: ${parsed.error.message}`,
);
return;
}
yield* handleAgentMessage(parsed.data);
});
},
sendBackup: (payload) => {
return Effect.gen(function* () {
const queued = yield* offerOutbound(createControllerMessage("backup.run", payload));
sendBackup: (payload) => offerOutbound(createControllerMessage("backup.run", payload)),
sendBackupCancel: (payload) => offerOutbound(createControllerMessage("backup.cancel", payload)),
sendRestore: (payload) => offerOutbound(createControllerMessage("restore.run", payload)),
sendRestoreCancel: (payload) => offerOutbound(createControllerMessage("restore.cancel", payload)),
runVolumeCommand: (command) =>
Effect.gen(function* () {
const commandId = Bun.randomUUIDv7();
const description = `volume command ${command.name}`;
const deferred = yield* Deferred.make<VolumeCommandResponsePayload, Error>();
yield* setPendingCommand(commandId, { deferred, description });
if (queued) {
yield* setTrackedBackupJob(payload.jobId, { scheduleId: payload.scheduleId, state: "pending" });
const queued = yield* offerOutbound(
createControllerMessage("volume.command", { commandId, command }),
);
if (!queued) {
yield* removePendingCommand(commandId);
return yield* Effect.fail(new Error(`Failed to queue volume command ${command.name}`));
}
return queued;
});
},
sendBackupCancel: (payload) => offerOutbound(createControllerMessage("backup.cancel", payload)),
return yield* Deferred.await(deferred).pipe(
Effect.timeoutFail({
duration: "60 seconds",
onTimeout: () => new Error(`Volume command ${command.name} timed out`),
}),
Effect.ensuring(removePendingCommand(commandId)),
);
}),
isReady: () => Ref.get(state).pipe(Effect.map((current) => current.isReady)),
run,
};

View file

@ -1,20 +1,26 @@
import { createAgentRuntimeState, type AgentRuntimeState } from "./runtime-state";
type LegacyAgentRuntimeState = Omit<AgentRuntimeState, "activeBackupsByScheduleId" | "activeBackupScheduleIdsByJobId"> &
Partial<Pick<AgentRuntimeState, "activeBackupsByScheduleId" | "activeBackupScheduleIdsByJobId">>;
type RuntimeMapKey = "activeBackupsByScheduleId" | "activeBackupScheduleIdsByJobId" | "activeRestoresByRestoreId";
type LegacyAgentRuntimeState = Omit<AgentRuntimeState, RuntimeMapKey> & Partial<Pick<AgentRuntimeState, RuntimeMapKey>>;
export type ProcessWithAgentRuntime = NodeJS.Process & {
__zerobyteAgentRuntime?: LegacyAgentRuntimeState;
};
const hasActiveBackupMaps = (runtime: LegacyAgentRuntimeState): runtime is AgentRuntimeState => {
return runtime.activeBackupsByScheduleId instanceof Map && runtime.activeBackupScheduleIdsByJobId instanceof Map;
const hasActiveRuntimeMaps = (runtime: LegacyAgentRuntimeState): runtime is AgentRuntimeState => {
return (
runtime.activeBackupsByScheduleId instanceof Map &&
runtime.activeBackupScheduleIdsByJobId instanceof Map &&
runtime.activeRestoresByRestoreId instanceof Map
);
};
const hydrateAgentRuntimeState = (runtime: LegacyAgentRuntimeState): AgentRuntimeState => ({
...runtime,
activeBackupsByScheduleId: runtime.activeBackupsByScheduleId ?? new Map(),
activeBackupScheduleIdsByJobId: runtime.activeBackupScheduleIdsByJobId ?? new Map(),
activeRestoresByRestoreId: runtime.activeRestoresByRestoreId ?? new Map(),
});
export const getDevAgentRuntimeState = (): AgentRuntimeState => {
@ -27,7 +33,7 @@ export const getDevAgentRuntimeState = (): AgentRuntimeState => {
return runtime;
}
if (hasActiveBackupMaps(existingRuntime)) {
if (hasActiveRuntimeMaps(existingRuntime)) {
return existingRuntime;
}

View file

@ -1,16 +1,32 @@
import type { ChildProcess } from "node:child_process";
import type { ResticBackupOutputDto } from "@zerobyte/core/restic";
import type { BackupProgressPayload } from "@zerobyte/contracts/agent-protocol";
import type {
BackupProgressPayload,
RestoreCompletedPayload,
RestoreProgressPayload,
} from "@zerobyte/contracts/agent-protocol";
import type { AgentManagerRuntime } from "../controller/server";
export type BackupExecutionProgress = BackupProgressPayload["progress"];
export type BackupExecutionResult =
| { status: "unavailable"; error: Error }
| { status: "completed"; exitCode: number; result: ResticBackupOutputDto | null; warningDetails: string | null }
| {
status: "completed";
exitCode: number;
result: ResticBackupOutputDto | null;
warningDetails: string | null;
}
| { status: "failed"; error: string }
| { status: "cancelled"; message?: string };
export type RestoreExecutionProgress = RestoreProgressPayload["progress"];
export type RestoreExecutionResult =
| { status: "unavailable"; error: Error }
| { status: "completed"; result: RestoreCompletedPayload["result"] }
| { status: "failed"; error: string }
| { status: "cancelled"; message?: string };
type ActiveBackupRun = {
agentId: string;
scheduleId: number;
jobId: string;
scheduleShortId: string;
@ -19,6 +35,15 @@ type ActiveBackupRun = {
cancellationRequested: boolean;
};
type ActiveRestoreRun = {
agentId: string;
restoreId: string;
onStarted: () => void;
onProgress: (progress: RestoreExecutionProgress) => void;
resolve: (result: RestoreExecutionResult) => void;
cancellationRequested: boolean;
};
export type AgentRuntimeState = {
agentManager: AgentManagerRuntime | null;
localAgent: ChildProcess | null;
@ -26,6 +51,7 @@ export type AgentRuntimeState = {
localAgentRestartTimeout: ReturnType<typeof setTimeout> | null;
activeBackupsByScheduleId: Map<number, ActiveBackupRun>;
activeBackupScheduleIdsByJobId: Map<string, number>;
activeRestoresByRestoreId: Map<string, ActiveRestoreRun>;
};
export const createAgentRuntimeState = (): AgentRuntimeState => ({
@ -35,4 +61,5 @@ export const createAgentRuntimeState = (): AgentRuntimeState => ({
localAgentRestartTimeout: null,
activeBackupsByScheduleId: new Map(),
activeBackupScheduleIdsByJobId: new Map(),
activeRestoresByRestoreId: new Map(),
});

View file

@ -1,4 +1,5 @@
import { cryptoUtils } from "~/server/utils/crypto";
import { LOCAL_AGENT_ID, LOCAL_AGENT_KIND, LOCAL_AGENT_NAME } from "../constants";
export const deriveLocalAgentToken = async () => {
return cryptoUtils.deriveSecret("zerobyte:local-agent-token");
@ -7,6 +8,6 @@ export const deriveLocalAgentToken = async () => {
export const validateAgentToken = async (token: string) => {
const localToken = await deriveLocalAgentToken();
if (token === localToken) {
return { agentId: "local", organizationId: null, agentName: "local" };
return { agentId: LOCAL_AGENT_ID, organizationId: null, agentName: LOCAL_AGENT_NAME, agentKind: LOCAL_AGENT_KIND };
}
};

View file

@ -11,7 +11,7 @@ type LocalAgentState = {
localAgentRestartTimeout: ReturnType<typeof setTimeout> | null;
};
export async function spawnLocalAgentProcess(runtime: LocalAgentState) {
export async function spawnLocalAgentProcess(runtime: LocalAgentState, controllerUrl: string) {
await stopLocalAgentProcess(runtime);
if (!config.flags.enableLocalAgent) {
@ -31,7 +31,7 @@ export async function spawnLocalAgentProcess(runtime: LocalAgentState) {
const agentProcess = spawn("bun", args, {
env: {
...process.env,
ZEROBYTE_CONTROLLER_URL: "ws://localhost:3001",
ZEROBYTE_CONTROLLER_URL: controllerUrl,
ZEROBYTE_AGENT_TOKEN: agentToken,
},
stdio: ["ignore", "pipe", "pipe"],
@ -62,8 +62,10 @@ export async function spawnLocalAgentProcess(runtime: LocalAgentState) {
runtime.localAgentRestartTimeout = setTimeout(() => {
runtime.localAgentRestartTimeout = null;
void spawnLocalAgentProcess(runtime).catch((error) => {
logger.error(`Failed to restart local agent: ${error instanceof Error ? error.message : String(error)}`);
void spawnLocalAgentProcess(runtime, controllerUrl).catch((error) => {
logger.error(
`Failed to restart local agent: ${error instanceof Error ? error.message : String(error)}`,
);
});
}, 1_000);
});

View file

@ -0,0 +1,137 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import { eq } from "drizzle-orm";
import { db } from "~/server/db/db";
import { account, passkey, usersTable } from "~/server/db/schema";
import { createUser, randomId, randomSlug } from "~/test/helpers/user-org";
import { hasActivePasskeyUser, userHasCredentialPassword, verifyUserPassword } from "../helpers";
const { verifyPassword } = vi.hoisted(() => ({
verifyPassword: vi.fn(async ({ hash }: { hash: string }) => hash === "credential-password-hash"),
}));
vi.mock("better-auth/crypto", () => ({
verifyPassword,
}));
async function createAccount({
password,
providerId,
userId,
}: {
password: string | null;
providerId: string;
userId: string;
}) {
await db.insert(account).values({
id: randomId(),
accountId: randomSlug("account"),
providerId,
userId,
password,
});
}
async function createPasskey(userId: string) {
await db.insert(passkey).values({
id: randomId(),
name: "Test passkey",
publicKey: randomSlug("public-key"),
userId,
credentialID: randomSlug("credential"),
counter: 0,
deviceType: "singleDevice",
backedUp: false,
transports: "internal",
});
}
describe("verifyUserPassword", () => {
beforeEach(async () => {
verifyPassword.mockClear();
await db.delete(passkey);
await db.delete(account);
await db.delete(usersTable);
});
test("verifies against the credential account when the user also has SSO accounts", async () => {
const userId = await createUser(`${randomSlug("user")}@example.com`);
await createAccount({ userId, providerId: "oidc-acme", password: null });
await createAccount({ userId, providerId: "credential", password: "credential-password-hash" });
const result = await verifyUserPassword({ userId, password: "correct-password" });
expect(result).toBe(true);
expect(verifyPassword).toHaveBeenCalledWith({
password: "correct-password",
hash: "credential-password-hash",
});
});
test("returns false when the user has no credential password account", async () => {
const userId = await createUser(`${randomSlug("user")}@example.com`);
await createAccount({ userId, providerId: "oidc-acme", password: null });
const result = await verifyUserPassword({ userId, password: "correct-password" });
expect(result).toBe(false);
expect(verifyPassword).not.toHaveBeenCalled();
});
});
describe("userHasCredentialPassword", () => {
beforeEach(async () => {
await db.delete(passkey);
await db.delete(account);
await db.delete(usersTable);
});
test("returns true when the user has a credential account with a password", async () => {
const userId = await createUser(`${randomSlug("user")}@example.com`);
await createAccount({ userId, providerId: "credential", password: "credential-password-hash" });
await expect(userHasCredentialPassword(userId)).resolves.toBe(true);
});
test("returns false when the user only has SSO accounts", async () => {
const userId = await createUser(`${randomSlug("user")}@example.com`);
await createAccount({ userId, providerId: "oidc-acme", password: null });
await expect(userHasCredentialPassword(userId)).resolves.toBe(false);
});
test("returns false when the credential account has no password", async () => {
const userId = await createUser(`${randomSlug("user")}@example.com`);
await createAccount({ userId, providerId: "credential", password: null });
await expect(userHasCredentialPassword(userId)).resolves.toBe(false);
});
});
describe("hasActivePasskeyUser", () => {
beforeEach(async () => {
await db.delete(passkey);
await db.delete(account);
await db.delete(usersTable);
});
test("returns true when a non-banned user has a passkey", async () => {
const userId = await createUser(`${randomSlug("user")}@example.com`);
await createPasskey(userId);
await expect(hasActivePasskeyUser()).resolves.toBe(true);
});
test("returns false when only banned users have passkeys", async () => {
const userId = await createUser(`${randomSlug("user")}@example.com`);
await db.update(usersTable).set({ banned: true }).where(eq(usersTable.id, userId));
await createPasskey(userId);
await expect(hasActivePasskeyUser()).resolves.toBe(false);
});
test("returns false when no users have passkeys", async () => {
await createUser(`${randomSlug("user")}@example.com`);
await expect(hasActivePasskeyUser()).resolves.toBe(false);
});
});

View file

@ -1,5 +1,7 @@
import { eq } from "drizzle-orm";
import { verifyPassword } from "better-auth/crypto";
import { db } from "~/server/db/db";
import { passkey, usersTable } from "~/server/db/schema";
type PasswordVerificationBody = {
userId: string;
@ -8,7 +10,7 @@ type PasswordVerificationBody = {
export const verifyUserPassword = async ({ password, userId }: PasswordVerificationBody) => {
const userAccount = await db.query.account.findFirst({
where: { userId },
where: { AND: [{ userId }, { providerId: "credential" }] },
});
if (!userAccount || !userAccount.password) {
@ -22,3 +24,23 @@ export const verifyUserPassword = async ({ password, userId }: PasswordVerificat
return true;
};
export const userHasCredentialPassword = async (userId: string) => {
const userAccount = await db.query.account.findFirst({
where: { AND: [{ userId }, { providerId: "credential" }] },
columns: { password: true },
});
return Boolean(userAccount?.password);
};
export const hasActivePasskeyUser = async () => {
const [user] = await db
.select({ id: usersTable.id })
.from(passkey)
.innerJoin(usersTable, eq(passkey.userId, usersTable.id))
.where(eq(usersTable.banned, false))
.limit(1);
return Boolean(user);
};

Some files were not shown because too many files have changed in this diff Show more