From adca6d8730b0f69f485a6b05e895878cf868b7db Mon Sep 17 00:00:00 2001 From: Steven RYDELL Date: Sat, 1 Aug 2026 18:32:13 +0200 Subject: [PATCH] feat(dev): switch dev tokens to a real admin account with configurable duration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Requested: dev-token.sh/.ps1 tokens should last 3h by default, with an argument to override the duration. Server-verified finding: STALWART_RECOVERY_ADMIN (the break-glass account docker-compose.yml and the scripts used) always issues OAuth tokens with a fixed 1h expiry, regardless of the server's configured accessTokenExpiry — confirmed against the live container, including after changing the setting and restarting. Confirmed x:ApiKey objects, by contrast, support an arbitrary expiresAt set per request, and their secret works directly as a bearer token. Add scripts/dev-server-init.sh (+ .ps1): a one-time, idempotent setup step that completes the server's bootstrap wizard (default domain, no TLS certificate request), creates a real "devadmin" admin account, and sets the server's default OAuth token lifetime to 3h. Rework dev-token.sh/.ps1 to authenticate as devadmin and create an x:ApiKey with a caller-supplied expiry (`dev-token.sh 1800` for 30 minutes, defaults to 10800s/3h) instead of running the OAuth PKCE flow against the recovery account. Verified end-to-end against a fresh container, including a real browser session against the running WebUI. Also includes an incidental package-lock.json sync (was still pinned to v1.0.8 / stale dependency ranges from before the upstream merge). --- .gitignore | 2 + AGENTS.md | 3 +- DEVELOPMENT.md | 66 +++-- docker-compose.yml | 4 +- package-lock.json | 501 +++++++++++++++++++++++++++++------- scripts/dev-server-init.ps1 | 122 +++++++++ scripts/dev-server-init.sh | 65 +++++ scripts/dev-token.ps1 | 83 +++--- scripts/dev-token.sh | 70 +++-- 9 files changed, 725 insertions(+), 191 deletions(-) create mode 100644 scripts/dev-server-init.ps1 create mode 100644 scripts/dev-server-init.sh diff --git a/.gitignore b/.gitignore index c45c992..d3cc6b7 100644 --- a/.gitignore +++ b/.gitignore @@ -26,6 +26,8 @@ dist-ssr scripts/* !scripts/dev-token.ps1 !scripts/dev-token.sh +!scripts/dev-server-init.ps1 +!scripts/dev-server-init.sh *.md !README.md !CHANGELOG.md diff --git a/AGENTS.md b/AGENTS.md index 374ab99..d82d5e4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,7 +9,8 @@ Community fork of [stalwartlabs/webui](https://github.com/stalwartlabs/webui), a ## Commands - `npm run dev:server` - Start a local disposable Stalwart test server (Docker); `npm run dev:server:down` to stop it -- `bash scripts/dev-token.sh` (or `pwsh ./scripts/dev-token.ps1` on Windows) - Get a dev access token from that server +- `bash scripts/dev-server-init.sh` (or `pwsh ./scripts/dev-server-init.ps1`) - One-time setup of that server (bootstrap, dev admin account, 3h default token lifetime); idempotent +- `bash scripts/dev-token.sh [duration_seconds]` (or `pwsh ./scripts/dev-token.ps1 [-DurationSeconds N]`) - Get a dev access token from that server, 3h by default - `npm run dev` - Dev server (proxies `/api` and `/jmap` to the test server) - `npm run typecheck` - `tsc --noEmit` - `npm run lint` - ESLint diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index bb52aba..87af2a5 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -39,27 +39,55 @@ npm run dev:server:down # stop it (add `-v` via `docker compose down -v` to al The server takes a couple of seconds to come up; `docker compose logs stalwart` will show `Network listener started ... localPort = 8080` once it's ready. -## 2. Get an access token +## 2. Initialize the server (first time only) -The WebUI normally authenticates through an OAuth flow in the browser, but -for local development it's simpler to skip that and use a bearer token -directly via `VITE_ACCESS_TOKEN` (see `.env.development`). +The container starts in Stalwart's bootstrap mode, which only allows +signing in as the break-glass `STALWART_RECOVERY_ADMIN` account — real +accounts and most settings aren't usable yet. Run once per fresh volume: ```bash # Windows / PowerShell -pwsh ./scripts/dev-token.ps1 +pwsh ./scripts/dev-server-init.ps1 # Linux / macOS / any POSIX shell (including most AI agent sandboxes) -bash ./scripts/dev-token.sh +bash ./scripts/dev-server-init.sh ``` -Both scripts log in as the dev container's admin account, run the full -OAuth PKCE flow against it, and write the resulting token to -`.env.development.local` (gitignored, never committed). Tokens expire -after 1 hour — re-run the script and restart `npm run dev` if the UI -starts returning 401s. +This completes the bootstrap wizard (default domain `example.org`, no TLS +certificate request — safe for local/offline use), creates a real +`devadmin@example.org` admin account, and sets the server's default OAuth +access token lifetime to 3 hours. It's idempotent — safe to re-run, it +no-ops once the server is already bootstrapped. You only need to re-run it +after `docker compose down -v` (which wipes the volumes). -## 3. Run the WebUI +## 3. Get an access token + +For local development it's simpler to skip interactive login and use a +bearer token directly via `VITE_ACCESS_TOKEN` (see `.env.development`). + +```bash +# Windows / PowerShell +pwsh ./scripts/dev-token.ps1 # 3 hour token (server default) +pwsh ./scripts/dev-token.ps1 -DurationSeconds 1800 # custom duration (30 min) + +# Linux / macOS / any POSIX shell (including most AI agent sandboxes) +bash ./scripts/dev-token.sh # 3 hour token +bash ./scripts/dev-token.sh 1800 # custom duration (30 min) +``` + +Both scripts authenticate as the `devadmin` account created in step 2 and +create a Stalwart API key with the requested expiry (default 3 hours, +overridable per invocation — this is a genuine per-request duration, not +a global setting), then write its secret to `.env.development.local` +(gitignored, never committed) as `VITE_ACCESS_TOKEN`. Re-run the script +and restart `npm run dev` once the token expires (the UI starts returning +401s). + +The `STALWART_RECOVERY_ADMIN` account is intentionally not used here: it's +a break-glass credential and its tokens always expire in a fixed 1 hour +regardless of server configuration, so it can't honor a custom duration. + +## 4. Run the WebUI ```bash npm install # first time only @@ -69,7 +97,7 @@ npm run dev Open `http://localhost:5173`. You should land directly in the admin panel (no login screen) since `VITE_ACCESS_TOKEN` is set. -## 4. Verify your change +## 5. Verify your change ```bash npm run typecheck @@ -91,15 +119,17 @@ behavior): npm run dev:server:down docker compose down -v # also removes the stalwart-etc/stalwart-data volumes npm run dev:server +bash ./scripts/dev-server-init.sh # re-run: fresh volume needs bootstrapping again ``` ## Notes for AI agents -- This whole workflow (steps 1–3) is scriptable end-to-end without a - browser: `npm run dev:server`, then `bash scripts/dev-token.sh`, then - the app is reachable at `http://localhost:5173` with - `VITE_ACCESS_TOKEN` already set. Verify backend connectivity directly - with `curl`, e.g. `curl -H "Authorization: Bearer $TOKEN" http://localhost:8080/jmap/session`. +- This whole workflow (steps 1–4) is scriptable end-to-end without a + browser: `npm run dev:server`, then `bash scripts/dev-server-init.sh` + (first time only), then `bash scripts/dev-token.sh`, then the app is + reachable at `http://localhost:5173` with `VITE_ACCESS_TOKEN` already + set. Verify backend connectivity directly with `curl`, e.g. + `curl -H "Authorization: Bearer $TOKEN" http://localhost:8080/jmap/session`. - Read [AGENTS.md](AGENTS.md) before touching anything under `src/` — the schema-fidelity rule applies to all development, local test server or not. diff --git a/docker-compose.yml b/docker-compose.yml index 12c34b3..647fe47 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -22,7 +22,9 @@ services: - stalwart-data:/var/lib/stalwart environment: # Disposable local dev credentials — do not reuse for anything real. - # Matches scripts/dev-token.ps1's defaults. + # Break-glass admin, used only by scripts/dev-server-init.sh (its + # tokens always expire in 1h regardless of server config, so it's + # not used for day-to-day dev tokens — see scripts/dev-token.sh). STALWART_RECOVERY_ADMIN: "admin@example.org:c8321iEscHDy0GWV" volumes: diff --git a/package-lock.json b/package-lock.json index 4f56d10..e9bbf83 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "stalwart-webui-fork", - "version": "1.0.8", + "version": "1.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "stalwart-webui-fork", - "version": "1.0.8", + "version": "1.1.0", "dependencies": { "@daypicker/react": "^10.0.1", "@radix-ui/react-alert-dialog": "^1.1.23", @@ -28,7 +28,7 @@ "clsx": "^2.1.1", "cmdk": "^1.1.1", "i18next": "^26.3.6", - "lucide-react": "^1.27.0", + "lucide-react": "1.28.0", "otpauth": "^9.5.1", "qrcode": "^1.5.4", "react": "^19.2.8", @@ -46,7 +46,7 @@ "@types/node": "^26.1.2", "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", - "@vitejs/plugin-react": "^6.0.4", + "@vitejs/plugin-react": "6.0.5", "eslint": "^10.8.0", "eslint-config-prettier": "^10.1.8", "eslint-plugin-react-hooks": "^7.1.1", @@ -57,7 +57,7 @@ "tailwindcss": "^4.3.3", "typescript": "~6.0.3", "typescript-eslint": "^8.65.0", - "vite": "^8.1.5", + "vite": "8.2.0", "vitest": "^4.1.10" } }, @@ -644,22 +644,25 @@ } }, "node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", - "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.2.tgz", + "integrity": "sha512-JfB4kuJQjaoHuCTseIINHtHWeJnvgEcxjwA5t/Y00ZgaOO1Crz3fjT/p8kT28zA/Caz7oiUMn3d6H2yOVCVwuw==", "dev": true, "license": "MIT", "optional": true, "dependencies": { "@tybys/wasm-util": "^0.10.3" }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=23.5.0" + }, "funding": { "type": "github", "url": "https://github.com/sponsors/Brooooooklyn" }, "peerDependencies": { - "@emnapi/core": "^1.7.1", - "@emnapi/runtime": "^1.7.1" + "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.3", + "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.3" } }, "node_modules/@noble/hashes": { @@ -675,9 +678,9 @@ } }, "node_modules/@oxc-project/types": { - "version": "0.139.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz", - "integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==", + "version": "0.142.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.142.0.tgz", + "integrity": "sha512-7W+2q5AKQVU36fkaryontrHn3YDt1RyUYXatw9i5H8ocYe2sPKSFB6eS8WNPeRKiN1qAWWZUPm7gwFzJGrccqQ==", "dev": true, "license": "MIT", "funding": { @@ -1661,9 +1664,9 @@ } }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz", - "integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.1.tgz", + "integrity": "sha512-02hOeOSryYxVrOIphmLAsqnCJWxwlzFk+pEt/N/i6OgT3lShHO7xGCU5cpgchRDHboAEbSjzgGh+O/u1GswQmA==", "cpu": [ "arm64" ], @@ -1678,9 +1681,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz", - "integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.1.tgz", + "integrity": "sha512-fMsTOnN0OjFm3CyppWPitKnc8UlliVARUULW6cfU6AIqjdtgmSFWSk9vecHzZduv/yMWIHDlRhM1e8Iff9uAfA==", "cpu": [ "arm64" ], @@ -1695,9 +1698,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz", - "integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.1.tgz", + "integrity": "sha512-1wjKdz/XLGKHaTNHjQveQ/B23TKx4ItAqm1JbyVuvNPc4Ze0Fb48s49TAd/2zcplPl8okE/UbTgmlVfwT7eFeQ==", "cpu": [ "x64" ], @@ -1712,9 +1715,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz", - "integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.1.tgz", + "integrity": "sha512-Fa0jHR07E7YBN4vOEsbVf2briYNsuOowfLJaXULZM0ldMlaCaj2LJgLMbMe4iacRyZmvR8efFhgR9wKuGclQUg==", "cpu": [ "x64" ], @@ -1729,9 +1732,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz", - "integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.1.tgz", + "integrity": "sha512-pzkgu1SSHGgRRyRZ4fbmSgmajbVt+epaLP99NDjFft69v/ypfTi6swBMiVdh2EkQ0OSnHE1lZDM7DRGkyAzUpA==", "cpu": [ "arm" ], @@ -1746,9 +1749,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz", - "integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.1.tgz", + "integrity": "sha512-QI5SEDY8cbiYWHx0VO4vIc3UlS6a32vXHjU8Qy/17adEmZIPuByJg13UEvo9c/UCiUkdcVWY83C+b+JrwnNyUg==", "cpu": [ "arm64" ], @@ -1766,9 +1769,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz", - "integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.1.tgz", + "integrity": "sha512-Sm41FyCeXqmYcERoYOCbGIL5hNfd8w9LQ7Y61Bev48HkcjaJqV/iiVOaiDxjVTRMS+QKrZmD8cfPt4uMVnvM+A==", "cpu": [ "arm64" ], @@ -1786,9 +1789,9 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz", - "integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.1.tgz", + "integrity": "sha512-2x+WhXTGl9yJYPbltW/BSEPTVz9OIWQyER4N+gJEDWkkn904eRcBzELqh/Hf7K0w/ubGbKNMv0ZC+94QK/IFEg==", "cpu": [ "ppc64" ], @@ -1806,9 +1809,9 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz", - "integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.1.tgz", + "integrity": "sha512-eEjmQpuRQayHPWWnywaWHkFT3ToPbP3RYy42VVd/B9aBGDA+Ol25EIWHxKQST3IiWJjikCWUF7KtbfqwZrzVwQ==", "cpu": [ "s390x" ], @@ -1826,9 +1829,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz", - "integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.1.tgz", + "integrity": "sha512-/Orga1fZYkLc/56jBICcHrKchl8Z2UKdDSr3LG9ToWO1lQ6a4Livk9Xz+9WN91zsz5QR3XQz2NNoSDEvP6qadw==", "cpu": [ "x64" ], @@ -1846,9 +1849,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz", - "integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.1.tgz", + "integrity": "sha512-xxBJRL+0q0Kce7orznGWLuylHDY65vuARXZRpX+hPdv+DqK2c3NlCsVA98tlWzWNEE7yPqA/1NQ5nnCrj49Y5A==", "cpu": [ "x64" ], @@ -1866,9 +1869,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz", - "integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.1.tgz", + "integrity": "sha512-M6AdXIXw3s+/8XpKMzdGDEXGS1S7kwUsy+rcTIUIOx5Ge4nXKCtAFHFV9YKkXvGcC5WMoTjAteLzlsQROVI0Yw==", "cpu": [ "arm64" ], @@ -1883,28 +1886,59 @@ } }, "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz", - "integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==", - "cpu": [ - "wasm32" - ], + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.2.1.tgz", + "integrity": "sha512-/TX0SoRGojHzSAHpfVBbavRVSazg5U3h3Y3VXfcc0cdugq6kxdqw8LPGFiPr+/7gE/60zRcsOY2Vi9b9eT0jww==", "dev": true, "license": "MIT", "optional": true, "dependencies": { - "@emnapi/core": "1.11.1", - "@emnapi/runtime": "1.11.1", - "@napi-rs/wasm-runtime": "^1.1.6" + "@emnapi/core": "2.0.0-alpha.3", + "@emnapi/runtime": "2.0.0-alpha.3", + "@napi-rs/wasm-runtime": "^1.2.0" }, "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": "^20.19.0 || ^22.13.0 || >=23.5.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/core": { + "version": "2.0.0-alpha.3", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-2.0.0-alpha.3.tgz", + "integrity": "sha512-AZypUeJ/yByuxyS7BlSNRDOMLMlROYtjYdIAuBmJssVz1UJDSeYxLrdizhXCFYhedC5bqd/ASy8EuNXbVVXp9g==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "2.0.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "2.0.0-alpha.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-2.0.0-alpha.3.tgz", + "integrity": "sha512-hFPAhMUjJD9BSyCANEISPOogeXC9Zo9ZQl7L6vKnaVsMkCtzznaW/naYypeyl0Gv5rYfWYsZbpixTMpjDJzQeA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-2.0.1.tgz", + "integrity": "sha512-9DsSk+o5NBX0CCJT8s0EROGSGxjR/tKu6aBTaVyq+SjAEQH4XcdcRxPBRzsBLizTTJ49MJjF+jgu3qnO9GLQcQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" } }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz", - "integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.1.tgz", + "integrity": "sha512-EvRrivJieyHG+AO9lleZWgq+g0+S7oV2C51yuqlcyU/R9net+sI4Pj0F+lUoP2bEr6TWX3SqFaaS0SzfLxSzkw==", "cpu": [ "arm64" ], @@ -1919,9 +1953,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz", - "integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.1.tgz", + "integrity": "sha512-Z4eCmn5QJ/5+azF9knpLWKfVd9aidn0mAe9TpJgvBLId9Ax3t0+JVxBmT25Bv7NBbVW1TZyKjQjQReouMeH5UQ==", "cpu": [ "x64" ], @@ -2708,9 +2742,9 @@ "license": "ISC" }, "node_modules/@vitejs/plugin-react": { - "version": "6.0.4", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.4.tgz", - "integrity": "sha512-XcCQz0TBpBgljhj0gMuuDj49i6Ytqh5q1osT/Gp5uAVJUCTWxyskk/l1jwYYiu2xcNHHipdMz40EGfM1VdamVg==", + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.5.tgz", + "integrity": "sha512-BOVzne/NL162sMdResB25mUv+vWMF5NoAjNf09TeGlE7ZpszZWSD3winycicLJw72yeVsoCn/2kOhEuCvEShMA==", "dev": true, "license": "MIT", "dependencies": { @@ -4609,9 +4643,9 @@ } }, "node_modules/lucide-react": { - "version": "1.27.0", - "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.27.0.tgz", - "integrity": "sha512-rJicGl/3Fly/E0rOH1YmPZ6e49JCnKknh1ox1vpHnkfjujAkKA6sqUZvH3MTAaXXjgexyUwgNwTJzTtYuAFYJw==", + "version": "1.28.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.28.0.tgz", + "integrity": "sha512-fARAFJULsGuDDydjp6+6blekG/sBIM29TerzLjc9bQUKAcEfrSc4ZQKb25KRz4OMKd87cZTb5dgq0w/T6KufVg==", "license": "ISC", "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" @@ -5872,13 +5906,13 @@ "license": "MIT" }, "node_modules/rolldown": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz", - "integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.1.tgz", + "integrity": "sha512-4FKJhg8d3OiyQOA6Q1Q0hoFFpW9/OoX+VsHzpECsdsIZoOArrAK90gl59YK/Z+gnDel45bgJZK03ozH/9bCqEw==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.139.0", + "@oxc-project/types": "=0.142.0", "@rolldown/pluginutils": "^1.0.0" }, "bin": { @@ -5888,21 +5922,21 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.1.5", - "@rolldown/binding-darwin-arm64": "1.1.5", - "@rolldown/binding-darwin-x64": "1.1.5", - "@rolldown/binding-freebsd-x64": "1.1.5", - "@rolldown/binding-linux-arm-gnueabihf": "1.1.5", - "@rolldown/binding-linux-arm64-gnu": "1.1.5", - "@rolldown/binding-linux-arm64-musl": "1.1.5", - "@rolldown/binding-linux-ppc64-gnu": "1.1.5", - "@rolldown/binding-linux-s390x-gnu": "1.1.5", - "@rolldown/binding-linux-x64-gnu": "1.1.5", - "@rolldown/binding-linux-x64-musl": "1.1.5", - "@rolldown/binding-openharmony-arm64": "1.1.5", - "@rolldown/binding-wasm32-wasi": "1.1.5", - "@rolldown/binding-win32-arm64-msvc": "1.1.5", - "@rolldown/binding-win32-x64-msvc": "1.1.5" + "@rolldown/binding-android-arm64": "1.2.1", + "@rolldown/binding-darwin-arm64": "1.2.1", + "@rolldown/binding-darwin-x64": "1.2.1", + "@rolldown/binding-freebsd-x64": "1.2.1", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.1", + "@rolldown/binding-linux-arm64-gnu": "1.2.1", + "@rolldown/binding-linux-arm64-musl": "1.2.1", + "@rolldown/binding-linux-ppc64-gnu": "1.2.1", + "@rolldown/binding-linux-s390x-gnu": "1.2.1", + "@rolldown/binding-linux-x64-gnu": "1.2.1", + "@rolldown/binding-linux-x64-musl": "1.2.1", + "@rolldown/binding-openharmony-arm64": "1.2.1", + "@rolldown/binding-wasm32-wasi": "1.2.1", + "@rolldown/binding-win32-arm64-msvc": "1.2.1", + "@rolldown/binding-win32-x64-msvc": "1.2.1" } }, "node_modules/scheduler": { @@ -6463,16 +6497,16 @@ } }, "node_modules/vite": { - "version": "8.1.5", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz", - "integrity": "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==", + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.0.tgz", + "integrity": "sha512-pn+CFpM0lwDeKwmOq1ZaBK/9sjorZcgqxki6MbY/jPEVd9vichIlmlD4HmQ5wdP5EgqQCFRaACBxMC7uEGc6lQ==", "dev": true, "license": "MIT", "dependencies": { - "lightningcss": "^1.32.0", + "lightningcss": "^1.33.0", "picomatch": "^4.0.5", - "postcss": "^8.5.17", - "rolldown": "~1.1.5", + "postcss": "^8.5.23", + "rolldown": "~1.2.0", "tinyglobby": "^0.2.17" }, "bin": { @@ -6489,7 +6523,7 @@ }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.3.0", + "@vitejs/devtools": "^0.4.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", @@ -6540,6 +6574,279 @@ } } }, + "node_modules/vite/node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/vite/node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, "node_modules/vitest": { "version": "4.1.10", "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", diff --git a/scripts/dev-server-init.ps1 b/scripts/dev-server-init.ps1 new file mode 100644 index 0000000..b165e81 --- /dev/null +++ b/scripts/dev-server-init.ps1 @@ -0,0 +1,122 @@ +#Requires -Version 5.1 +<# +.SYNOPSIS + One-time setup for the disposable Stalwart test server from + docker-compose.yml: completes the bootstrap wizard, creates a real + "devadmin" account, and sets the default OAuth access token lifetime. + +.DESCRIPTION + Local development only. Requires the dev container to be running + ('npm run dev:server'). The STALWART_RECOVERY_ADMIN account is + break-glass only and always issues fixed 1h OAuth tokens regardless of + server config, so dev-token.ps1 authenticates as "devadmin" instead, + created here. Idempotent: safe to re-run; does nothing if the server + already left bootstrap mode. +#> +param( + [string]$ApiBaseUrl = "http://localhost:8080" +) + +$ErrorActionPreference = 'Stop' + +$RecoveryAccount = "admin@example.org" +$RecoverySecret = "c8321iEscHDy0GWV" +$DevDomain = "example.org" +$DevHostname = "mail.example.org" +$DevAdminName = "devadmin" +$DevAdminSecret = "DevAdminPass123!" +$DefaultTokenExpiryMs = 10800000 # 3 hours + +$recoveryCreds = [System.Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes("$($RecoveryAccount):$($RecoverySecret)")) +$authHeader = @{ Authorization = "Basic $recoveryCreds" } + +function Invoke-Jmap($body) { + Invoke-RestMethod -Uri "$ApiBaseUrl/jmap/" -Method Post -ContentType "application/json" -Headers $authHeader -Body ($body | ConvertTo-Json -Depth 10 -Compress) -TimeoutSec 15 +} + +Write-Host "Waiting for $ApiBaseUrl to be reachable..." +$ready = $false +for ($i = 0; $i -lt 30; $i++) { + try { + Invoke-RestMethod -Uri "$ApiBaseUrl/jmap/session" -Headers $authHeader -TimeoutSec 5 | Out-Null + $ready = $true + break + } catch { Start-Sleep -Seconds 1 } +} +if (-not $ready) { throw "Server did not become reachable at $ApiBaseUrl" } + +$session = Invoke-RestMethod -Uri "$ApiBaseUrl/jmap/session" -Headers $authHeader -TimeoutSec 15 +$accountId = $session.primaryAccounts.'urn:stalwart:jmap' + +$queryResult = Invoke-Jmap @{ + using = @("urn:ietf:params:jmap:core", "urn:stalwart:jmap") + methodCalls = @(, @("x:Domain/query", @{ accountId = $accountId }, "0")) +} +$alreadyBootstrapped = -not ($queryResult.methodResponses[0][1].type -eq "forbidden") + +if ($alreadyBootstrapped) { + Write-Host "Server already bootstrapped, skipping setup. (Use 'docker compose down -v; npm run dev:server' to start fresh.)" + return +} + +Write-Host "Completing server bootstrap (domain: $DevDomain, no TLS certificate request)..." +Invoke-Jmap @{ + using = @("urn:ietf:params:jmap:core", "urn:stalwart:jmap") + methodCalls = @(, @("x:Bootstrap/set", @{ + accountId = $accountId + update = @{ singleton = @{ defaultDomain = $DevDomain; serverHostname = $DevHostname; requestTlsCertificate = $false } } + }, "0")) +} | Out-Null + +Write-Host "Restarting the container to apply bootstrap config (one-time only)..." +docker compose restart stalwart | Out-Null +$ready = $false +for ($i = 0; $i -lt 30; $i++) { + try { + Invoke-RestMethod -Uri "$ApiBaseUrl/jmap/session" -Headers $authHeader -TimeoutSec 5 | Out-Null + $ready = $true + break + } catch { Start-Sleep -Seconds 1 } +} +if (-not $ready) { throw "Server did not come back up after restart" } + +$domainResult = Invoke-Jmap @{ + using = @("urn:ietf:params:jmap:core", "urn:stalwart:jmap") + methodCalls = @(, @("x:Domain/query", @{ accountId = $accountId }, "0")) +} +$domainId = $domainResult.methodResponses[0][1].ids[0] + +Write-Host "Creating devadmin account ($DevAdminName@$DevDomain)..." +$createResult = Invoke-Jmap @{ + using = @("urn:ietf:params:jmap:core", "urn:stalwart:jmap") + methodCalls = @(, @("x:Account/set", @{ + accountId = $accountId + create = @{ u1 = @{ "@type" = "User"; name = $DevAdminName; domainId = $domainId; roles = @{ "@type" = "Admin" } } } + }, "0")) +} +$devAdminId = $createResult.methodResponses[0][1].created.u1.id + +Invoke-Jmap @{ + using = @("urn:ietf:params:jmap:core", "urn:stalwart:jmap") + methodCalls = @(, @("x:Account/set", @{ + accountId = $accountId + update = @{ $devAdminId = @{ credentials = @{ "0" = @{ "@type" = "Password"; secret = $DevAdminSecret } } } } + }, "0")) +} | Out-Null + +Write-Host "Setting default OAuth access token lifetime to 3 hours..." +Invoke-Jmap @{ + using = @("urn:ietf:params:jmap:core", "urn:stalwart:jmap") + methodCalls = @(, @("x:OidcProvider/set", @{ + accountId = $accountId + update = @{ singleton = @{ accessTokenExpiry = $DefaultTokenExpiryMs } } + }, "0")) +} | Out-Null +try { + Invoke-Jmap @{ + using = @("urn:ietf:params:jmap:core", "urn:stalwart:jmap") + methodCalls = @(, @("x:Action/set", @{ accountId = $accountId; create = @{ a1 = @{ "@type" = "ReloadSettings" } } }, "0")) + } | Out-Null +} catch {} + +Write-Host "Done. $DevAdminName@$DevDomain / $DevAdminSecret is ready - run scripts/dev-token.ps1 to get a token." diff --git a/scripts/dev-server-init.sh b/scripts/dev-server-init.sh new file mode 100644 index 0000000..b02f3ed --- /dev/null +++ b/scripts/dev-server-init.sh @@ -0,0 +1,65 @@ +#!/usr/bin/env bash +# Local development only. One-time setup for the disposable Stalwart test +# server from docker-compose.yml: completes the server's bootstrap wizard, +# creates a real "devadmin" account (used by dev-token.sh/.ps1 — the +# STALWART_RECOVERY_ADMIN account is break-glass only and always issues +# fixed 1h OAuth tokens regardless of server config), and sets the default +# OAuth access token lifetime to 3 hours. +# +# Idempotent: safe to re-run; does nothing if the server already left +# bootstrap mode. Run this once after `npm run dev:server` on a fresh +# volume (or after `docker compose down -v`). +set -euo pipefail + +API_BASE_URL="${1:-http://localhost:8080}" +RECOVERY_ACCOUNT="admin@example.org" +RECOVERY_SECRET="c8321iEscHDy0GWV" +DEV_DOMAIN="example.org" +DEV_HOSTNAME="mail.example.org" +DEVADMIN_NAME="devadmin" +DEVADMIN_SECRET="DevAdminPass123!" +DEFAULT_TOKEN_EXPIRY_MS=10800000 # 3 hours + +jmap() { + curl -sf --compressed -u "$RECOVERY_ACCOUNT:$RECOVERY_SECRET" \ + -X POST -H "Content-Type: application/json" -d "$1" "$API_BASE_URL/jmap/" +} + +echo "Waiting for $API_BASE_URL to be reachable..." +for _ in $(seq 1 30); do + curl -sf -o /dev/null -u "$RECOVERY_ACCOUNT:$RECOVERY_SECRET" "$API_BASE_URL/jmap/session" && break + sleep 1 +done + +SESSION=$(curl -sf --compressed -u "$RECOVERY_ACCOUNT:$RECOVERY_SECRET" "$API_BASE_URL/jmap/session") +ACCOUNT_ID=$(printf '%s' "$SESSION" | grep -o '"urn:stalwart:jmap":"[^"]*"' | cut -d'"' -f4) + +QUERY_RESULT=$(jmap "{\"using\":[\"urn:ietf:params:jmap:core\",\"urn:stalwart:jmap\"],\"methodCalls\":[[\"x:Domain/query\",{\"accountId\":\"$ACCOUNT_ID\"},\"0\"]]}") +if ! printf '%s' "$QUERY_RESULT" | grep -q '"forbidden"'; then + echo "Server already bootstrapped, skipping setup. (Use 'docker compose down -v && npm run dev:server' to start fresh.)" +else + echo "Completing server bootstrap (domain: $DEV_DOMAIN, no TLS certificate request)..." + jmap "{\"using\":[\"urn:ietf:params:jmap:core\",\"urn:stalwart:jmap\"],\"methodCalls\":[[\"x:Bootstrap/set\",{\"accountId\":\"$ACCOUNT_ID\",\"update\":{\"singleton\":{\"defaultDomain\":\"$DEV_DOMAIN\",\"serverHostname\":\"$DEV_HOSTNAME\",\"requestTlsCertificate\":false}}},\"0\"]]}" > /dev/null + + echo "Restarting the container to apply bootstrap config (one-time only)..." + docker compose restart stalwart > /dev/null + for _ in $(seq 1 30); do + curl -sf -o /dev/null -u "$RECOVERY_ACCOUNT:$RECOVERY_SECRET" "$API_BASE_URL/jmap/session" && break + sleep 1 + done + + DOMAIN_RESULT=$(jmap "{\"using\":[\"urn:ietf:params:jmap:core\",\"urn:stalwart:jmap\"],\"methodCalls\":[[\"x:Domain/query\",{\"accountId\":\"$ACCOUNT_ID\"},\"0\"]]}") + DOMAIN_ID=$(printf '%s' "$DOMAIN_RESULT" | grep -o '"ids":\["[^"]*"' | cut -d'"' -f4) + + echo "Creating devadmin account ($DEVADMIN_NAME@$DEV_DOMAIN)..." + CREATE_RESULT=$(jmap "{\"using\":[\"urn:ietf:params:jmap:core\",\"urn:stalwart:jmap\"],\"methodCalls\":[[\"x:Account/set\",{\"accountId\":\"$ACCOUNT_ID\",\"create\":{\"u1\":{\"@type\":\"User\",\"name\":\"$DEVADMIN_NAME\",\"domainId\":\"$DOMAIN_ID\",\"roles\":{\"@type\":\"Admin\"}}}},\"0\"]]}") + DEVADMIN_ID=$(printf '%s' "$CREATE_RESULT" | grep -o '"id":"[^"]*"' | cut -d'"' -f4) + + jmap "{\"using\":[\"urn:ietf:params:jmap:core\",\"urn:stalwart:jmap\"],\"methodCalls\":[[\"x:Account/set\",{\"accountId\":\"$ACCOUNT_ID\",\"update\":{\"$DEVADMIN_ID\":{\"credentials\":{\"0\":{\"@type\":\"Password\",\"secret\":\"$DEVADMIN_SECRET\"}}}}},\"0\"]]}" > /dev/null + + echo "Setting default OAuth access token lifetime to 3 hours..." + jmap "{\"using\":[\"urn:ietf:params:jmap:core\",\"urn:stalwart:jmap\"],\"methodCalls\":[[\"x:OidcProvider/set\",{\"accountId\":\"$ACCOUNT_ID\",\"update\":{\"singleton\":{\"accessTokenExpiry\":$DEFAULT_TOKEN_EXPIRY_MS}}},\"0\"]]}" > /dev/null + jmap "{\"using\":[\"urn:ietf:params:jmap:core\",\"urn:stalwart:jmap\"],\"methodCalls\":[[\"x:Action/set\",{\"accountId\":\"$ACCOUNT_ID\",\"create\":{\"a1\":{\"@type\":\"ReloadSettings\"}}},\"0\"]]}" > /dev/null 2>&1 || true + + echo "Done. devadmin@$DEV_DOMAIN / $DEVADMIN_SECRET is ready — run scripts/dev-token.sh to get a token." +fi diff --git a/scripts/dev-token.ps1 b/scripts/dev-token.ps1 index 96b8cdd..a65ebf4 100644 --- a/scripts/dev-token.ps1 +++ b/scripts/dev-token.ps1 @@ -1,60 +1,71 @@ #Requires -Version 5.1 <# .SYNOPSIS - Generates a fresh OAuth access token from the local Stalwart dev container - and writes it to .env.development.local (gitignored). + Generates a fresh access token from the local Stalwart dev container and + writes it to .env.development.local (gitignored). .DESCRIPTION - Local development only. Requires the dev container from docker-compose.yml - (`docker compose up -d`) to be running. Tokens expire after 1 hour; re-run - this script and restart "npm run dev" when the UI starts returning 401s. - The credentials below belong to the disposable local Stalwart container. - See DEVELOPMENT.md for the full workflow. Non-Windows shells (and AI - agents without PowerShell) can use scripts/dev-token.sh instead. + Local development only. Requires scripts/dev-server-init.ps1 to have been + run once first (creates the "devadmin" account this script authenticates + as — the STALWART_RECOVERY_ADMIN account is break-glass only and always + issues fixed 1h tokens regardless of server config, so it can't honor a + custom duration). See DEVELOPMENT.md for the full workflow. Non-Windows + shells (and AI agents without PowerShell) can use scripts/dev-token.sh + instead. + +.PARAMETER DurationSeconds + How long the token should stay valid, in seconds. Defaults to 3 hours + (10800), matching the server default set by dev-server-init.ps1. + +.EXAMPLE + ./dev-token.ps1 # 3 hour token +.EXAMPLE + ./dev-token.ps1 -DurationSeconds 1800 # 30 minute token #> param( - [string]$ApiBaseUrl = "http://localhost:8080", - [string]$AccountName = "admin@example.org", - [string]$AccountSecret = "c8321iEscHDy0GWV" + [int]$DurationSeconds = 10800, + [string]$ApiBaseUrl = "http://localhost:8080" ) $ErrorActionPreference = 'Stop' $root = Split-Path -Parent $PSScriptRoot -# PKCE pair (S256) -$chars = (48..57) + (65..90) + (97..122) -$verifier = -join ($chars | Get-Random -Count 64 | ForEach-Object { [char]$_ }) -$sha = [System.Security.Cryptography.SHA256]::Create() -$challenge = [Convert]::ToBase64String($sha.ComputeHash([Text.Encoding]::UTF8.GetBytes($verifier))).Replace('+', '-').Replace('/', '_').TrimEnd('=') +$DevAdminAccount = "devadmin@example.org" +$DevAdminSecret = "DevAdminPass123!" -$redirectUri = "http://localhost:3005/oauth/callback" +$creds = [System.Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes("$($DevAdminAccount):$($DevAdminSecret)")) +$authHeader = @{ Authorization = "Basic $creds" } -$authPayload = @{ - type = "authCode" - accountName = $AccountName - accountSecret = $AccountSecret - clientId = "stalwart-webui" - redirectUri = $redirectUri - scope = "openid email profile offline_access" - state = [guid]::NewGuid().ToString("N") - codeChallenge = $challenge - codeChallengeMethod = "S256" -} | ConvertTo-Json -Compress - -$auth = Invoke-RestMethod -Uri "$ApiBaseUrl/api/auth" -Method Post -ContentType "application/json" -Body $authPayload -TimeoutSec 15 -if ($auth.type -ne "authenticated" -or -not $auth.client_code) { - throw "Unexpected /api/auth response: $($auth | ConvertTo-Json -Compress)" +try { + $session = Invoke-RestMethod -Uri "$ApiBaseUrl/jmap/session" -Headers $authHeader -TimeoutSec 15 +} catch { + throw "Could not reach $ApiBaseUrl as $DevAdminAccount. Is the server running ('npm run dev:server') and initialized ('scripts/dev-server-init.ps1')?" } +$accountId = $session.primaryAccounts.'urn:stalwart:jmap' -$tokenBody = "grant_type=authorization_code&code=$($auth.client_code)&code_verifier=$verifier&client_id=stalwart-webui&redirect_uri=$([uri]::EscapeDataString($redirectUri))" -$token = Invoke-RestMethod -Uri "$ApiBaseUrl/auth/token" -Method Post -ContentType "application/x-www-form-urlencoded" -Body $tokenBody -TimeoutSec 15 +$expiresAt = [DateTime]::UtcNow.AddSeconds($DurationSeconds).ToString("yyyy-MM-ddTHH:mm:ssZ") + +$request = @{ + using = @("urn:ietf:params:jmap:core", "urn:stalwart:jmap") + methodCalls = @(, @("x:ApiKey/set", @{ + accountId = $accountId + create = @{ k1 = @{ description = "dev-token.ps1"; expiresAt = $expiresAt } } + }, "0")) +} | ConvertTo-Json -Depth 10 -Compress + +$response = Invoke-RestMethod -Uri "$ApiBaseUrl/jmap/" -Method Post -ContentType "application/json" -Headers $authHeader -Body $request -TimeoutSec 15 +$secret = $response.methodResponses[0][1].created.k1.secret + +if (-not $secret) { + throw "Unexpected x:ApiKey/set response: $($response | ConvertTo-Json -Depth 10 -Compress)" +} $envPath = Join-Path $root ".env.development.local" @" # Generated by scripts/dev-token.ps1 - gitignored, do not commit. # Empty base URL: API calls stay same-origin and go through the Vite proxy. VITE_API_BASE_URL= -VITE_ACCESS_TOKEN=$($token.access_token) +VITE_ACCESS_TOKEN=$secret "@ | Set-Content -Path $envPath -Encoding ascii -Write-Host "Token written to $envPath (expires in $($token.expires_in)s). Restart 'npm run dev' to pick it up." +Write-Host "Token written to $envPath (expires $expiresAt, in ${DurationSeconds}s). Restart 'npm run dev' to pick it up." diff --git a/scripts/dev-token.sh b/scripts/dev-token.sh index b1baef8..8e67064 100644 --- a/scripts/dev-token.sh +++ b/scripts/dev-token.sh @@ -1,54 +1,48 @@ #!/usr/bin/env bash -# Local development only. Generates a fresh OAuth access token from the local +# Local development only. Generates a fresh access token from the local # Stalwart dev container (see docker-compose.yml) and writes it to # .env.development.local (gitignored). Bash equivalent of dev-token.ps1, for # non-Windows shells (and AI agents without PowerShell). # -# Tokens expire after 1 hour; re-run this script and restart "npm run dev" -# when the UI starts returning 401s. -# The credentials below belong to the disposable local Stalwart container. +# Requires scripts/dev-server-init.sh to have been run once first (creates +# the "devadmin" account this script authenticates as — the +# STALWART_RECOVERY_ADMIN account is break-glass only and always issues +# fixed 1h tokens regardless of server config, so it can't honor a custom +# duration). +# +# Usage: dev-token.sh [duration_seconds] [api_base_url] +# dev-token.sh # 3 hour token (server default, see dev-server-init.sh) +# dev-token.sh 1800 # 30 minute token set -euo pipefail -API_BASE_URL="${1:-http://localhost:8080}" -ACCOUNT_NAME="${2:-admin@example.org}" -ACCOUNT_SECRET="${3:-c8321iEscHDy0GWV}" -REDIRECT_URI="http://localhost:3005/oauth/callback" +DURATION_SECONDS="${1:-10800}" +API_BASE_URL="${2:-http://localhost:8080}" +DEVADMIN_ACCOUNT="devadmin@example.org" +DEVADMIN_SECRET="DevAdminPass123!" ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -b64url() { - base64 | tr '+/' '-_' | tr -d '=\n' +if ! date -u -d "+1 minute" +"%Y-%m-%dT%H:%M:%SZ" >/dev/null 2>&1; then + EXPIRES_AT=$(date -u -v+"${DURATION_SECONDS}"S +"%Y-%m-%dT%H:%M:%SZ") # BSD/macOS date +else + EXPIRES_AT=$(date -u -d "+${DURATION_SECONDS} seconds" +"%Y-%m-%dT%H:%M:%SZ") # GNU date +fi + +SESSION=$(curl -sf --compressed -u "$DEVADMIN_ACCOUNT:$DEVADMIN_SECRET" "$API_BASE_URL/jmap/session") || { + echo "Could not reach $API_BASE_URL as $DEVADMIN_ACCOUNT. Is the server running ('npm run dev:server') and initialized ('scripts/dev-server-init.sh')?" >&2 + exit 1 } +ACCOUNT_ID=$(printf '%s' "$SESSION" | grep -o '"urn:stalwart:jmap":"[^"]*"' | cut -d'"' -f4) -VERIFIER="$(head -c 48 /dev/urandom | b64url | head -c 64)" -CHALLENGE="$(printf '%s' "$VERIFIER" | openssl dgst -sha256 -binary | b64url)" -STATE="$(head -c 16 /dev/urandom | xxd -p)" - -AUTH_PAYLOAD=$(cat <&2 - exit 1 -fi - -TOKEN_RESPONSE=$(curl -sf "$API_BASE_URL/auth/token" -X POST \ - -H "Content-Type: application/x-www-form-urlencoded" \ - --data-urlencode "grant_type=authorization_code" \ - --data-urlencode "code=$CLIENT_CODE" \ - --data-urlencode "code_verifier=$VERIFIER" \ - --data-urlencode "client_id=stalwart-webui" \ - --data-urlencode "redirect_uri=$REDIRECT_URI") - -ACCESS_TOKEN=$(printf '%s' "$TOKEN_RESPONSE" | grep -o '"access_token":"[^"]*"' | cut -d'"' -f4) -EXPIRES_IN=$(printf '%s' "$TOKEN_RESPONSE" | grep -o '"expires_in":[0-9]*' | cut -d':' -f2) - -if [ -z "$ACCESS_TOKEN" ]; then - echo "Unexpected /auth/token response: $TOKEN_RESPONSE" >&2 +if [ -z "$TOKEN" ]; then + echo "Unexpected x:ApiKey/set response: $RESPONSE" >&2 exit 1 fi @@ -57,7 +51,7 @@ cat > "$ENV_PATH" <